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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user