batchCooking/apps/api/test/sources.test.ts
Nicolas abc5b0a0e9 fix(recipes): corrige plusieurs bugs d'import TheMealDB
- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.

Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.

Closes #52, #53, #54

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 07:55:00 +02:00

402 lines
16 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 {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../src/lib/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `recipe.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 }),
};
}
/**
* A minimal, real English-content fake adapter — `parse()` deliberately
* mixes one ingredient that resolves against the real seeded catalog
* ("onion") with one that doesn't ("mystery paste"), and a step whose
* text matches a real seeded English tech-step mapping ("chop") — same
* "exercise the real catalog, not a mock of it" approach the ingredient/
* tech-step matcher tests already use.
*/
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake Source",
official: true,
iconUrl: null,
locale: "en",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" },
{ externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
if (externalId === "missing") {
throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`);
}
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Fake recipe ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "1 onion", quantity: null, unit: null, name: "onion" },
// No leading number and no recognizable unit word — exercises
// quantity/unit staying null alongside the ingredient itself not
// resolving, not just the ingredient.
{ rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" },
],
steps: [{ description: "Chop the onions finely", picture: null }],
};
},
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
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;
}
describe("Sources", () => {
const app = createApp();
/** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */
async function signupWithHouse(): Promise<{
agent: ReturnType<typeof request.agent>;
houseId: number;
}> {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
return { agent, houseId: houseRes.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
clearRecipeSources();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /sources/:sourceKey/browse", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/sources/fakeSource/browse");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
const res = await agent.get("/sources/unknown/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => {
const { agent, houseId } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const importedRecipe = await prisma.recipe.create({
data: {
name: "Already imported",
authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(200);
expect(res.body.nextCursor).to.equal(null);
expect(res.body.items).to.deep.equal([
{
externalId: "1",
title: "Onion soup",
picture: null,
url: "https://fake.test/1",
alreadyImported: true,
recipeId: importedRecipe.id,
},
{
externalId: "2",
title: "Mystery stew",
picture: null,
url: "https://fake.test/2",
alreadyImported: false,
recipeId: null,
},
]);
});
});
describe("GET /sources/:sourceKey/preview/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent };
}
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => {
const { agent } = await enableFakeSource();
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body).to.deep.include({
sourceKey: "fakeSource",
externalId: "1",
name: "Fake recipe 1",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://fake.test/1",
});
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("1 onion");
expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(unresolved.rawText).to.equal("some mystery paste");
expect(unresolved.ingredient).to.equal(null);
expect(unresolved.unit).to.equal(null);
expect(unresolved.quantity).to.equal(null);
expect(res.body.steps).to.have.length(1);
const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1);
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop");
});
it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => {
const { agent } = await enableFakeSource();
const res = await agent.get("/sources/fakeSource/preview/missing");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
describe("POST /sources/:sourceKey/import/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
sourceId: number;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent, sourceId: source.id };
}
/** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */
async function buildImportPayload() {
return {
name: "Fake recipe 1 (revue)",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
],
steps: [{ description: "Chop the onions finely" }],
};
}
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => {
const { agent, sourceId } = await enableFakeSource();
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(201);
const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } });
expect(created.sourceId).to.equal(sourceId);
expect(created.externalId).to.equal("1");
// The step text is English ("Chop the onions finely") — this only
// matches "chop" if the fake adapter's own locale ("en") was used
// for tech-step matching, not the hardcoded French default (which
// would find nothing in English text — see recipe-translation.test.ts's
// "locales are separate rule sets" test for the same point made the
// other way around).
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } });
const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } });
expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]);
});
it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => {
const { agent } = await enableFakeSource();
const first = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(first.status).to.equal(201);
const second = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => {
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients[0].ingredientId = 999_999;
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// A source's raw ingredient lines aren't deduplicated by the matcher
// (see `ingredient-matcher.ts`) — two different lines (e.g. "Egg
// Yolks" and "Eggs") can resolve to the same catalog ingredient, same
// as `recipe.test.ts`'s equivalent for a manual creation, just
// reached here through the review screen's pre-filled payload
// instead.
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients.push({ ...payload.ingredients[0] });
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
// Registers/syncs the adapter once — enableFakeSource() itself does
// this too, and registerRecipeSource() throws on a duplicate key, so
// calling it twice in one test (once per household) isn't an option.
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const { agent: firstAgent } = await signupWithHouse();
await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const firstImport = await firstAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(firstImport.status).to.equal(201);
const { agent: secondAgent } = await signupWithHouse();
await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const secondImport = await secondAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(secondImport.status).to.equal(409);
expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
});
});