batchCooking/apps/api/test/cooking-session.test.ts
Nicolas 9532ea4e83
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
fix(tests): corrige 3 suites Mocha DB revelees par leur 1er run sur main
Ces suites ont ete ecrites pendant le dev des features cooking / admin /
hors-catalogue mais jamais executees (pas de Postgres dans ces sessions).
Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas
dans le code merge.

- reference.test.ts : `GET /reference/ingredients` renvoie desormais
  `isPlaceholder` (toujours false) et `displayName` (toujours null) depuis
  la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])`
  exacte est mise a jour.
- cooking-session.test.ts : la fixture "pooling merged-prep" avait 2
  recettes symetriques (chop -> simmer) ; apres mise en commun du chop les
  deux simmer tournent dans l'unique phase de cuisson, donc aucun
  `background` possible (l'optimiseur est correct, cf. le test pur
  equivalent). « Tarte » recoit une etape active `mix` de plus pour que son
  simmer flotte en background pendant que « Soupe » est en hold.
- admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/
  `.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc
  le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de
  409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant
  l'attente de 100 ms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:00:43 +02:00

214 lines
7.5 KiB
TypeScript

import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
describe("Cooking session", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /cooking-session", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty plan when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes).to.deep.equal([]);
expect(res.body.phases).to.deep.equal([]);
});
it("returns an empty plan when no planning covers that week", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.phases).to.deep.equal([]);
});
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const pieceId = await unitId("piece");
const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer");
const mixId = await techStepId("mix");
/**
* A recipe: one pure-prep "chop onion" step, then (optionally) an
* active "mix" step, then one simmer step. The `withActiveStep` recipe
* is still doing hands-on work in the phase after its simmer starts, so
* the other recipe's simmer floats into that phase as `background`.
*/
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
const chopStep = {
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
};
const activeStep = {
order: 1,
description: "Mélanger l'appareil",
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
};
const simmerStep = {
order: withActiveStep ? 2 : 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
};
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3, true);
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: tarte.id,
portions: 4,
},
],
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
"Soupe",
"Tarte",
]);
const mise = res.body.phases[0];
expect(mise.kind).to.equal("mise-en-place");
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
expect(merged).to.have.length(1);
expect(merged[0].technique.key).to.equal("chop");
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
expect(merged[0].ingredients[0].quantity).to.equal(5);
expect(merged[0].sourceRecipes).to.have.length(2);
// The simmer steps land in a later phase, and one shows as background.
const later = res.body.phases.slice(1);
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0);
});
});
});