bbe7526ef3
- 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
586 lines
23 KiB
TypeScript
586 lines
23 KiB
TypeScript
import "./setup-env.js";
|
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
|
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";
|
|
import { cancelTimedOutCookingSessions } from "@app/database";
|
|
import { computeBalance } from "@app/inventory-engine";
|
|
|
|
describe("cooking sessions", () => {
|
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
|
const config = loadConfig();
|
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
|
let token: string;
|
|
let userId: string;
|
|
let householdId: string;
|
|
let recipeId: string;
|
|
const email = "cooking-session-test@example.invalid";
|
|
|
|
async function cleanup() {
|
|
const existing = await testDb.db
|
|
.select({ id: schema.users.id })
|
|
.from(schema.users)
|
|
.where(inArray(schema.users.email, [email]));
|
|
for (const u of existing) {
|
|
const sessions = await testDb.db
|
|
.select({ id: schema.cookingSessions.id })
|
|
.from(schema.cookingSessions)
|
|
.where(eq(schema.cookingSessions.startedByUserId, u.id));
|
|
for (const s of sessions) {
|
|
await testDb.db
|
|
.delete(schema.inventoryTransactions)
|
|
.where(eq(schema.inventoryTransactions.cookingSessionId, s.id));
|
|
await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id));
|
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id));
|
|
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id));
|
|
}
|
|
await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id));
|
|
const memberships = await testDb.db
|
|
.select({ householdId: schema.householdMembers.householdId })
|
|
.from(schema.householdMembers)
|
|
.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.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));
|
|
}
|
|
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
|
await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id));
|
|
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await cleanup();
|
|
app = await buildServer(config);
|
|
await app.ready();
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/auth/register",
|
|
payload: { email, password: "Password123!", displayName: "Cooking Test" },
|
|
});
|
|
token = (JSON.parse(res.body) as { accessToken: string }).accessToken;
|
|
const profile = await app.inject({ method: "GET", url: "/v1/me", headers: { authorization: `Bearer ${token}` } });
|
|
userId = (JSON.parse(profile.body) as { id: string }).id;
|
|
|
|
const quick = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/onboarding/quick-start",
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { goals: ["less_waste"], precisionMode: "simple" },
|
|
});
|
|
householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;
|
|
|
|
// Hitta ett seedat recept
|
|
const recipes = await app.inject({
|
|
method: "GET",
|
|
url: "/v1/recipes?limit=1",
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
recipeId = (JSON.parse(recipes.body) as { recipes: Array<{ id: string }> }).recipes[0]!.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
await closeDatabase();
|
|
await app.close();
|
|
});
|
|
|
|
it("creates a planned session without firing cooking_session_started", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { portions: 4, mealType: "dinner" },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body) as { session: { status: string; plannedPortions: number } };
|
|
expect(body.session.status).toBe("planned");
|
|
expect(body.session.plannedPortions).toBe(4);
|
|
|
|
const events = await testDb.db
|
|
.select({ name: schema.productAnalyticsEvents.eventName })
|
|
.from(schema.productAnalyticsEvents)
|
|
.where(eq(schema.productAnalyticsEvents.userId, userId));
|
|
expect(events.filter((e) => e.name === "cooking_session_started").length).toBe(0);
|
|
});
|
|
|
|
it("starts a planned session and fires cooking_session_started", async () => {
|
|
const start = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { portions: 2 },
|
|
});
|
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/cooking-sessions/${sessionId}/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { session: { status: string } };
|
|
expect(body.session.status).toBe("started");
|
|
|
|
const started = await testDb.db
|
|
.select()
|
|
.from(schema.productAnalyticsEvents)
|
|
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
|
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
|
expect(started.filter((e) => e.eventName === "cooking_session_started").length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("cancels a session without touching inventory", async () => {
|
|
const start = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { startNow: true },
|
|
});
|
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
|
|
|
const before = await testDb.db
|
|
.select({ count: count(schema.inventoryTransactions.id) })
|
|
.from(schema.inventoryTransactions)
|
|
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
|
|
.where(eq(schema.inventoryItems.householdId, householdId));
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/cooking-sessions/${sessionId}/cancel`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { reason: "vi åt ute" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { session: { status: string } };
|
|
expect(body.session.status).toBe("cancelled");
|
|
|
|
const after = await testDb.db
|
|
.select({ count: count(schema.inventoryTransactions.id) })
|
|
.from(schema.inventoryTransactions)
|
|
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
|
|
.where(eq(schema.inventoryItems.householdId, householdId));
|
|
expect(after[0]!.count).toBe(before[0]!.count);
|
|
});
|
|
|
|
it("completes a started session and links inventory, meals and recipe_cooks", 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, deductInventory: true },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as {
|
|
session: { status: string };
|
|
mealIds: string[];
|
|
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
|
};
|
|
expect(body.session.status).toBe("completed");
|
|
expect(body.mealIds.length).toBeGreaterThan(0);
|
|
|
|
const txCount = await testDb.db
|
|
.select({ count: count(schema.inventoryTransactions.id) })
|
|
.from(schema.inventoryTransactions)
|
|
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
|
|
expect(Number(txCount[0]!.count)).toBeGreaterThanOrEqual(body.inventoryDeductions.length);
|
|
|
|
const cookCount = await testDb.db
|
|
.select({ count: count(schema.recipeCooks.id) })
|
|
.from(schema.recipeCooks)
|
|
.where(eq(schema.recipeCooks.cookingSessionId, sessionId));
|
|
expect(Number(cookCount[0]!.count)).toBe(1);
|
|
|
|
// Transaktionsinvariant: computeBalance ska matcha item.quantity
|
|
for (const d of body.inventoryDeductions) {
|
|
const item = await testDb.db
|
|
.select({ id: schema.inventoryItems.id, quantity: schema.inventoryItems.quantity })
|
|
.from(schema.inventoryItems)
|
|
.where(eq(schema.inventoryItems.id, d.itemId))
|
|
.limit(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, d.itemId));
|
|
const balance = computeBalance(txs);
|
|
expect(Math.abs(balance.balance - (item[0]?.quantity ?? 0))).toBeLessThan(1e-6);
|
|
}
|
|
});
|
|
|
|
it("legacy POST /v1/recipes/:id/cook still works as shortcut", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null };
|
|
expect(body.ok).toBe(true);
|
|
expect(body.sessionId).toBeDefined();
|
|
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",
|
|
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; optional?: boolean }> };
|
|
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
|
expect(firstNonOptionalIngredientId).toBeDefined();
|
|
|
|
await app.inject({
|
|
method: "POST",
|
|
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 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 () => {
|
|
// complete
|
|
const startComplete = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { startNow: true },
|
|
});
|
|
const completeId = (JSON.parse(startComplete.body) as { session: { id: string } }).session.id;
|
|
await app.inject({
|
|
method: "POST",
|
|
url: `/v1/cooking-sessions/${completeId}/complete`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { mealBoxPortions: 0, deductInventory: true },
|
|
});
|
|
|
|
// cancel
|
|
const startCancel = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { startNow: true },
|
|
});
|
|
const cancelId = (JSON.parse(startCancel.body) as { session: { id: string } }).session.id;
|
|
await app.inject({
|
|
method: "POST",
|
|
url: `/v1/cooking-sessions/${cancelId}/cancel`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { reason: "test" },
|
|
});
|
|
|
|
const names = (
|
|
await testDb.db
|
|
.select({ name: schema.productAnalyticsEvents.eventName })
|
|
.from(schema.productAnalyticsEvents)
|
|
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
|
).map((r) => r.name);
|
|
expect(names).toContain("cooking_session_completed");
|
|
expect(names).toContain("cooking_session_cancelled");
|
|
});
|
|
|
|
it("times out started sessions older than 24 h", async () => {
|
|
const start = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/recipes/${recipeId}/cook/start`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { startNow: true },
|
|
});
|
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
|
|
|
// Simulera 25 h gammal session
|
|
await testDb.db
|
|
.update(schema.cookingSessions)
|
|
.set({ startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
|
|
.where(eq(schema.cookingSessions.id, sessionId));
|
|
|
|
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
|
|
expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
|
|
});
|
|
});
|