fix(3b): delad kompletteringsväg för legacy /cook + deterministiska cooking-assumptions
- cookRecipeInputSchema utökas med actualPortionsEaten, leftoverEstimatePortions, leftoverNote - completeCookingSession-wrapper i apps/api/src/lib/cooking.ts hanterar summavalidering, persistens av svar, profiluppdateringar och analytics (started+completed) för båda vägarna - Legacy POST /v1/recipes/:id/cook använder wrappern med emitStartedEvent - GET /v1/recipes/:id/cooking-assumptions väljer deterministiskt bland icke-valfria ingredienser - i18n: nyckel cooked.portionsSumExceedsPlanned i samtliga 12 lokaler + server-i18n - Nya tester för legacy /cook och cooking-assumptions med valfri första ingrediens Refs: steg 3b-fix, granskningsrunda 2026-08-07
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import "./setup-env.js";
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { and, eq, inArray, count } from "drizzle-orm";
|
||||
import { and, eq, inArray, count, sql } from "drizzle-orm";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||
@@ -235,6 +235,115 @@ describe("cooking sessions", () => {
|
||||
expect(body.mealBoxId).toBeDefined();
|
||||
});
|
||||
|
||||
it("legacy /cook stores actual portions and leftover estimate on the session row", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
actualPortionsEaten: 2,
|
||||
leftoverEstimatePortions: 1,
|
||||
leftoverNote: "sparas i kylen",
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
|
||||
|
||||
const session = await testDb.db
|
||||
.select()
|
||||
.from(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.id, sessionId))
|
||||
.limit(1);
|
||||
expect(session[0]!.actualPortionsEaten).toBe(2);
|
||||
expect(session[0]!.leftoverEstimatePortions).toBe(1);
|
||||
expect(session[0]!.leftoverNote).toBe("sparas i kylen");
|
||||
});
|
||||
|
||||
it("legacy /cook updates 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();
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
|
||||
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(1);
|
||||
expect(profile[0]!.observationCount).toBe(1);
|
||||
expect(profile[0]!.averageEatenPortions).toBe(3);
|
||||
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
||||
});
|
||||
|
||||
it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => {
|
||||
const before = await testDb.db
|
||||
.select({ name: schema.productAnalyticsEvents.eventName })
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId));
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, deductInventory: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
|
||||
|
||||
const after = await testDb.db
|
||||
.select()
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
||||
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
||||
const newEvents = after.slice(before.length);
|
||||
const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
|
||||
const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId);
|
||||
const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId);
|
||||
expect(started.length).toBe(1);
|
||||
expect(completed.length).toBe(1);
|
||||
});
|
||||
|
||||
it("legacy /cook rejects when eaten + leftovers exceed planned portions", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 0,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 2,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("stores actual portions and leftover estimate on complete", async () => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
@@ -289,8 +398,9 @@ describe("cooking sessions", () => {
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string }> };
|
||||
const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId;
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -299,22 +409,118 @@ describe("cooking sessions", () => {
|
||||
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
|
||||
});
|
||||
|
||||
if (firstIngredientId) {
|
||||
const profile = await testDb.db
|
||||
.select()
|
||||
.from(schema.cookingAssumptionProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
||||
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
expect(profile.length).toBe(1);
|
||||
expect(profile[0]!.observationCount).toBe(1);
|
||||
expect(profile[0]!.averageEatenPortions).toBe(2);
|
||||
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
||||
}
|
||||
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(1);
|
||||
expect(profile[0]!.observationCount).toBe(1);
|
||||
expect(profile[0]!.averageEatenPortions).toBe(2);
|
||||
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
||||
});
|
||||
|
||||
it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
// Skapa ett recept där den första ingrediensen är valfri.
|
||||
const [recipe] = await testDb.db
|
||||
.insert(schema.recipes)
|
||||
.values({
|
||||
slug: `optional-first-${Date.now()}`,
|
||||
titleSv: "Testrecept valfri först",
|
||||
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: "olive_oil",
|
||||
displayNameSv: "Olivolja",
|
||||
quantity: 1,
|
||||
unit: "TABLESPOON",
|
||||
optional: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
recipeId: testRecipeId,
|
||||
canonicalIngredientId: "pasta_dry",
|
||||
displayNameSv: "Pasta",
|
||||
quantity: 320,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
sortOrder: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Completa en session och skriv profil för den icke-valfria ingrediensen.
|
||||
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: 3, leftoverEstimatePortions: 1 },
|
||||
});
|
||||
|
||||
const assumptions = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${testRecipeId}/cooking-assumptions`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(assumptions.statusCode).toBe(200);
|
||||
const body = JSON.parse(assumptions.body) as {
|
||||
defaultActualPortionsEaten: number | null;
|
||||
defaultLeftoverEstimatePortions: number | null;
|
||||
observationCount: number;
|
||||
};
|
||||
expect(body.observationCount).toBe(1);
|
||||
expect(body.defaultActualPortionsEaten).toBe(3);
|
||||
expect(body.defaultLeftoverEstimatePortions).toBe(1);
|
||||
|
||||
// Städa upp testreceptet.
|
||||
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("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
|
||||
|
||||
Reference in New Issue
Block a user