8f20b2ef1b
- 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.
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|