import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { and, eq, inArray, count } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; import { cancelTimedOutCookingSessions } from "@app/database"; import { computeBalance } from "@app/inventory-engine"; describe("cooking sessions", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); 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"; async function cleanup() { const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(inArray(schema.users.email, [email])); for (const u of existing) { const sessions = await testDb.db .select({ id: schema.cookingSessions.id }) .from(schema.cookingSessions) .where(eq(schema.cookingSessions.startedByUserId, u.id)); for (const s of sessions) { await testDb.db .delete(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.cookingSessionId, s.id)); await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id)); await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id)); await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id)); } await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id)); const memberships = await testDb.db .select({ householdId: schema.householdMembers.householdId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); for (const m of memberships) { await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId)); await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, 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.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } beforeAll(async () => { await cleanup(); app = await buildServer(config); await app.ready(); const res = await app.inject({ method: "POST", url: "/v1/auth/register", 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", url: "/v1/onboarding/quick-start", headers: { authorization: `Bearer ${token}` }, payload: { goals: ["less_waste"], precisionMode: "simple" }, }); householdId = (JSON.parse(quick.body) as { householdId: string }).householdId; // Hitta ett seedat recept const recipes = await app.inject({ method: "GET", url: "/v1/recipes?limit=1", headers: { authorization: `Bearer ${token}` }, }); recipeId = (JSON.parse(recipes.body) as { recipes: Array<{ id: string }> }).recipes[0]!.id; }); afterAll(async () => { await cleanup(); await closeDatabase(); await app.close(); }); it("creates a planned session without firing cooking_session_started", async () => { const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { portions: 4, mealType: "dinner" }, }); expect(res.statusCode).toBe(201); 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 and fires cooking_session_started", async () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { portions: 2 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const res = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/start`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); 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 () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const before = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id)) .where(eq(schema.inventoryItems.householdId, householdId)); const res = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/cancel`, headers: { authorization: `Bearer ${token}` }, payload: { reason: "vi åt ute" }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { session: { status: string } }; expect(body.session.status).toBe("cancelled"); const after = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id)) .where(eq(schema.inventoryItems.householdId, householdId)); expect(after[0]!.count).toBe(before[0]!.count); }); it("completes a started session and links inventory, meals and recipe_cooks", async () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const res = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, deductInventory: true }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { session: { status: string }; mealIds: string[]; inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>; }; expect(body.session.status).toBe("completed"); expect(body.mealIds.length).toBeGreaterThan(0); const txCount = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId)); expect(Number(txCount[0]!.count)).toBeGreaterThanOrEqual(body.inventoryDeductions.length); const cookCount = await testDb.db .select({ count: count(schema.recipeCooks.id) }) .from(schema.recipeCooks) .where(eq(schema.recipeCooks.cookingSessionId, sessionId)); expect(Number(cookCount[0]!.count)).toBe(1); // Transaktionsinvariant: computeBalance ska matcha item.quantity for (const d of body.inventoryDeductions) { const item = await testDb.db .select({ id: schema.inventoryItems.id, quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, d.itemId)) .limit(1); const txs = await testDb.db .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, d.itemId)); const balance = computeBalance(txs); expect(Math.abs(balance.balance - (item[0]?.quantity ?? 0))).toBeLessThan(1e-6); } }); it("legacy POST /v1/recipes/:id/cook still works as shortcut", async () => { const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null }; expect(body.ok).toBe(true); expect(body.sessionId).toBeDefined(); expect(body.mealBoxId).toBeDefined(); }); it("stores actual portions and leftover estimate on complete", async () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const res = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } }; expect(body.session.actualPortionsEaten).toBe(2); expect(body.session.leftoverEstimatePortions).toBe(1); }); it("rejects complete when eaten + leftovers exceed planned portions", async () => { const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const res = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2 }, }); expect(res.statusCode).toBe(400); }); it("updates cooking assumption profiles per household and ingredient", async () => { await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); const start = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const recipe = (await app.inject({ method: "GET", url: `/v1/recipes/${recipeId}`, headers: { authorization: `Bearer ${token}` }, })).json() as { ingredients: Array<{ canonicalIngredientId: string }> }; const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); if (firstIngredientId) { const profile = await testDb.db .select() .from(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, householdId), eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId), ), ) .limit(1); expect(profile.length).toBe(1); expect(profile[0]!.observationCount).toBe(1); expect(profile[0]!.averageEatenPortions).toBe(2); expect(profile[0]!.averageLeftoverPortions).toBe(1); } }); 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", url: `/v1/recipes/${recipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; // Simulera 25 h gammal session await testDb.db .update(schema.cookingSessions) .set({ startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }) .where(eq(schema.cookingSessions.id, sessionId)); const timedOut = await cancelTimedOutCookingSessions(testDb.db); expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true); }); });