Files
Cibello-app/apps/api/test/cooking-sessions.test.ts
T
Sven (AAMOS AI) c1ab2c8f37 3c-fix: wrapper passes defaulted portions to core
completeCookingSession now forwards actualPortionsEaten and
leftoverEstimatePortions (after applying wrapper defaults) into
completeCookingSessionCore, so API callers omitting leftovers get
correct 100% deduction when mealBoxPortions is provided instead of
core's fallback to 0. Added regression test with leftovers omitted.
2026-08-07 15:51:02 +07:00

1311 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReturnType<typeof buildServer>>;
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);
});
});