3c: partial consumption + undo for cooking sessions
- Append-only undo via correction transactions tied to cookingSessionId. - POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry). - Complete undo scope: meals removed, meal boxes discarded, recipe_cooks deleted, cookCount decremented; activation milestones left intact. - Deterministic assumption-profile rollback via lastSessionAnswers.sessionId; profile deleted when observationCount reaches 0. - New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES. - Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED, MEAL_REMOVED, COOKING_SESSION_UNDONE. - Partial consumption uses actualPortionsEaten/plannedPortions factor. - i18n coverage for cooked.undoWindowExpired across all 12 locales + server. - Invariant tests: computeBalance(transactions) === item.quantity after complete, undo, and undo + new complete; transaction count increases on undo. - Migration 0014_expand_event_types.sql adds new enum values. - Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
This commit is contained in:
@@ -6,6 +6,7 @@ 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!);
|
||||
@@ -17,6 +18,36 @@ describe("cooking sessions", () => {
|
||||
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 })
|
||||
@@ -582,4 +613,419 @@ describe("cooking sessions", () => {
|
||||
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 → 200 g, alltså 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("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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user