All checks were successful
`RecipeDiet` (tag manuel) est supprimé — table droppée, `dietIds` retiré de `createRecipeSchema`, `DietTagSelect` retiré des formulaires de recette (création + import). `RecipeSummaryView.diets` / `RecipeView.diets` sont désormais **dérivés** via `computeRecipeDietViews` (`@batch-cooking/shared`, pur, testé isolément) : - un régime (vegetarian / vegan / pescatarian) est retenu ssi *tous* les ingrédients y sont compatibles (`IngredientDiet`, exposé par `IngredientView.diets`) ; - `glutenFree` est dérivé des allergènes (aucun ingrédient ne porte `gluten`) — cohérent avec le fait que le seed ne le stocke pas dans `IngredientDiet` ; - `[]` dès qu'une recette contient un ingrédient placeholder (hors-catalogue) : un ingrédient inconnu ne certifie rien. Filtres du sélecteur de planning (`suitableForHousehold`, `dietIds`) : ne peuvent plus être du SQL sur une colonne stockée — devenus des post-filtres sur `diets` calculé dans `listRecipes` ; `omnivore` traité comme « aucune contrainte ». `assertDietsExist` / `ErrorCode.DIET_NOT_FOUND` sur la création recette supprimés (le code d'erreur reste, utilisé par la préférence profil). `DietBadges` inchangé (affiche `diets` tel quel). `RecipePickerDialog` garde son filtre régime (fonctionne via le post-filtre). Tests : `recipe-diets.test.ts` (fonction pure) + `recipe.test.ts` réécrit (régimes calculés attendus, tests DIET_NOT_FOUND retirés) ; Cypress OK (formulaire sans sélecteur de régime, régression recettes/sources/planning). Migration `DROP TABLE recipe_diet` — aucune donnée à conserver. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
7 KiB
TypeScript
189 lines
7 KiB
TypeScript
import type { SignupInput } from "@batch-cooking/shared";
|
|
import { ErrorCode } 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 { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/** See `auth.test.ts` — generated, never a real-looking person. */
|
|
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 }),
|
|
};
|
|
}
|
|
|
|
/** Resolves a reference unit's id by its seed uid (also its DB `key`). */
|
|
async function unitId(key: string): Promise<number> {
|
|
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
|
|
/** Resolves a reference ingredient's id by its seed uid. */
|
|
async function ingredientId(key: string): Promise<number> {
|
|
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
|
|
/**
|
|
* The off-catalog ("placeholder") ingredient escape hatch on `POST /recipes`
|
|
* / `PATCH /recipes/:id` — a line with `placeholderName` instead of
|
|
* `ingredientId` creates a dedicated `Ingredient` row
|
|
* (`isPlaceholder: true`) so the recipe still saves, and that row is
|
|
* surfaced (with its `displayName`) inside the recipe but never in the
|
|
* browsable catalog.
|
|
*/
|
|
describe("Recipes — off-catalog placeholder ingredients", () => {
|
|
const app = createApp();
|
|
|
|
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
|
|
const agent = request.agent(app);
|
|
const res = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
return { agent, profileId: res.body.id };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it("creates a placeholder Ingredient row for a `placeholderName` line and returns it inside the recipe", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const piece = await unitId("piece");
|
|
const tomato = await ingredientId("tomato");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Poulet basquaise",
|
|
portions: 4,
|
|
ingredients: [
|
|
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
|
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
|
],
|
|
steps: [{ description: "Tout mélanger" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body.ingredients).to.have.length(2);
|
|
|
|
const placeholderLine = res.body.ingredients.find(
|
|
(line: { ingredient: { isPlaceholder: boolean } }) => line.ingredient.isPlaceholder,
|
|
);
|
|
expect(placeholderLine, "a placeholder line is present").to.not.equal(undefined);
|
|
expect(placeholderLine.ingredient.displayName).to.equal("Piment d'Espelette");
|
|
expect(placeholderLine.ingredient.key).to.match(/^placeholder:/);
|
|
expect(placeholderLine.ingredient.allergens).to.deep.equal([]);
|
|
expect(placeholderLine.quantity).to.equal(1);
|
|
|
|
const row = await prisma.ingredient.findUniqueOrThrow({
|
|
where: { id: placeholderLine.ingredient.id },
|
|
});
|
|
expect(row.isPlaceholder).to.equal(true);
|
|
expect(row.displayName).to.equal("Piment d'Espelette");
|
|
expect(row.createdById).to.equal(profileId);
|
|
expect(row.createdAt).to.be.an.instanceOf(Date);
|
|
expect(row.reviewedAt).to.equal(null);
|
|
});
|
|
|
|
it("never lists placeholder rows in GET /reference/ingredients", async () => {
|
|
const { agent } = await signup();
|
|
const piece = await unitId("piece");
|
|
|
|
await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 2,
|
|
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
|
|
const reference = await agent.get("/reference/ingredients");
|
|
expect(reference.status).to.equal(200);
|
|
expect(
|
|
reference.body.some(
|
|
(i: { isPlaceholder?: boolean; displayName?: string }) =>
|
|
i.isPlaceholder === true || i.displayName === "Feuille de combava",
|
|
),
|
|
).to.equal(false);
|
|
});
|
|
|
|
it("reuses the existing placeholder row on edit (no duplicate) and drops it when the line is removed", async () => {
|
|
const { agent } = await signup();
|
|
const piece = await unitId("piece");
|
|
const tomato = await ingredientId("tomato");
|
|
|
|
const created = await agent.post("/recipes").send({
|
|
name: "Édition",
|
|
portions: 2,
|
|
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
const placeholderId = created.body.ingredients[0].ingredient.id;
|
|
|
|
// Re-submit the same recipe, keeping the placeholder line by its real id.
|
|
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
name: "Édition",
|
|
portions: 2,
|
|
ingredients: [
|
|
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
|
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
|
],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
expect(edited.status).to.equal(200);
|
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
|
|
|
// Now edit again, dropping the placeholder line entirely.
|
|
await agent.patch(`/recipes/${created.body.id}`).send({
|
|
name: "Édition",
|
|
portions: 2,
|
|
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
// The row is now an orphan (kept on purpose — the admin catalog view
|
|
// prunes it), but no *new* placeholder was created.
|
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
|
});
|
|
|
|
it("rejects a line carrying both ingredientId and placeholderName with 400", async () => {
|
|
const { agent } = await signup();
|
|
const piece = await unitId("piece");
|
|
const tomato = await ingredientId("tomato");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Invalide",
|
|
portions: 2,
|
|
ingredients: [
|
|
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
|
],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
|
|
it("allows two placeholder lines with the same text (each becomes its own row)", async () => {
|
|
const { agent } = await signup();
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Doublons libres",
|
|
portions: 2,
|
|
ingredients: [
|
|
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
|
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
|
],
|
|
steps: [{ description: "x" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body.ingredients).to.have.length(2);
|
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(2);
|
|
});
|
|
});
|