batchCooking/apps/api/test/house.test.ts
Nicolas 44ef5e071f feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)
Première étape du chantier "onglet Sources" (parcourir toutes les
recettes externes des sources activées par le foyer, importées ou non,
et déclencher leur import à l'ajout au planning) — celle-ci pose les
endpoints backend de lecture seule, rien n'est encore sauvegardé.

- RecipeSourceAdapter gagne `locale` (theMealDbAdapter: "en") — nécessaire
  pour que translateRecipe/matchTechStepSpans sachent contre quel jeu de
  TechStepMapping/labels d'ingrédients traduire une source donnée.
- findImportedExternalIds (recipe-source-sync.ts) devient
  findImportedRecipeIds : renvoie une Map<externalId, recipeId> au lieu
  d'un simple Set — son premier vrai appelant (le parcours) a besoin de
  l'id réel pour naviguer directement vers la recette déjà importée, pas
  seulement savoir qu'elle l'est.
- Nouveau module apps/api/src/modules/sources/ :
  - GET /sources/:sourceKey/browse — appelle list() de l'adaptateur,
    flague chaque item alreadyImported/recipeId. Restreint aux sources
    activées par le foyer courant (HouseSource) ; 404 SOURCE_NOT_FOUND
    sinon, même si la source existe (même posture que la visibilité des
    recettes : "pas trouvée" plutôt que "pas autorisée").
  - GET /sources/:sourceKey/preview/:externalId — fetchDetail + parse +
    résolution complète (translateRecipeIngredients, matchTechStepSpans
    avec spans réels) contre la locale de la source, sans rien
    sauvegarder. Ingrédients non résolus → null plutôt qu'une erreur.
- Nouveaux types partagés (packages/shared/src/types/sources.ts) :
  BrowsableSourceItemView, RecipeImportDraftView (+ Draft*View).

Vérifié en conditions réelles contre TheMealDB (recette "Chicken Handi") :
ingrédients résolus avec la bonne quantité/unité (1.2 kg de poulet, 8
gousses d'ail...), non-résolus corrects (huile végétale, piment vert),
et chaque étape avec ses techniques détectées et leurs spans exacts
(cook/fry/plate/setAside sur la même phrase, etc.).

Tests : 276 passing (+8 nouveaux, sources.test.ts). Étape suivante (2/4) :
l'UI de parcours (onglet Sources) — voir le plan de session.

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

438 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,
locale: "fr",
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);
});
});
});