3c-fix: partial consumption now deducts eaten + leftovers
- consumptionPortions = min(planned, actualPortionsEaten + leftoverEstimatePortions). Meal boxes are a subset of leftovers; raw inventory is only retained for portions that were never cooked. - Added validation leftoverEstimatePortions >= mealBoxPortions with localized 400 error (cooked.leftoverLessThanBox) in server i18n + all 12 locales. - Removed unused COOKING_SESSION_STATUSES / MEAL_BOX_STATUSES imports from apps/api/src/lib/cooking.ts. - Hardened test cleanup to delete mealBoxes/meals/recipeCooks/cookingSessions by household before dropping storageLocations. - New regression tests: meal-box double-counting, eaten+leftover split, and leftover < box rejection. - Updated FAS3-COOKING-SESSIONS-AUDIT.md with corrected physics semantics.
This commit is contained in:
@@ -73,6 +73,10 @@ describe("cooking sessions", () => {
|
||||
.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));
|
||||
@@ -808,7 +812,7 @@ describe("cooking sessions", () => {
|
||||
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
|
||||
});
|
||||
|
||||
// 4 portioner → 400 g, 2 ätna → 200 g, alltså 200 g kvar.
|
||||
// 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)
|
||||
@@ -839,6 +843,193 @@ describe("cooking sessions", () => {
|
||||
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("undo + new complete maintains inventory invariant", async () => {
|
||||
const [recipe] = await testDb.db
|
||||
.insert(schema.recipes)
|
||||
|
||||
Reference in New Issue
Block a user