Files
Cibello-app/apps/api/test/cooking-sessions.test.ts
T

297 lines
12 KiB
TypeScript

import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { 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";
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.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("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);
});
});