diff --git a/apps/api/src/routes/cooking-sessions.ts b/apps/api/src/routes/cooking-sessions.ts index de493cd..1dc2b81 100644 --- a/apps/api/src/routes/cooking-sessions.ts +++ b/apps/api/src/routes/cooking-sessions.ts @@ -58,19 +58,22 @@ export async function cookingSessionRoutes(app: FastifyInstance) { }) .returning(); - await trackProductAnalytics( - app.db, - req.userId, - cookingSessionStarted({ - householdId, - properties: { - cookingSessionId: session!.id, - recipeId: id, - status, - plannedPortions: input.portions ?? recipe.portions, - }, - }), - ); + // cooking_session_started skickas endast vid faktisk start, inte vid planned. + if (status === "started") { + await trackProductAnalytics( + app.db, + req.userId, + cookingSessionStarted({ + householdId, + properties: { + cookingSessionId: session!.id, + recipeId: id, + status, + plannedPortions: input.portions ?? recipe.portions, + }, + }), + ); + } return reply.status(201).send({ session: session }); }); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index 3802b47..f7f7655 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -12,6 +12,7 @@ describe("cooking sessions", () => { const config = loadConfig(); let app: Awaited>; let token: string; + let userId: string; let householdId: string; let recipeId: string; const email = "cooking-session-test@example.invalid"; @@ -46,6 +47,7 @@ describe("cooking sessions", () => { await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); + await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } @@ -61,6 +63,8 @@ describe("cooking sessions", () => { payload: { email, password: "Password123!", displayName: "Cooking Test" }, }); token = (JSON.parse(res.body) as { accessToken: string }).accessToken; + const profile = await app.inject({ method: "GET", url: "/v1/me", headers: { authorization: `Bearer ${token}` } }); + userId = (JSON.parse(profile.body) as { id: string }).id; const quick = await app.inject({ method: "POST", @@ -85,7 +89,7 @@ describe("cooking sessions", () => { await app.close(); }); - it("creates a planned session", async () => { + it("creates a planned session without firing cooking_session_started", async () => { const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, @@ -96,9 +100,15 @@ describe("cooking sessions", () => { const body = JSON.parse(res.body) as { session: { status: string; plannedPortions: number } }; expect(body.session.status).toBe("planned"); expect(body.session.plannedPortions).toBe(4); + + const events = await testDb.db + .select({ name: schema.productAnalyticsEvents.eventName }) + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)); + expect(events.filter((e) => e.name === "cooking_session_started").length).toBe(0); }); - it("starts a planned session", async () => { + it("starts a planned session and fires cooking_session_started", async () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, @@ -116,6 +126,13 @@ describe("cooking sessions", () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { session: { status: string } }; expect(body.session.status).toBe("started"); + + const started = await testDb.db + .select() + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)) + .orderBy(schema.productAnalyticsEvents.occurredAt); + expect(started.filter((e) => e.eventName === "cooking_session_started").length).toBeGreaterThanOrEqual(1); }); it("cancels a session without touching inventory", async () => { @@ -217,6 +234,47 @@ describe("cooking sessions", () => { expect(body.mealBoxId).toBeDefined(); }); + it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => { + // complete + const startComplete = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true }, + }); + const completeId = (JSON.parse(startComplete.body) as { session: { id: string } }).session.id; + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${completeId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 0, deductInventory: true }, + }); + + // cancel + const startCancel = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true }, + }); + const cancelId = (JSON.parse(startCancel.body) as { session: { id: string } }).session.id; + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${cancelId}/cancel`, + headers: { authorization: `Bearer ${token}` }, + payload: { reason: "test" }, + }); + + const names = ( + await testDb.db + .select({ name: schema.productAnalyticsEvents.eventName }) + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)) + ).map((r) => r.name); + expect(names).toContain("cooking_session_completed"); + expect(names).toContain("cooking_session_cancelled"); + }); + it("times out started sessions older than 24 h", async () => { const start = await app.inject({ method: "POST", diff --git a/docs/FAS3-COOKING-SESSIONS-AUDIT.md b/docs/FAS3-COOKING-SESSIONS-AUDIT.md index 0df5b6d..5a2e736 100644 --- a/docs/FAS3-COOKING-SESSIONS-AUDIT.md +++ b/docs/FAS3-COOKING-SESSIONS-AUDIT.md @@ -4,6 +4,14 @@ --- +## Hermetiska testregler + +Alla integrationstester ska vara hermetiska: +- De får inte bero på innehållet i `.env` (värden som `AAMOS_MODE`, `EMAIL_MODE`, `S3_MODE` skrivs över i `setup-env.ts`). +- De får inte bero på förkonfigurerad konfiguration eller externa tjänster. +- De får inte bero på databasinnehåll som skapats utanför testet (exempelvis seedade recept från en tidigare körning). Testkörningen ansvarar själv för att testdatabasen är migrerad **och** seedad innan testerna startar; seeden är idempotent. +- Körning: `pnpm --filter @app/database run db:test-setup` (migrate + seed) ska alltid föregå API-testerna. + ## 1. REUSE/GAP-matris mot befintligt matlagningsflöde | §6-krav / område | Befintlig implementation | Status | Rekommenderad åtgärd | diff --git a/packages/database/package.json b/packages/database/package.json index a6718c5..03de2e7 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -11,10 +11,13 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "tsx src/migrate.ts --test && vitest run --passWithNoTests", + "test": "pnpm run db:test-setup && vitest run --passWithNoTests", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/migrate.ts", - "db:seed": "tsx src/seed/run.ts" + "db:seed": "tsx src/seed/run.ts", + "db:test-migrate": "tsx src/migrate.ts --test", + "db:test-seed": "tsx src/seed/run.ts --test", + "db:test-setup": "tsx src/migrate.ts --test && tsx src/seed/run.ts --test" }, "dependencies": { "@app/nutrition-engine": "workspace:*", diff --git a/turbo.json b/turbo.json index 28ed63d..e4f6c8f 100644 --- a/turbo.json +++ b/turbo.json @@ -10,11 +10,27 @@ "dependsOn": ["^typecheck"], "outputs": [] }, + "db:test-setup": { + "dependsOn": [], + "outputs": [], + "cache": false, + "env": ["TEST_DATABASE_URL"] + }, "test": { "dependsOn": ["^typecheck"], "outputs": [], "env": ["TEST_DATABASE_URL"] }, + "@app/api#test": { + "dependsOn": ["@app/database#db:test-setup"], + "outputs": [], + "env": ["TEST_DATABASE_URL"] + }, + "@app/database#test": { + "dependsOn": ["@app/database#db:test-setup"], + "outputs": [], + "env": ["TEST_DATABASE_URL"] + }, "dev": { "cache": false, "persistent": true