feat(ops): /ops/v1/summary med ai/aktivering/engagemang/betalning/jobb/sakerhet + canary

- Nya toppnivåblock i /ops/v1/summary: ai_scan, aktivering, engagemang,
  betalning, jobb, sakerhet. Alla värden läses från DB/cache; null där data
  saknas, inga påhittade värden.
- AI-kostnad i USD mikrocent (native); intäkter fortsatt SEK-öre.
- product_analytics_events är källa för volym, latens, lyckandegrad,
  felfrekvens, aktivering, retention och engagemang.
- Nya händelser: scan_started (API) och scan_failed/scan_completed med
  latencyMs + felkod (worker). latencyMs flödar nu in i scan_completed.
- Safety canary-jobb varje timme: re-härleder allergener för alla recept,
  räknar överifierade publika recept och food-safety-lint; skriver EN rad till
  ops_safety_canary. Endpointen läser endast sista raden.
- Cache-refresh-jobb var 60 s skriver hela summariet till Redis; endpointen
  serverar cachen med 503 vid cache-miss.
- Bearer-token-skydd med OPS_TOKEN; HTTPS-tvång i produktion; ingen PII.
- Tester för endpoint, auth, cache-miss och safety canary.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 05:47:43 +07:00
parent f4a603e977
commit 8f20b2ef1b
27 changed files with 1169 additions and 41 deletions
+126
View File
@@ -0,0 +1,126 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, schema } from "@app/database";
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
const queueSummary: OpsQueueSummary = {
vantande: 2,
aktiva: 1,
misslyckade_24h: 0,
aldsta_vantande_sek: null,
workers_ok: true,
};
describe("/ops/v1/summary", () => {
let app: Awaited<ReturnType<typeof buildServer>>;
beforeAll(async () => {
app = await buildServer(config);
await app.ready();
await cleanup();
});
afterAll(async () => {
await cleanup();
await app.close();
await testDb.pool.end();
});
async function cleanup() {
await testDb.db.delete(schema.opsSafetyCanary);
await testDb.db.delete(schema.productAnalyticsEvents);
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
}
it("avvisar anrop utan token", async () => {
const res = await app.inject({ method: "GET", url: "/ops/v1/summary" });
expect(res.statusCode).toBe(401);
});
it("avvisar felaktig token", async () => {
const res = await app.inject({
method: "GET",
url: "/ops/v1/summary",
headers: { authorization: "Bearer wrong-token" },
});
expect(res.statusCode).toBe(401);
});
it("returnerar 503 när cachen saknas", async () => {
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
const res = await app.inject({
method: "GET",
url: "/ops/v1/summary",
headers: { authorization: `Bearer ${config.OPS_TOKEN}` },
});
expect(res.statusCode).toBe(503);
const body = JSON.parse(res.body) as { error: { code: string } };
expect(body.error.code).toBe("CACHE_MISS");
});
it("serverar cachad summary med rätt block", async () => {
const now = new Date();
await testDb.db.insert(schema.productAnalyticsEvents).values([
{ eventName: "scan_started", occurredAt: now, properties: {}, userId: null },
{
eventName: "scan_completed",
occurredAt: now,
properties: { latencyMs: 120 },
userId: null,
},
{
eventName: "scan_completed",
occurredAt: now,
properties: { latencyMs: 240 },
userId: null,
},
{
eventName: "cooking_session_completed",
occurredAt: now,
properties: {},
userId: null,
},
]);
await testDb.db.insert(schema.opsSafetyCanary).values({
allergenInvariantBrott: 0,
overifieradeVisade: 3,
foodSafetyLintAvvisade7d: 1,
});
const summary = await computeOpsSummary({
db: testDb.db,
budgetUsd: 0,
queueSummary,
});
await app.redis.set("ops:summary:cache", JSON.stringify(summary), "EX", 60);
await app.redis.set("ops:summary:computed_at", summary.as_of, "EX", 60);
const res = await app.inject({
method: "GET",
url: "/ops/v1/summary",
headers: { authorization: `Bearer ${config.OPS_TOKEN}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as {
ai_scan: { scans_24h: number; latens_p50_ms: number | null };
engagemang: { lagade_maltider_24h: number };
sakerhet: { overifierade_visade: number; senaste_kontroll: string | null };
jobb: { vantande: number; workers_ok: boolean };
cached_at: string;
};
expect(body.ai_scan.scans_24h).toBe(2);
expect(body.ai_scan.latens_p50_ms).toBeGreaterThan(0);
expect(body.engagemang.lagade_maltider_24h).toBe(1);
expect(body.sakerhet.overifierade_visade).toBe(3);
expect(body.sakerhet.senaste_kontroll).toBeTruthy();
expect(body.jobb.vantande).toBe(2);
expect(body.jobb.workers_ok).toBe(true);
expect(body.cached_at).toBe(summary.as_of);
});
});
+3 -1
View File
@@ -8,8 +8,10 @@ process.env.AAMOS_MODE = "mock";
process.env.EMAIL_MODE = "log";
process.env.S3_MODE = "mock";
process.env.LOG_LEVEL = "error";
process.env.OPS_TOKEN = "test-ops-token-not-for-production";
// Local test database fallback tests still need a Postgres instance, but
// the connection string is not a secret and the value is predictable.
process.env.TEST_DATABASE_URL ||= "postgres://app_user:app_dev_password@localhost:5432/cibello_test";
process.env.TEST_DATABASE_URL ||=
"postgres://app_user:app_dev_password@localhost:5432/cibello_test";
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;