import { expect } from "chai"; import type { ParsedRecipe, RecipeSourceAdapter, RecipeSourceListItem, RecipeSourceListParams, RecipeSourceListResult, } from "../src/lib/recipe-source-adapter.js"; import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js"; import { RecipeSourceError, RecipeSourceFetchError, RecipeSourceParseError, } from "../src/lib/recipe-source-errors.js"; import { clearRecipeSources, getRecipeSource, listRecipeSources, registerRecipeSource, } from "../src/lib/recipe-source-registry.js"; interface FakeRawRecipe { externalId: string; title: string; servings: number; ingredientLines: string[]; instructionLines: string[]; } const FAKE_CATALOG: FakeRawRecipe[] = [ { externalId: "1", title: "Tarte aux pommes", servings: 6, ingredientLines: ["3 pommes", "200 g de farine"], instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"], }, { externalId: "2", title: "Soupe de légumes", servings: 4, ingredientLines: ["2 carottes"], instructionLines: ["Mijoter 20 minutes"], }, { externalId: "3", title: "Salade César", servings: 2, ingredientLines: ["1 salade"], instructionLines: ["Mélanger"], }, ]; const PAGE_SIZE = 2; /** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { return { key, name: "Fake Source", official: false, async list(params: RecipeSourceListParams): Promise { const start = params.cursor ? Number(params.cursor) : 0; const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); const nextStart = start + PAGE_SIZE; return { items: page.map((recipe) => ({ externalId: recipe.externalId, title: recipe.title, picture: null, url: `https://fake.test/recipes/${recipe.externalId}`, })), nextCursor: nextStart < FAKE_CATALOG.length ? String(nextStart) : null, }; }, async fetchDetail(externalId: string): Promise { const found = FAKE_CATALOG.find((recipe) => recipe.externalId === externalId); if (!found) throw new RecipeSourceFetchError(key, `Unknown recipe ${externalId}`); return found; }, parse(raw: FakeRawRecipe): ParsedRecipe { return { name: raw.title, description: null, picture: null, portions: raw.servings, sourceUrl: `https://fake.test/recipes/${raw.externalId}`, ingredients: raw.ingredientLines.map((line) => ({ rawText: line, quantity: null, unit: null, name: line, })), steps: raw.instructionLines.map((line) => ({ description: line, picture: null })), }; }, }; } describe("recipe-source", () => { afterEach(() => { clearRecipeSources(); }); describe("registry", () => { it("registers and retrieves an adapter by key", () => { const adapter = buildFakeAdapter(); registerRecipeSource(adapter); expect(getRecipeSource("fakeSource")).to.equal(adapter); }); it("returns undefined for an unregistered key", () => { expect(getRecipeSource("unknown")).to.be.undefined; }); it("lists every registered adapter", () => { registerRecipeSource(buildFakeAdapter("fakeSource")); registerRecipeSource(buildFakeAdapter("otherSource")); expect( listRecipeSources() .map((adapter) => adapter.key) .sort(), ).to.deep.equal(["fakeSource", "otherSource"]); }); it("rejects registering the same key twice", () => { registerRecipeSource(buildFakeAdapter()); expect(() => registerRecipeSource(buildFakeAdapter())).to.throw(/already registered/); }); it("clearRecipeSources empties the registry", () => { registerRecipeSource(buildFakeAdapter()); clearRecipeSources(); expect(listRecipeSources()).to.deep.equal([]); }); }); describe("adapter contract (via a fake adapter)", () => { it("browses in pages until nextCursor is null", async () => { const adapter = buildFakeAdapter(); const firstPage = await adapter.list({}); expect(firstPage.items.map((item) => item.externalId)).to.deep.equal(["1", "2"]); expect(firstPage.nextCursor).to.equal("2"); const secondPage = await adapter.list({ cursor: firstPage.nextCursor }); expect(secondPage.items.map((item) => item.externalId)).to.deep.equal(["3"]); expect(secondPage.nextCursor).to.be.null; }); it("filters by query the same way, when the source supports it (fake adapter ignores it — only pagination is exercised here)", async () => { const adapter = buildFakeAdapter(); const res = await adapter.list({ query: "tarte" }); // Documents that `query` is a valid, optional param even though this // particular fake doesn't act on it — a real adapter would filter. expect(res.items).to.have.length(2); }); it("fetches the detail for a selected item, then parses it into a ParsedRecipe", async () => { const adapter = buildFakeAdapter(); const raw = await adapter.fetchDetail("1"); const parsed = adapter.parse(raw); expect(parsed.name).to.equal("Tarte aux pommes"); expect(parsed.description).to.be.null; expect(parsed.portions).to.equal(6); expect(parsed.sourceUrl).to.equal("https://fake.test/recipes/1"); expect(parsed.ingredients).to.have.length(2); expect(parsed.ingredients[0]).to.deep.equal({ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes", }); expect(parsed.steps).to.deep.equal([ { description: "Éplucher les pommes", picture: null }, { description: "Cuire 30 minutes", picture: null }, ]); }); it("throws RecipeSourceFetchError for an unknown externalId", async () => { const adapter = buildFakeAdapter(); try { await adapter.fetchDetail("does-not-exist"); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("fakeSource"); } }); }); describe("markAlreadyImported", () => { const items: RecipeSourceListItem[] = [ { externalId: "1", title: "Tarte aux pommes", picture: null, url: "https://fake.test/recipes/1", }, { externalId: "2", title: "Soupe de légumes", picture: null, url: "https://fake.test/recipes/2", }, { externalId: "3", title: "Salade César", picture: null, url: "https://fake.test/recipes/3" }, ]; it("flags items whose externalId is in the imported set, leaves the rest false", () => { const result = markAlreadyImported(items, new Set(["1", "3"])); expect( result.map((item) => ({ externalId: item.externalId, alreadyImported: item.alreadyImported, })), ).to.deep.equal([ { externalId: "1", alreadyImported: true }, { externalId: "2", alreadyImported: false }, { externalId: "3", alreadyImported: true }, ]); }); it("flags nothing when the imported set is empty", () => { const result = markAlreadyImported(items, new Set()); expect(result.every((item) => item.alreadyImported === false)).to.be.true; }); it("returns an empty list unchanged", () => { expect(markAlreadyImported([], new Set(["1"]))).to.deep.equal([]); }); it("preserves every field from the original item alongside the new flag", () => { const [first] = markAlreadyImported([items[0]], new Set(["1"])); expect(first).to.deep.equal({ ...items[0], alreadyImported: true }); }); it("doesn't mutate the input items", () => { const snapshot = structuredClone(items); markAlreadyImported(items, new Set(["1"])); expect(items).to.deep.equal(snapshot); }); }); describe("RecipeSourceError hierarchy", () => { it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => { const cause = new Error("network down"); const err = new RecipeSourceFetchError("fakeSource", "could not reach source", { cause }); expect(err).to.be.instanceOf(Error); expect(err).to.be.instanceOf(RecipeSourceError); expect(err.name).to.equal("RecipeSourceFetchError"); expect(err.sourceKey).to.equal("fakeSource"); expect(err.message).to.equal("could not reach source"); expect(err.cause).to.equal(cause); }); it("RecipeSourceParseError carries the source key and works without a cause", () => { const err = new RecipeSourceParseError("fakeSource", "unexpected shape"); expect(err).to.be.instanceOf(RecipeSourceError); expect(err.name).to.equal("RecipeSourceParseError"); expect(err.sourceKey).to.equal("fakeSource"); expect(err.cause).to.be.undefined; }); }); });