feat(3d): meal-box merge, stored mutations, undo, mobile undo UI + i18n ×12

- Merge leftovers into existing meal_box when same recipeId + cookedAt date + frozen + available.
- Store mealBoxMutations JSONB on cooking_sessions for deterministic undo.
- Undo decrements portions/remaining, discards box at zero, appends correction ledger rows.
- Mobile: undo button in cooking/[id].tsx after-flow and meal-boxes.tsx within 24h window.
- i18n undo strings across all 12 locales; parity test green.
- 6 new integration tests: merge, no cross-date merge, frozen split, undo restore, undo discard, ledger invariant.
- Update FAS3 audit doc with 3d semantics.

Closes Fas 3d
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 17:04:42 +07:00
parent c1ab2c8f37
commit 74f95daab2
26 changed files with 610 additions and 305 deletions
+221
View File
@@ -1307,4 +1307,225 @@ describe("cooking sessions", () => {
});
expect(undo.statusCode).toBe(409);
});
it("3d: leftovers merge into existing meal box with same recipe, date and frozen state", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const cook1 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
});
expect(cook1.statusCode).toBe(200);
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1));
expect(boxes1.length).toBe(1);
const boxId = boxes1[0]!.id;
const cook2 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
});
expect(cook2.statusCode).toBe(200);
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
expect(boxes2[0]!.portions).toBe(3);
expect(boxes2[0]!.portionsRemaining).toBe(3);
const sessions = await testDb.db
.select({ mutations: schema.cookingSessions.mealBoxMutations })
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, sessionId2));
expect(sessions[0]!.mutations).toEqual([{ mealBoxId: boxId, deltaPortions: 2, frozen: false }]);
});
it("3d: leftovers do not merge across cooking dates", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const cook1 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true },
});
expect(cook1.statusCode).toBe(200);
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
const cook2 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
});
expect(cook2.statusCode).toBe(200);
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
const allBoxes = await testDb.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
),
);
expect(allBoxes.length).toBe(2);
});
it("3d: fridge and freezer leftovers do not merge", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const cook1 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
});
expect(cook1.statusCode).toBe(200);
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
const cook2 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true },
});
expect(cook2.statusCode).toBe(200);
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
const boxes = await testDb.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
),
);
expect(boxes.length).toBe(2);
expect(boxes.filter((b) => b.frozen).length).toBe(1);
expect(boxes.filter((b) => !b.frozen).length).toBe(1);
});
it("3d: undo restores merged meal box portions", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const cook1 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
});
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id;
const cook2 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
});
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
expect(beforeUndo[0]!.portions).toBe(3);
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId2}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(200);
const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
expect(afterUndo[0]!.portions).toBe(1);
expect(afterUndo[0]!.portionsRemaining).toBe(1);
expect(afterUndo[0]!.status).toBe("available");
});
it("3d: undo discards a meal box created solely by the session", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const cook = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
});
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 boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
expect(boxes[0]!.status).toBe("discarded");
expect(boxes[0]!.portionsRemaining).toBe(0);
});
it("3d: ledger invariant holds after merge and undo", async () => {
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
const itemId = await createItemWithPurchase("carrot", "morot", 10, "COUNT");
const cook1 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
});
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
const cook2 = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
});
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
const assertItemBalance = async () => {
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);
const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1);
expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6);
};
await assertItemBalance();
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId2}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
await assertItemBalance();
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId1}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
await assertItemBalance();
});
});