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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user