batchCooking/apps/api/test/sources.test.ts
Nicolas a53e8aff4a fix(recipes): concatène les ingrédients dupliqués à l'import
Suite au retour utilisateur sur #53 (follow-up) : au lieu de bloquer
l'import et de demander à l'utilisateur de retirer une ligne en double
à la main, deux lignes source qui résolvent vers le même ingrédient
catalogue sont désormais fusionnées automatiquement, quantité
concaténée (sommée), avant même que l'écran de revue ne s'affiche.

- mergeDuplicateIngredients (recipe-translation.ts) : même unité des
  deux côtés -> somme directe. Unité différente mais même UnitType
  (MASS/VOLUME) -> conversion via toBaseFactor avant de sommer, exprimée
  dans l'unité de la première ligne. UnitType différent, ou COUNT des
  deux côtés (une "pincée" n'est pas une fraction fixe d'une "gousse",
  cf. le commentaire de UnitView) -> jamais fusionnées, laissées en
  double (createRecipeSchema/RecipeImportForm continuent de les
  signaler, filet de sécurité déjà en place). Les lignes non résolues
  (ingredientId: null) ne sont jamais fusionnées entre elles.
- rawText concaténé ("100g Sugar + 45g Sugar") pour la traçabilité.
- Branché dans previewSourceItem (sources.service.ts), juste après
  translateRecipeIngredients — c'est le seul endroit où des doublons
  peuvent apparaître (la création manuelle ne peut pas en produire,
  IngredientPicker exclut déjà les ingrédients déjà sélectionnés).

Vérifié via l'API en local (import réel de "Flan" depuis TheMealDB) :
"100g Sugar"/"45g Sugar" -> une seule ligne Sucre, 145g.

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

463 lines
18 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 }],
};
},
};
}
/**
* A fake adapter whose two ingredient lines both resolve to the same real
* seeded ingredient ("onion"), in the same unit (grams) — exercises
* `previewSourceItem`'s duplicate-merging (`mergeDuplicateIngredients`,
* see issue #53's follow-up) through the real HTTP endpoint/catalog,
* rather than only as a pure unit test of the merge function itself.
*/
function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSourceAdapter<{
externalId: string;
}> {
return {
key,
name: "Fake Source With Duplicates",
official: true,
iconUrl: null,
locale: "en",
async list(): Promise<RecipeSourceListResult> {
return { items: [], nextCursor: null };
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
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: "100g Onion", quantity: null, unit: null, name: "onion" },
{ rawText: "50g Onion", quantity: null, unit: null, name: "onion" },
],
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);
});
it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildDuplicateIngredientAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({
where: { key: "duplicateFakeSource" },
});
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const gram = await prisma.unit.findFirstOrThrow({ where: { key: "gram" } });
const res = await agent.get("/sources/duplicateFakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body.ingredients).to.have.length(1);
const [merged] = res.body.ingredients;
expect(merged.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(merged.unit).to.deep.include({ id: gram.id, key: "gram" });
expect(merged.quantity).to.equal(150);
expect(merged.rawText).to.equal("100g Onion + 50g Onion");
});
});
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);
});
});
});