Répond à deux besoins : permettre à chaque foyer de choisir quelles sources apparaissent dans ses onglets de recettes, et distinguer les sources à API officielle des sources scrapées. - RecipeSourceAdapter.official (booléen, sans défaut — chaque adaptateur doit le déclarer explicitement) synchronisé sur Source.official par syncRecipeSources. - HouseSource : table de jointure opt-in (House <-> Source) — aucune ligne = source masquée. Un foyer nouvellement créé ne voit aucune source tant qu'il ne les active pas explicitement. - GET /reference/sources (catalogue des sources implémentées, avec le flag officiel). - GET/PATCH /house/current/sources (lecture/remplacement complet des sources activées par le foyer courant). - recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS les onglets (perso/foyer/publique/favoris) — une recette sans source reste toujours visible ; une recette importée ne l'est que si sa source est activée pour le foyer du viewer. Un viewer sans foyer ne voit aucune recette sourcée. Côté web : - Nouvelle étape /onboarding/sources dans le wizard d'inscription, atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on saute direct aux allergènes) ; s'auto-saute aussi si aucune source n'est encore implémentée (catalogue vide aujourd'hui). - Nouvelle section « Sources de recettes » dans /parametres/foyer (masquée dans les mêmes conditions), avec sauvegarde à la volée (même pattern que les autres préférences hot-saved). - SourceSelect (features/house/), grille de cases à cocher avec badge officiel/non-officielle, sur le même principe qu'AllergySelect. 172 tests backend passent (dont 25 nouveaux). Build et lint propres. Vérifié manuellement en navigateur : le parcours d'onboarding saute bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un foyer a été créé ; la section paramètres reste invisible tant qu'aucune source n'existe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
891 lines
33 KiB
TypeScript
891 lines
33 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 { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
|
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|
return {
|
|
key,
|
|
name,
|
|
official: false,
|
|
async list() {
|
|
return { items: [], nextCursor: null };
|
|
},
|
|
async fetchDetail() {
|
|
throw new Error("not implemented");
|
|
},
|
|
parse() {
|
|
throw new Error("not implemented");
|
|
},
|
|
};
|
|
}
|
|
|
|
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
|
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 ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`). */
|
|
async function ingredientId(key: string): Promise<number> {
|
|
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
|
return ingredient.id;
|
|
}
|
|
|
|
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
|
|
async function unitId(key: string): Promise<number> {
|
|
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
|
return unit.id;
|
|
}
|
|
|
|
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
|
|
async function techStepId(key: string): Promise<number> {
|
|
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
|
return techStep.id;
|
|
}
|
|
|
|
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */
|
|
async function stepTechStepIds(stepId: number): Promise<number[]> {
|
|
const links = await prisma.stepTechStep.findMany({
|
|
where: { stepId },
|
|
orderBy: { order: "asc" },
|
|
select: { techStepId: true },
|
|
});
|
|
return links.map((link) => link.techStepId);
|
|
}
|
|
|
|
describe("Recipes", () => {
|
|
const app = createApp();
|
|
|
|
/** Signs up a fresh profile and returns both its session `agent` and profile id — most tests below need the id for `authorId` on directly-created fixture rows. */
|
|
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();
|
|
});
|
|
|
|
describe("GET /recipes", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).get("/recipes").query({ tab: "publique" });
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
|
|
it("rejects a missing ?tab= with 400 VALIDATION_ERROR", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.get("/recipes");
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
|
|
it("returns an empty catalog when no recipe exists yet", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.deep.equal([]);
|
|
});
|
|
|
|
it("filters the catalog by name when ?search= is given", async () => {
|
|
const { agent, profileId } = await signup();
|
|
await prisma.recipe.create({
|
|
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
await prisma.recipe.create({
|
|
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC", portions: 6 },
|
|
});
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Ratatouille"]);
|
|
});
|
|
|
|
it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const { profileId: otherId } = await signup();
|
|
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId, portions: 4 } });
|
|
await prisma.recipe.create({
|
|
data: { name: "Pas la mienne", authorId: otherId, portions: 4 },
|
|
});
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "perso" });
|
|
|
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["La mienne"]);
|
|
});
|
|
|
|
it("foyer tab only returns HOUSE recipes authored within the viewer's current house", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
const { profileId: otherId } = await signup();
|
|
const otherHouseRes = await request.agent(app).post("/house").send({ name: "Chez un autre" });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Recette du foyer",
|
|
authorId: profileId,
|
|
visibility: "HOUSE",
|
|
authorHouseId: houseRes.body.id,
|
|
portions: 4,
|
|
},
|
|
});
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Recette d'un autre foyer",
|
|
authorId: otherId,
|
|
visibility: "HOUSE",
|
|
authorHouseId: otherHouseRes.body.id,
|
|
portions: 4,
|
|
},
|
|
});
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "foyer" });
|
|
|
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Recette du foyer"]);
|
|
});
|
|
|
|
it("foyer tab is empty when the viewer has no household", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "foyer" });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.deep.equal([]);
|
|
});
|
|
|
|
it("favoris tab only returns recipes the viewer has favorited", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const favorited = await prisma.recipe.create({
|
|
data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
await prisma.recipe.create({
|
|
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
await agent.post(`/recipes/${favorited.id}/favorite`);
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "favoris" });
|
|
|
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Favorite"]);
|
|
});
|
|
|
|
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
|
|
const { agent } = await signup();
|
|
const { profileId: otherId } = await signup();
|
|
await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId, portions: 4 } });
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
|
|
|
expect(res.body).to.deep.equal([]);
|
|
});
|
|
|
|
describe("source visibility", () => {
|
|
afterEach(() => {
|
|
clearRecipeSources();
|
|
});
|
|
|
|
/** Registers+syncs a throwaway adapter and returns the `Source` row `syncRecipeSources` created for it. */
|
|
async function registerAndSyncSource(key: string) {
|
|
registerRecipeSource(buildFakeAdapter(key, key));
|
|
await syncRecipeSources(prisma);
|
|
return prisma.source.findUniqueOrThrow({ where: { key } });
|
|
}
|
|
|
|
it("always shows a manually-authored recipe, even for a household with nothing enabled", async () => {
|
|
const { agent, profileId } = await signup();
|
|
await agent.post("/house").send({ name: "Chez moi" });
|
|
await prisma.recipe.create({
|
|
data: { name: "Maison", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Maison"]);
|
|
});
|
|
|
|
it("hides a sourced recipe until the viewer's household enables that source, then shows it", async () => {
|
|
const { agent, profileId } = await signup();
|
|
await agent.post("/house").send({ name: "Chez moi" });
|
|
const source = await registerAndSyncSource("fakeSource");
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Importée",
|
|
authorId: profileId,
|
|
visibility: "PUBLIC",
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
const hidden = await agent.get("/recipes").query({ tab: "publique" });
|
|
expect(hidden.body).to.deep.equal([]);
|
|
|
|
const patchRes = await agent
|
|
.patch("/house/current/sources")
|
|
.send({ sourceIds: [source.id] });
|
|
expect(patchRes.status).to.equal(200);
|
|
|
|
const visible = await agent.get("/recipes").query({ tab: "publique" });
|
|
expect(visible.body.map((r: { name: string }) => r.name)).to.deep.equal(["Importée"]);
|
|
});
|
|
|
|
it("hides a disabled-source recipe in every tab, including the viewer's own perso and favoris", async () => {
|
|
const { agent, profileId } = await signup();
|
|
await agent.post("/house").send({ name: "Chez moi" });
|
|
const source = await registerAndSyncSource("fakeSource");
|
|
const recipe = await prisma.recipe.create({
|
|
data: {
|
|
name: "Ma recette importée",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
await prisma.recipeFavorite.create({
|
|
data: { userProfileId: profileId, recipeId: recipe.id },
|
|
});
|
|
|
|
expect((await agent.get("/recipes").query({ tab: "perso" })).body).to.deep.equal([]);
|
|
expect((await agent.get("/recipes").query({ tab: "favoris" })).body).to.deep.equal([]);
|
|
|
|
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
|
|
|
const persoAfter = await agent.get("/recipes").query({ tab: "perso" });
|
|
expect(persoAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
|
|
"Ma recette importée",
|
|
]);
|
|
const favorisAfter = await agent.get("/recipes").query({ tab: "favoris" });
|
|
expect(favorisAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
|
|
"Ma recette importée",
|
|
]);
|
|
});
|
|
|
|
it("hides every sourced recipe for a viewer with no household at all", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const source = await registerAndSyncSource("fakeSource");
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Importée",
|
|
authorId: profileId,
|
|
visibility: "PUBLIC",
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
|
expect(res.body).to.deep.equal([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POST /recipes", () => {
|
|
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const oeuf = await ingredientId("egg");
|
|
const piece = await unitId("piece");
|
|
const vegetarien = await prisma.diet.findFirstOrThrow({
|
|
where: { key: "vegetarian" },
|
|
});
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Omelette provençale",
|
|
description: "Rapide et savoureuse",
|
|
portions: 2,
|
|
dietIds: [vegetarien.id],
|
|
ingredients: [
|
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
|
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
|
],
|
|
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body.name).to.equal("Omelette provençale");
|
|
expect(res.body.portions).to.equal(2);
|
|
expect(res.body.ingredients).to.have.length(2);
|
|
expect(res.body.ingredients[0].unit.key).to.equal("piece");
|
|
expect(
|
|
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
|
).to.deep.equal([0, 1]);
|
|
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
|
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
|
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
|
|
});
|
|
|
|
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
|
const { agent } = await signup();
|
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.body.visibility).to.equal("PERSONAL");
|
|
// authorHouseId isn't in the API response, but the "foyer" tab
|
|
// proves it was stamped — a HOUSE recipe created next should show up.
|
|
const houseRecipe = await agent.post("/recipes").send({
|
|
name: "Foyer",
|
|
visibility: "HOUSE",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
|
|
expect(foyerRes.body.map((r: { id: number }) => r.id)).to.include(houseRecipe.body.id);
|
|
expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
|
|
});
|
|
|
|
it("auto-detects a step's technique from its description and persists it", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const simmer = await techStepId("simmer");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Ragoût",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Faire mijoter à feu doux pendant 30 minutes" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
// Not in the API response (see StepView) — check via Prisma directly.
|
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
|
expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]);
|
|
});
|
|
|
|
it("leaves a step's technique sequence empty when its description matches no known technique", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Servir immédiatement" }],
|
|
});
|
|
|
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
|
expect(await stepTechStepIds(step.id)).to.deep.equal([]);
|
|
});
|
|
|
|
it("detects each step's technique(s) independently, preserving order", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const simmer = await techStepId("simmer");
|
|
const chop = await techStepId("chop");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Ragoût",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [
|
|
{ description: "Hacher les oignons" },
|
|
{ description: "Servir immédiatement" },
|
|
{ description: "Faire mijoter à feu doux" },
|
|
],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
const steps = await prisma.step.findMany({
|
|
where: { recipeId: res.body.id },
|
|
orderBy: { order: "asc" },
|
|
});
|
|
expect(await Promise.all(steps.map((s) => stepTechStepIds(s.id)))).to.deep.equal([
|
|
[chop],
|
|
[],
|
|
[simmer],
|
|
]);
|
|
});
|
|
|
|
it("picks the more specific technique end-to-end when a description matches more than one", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const bake = await techStepId("bake");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Gratin",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
|
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
|
expect(await stepTechStepIds(step.id)).to.deep.equal([bake]);
|
|
});
|
|
|
|
it("detects a sequence of several distinct techniques within a single step, in reading order", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const preheat = await techStepId("preheat");
|
|
const melt = await techStepId("melt");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Poêlée",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
// The case that motivated the sequence model: one instruction, two techniques.
|
|
steps: [{ description: "Préchauffer la poêle, puis faire fondre le beurre" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
|
expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]);
|
|
});
|
|
|
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
|
const { agent } = await signup();
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [999_999],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.post("/recipes").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
|
|
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const basePayload = {
|
|
name: "Test",
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
};
|
|
|
|
const missing = await agent.post("/recipes").send(basePayload);
|
|
const zero = await agent.post("/recipes").send({ ...basePayload, portions: 0 });
|
|
|
|
expect(missing.status).to.equal(400);
|
|
expect(missing.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
expect(zero.status).to.equal(400);
|
|
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
});
|
|
|
|
describe("GET /recipes/:id", () => {
|
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.get("/recipes/999999");
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
});
|
|
|
|
it("returns the full recipe detail", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const created = await agent.post("/recipes").send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Couper" }],
|
|
});
|
|
|
|
const res = await agent.get(`/recipes/${created.body.id}`);
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.name).to.equal("Salade");
|
|
expect(res.body.portions).to.equal(4);
|
|
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
|
|
expect(res.body.ingredients[0].unit.key).to.equal("piece");
|
|
expect(res.body.isFavorite).to.equal(false);
|
|
});
|
|
|
|
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
|
|
const { agent } = await signup();
|
|
const { profileId: otherId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Secrète", authorId: otherId, portions: 4 },
|
|
});
|
|
|
|
const res = await agent.get(`/recipes/${recipe.id}`);
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
});
|
|
|
|
it("returns 200 for a PUBLIC recipe belonging to someone else", async () => {
|
|
const { agent } = await signup();
|
|
const { profileId: otherId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
const res = await agent.get(`/recipes/${recipe.id}`);
|
|
|
|
expect(res.status).to.equal(200);
|
|
});
|
|
|
|
it("returns 200 for a HOUSE recipe shared with the viewer's household, 404 otherwise", async () => {
|
|
const { agent } = await signup();
|
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
const { profileId: otherId } = await signup();
|
|
const inHouse = await prisma.recipe.create({
|
|
data: {
|
|
name: "Du foyer",
|
|
authorId: otherId,
|
|
visibility: "HOUSE",
|
|
authorHouseId: houseRes.body.id,
|
|
portions: 4,
|
|
},
|
|
});
|
|
const otherHouseId = (
|
|
await prisma.house.create({
|
|
data: { name: "Autre", adminId: otherId, inviteCode: "TESTHOUS" },
|
|
})
|
|
).id;
|
|
const outsideHouse = await prisma.recipe.create({
|
|
data: {
|
|
name: "D'un autre foyer",
|
|
authorId: otherId,
|
|
visibility: "HOUSE",
|
|
authorHouseId: otherHouseId,
|
|
portions: 4,
|
|
},
|
|
});
|
|
|
|
const inHouseRes = await agent.get(`/recipes/${inHouse.id}`);
|
|
const outsideHouseRes = await agent.get(`/recipes/${outsideHouse.id}`);
|
|
|
|
expect(inHouseRes.status).to.equal(200);
|
|
expect(outsideHouseRes.status).to.equal(404);
|
|
});
|
|
});
|
|
|
|
describe("PATCH /recipes/:id", () => {
|
|
it("replaces the recipe's whole content", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const oignon = await ingredientId("onion");
|
|
const piece = await unitId("piece");
|
|
const gram = await unitId("gram");
|
|
const created = await agent.post("/recipes").send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Couper" }],
|
|
});
|
|
|
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
name: "Salade composée",
|
|
portions: 6,
|
|
visibility: "PUBLIC",
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
|
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.name).to.equal("Salade composée");
|
|
expect(res.body.portions).to.equal(6);
|
|
expect(res.body.visibility).to.equal("PUBLIC");
|
|
expect(res.body.ingredients).to.have.length(1);
|
|
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
|
|
expect(res.body.ingredients[0].unit.key).to.equal("gram");
|
|
expect(res.body.steps).to.have.length(2);
|
|
});
|
|
|
|
it("recomputes each replaced step's technique sequence", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const mince = await techStepId("mince");
|
|
const created = await agent.post("/recipes").send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Servir immédiatement" }],
|
|
});
|
|
|
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Émincer les tomates" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(200);
|
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } });
|
|
expect(await stepTechStepIds(step.id)).to.deep.equal([mince]);
|
|
});
|
|
|
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
|
|
const res = await agent.patch("/recipes/999999").send({
|
|
name: "Test",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
|
const { agent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const created = await agent.post("/recipes").send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Couper" }],
|
|
});
|
|
|
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
name: "Salade",
|
|
portions: 4,
|
|
dietIds: [999_999],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Couper" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const { agent: otherAgent } = await signup();
|
|
const tomate = await ingredientId("tomato");
|
|
const piece = await unitId("piece");
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
|
name: "Hack",
|
|
portions: 4,
|
|
dietIds: [],
|
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
|
steps: [{ description: "Étape" }],
|
|
});
|
|
|
|
expect(res.status).to.equal(403);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /recipes/:id", () => {
|
|
it("deletes a recipe not referenced by any planning item", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "À supprimer", authorId: profileId, portions: 4 },
|
|
});
|
|
|
|
const res = await agent.delete(`/recipes/${recipe.id}`);
|
|
expect(res.status).to.equal(204);
|
|
|
|
const getRes = await agent.get(`/recipes/${recipe.id}`);
|
|
expect(getRes.status).to.equal(404);
|
|
});
|
|
|
|
it("rejects deleting a recipe still used by a planning item with 409 RECIPE_IN_USE", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Ratatouille", authorId: profileId, portions: 4 },
|
|
});
|
|
const planning = await prisma.planning.create({
|
|
data: {
|
|
houseId: houseRes.body.id,
|
|
startDate: new Date(Date.UTC(2026, 0, 1)),
|
|
finishDate: new Date(Date.UTC(2026, 0, 7)),
|
|
},
|
|
});
|
|
await prisma.planningItem.create({
|
|
data: {
|
|
planningId: planning.id,
|
|
weekDay: "lundi",
|
|
meal: "diner",
|
|
recipeId: recipe.id,
|
|
portions: 4,
|
|
},
|
|
});
|
|
|
|
const res = await agent.delete(`/recipes/${recipe.id}`);
|
|
|
|
expect(res.status).to.equal(409);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_IN_USE);
|
|
});
|
|
|
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
|
const { agent } = await signup();
|
|
|
|
const res = await agent.delete("/recipes/999999");
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects deleting someone else's recipe with 403 NOT_RECIPE_AUTHOR", async () => {
|
|
const { profileId } = await signup();
|
|
const { agent: otherAgent } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
const res = await otherAgent.delete(`/recipes/${recipe.id}`);
|
|
|
|
expect(res.status).to.equal(403);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
|
|
});
|
|
});
|
|
|
|
describe("POST/DELETE /recipes/:id/favorite", () => {
|
|
it("adds and removes a recipe from the viewer's favorites", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
|
|
expect(addRes.status).to.equal(204);
|
|
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(true);
|
|
|
|
const removeRes = await agent.delete(`/recipes/${recipe.id}/favorite`);
|
|
expect(removeRes.status).to.equal(204);
|
|
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(false);
|
|
});
|
|
|
|
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
|
|
const { agent, profileId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
|
});
|
|
|
|
await agent.post(`/recipes/${recipe.id}/favorite`);
|
|
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
|
|
|
|
expect(res.status).to.equal(204);
|
|
});
|
|
|
|
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
|
|
const { agent } = await signup();
|
|
const { profileId: otherId } = await signup();
|
|
const recipe = await prisma.recipe.create({
|
|
data: { name: "Secrète", authorId: otherId, portions: 4 },
|
|
});
|
|
|
|
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
});
|
|
});
|
|
});
|