diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index 7a5dbd5..db1d32a 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -97,7 +97,13 @@ export async function completeCookingSession( app, session, userId, - { ...input, portionsCooked: plannedPortions, mealBoxPortions }, + { + ...input, + portionsCooked: plannedPortions, + mealBoxPortions, + actualPortionsEaten, + leftoverEstimatePortions, + }, correlationId, ); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index 59e0de5..ae0f092 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -1030,6 +1030,94 @@ describe("cooking sessions", () => { 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)