238 lines
7.3 KiB
TypeScript
238 lines
7.3 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 { sql } from "drizzle-orm";
|
||
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,
|
||
};
|
||
|
||
const planPrices = {
|
||
free: 0,
|
||
household: 9900,
|
||
family: 14900,
|
||
large_household: 19900,
|
||
};
|
||
|
||
describe("/ops/v1/health", () => {
|
||
let app: Awaited<ReturnType<typeof buildServer>>;
|
||
|
||
beforeAll(async () => {
|
||
app = await buildServer(config);
|
||
await app.ready();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
it("avvisar anrop utan token", async () => {
|
||
const res = await app.inject({ method: "GET", url: "/ops/v1/health" });
|
||
expect(res.statusCode).toBe(401);
|
||
});
|
||
|
||
it("returnerar ok med app-namn vid giltig token", async () => {
|
||
const res = await app.inject({
|
||
method: "GET",
|
||
url: "/ops/v1/health",
|
||
headers: { authorization: `Bearer ${config.OPS_TOKEN}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const body = JSON.parse(res.body) as { ok: boolean; app: string };
|
||
expect(body.ok).toBe(true);
|
||
expect(body.app).toBe("cibello");
|
||
});
|
||
});
|
||
|
||
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 testDb.db.delete(schema.feedback);
|
||
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,
|
||
},
|
||
{
|
||
eventName: "account_created",
|
||
occurredAt: now,
|
||
properties: {},
|
||
userId: null,
|
||
},
|
||
{
|
||
eventName: "account_created",
|
||
occurredAt: now,
|
||
properties: {},
|
||
userId: null,
|
||
},
|
||
{
|
||
eventName: "recommendations_viewed",
|
||
occurredAt: now,
|
||
properties: {},
|
||
userId: null,
|
||
},
|
||
{
|
||
eventName: "recommendation_opened",
|
||
occurredAt: now,
|
||
properties: {},
|
||
userId: null,
|
||
},
|
||
]);
|
||
await testDb.db.insert(schema.opsSafetyCanary).values({
|
||
allergenInvariantBrott: 0,
|
||
overifieradeVisade: 0,
|
||
foodSafetyLintAvvisade7d: 0,
|
||
});
|
||
await testDb.db.insert(schema.feedback).values([
|
||
{
|
||
userId: null,
|
||
typ: "bug",
|
||
rubrik: "Knappen fungerar inte",
|
||
text: "Detaljerad text – ska aldrig visas i summary.",
|
||
status: "oppen",
|
||
},
|
||
{
|
||
userId: null,
|
||
typ: "onskemal",
|
||
rubrik: "Mörkt läge",
|
||
text: "Önskemålstext – ska aldrig visas i summary.",
|
||
status: "stangd",
|
||
},
|
||
]);
|
||
|
||
const summary = await computeOpsSummary({
|
||
db: testDb.db,
|
||
budgetUsd: 0,
|
||
queueSummary,
|
||
planPrices,
|
||
});
|
||
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 {
|
||
app: { app: string; generated_at: string };
|
||
ekonomi: { mrr: number | null; intakt_24h: number | null; valuta: string };
|
||
anvandare: { aktiva_nu: number; dau: number; mau: number; nya_24h: number; nya_7d: number };
|
||
prenumerationer: { trial_aktiva: number; betalande: number };
|
||
butik: { butik: null };
|
||
feedback: { oppna: number; nya_24h: number; senaste: Array<unknown> };
|
||
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.app.app).toBe("cibello");
|
||
expect(body.app.generated_at).toBeTruthy();
|
||
expect(body.ekonomi.valuta).toBe("SEK");
|
||
expect(body.anvandare.nya_24h).toBe(2);
|
||
expect(body.anvandare.dau).toBe(0);
|
||
|
||
const payingNow = await testDb.db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(schema.subscriptions)
|
||
.where(
|
||
sql`${schema.subscriptions.status} = 'active' AND (${schema.subscriptions.expiresAt} IS NULL OR ${schema.subscriptions.expiresAt} > now())`,
|
||
);
|
||
expect(body.prenumerationer.betalande).toBe(Number(payingNow[0]?.count ?? 0));
|
||
|
||
const trialsNow = await testDb.db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(schema.trials)
|
||
.where(sql`${schema.trials.endsAt} >= now()`);
|
||
expect(body.prenumerationer.trial_aktiva).toBe(Number(trialsNow[0]?.count ?? 0));
|
||
|
||
expect(body.butik.butik).toBeNull();
|
||
expect(body.feedback.oppna).toBe(1);
|
||
expect(body.feedback.nya_24h).toBe(2);
|
||
expect(body.feedback.senaste.length).toBe(2);
|
||
expect(body.feedback.senaste[0]).not.toHaveProperty("text");
|
||
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(0);
|
||
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);
|
||
});
|
||
});
|