Fas 3 steg 3a-fix: hermetisk test-setup + analytics-event regler för cooking sessions
This commit is contained in:
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user