import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { 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 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.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.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 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", 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); }); it("starts a planned session", 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"); }); 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("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); }); });