import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { and, eq, inArray, count, sql } 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"; import type { Unit } from "@app/shared-types"; 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 createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) { const [location] = await testDb.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where(eq(schema.storageLocations.householdId, householdId)) .limit(1); const [item] = await testDb.db .insert(schema.inventoryItems) .values({ householdId, canonicalIngredientId, displayName, quantity, unit, storageLocationId: location!.id, source: "manual_search", }) .returning(); await testDb.db.insert(schema.inventoryTransactions).values({ householdId, inventoryItemId: item!.id, type: "purchase", quantityDelta: quantity, unit, refType: "test_setup", actorUserId: userId, }); return item!.id; } 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.mealBoxes).where(eq(schema.mealBoxes.householdId, m.householdId)); await testDb.db.delete(schema.meals).where(eq(schema.meals.householdId, m.householdId)); await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.householdId, m.householdId)); await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.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("legacy /cook stores actual portions and leftover estimate on the session row", async () => { const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1, leftoverNote: "sparas i kylen", deductInventory: true, }, }); expect(res.statusCode).toBe(200); const { sessionId } = JSON.parse(res.body) as { sessionId: string }; const session = await testDb.db .select() .from(schema.cookingSessions) .where(eq(schema.cookingSessions.id, sessionId)) .limit(1); expect(session[0]!.actualPortionsEaten).toBe(2); expect(session[0]!.leftoverEstimatePortions).toBe(1); expect(session[0]!.leftoverNote).toBe("sparas i kylen"); }); it("legacy /cook updates cooking assumption profiles", async () => { await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); const recipe = (await app.inject({ method: "GET", url: `/v1/recipes/${recipeId}`, headers: { authorization: `Bearer ${token}` }, })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true, }, }); const profile = await testDb.db .select() .from(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, householdId), eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!), ), ) .limit(1); expect(profile.length).toBe(1); expect(profile[0]!.observationCount).toBe(1); expect(profile[0]!.averageEatenPortions).toBe(3); expect(profile[0]!.averageLeftoverPortions).toBe(1); }); it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => { const before = await testDb.db .select({ name: schema.productAnalyticsEvents.eventName }) .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)); const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, deductInventory: true }, }); expect(res.statusCode).toBe(200); const { sessionId } = JSON.parse(res.body) as { sessionId: string }; const after = await testDb.db .select() .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)) .orderBy(schema.productAnalyticsEvents.occurredAt); const newEvents = after.slice(before.length); const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId); const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId); expect(started.length).toBe(1); expect(completed.length).toBe(1); }); it("legacy /cook rejects when eaten + leftovers exceed planned portions", async () => { const res = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2, }, }); expect(res.statusCode).toBe(400); }); 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; optional?: boolean }> }; const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); const profile = await testDb.db .select() .from(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, householdId), eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!), ), ) .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("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => { await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); // Skapa ett recept där den första ingrediensen är valfri. const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `optional-first-${Date.now()}`, titleSv: "Testrecept valfri först", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5, }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values([ { recipeId: testRecipeId, canonicalIngredientId: "olive_oil", displayNameSv: "Olivolja", quantity: 1, unit: "TABLESPOON", optional: true, sortOrder: 0, }, { recipeId: testRecipeId, canonicalIngredientId: "pasta_dry", displayNameSv: "Pasta", quantity: 320, unit: "GRAM", optional: false, sortOrder: 1, }, ]); // Completa en session och skriv profil för den icke-valfria ingrediensen. const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 1 }, }); const assumptions = await app.inject({ method: "GET", url: `/v1/recipes/${testRecipeId}/cooking-assumptions`, headers: { authorization: `Bearer ${token}` }, }); expect(assumptions.statusCode).toBe(200); const body = JSON.parse(assumptions.body) as { defaultActualPortionsEaten: number | null; defaultLeftoverEstimatePortions: number | null; observationCount: number; }; expect(body.observationCount).toBe(1); expect(body.defaultActualPortionsEaten).toBe(3); expect(body.defaultLeftoverEstimatePortions).toBe(1); // Städa upp testreceptet. await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); 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); }); it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => { const recipe = (await app.inject({ method: "GET", url: `/v1/recipes/${recipeId}`, headers: { authorization: `Bearer ${token}` }, })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; // Sätt upp ett känt lager om receptet har en icke-valfri ingrediens. let itemId: string | undefined; if (firstNonOptional) { itemId = await createItemWithPurchase(firstNonOptional, "Testvara", 1000, "GRAM"); } 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 complete = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 1, actualPortionsEaten: 3, leftoverEstimatePortions: 1 }, }); expect(complete.statusCode).toBe(200); const txsBefore = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId)); const undo = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(undo.statusCode).toBe(200); const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] }; expect(undoBody.ok).toBe(true); // Sessionstatus = undone. const session = await testDb.db .select() .from(schema.cookingSessions) .where(eq(schema.cookingSessions.id, sessionId)) .limit(1); expect(session[0]!.status).toBe("undone"); // Append-only: fler transaktioner efter undo. const txsAfter = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId)); expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count)); // Meals borttagna. const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId)); expect(Number(meals[0]!.count)).toBe(0); // Matlådor markerade discarded. const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length); for (const box of boxes) expect(box.status).toBe("discarded"); // recipe_cooks borttagen och cookCount backad. const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId)); expect(cooks.length).toBe(0); // Inventory-transaktionsinvariant. if (itemId) { const item = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); const itemTxs = await testDb.db .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(itemTxs); expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); } // Analytics. const analytics = await testDb.db .select() .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)) .orderBy(schema.productAnalyticsEvents.occurredAt); const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true); if (itemId) { await testDb.db .delete(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)); } }); it("undo is rejected after 24h window", 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; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0 }, }); // Simulera 25 h gammal completed session. await testDb.db .update(schema.cookingSessions) .set({ completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }) .where(eq(schema.cookingSessions.id, sessionId)); const undo = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(undo.statusCode).toBe(409); }); it("partial consumption deducts only actual portions eaten from inventory", async () => { // Skapa ett enkelt recept med en enda icke-valfri ingrediens. const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `partial-${Date.now()}`, titleSv: "Partial test", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values({ recipeId: testRecipeId, canonicalIngredientId: "pasta_dry", displayNameSv: "Pasta", quantity: 400, unit: "GRAM", optional: false, sortOrder: 0, }); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM"); const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 }, }); // 4 portioner → 400 g, 2 ätna + 0 rester = 2 tillagade → 200 g borta, 200 g kvar. const afterComplete = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); expect(afterComplete[0]!.quantity).toBeCloseTo(200, 1); // Undo återställer. const undo = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(undo.statusCode).toBe(200); const afterUndo = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); expect(afterUndo[0]!.quantity).toBeCloseTo(400, 1); // Städa testreceptet och lagerposten. await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("leftover estimate must be at least meal box 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 complete = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 2, actualPortionsEaten: 1, leftoverEstimatePortions: 1 }, }); expect(complete.statusCode).toBe(400); expect(complete.body).toContain("BAD_REQUEST"); }); it("meal boxes are part of cooked food: 2 eaten + 2 boxed = 100% inventory deduction", async () => { const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `boxreg-${Date.now()}`, titleSv: "Box regression test", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values({ recipeId: testRecipeId, canonicalIngredientId: "chicken_breast", displayNameSv: "Kyckling", quantity: 400, unit: "GRAM", optional: false, sortOrder: 0, }); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"))); const itemId = await createItemWithPurchase("chicken_breast", "Kyckling", 400, "GRAM"); const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const complete = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 2, actualPortionsEaten: 2, leftoverEstimatePortions: 2 }, }); expect(complete.statusCode).toBe(200); // Alla 400 g ska vara borta (ätet + matlådor = tillagat). const item = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); expect(item[0]!.quantity).toBeCloseTo(0, 1); // Matlådan ska ha 2 portioner. const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(1); expect(boxes[0]!.portions).toBe(2); // Invariant. 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, itemId)); const balance = computeBalance(txs); expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"))); await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("2 eaten + 1 leftover = 75% deduction; 1 portion never cooked stays raw", async () => { const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `leftover-${Date.now()}`, titleSv: "Leftover test", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values({ recipeId: testRecipeId, canonicalIngredientId: "carrot", displayNameSv: "Morötter", quantity: 400, unit: "GRAM", optional: false, sortOrder: 0, }); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot"))); const itemId = await createItemWithPurchase("carrot", "Morötter", 400, "GRAM"); const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const complete = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); expect(complete.statusCode).toBe(200); // 3 av 4 tillagade → 300 g borta, 100 g kvar som aldrig tillagats. const item = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); expect(item[0]!.quantity).toBeCloseTo(100, 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, itemId)); const balance = computeBalance(txs); expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot"))); await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("defaults leftover estimate to meal box portions: 3 eaten + 1 box = 100% deduction", async () => { const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `default-leftover-${Date.now()}`, titleSv: "Default leftover test", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values({ recipeId: testRecipeId, canonicalIngredientId: "potato", displayNameSv: "Potatis", quantity: 400, unit: "GRAM", optional: false, sortOrder: 0, }); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato"))); const itemId = await createItemWithPurchase("potato", "Potatis", 400, "GRAM"); const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; const complete = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, // leftoverEstimatePortions UTELÄMNAD – wrapper ska defaulta till mealBoxPortions=1. payload: { mealBoxPortions: 1, actualPortionsEaten: 3 }, }); expect(complete.statusCode).toBe(200); // 3 ätna + 1 matlåda = 4 tillagade → allt 400 g borta. const item = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, itemId)) .limit(1); expect(item[0]!.quantity).toBeCloseTo(0, 1); // Lådan ska ha 1 portion. const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(1); expect(boxes[0]!.portions).toBe(1); // Invariant. 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, itemId)); const balance = computeBalance(txs); expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato"))); await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("undo + new complete maintains inventory invariant", async () => { const [recipe] = await testDb.db .insert(schema.recipes) .values({ slug: `invariant-${Date.now()}`, titleSv: "Invariant test", descriptionSv: "", cuisine: "international", mealTypes: ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", prepTimeMinutes: 5, cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, allergens: [], spiceLevel: 0, dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", creatorDisplayName: "Test", }) .returning(); const testRecipeId = recipe!.id; await testDb.db.insert(schema.recipeIngredients).values({ recipeId: testRecipeId, canonicalIngredientId: "rice_white", displayNameSv: "Ris", quantity: 400, unit: "GRAM", optional: false, sortOrder: 0, }); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM"); async function assertInvariant() { const item = await testDb.db .select({ quantity: schema.inventoryItems.quantity }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.id, 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, itemId)); const balance = computeBalance(txs); expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); } const start = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 4, leftoverEstimatePortions: 0 }, }); await assertInvariant(); await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); await assertInvariant(); // Ny session + complete efter undo ska bibehålla invarianten. const start2 = await app.inject({ method: "POST", url: `/v1/recipes/${testRecipeId}/cook/start`, headers: { authorization: `Bearer ${token}` }, payload: { startNow: true, portions: 4 }, }); const sessionId2 = (JSON.parse(start2.body) as { session: { id: string } }).session.id; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId2}/complete`, headers: { authorization: `Bearer ${token}` }, payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 }, }); await assertInvariant(); await testDb.db .delete(schema.inventoryItems) .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("legacy /cook followed by undo works", async () => { const cook = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true }, }); expect(cook.statusCode).toBe(200); const { sessionId } = JSON.parse(cook.body) as { sessionId: string }; const undo = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(undo.statusCode).toBe(200); const session = await testDb.db .select() .from(schema.cookingSessions) .where(eq(schema.cookingSessions.id, sessionId)) .limit(1); expect(session[0]!.status).toBe("undone"); const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.every((b) => b.status === "discarded")).toBe(true); }); it("undo rolls back cooking assumption profiles", async () => { await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); const recipe = (await app.inject({ method: "GET", url: `/v1/recipes/${recipeId}`, headers: { authorization: `Bearer ${token}` }, })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); const cook = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true }, }); const { sessionId } = JSON.parse(cook.body) as { sessionId: string }; await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); const profile = await testDb.db .select() .from(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, householdId), eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!), ), ) .limit(1); expect(profile.length).toBe(0); }); it("cannot undo a non-completed session", 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 undo = await app.inject({ method: "POST", url: `/v1/cooking-sessions/${sessionId}/undo`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(undo.statusCode).toBe(409); }); });