Phase 1 ops core: base blocks, feedback, canary fixes
- Add app/ekonomi/anvandare/prenumerationer/butik/feedback blocks to computeOpsSummary. - New feedback table, POST /api/feedback, GDPR erasure wiring. - Canary: only count unverified/rejected published recipes; treat verified/editorial as safe. - Add 'rejected' recipe verification status enum value. - Fix food-safety lint baseline: remove egg/cured fish from raw-protein list, relax genomstekt regex, add safe-cooking phrases to seed recipes. - Seed recipes now get verificationStatus=editorial. - Tests: ops summary blocks, feedback route, me.residual cleanup.
This commit is contained in:
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
@@ -16,6 +17,13 @@ const queueSummary: OpsQueueSummary = {
|
||||
workers_ok: true,
|
||||
};
|
||||
|
||||
const planPrices = {
|
||||
free: 0,
|
||||
household: 9900,
|
||||
family: 14900,
|
||||
large_household: 19900,
|
||||
};
|
||||
|
||||
describe("/ops/v1/summary", () => {
|
||||
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||
|
||||
@@ -34,6 +42,7 @@ describe("/ops/v1/summary", () => {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -85,17 +94,58 @@ describe("/ops/v1/summary", () => {
|
||||
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: 3,
|
||||
foodSafetyLintAvvisade7d: 1,
|
||||
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);
|
||||
@@ -108,16 +158,47 @@ describe("/ops/v1/summary", () => {
|
||||
|
||||
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(3);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user