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(); .returning();
await trackProductAnalytics( // cooking_session_started skickas endast vid faktisk start, inte vid planned.
app.db, if (status === "started") {
req.userId, await trackProductAnalytics(
cookingSessionStarted({ app.db,
householdId, req.userId,
properties: { cookingSessionStarted({
cookingSessionId: session!.id, householdId,
recipeId: id, properties: {
status, cookingSessionId: session!.id,
plannedPortions: input.portions ?? recipe.portions, recipeId: id,
}, status,
}), plannedPortions: input.portions ?? recipe.portions,
); },
}),
);
}
return reply.status(201).send({ session: session }); return reply.status(201).send({ session: session });
}); });
+60 -2
View File
@@ -12,6 +12,7 @@ describe("cooking sessions", () => {
const config = loadConfig(); const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>; let app: Awaited<ReturnType<typeof buildServer>>;
let token: string; let token: string;
let userId: string;
let householdId: string; let householdId: string;
let recipeId: string; let recipeId: string;
const email = "cooking-session-test@example.invalid"; 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.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.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)); 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" }, payload: { email, password: "Password123!", displayName: "Cooking Test" },
}); });
token = (JSON.parse(res.body) as { accessToken: string }).accessToken; 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({ const quick = await app.inject({
method: "POST", method: "POST",
@@ -85,7 +89,7 @@ describe("cooking sessions", () => {
await app.close(); 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({ const res = await app.inject({
method: "POST", method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`, 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 } }; const body = JSON.parse(res.body) as { session: { status: string; plannedPortions: number } };
expect(body.session.status).toBe("planned"); expect(body.session.status).toBe("planned");
expect(body.session.plannedPortions).toBe(4); 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({ const start = await app.inject({
method: "POST", method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`, url: `/v1/recipes/${recipeId}/cook/start`,
@@ -116,6 +126,13 @@ describe("cooking sessions", () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { session: { status: string } }; const body = JSON.parse(res.body) as { session: { status: string } };
expect(body.session.status).toBe("started"); 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 () => { it("cancels a session without touching inventory", async () => {
@@ -217,6 +234,47 @@ describe("cooking sessions", () => {
expect(body.mealBoxId).toBeDefined(); 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 () => { it("times out started sessions older than 24 h", async () => {
const start = await app.inject({ const start = await app.inject({
method: "POST", 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 ## 1. REUSE/GAP-matris mot befintligt matlagningsflöde
| §6-krav / område | Befintlig implementation | Status | Rekommenderad åtgärd | | §6-krav / område | Befintlig implementation | Status | Rekommenderad åtgärd |
+5 -2
View File
@@ -11,10 +11,13 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "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:generate": "drizzle-kit generate",
"db:migrate": "tsx src/migrate.ts", "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": { "dependencies": {
"@app/nutrition-engine": "workspace:*", "@app/nutrition-engine": "workspace:*",
+16
View File
@@ -10,11 +10,27 @@
"dependsOn": ["^typecheck"], "dependsOn": ["^typecheck"],
"outputs": [] "outputs": []
}, },
"db:test-setup": {
"dependsOn": [],
"outputs": [],
"cache": false,
"env": ["TEST_DATABASE_URL"]
},
"test": { "test": {
"dependsOn": ["^typecheck"], "dependsOn": ["^typecheck"],
"outputs": [], "outputs": [],
"env": ["TEST_DATABASE_URL"] "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": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true