Cause racine du signalement "beaucoup d'ingredients ne sont pas linkes,
de meme pour les unites et les quantites" sur Marmiton/750g/Manger
Bouger : translateRecipe (recipe-translation.ts) ET previewSourceItem
(sources.service.ts) sautaient integralement loadIngredientCatalog/
loadUnitCatalog/translateRecipeIngredients des que locale !== "en" —
aucune tentative de matching n'etait jamais faite pour une source
francaise, pas un probleme de qualite de matching. Les trois sources
ajoutees dans cette session sont toutes locale: "fr".
Corrige en trois temps :
- packages/shared/src/data/catalog-labels-fr.ts (nouveau) :
INGREDIENT_LABELS_FR (554 entrees, copiees depuis
apps/web/src/locales/fr/translation.json qui les avait deja pour
l'UI — pas une nouvelle redaction), INGREDIENT_LABEL_SYNONYMS_FR
(mecanisme existant, pour patcher au cas par cas les libelles dont le
phrasage "affichage" ne correspond pas a l'ordre naturel d'un texte
de recette — ex. vanillaBean), UNIT_LABELS_FR (17 entrees,
redigees a la main comme UNIT_LABELS_EN — abreviations/variantes
reellement utilisees en francais : cuillere a soupe/cas/c.a.s...).
- ingredient-matcher.ts : stemWord se scinde en stemWordEn/stemWordFr
(locale parametrable, defaut "en" pour ne rien casser) — le stemmer
anglais appliquait sa regle "es" -> "" a des pluriels francais
reguliers ("carottes" -> "carott" au lieu de "carotte"), cassant
silencieusement le matching pour la quasi-totalite des ingredients
francais dont le singulier se termine par une voyelle. matchUnit est
reecrit pour chercher une sous-sequence ordonnee (comme
matchIngredientName) plutot qu'une egalite du seul premier mot : un
synonyme francais peut etre multi-mots ("cuillere a soupe"), une
phrase entiere ne pouvant jamais egaler un seul mot extrait.
loadIngredientCatalog/loadUnitCatalog prennent un parametre locale.
- recipe-translation.ts/sources.service.ts : suppression du
if (locale !== "en") qui court-circuitait tout — les catalogues sont
desormais toujours charges avec la locale de la source ; une locale
sans table de libelles recoit simplement des catalogues vides (degrade
gracieusement, ne plante pas).
Tests : 14 nouveaux tests purs (matchIngredientName/matchUnit fr,
stemmer, regression), 3 nouveaux tests DB (loadIngredientCatalog/
loadUnitCatalog fr + locale inconnue), 3 nouveaux tests
recipe-translation remplacant un test qui figeait l'ancien comportement
cassé, 1 nouveau test d'integration HTTP (sources.test.ts) avec un
adaptateur factice francais bout en bout. Les tests DB n'ont pas pu
etre executes localement (pas de Postgres/Docker dans cet environnement
sandbox) — a verifier en CI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
546 lines
22 KiB
TypeScript
546 lines
22 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-sources/recipe-source-adapter.js";
|
|
import { RecipeSourceFetchError } from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
import {
|
|
clearRecipeSources,
|
|
registerRecipeSource,
|
|
} from "../../src/lib/recipe-sources/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 }],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A minimal French-content fake adapter — same shape as {@link buildFakeAdapter},
|
|
* `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
|
|
* resolving ingredients for a non-English source through the real HTTP
|
|
* endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
|
|
* called only for `locale === "en"`, silently leaving every ingredient
|
|
* unresolved for a French source like Marmiton/750g/Manger Bouger — the
|
|
* regression this test guards against.
|
|
*/
|
|
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
|
|
return {
|
|
key,
|
|
name: "Fake French Source",
|
|
official: true,
|
|
iconUrl: null,
|
|
locale: "fr",
|
|
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
|
return {
|
|
items: [
|
|
{ externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
|
|
],
|
|
nextCursor: null,
|
|
};
|
|
},
|
|
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
|
|
return { externalId };
|
|
},
|
|
parse(raw: { externalId: string }): ParsedRecipe {
|
|
return {
|
|
name: `Recette factice ${raw.externalId}`,
|
|
description: null,
|
|
picture: null,
|
|
portions: 4,
|
|
sourceUrl: `https://fake.test/${raw.externalId}`,
|
|
ingredients: [
|
|
{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
|
|
{
|
|
rawText: "un ingrédient mystère",
|
|
quantity: null,
|
|
unit: null,
|
|
name: "ingrédient mystère",
|
|
},
|
|
],
|
|
steps: [{ description: "Faire mijoter à feu doux", 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("translates a French-locale source's item too, resolving ingredients against the French catalog (previously only 'en' sources ever got matched)", async () => {
|
|
const { agent } = await signupWithHouse();
|
|
registerRecipeSource(buildFrenchFakeAdapter());
|
|
await syncRecipeSources(prisma);
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeFrSource" } });
|
|
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
|
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
|
|
const piece = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
|
|
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
|
|
|
const res = await agent.get("/sources/fakeFrSource/preview/1");
|
|
|
|
expect(res.status).to.equal(200);
|
|
const [resolved, unresolved] = res.body.ingredients;
|
|
expect(resolved.rawText).to.equal("3 carottes");
|
|
expect(resolved.ingredient).to.deep.include({ id: carrot.id, key: "carrot" });
|
|
// No explicit unit word in "3 carottes" — falls back to the generic
|
|
// "piece" unit (see translateRecipeIngredients' own doc comment on
|
|
// issue #53), same as the English fake adapter's "1 onion" would.
|
|
expect(resolved.unit).to.deep.include({ id: piece.id, key: "piece" });
|
|
expect(resolved.quantity).to.equal(3);
|
|
expect(unresolved.rawText).to.equal("un ingrédient mystère");
|
|
expect(unresolved.ingredient).to.equal(null);
|
|
|
|
expect(res.body.steps).to.have.length(1);
|
|
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
|
id: simmer.id,
|
|
key: "simmer",
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|