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 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 { const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } }); return ingredient.id; } 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; 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("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 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, unit: "unité" }, { ingredientId: oeuf, quantity: 3, unit: "unité" }, ], 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.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 res = await agent.post("/recipes").send({ name: "Test", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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, unit: "unité" }], 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("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { const { agent } = await signup(); const res = await agent.post("/recipes").send({ name: "Test", portions: 4, dietIds: [], ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }], steps: [{ description: "Étape" }], }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); }); it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const res = await agent.post("/recipes").send({ name: "Test", portions: 4, dietIds: [999_999], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }], 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 basePayload = { name: "Test", dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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 created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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.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 created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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, unit: "unité" }], 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.steps).to.have.length(2); }); it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const res = await agent.patch("/recipes/999999").send({ name: "Test", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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 created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], 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, unit: "unité" }], 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 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, unit: "unité" }], 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); }); }); });