batchCooking/apps/api/test/house.test.ts
Nicolas b725ec1016 feat(recipes): première source concrète (TheMealDB) + icône de source
Marmiton s'est avéré inaccessible pour du scraping (bloqué même via
WebFetch, signe de protection anti-bot) — TheMealDB (themealdb.com)
est une vraie API JSON publique et gratuite, sans scraping, testée
en conditions réelles (list → fetchDetail → parse fonctionnent
bout en bout contre l'API live).

- Source.iconUrl (nullable) + RecipeSourceAdapter.iconUrl (requis,
  même convention que `official`) synchronisé par syncRecipeSources.
- apps/api/src/sources/the-meal-db.ts : premier RecipeSourceAdapter
  réel — official: true (API officielle, pas de scraping), utilise
  fetch natif (aucune dépendance ajoutée). list() fait une recherche
  (pas de vrai "browse" côté TheMealDB, mais une requête vide renvoie
  un échantillon de secours) ; parse() éclate les instructions en
  étapes par ligne et ignore les emplacements d'ingrédients vides.
- apps/api/src/sources/index.ts : registerAllRecipeSources(), appelé
  par server.ts (process réel) et prisma/seed.ts — délibérément PAS
  importé par app.ts, pour ne jamais dépendre de l'ordre des tests.
- SourceSelect (web) affiche désormais le logo de la source à côté
  de son nom.

Vérifié en conditions réelles : seed → table sources peuplée avec le
vrai logo TheMealDB ; endpoint /reference/sources sur serveur réel ;
parcours navigateur complet (onboarding → étape sources visible avec
icône chargée → activation → paramètres foyer reflète le choix).

186 tests passent (16 nouveaux, dont le moteur TheMealDB testé avec
un stub de fetch — aucun appel réseau réel dans la suite automatisée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:16:13 +02:00

437 lines
17 KiB
TypeScript

import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import type { Express } from "express";
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";
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 }),
};
}
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
iconUrl: null,
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */
async function signupAgent(app: Express) {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profile: res.body };
}
describe("Household", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /house/current", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/house/current");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns null when the profile has no household yet", async () => {
const { agent } = await signupAgent(app);
const res = await agent.get("/house/current");
expect(res.status).to.equal(200);
expect(res.body).to.equal(null);
});
it("returns the household with its admin and member list, once created", async () => {
const { agent, profile } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.get("/house/current");
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Chez Alice");
expect(res.body.adminId).to.equal(profile.id);
expect(res.body.inviteCode).to.match(/^[A-Z2-9]{8}$/);
expect(res.body.members).to.deep.equal([
{ id: profile.id, firstName: profile.firstName, lastName: profile.lastName },
]);
});
});
describe("PATCH /house/current", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).patch("/house/current").send({ name: "Chez nous" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects renaming when the profile has no household yet with 404 HOUSE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
const res = await agent.patch("/house/current").send({ name: "Chez nous" });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("renames the household", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" });
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Chez les Dupont");
const refetch = await agent.get("/house/current");
expect(refetch.body.name).to.equal("Chez les Dupont");
});
it("rejects an empty name with 400 VALIDATION_ERROR", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.patch("/house/current").send({ name: "" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("POST /house", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/house").send({ name: "Chez nous" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("creates a household with the caller as its admin", async () => {
const { agent, profile } = await signupAgent(app);
const res = await agent.post("/house").send({ name: "Chez Alice" });
expect(res.status).to.equal(201);
expect(res.body.name).to.equal("Chez Alice");
expect(res.body.adminId).to.equal(profile.id);
const me = await agent.get("/auth/me");
expect(me.body.houseId).to.equal(res.body.id);
});
it("rejects an empty name with 400 VALIDATION_ERROR", async () => {
const { agent } = await signupAgent(app);
const res = await agent.post("/house").send({ name: "" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects creating a second household with 409 ALREADY_HAS_HOUSE", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.post("/house").send({ name: "Chez Alice bis" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE);
});
});
describe("POST /house/join", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/house/join").send({ inviteCode: "ABCDEFGH" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("joins an existing household by invite code", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: joinerAgent, profile: joiner } = await signupAgent(app);
const res = await joinerAgent
.post("/house/join")
.send({ inviteCode: created.body.inviteCode });
expect(res.status).to.equal(200);
expect(res.body.id).to.equal(created.body.id);
expect(res.body.members.map((m: { id: number }) => m.id)).to.include(joiner.id);
});
it("rejects an unknown invite code with 404 INVITE_CODE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
const res = await agent.post("/house/join").send({ inviteCode: "ZZZZZZZZ" });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INVITE_CODE_NOT_FOUND);
});
it("rejects joining when the profile already belongs to a household with 409 ALREADY_HAS_HOUSE", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Bob" });
const res = await agent.post("/house/join").send({ inviteCode: created.body.inviteCode });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE);
});
});
describe("POST /house/leave", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/house/leave");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects leaving when the profile has no household with 404 HOUSE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
const res = await agent.post("/house/leave");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("deletes the household when its sole member leaves", async () => {
const { agent } = await signupAgent(app);
const created = await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.post("/house/leave");
expect(res.status).to.equal(204);
expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null);
});
it("transfers adminship to the remaining member when the admin leaves", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: memberAgent, profile: member } = await signupAgent(app);
await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode });
const res = await adminAgent.post("/house/leave");
expect(res.status).to.equal(204);
const house = await prisma.house.findUnique({ where: { id: created.body.id } });
expect(house?.adminId).to.equal(member.id);
});
});
describe("DELETE /house/current", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).delete("/house/current");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects deleting when the profile has no household with 404 HOUSE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
const res = await agent.delete("/house/current");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: memberAgent } = await signupAgent(app);
await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode });
const res = await memberAgent.delete("/house/current");
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN);
});
it("deletes the household for every member, cascading its plannings", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: memberAgent, profile: member } = await signupAgent(app);
await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode });
const planning = await prisma.planning.create({
data: {
houseId: created.body.id,
startDate: new Date(Date.UTC(2000, 0, 1)),
finishDate: new Date(Date.UTC(2000, 0, 7)),
},
});
const res = await adminAgent.delete("/house/current");
expect(res.status).to.equal(204);
expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null);
expect(await prisma.planning.findUnique({ where: { id: planning.id } })).to.equal(null);
const memberProfile = await prisma.userProfile.findUniqueOrThrow({
where: { id: member.id },
});
expect(memberProfile.houseId).to.equal(null);
});
});
describe("DELETE /house/members/:memberId", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).delete("/house/members/1");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => {
const { agent: adminAgent, profile: admin } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: memberAgent } = await signupAgent(app);
await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode });
const res = await memberAgent.delete(`/house/members/${admin.id}`);
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN);
});
it("rejects the admin trying to remove themselves with 400 VALIDATION_ERROR", async () => {
const { agent: adminAgent, profile: admin } = await signupAgent(app);
await adminAgent.post("/house").send({ name: "Chez Alice" });
const res = await adminAgent.delete(`/house/members/${admin.id}`);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("removes the targeted member from the household", async () => {
const { agent: adminAgent } = await signupAgent(app);
const created = await adminAgent.post("/house").send({ name: "Chez Alice" });
const { agent: memberAgent, profile: member } = await signupAgent(app);
await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode });
const res = await adminAgent.delete(`/house/members/${member.id}`);
expect(res.status).to.equal(200);
expect(res.body.members.map((m: { id: number }) => m.id)).to.not.include(member.id);
const memberProfile = await prisma.userProfile.findUniqueOrThrow({
where: { id: member.id },
});
expect(memberProfile.houseId).to.equal(null);
});
});
describe("GET /house/current/sources + PATCH /house/current/sources", () => {
beforeEach(() => {
clearRecipeSources();
});
afterEach(() => {
clearRecipeSources();
});
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const getRes = await request(app).get("/house/current/sources");
const patchRes = await request(app).patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(401);
expect(patchRes.status).to.equal(401);
});
it("rejects reading/writing with 404 HOUSE_NOT_FOUND when the profile has no household yet", async () => {
const { agent } = await signupAgent(app);
const getRes = await agent.get("/house/current/sources");
const patchRes = await agent.patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(404);
expect(getRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
expect(patchRes.status).to.equal(404);
expect(patchRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("starts with nothing enabled, opt-in — not just an empty array by coincidence", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.get("/house/current/sources");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("replaces (not merges) the enabled-source set, and it's readable back", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
await syncRecipeSources(prisma);
const fakeSource = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const firstPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id, otherSource.id] });
expect(firstPatch.status).to.equal(200);
expect(firstPatch.body.sort()).to.deep.equal([fakeSource.id, otherSource.id].sort());
const secondPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id] });
expect(secondPatch.status).to.equal(200);
expect(secondPatch.body).to.deep.equal([fakeSource.id]);
const res = await agent.get("/house/current/sources");
expect(res.body).to.deep.equal([fakeSource.id]);
});
it("rejects an unknown sourceId with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.patch("/house/current/sources").send({ sourceIds: [999999] });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
});
});