Fas 3 steg 3a-fix: hermetisk test-setup + analytics-event regler för cooking sessions

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 03:36:54 +07:00
parent e9fb4a17d4
commit 44f6385dce
5 changed files with 105 additions and 17 deletions
+16 -13
View File
@@ -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 });
});
+60 -2
View File
@@ -12,6 +12,7 @@ describe("cooking sessions", () => {
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
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",
+8
View File
@@ -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 |
+5 -2
View File
@@ -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:*",
+16
View File
@@ -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