From bc456973b7810570eef352a61fa2b06c0745d9a1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 22 Aug 2026 16:12:04 +0200 Subject: [PATCH] feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Marmiton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étend jsonLdRecipeAdapter (json-ld-recipe.ts) plutôt que de dupliquer sa logique : marmitonAdapter délègue fetchDetail/parse directement à l'adaptateur générique JSON-LD (une page recette marmiton.org expose un Recipe schema.org standard), et n'ajoute que ce que l'adaptateur générique ne peut pas offrir — un list() qui lit l'ItemList schema.org embarqué sur la page de résultats de recherche de marmiton.org (pagination via &page=N, fin de résultats détectée via la réponse 404 renvoyée au-delà de la dernière page). extractJsonLdBlocks est exporté depuis json-ld-recipe.ts pour être réutilisé par marmiton.ts sans dupliquer le regex d'extraction des blocs `; +} + +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"); + } + }); + }); +});