import { expect } from "chai"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../../src/lib/recipe-sources/recipe-source-errors.js"; import { marmitonAdapter } from "../../src/sources/marmiton.js"; /** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */ function stubFetchHtml(html: string, status = 200) { globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch; } /** * Wraps a schema.org `ItemList` payload (already an object, not yet * stringified) in a minimal HTML page carrying it as one * ``; } const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx"; const baseListItem = { "@type": "ListItem", position: 1, url: RECIPE_URL, name: "Tarte aux pommes", image: "https://assets.afcdn.com/recipe/tarte.jpg", }; const baseRecipeJsonLd = { "@context": "https://schema.org", "@type": "Recipe", name: "Tarte aux pommes", description: "Une tarte aux pommes classique.", image: "https://assets.afcdn.com/recipe/tarte.jpg", recipeYield: "6 personnes", recipeIngredient: ["3 pommes", "1 pâte brisée"], recipeInstructions: [ { "@type": "HowToStep", text: "Épluchez les pommes." }, { "@type": "HowToStep", text: "Enfournez 30 minutes." }, ], }; function htmlWithRecipeJsonLd(): string { return ``; } describe("marmitonAdapter", () => { let originalFetch: typeof fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("declares itself as an unofficial, French-locale source with an icon", () => { expect(marmitonAdapter.key).to.equal("marmiton"); expect(marmitonAdapter.name).to.equal("Marmiton"); expect(marmitonAdapter.official).to.equal(false); expect(marmitonAdapter.iconUrl).to.be.a("string"); expect(marmitonAdapter.locale).to.equal("fr"); }); describe("list", () => { it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => { stubFetchHtml(htmlWithItemListJsonLd([baseListItem])); const result = await marmitonAdapter.list({ query: "tarte aux pommes" }); expect(result.items).to.deep.equal([ { externalId: RECIPE_URL, title: "Tarte aux pommes", picture: "https://assets.afcdn.com/recipe/tarte.jpg", url: RECIPE_URL, }, ]); expect(result.nextCursor).to.equal("2"); }); it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response(htmlWithItemListJsonLd([]), { status: 200 }); }) as typeof fetch; const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" }); expect(requestedUrl).to.include("page=3"); expect(result.items).to.deep.equal([]); expect(result.nextCursor).to.be.null; }); it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => { stubFetchHtml("", 404); const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" }); expect(result.items).to.deep.equal([]); expect(result.nextCursor).to.be.null; }); it("skips a ListItem missing a url or a name", async () => { stubFetchHtml( htmlWithItemListJsonLd([ { "@type": "ListItem", position: 1, name: "No url" }, { "@type": "ListItem", position: 2, url: RECIPE_URL }, ]), ); const result = await marmitonAdapter.list({ query: "x" }); expect(result.items).to.deep.equal([]); }); it("returns an empty page rather than throwing when the page has no ItemList at all", async () => { stubFetchHtml("Rien ici"); const result = await marmitonAdapter.list({ query: "x" }); expect(result.items).to.deep.equal([]); expect(result.nextCursor).to.be.null; }); it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => { stubFetchHtml("", 500); try { await marmitonAdapter.list({ query: "x" }); expect.fail("expected list to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); } }); it("throws RecipeSourceFetchError when the network request itself fails", async () => { globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; try { await marmitonAdapter.list({ query: "x" }); expect.fail("expected list to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); } }); }); describe("fetchDetail", () => { it("fetches the given recipe URL and returns its html alongside the url", async () => { stubFetchHtml(htmlWithRecipeJsonLd()); const result = await marmitonAdapter.fetchDetail(RECIPE_URL); expect(result.url).to.equal(RECIPE_URL); expect(result.html).to.include("Tarte aux pommes"); }); it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => { stubFetchHtml("", 404); try { await marmitonAdapter.fetchDetail(RECIPE_URL); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton"); } }); }); describe("parse", () => { it("delegates to the generic JSON-LD parser end to end", () => { const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL }); expect(parsed.name).to.equal("Tarte aux pommes"); expect(parsed.portions).to.equal(6); expect(parsed.sourceUrl).to.equal(RECIPE_URL); expect(parsed.ingredients).to.deep.equal([ { rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" }, { rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" }, ]); expect(parsed.steps).to.deep.equal([ { description: "Épluchez les pommes.", picture: null }, { description: "Enfournez 30 minutes.", picture: null }, ]); }); it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => { const html = "Pas de JSON-LD ici"; try { marmitonAdapter.parse({ html, url: RECIPE_URL }); expect.fail("expected parse to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceParseError); expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton"); } }); }); });