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 { 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 { 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 { 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 { 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 { 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; 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; }> { 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; 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); }); }); });