Fas 3 steg 3b: minimala efterfrågor + hushållsantagandeprofiler per ingrediens

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 03:56:41 +07:00
parent 44f6385dce
commit e46281b9b5
24 changed files with 10130 additions and 5 deletions
+84 -1
View File
@@ -1,6 +1,6 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray, count } from "drizzle-orm";
import { and, eq, inArray, count } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
@@ -43,6 +43,7 @@ describe("cooking sessions", () => {
for (const m of memberships) {
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.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));
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
}
@@ -234,6 +235,88 @@ describe("cooking sessions", () => {
expect(body.mealBoxId).toBeDefined();
});
it("stores actual portions and leftover estimate on complete", 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 res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } };
expect(body.session.actualPortionsEaten).toBe(2);
expect(body.session.leftoverEstimatePortions).toBe(1);
});
it("rejects complete when eaten + leftovers exceed planned 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 res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2 },
});
expect(res.statusCode).toBe(400);
});
it("updates cooking assumption profiles per household and ingredient", async () => {
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
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 recipe = (await app.inject({
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;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
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);
}
});
it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
// complete
const startComplete = await app.inject({