import { expect } from "chai"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../../src/lib/recipe-sources/recipe-source-errors.js"; import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js"; /** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */ function stubFetchHtml(html: string, status = 200) { globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch; } const DETAIL_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes"; /** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */ function htmlWithListNextData(recipesState: unknown): string { const payload = { props: { initialState: { recipes: recipesState } } }; return ``; } /** * A real Slate.js rich-text document (paragraph + bulleted-list of * list-items, the only block types ever observed live), JSON-stringified — * exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s * `text` field. */ const SLATE_STEP_DOCUMENT = JSON.stringify([ { type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] }, { type: "bulleted-list", children: [ { type: "list-item", children: [{ text: "Épluchez les courgettes" }] }, { type: "list-item", children: [{ text: "Coupez-les en rondelles" }] }, ], }, ]); /** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */ const RECIPE_JSON_LD_NO_YIELD = { "@context": "https://schema.org", "@type": "Recipe", name: "Salade de pâtes aux courgettes", image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"], recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }], url: DETAIL_URL, }; /** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */ function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string { const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions); return `${nextData}`; } function htmlWithDetailNextDataScript(portions: number): string { const payload = { props: { initialState: { recipe: { recipe: { portions } } } } }; return ``; } describe("mangerBougerAdapter", () => { 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(mangerBougerAdapter.key).to.equal("mangerBouger"); expect(mangerBougerAdapter.name).to.equal("Manger Bouger"); expect(mangerBougerAdapter.official).to.equal(false); expect(mangerBougerAdapter.iconUrl).to.be.a("string"); expect(mangerBougerAdapter.locale).to.equal("fr"); }); describe("list", () => { it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => { stubFetchHtml( htmlWithListNextData({ list: [ { id: "2854", slug: "2854-salade-de-pates-aux-courgettes", name: "Salade de pâtes aux courgettes", image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", }, ], hasMorePages: false, }), ); const result = await mangerBougerAdapter.list({ query: "salade" }); expect(result.items).to.deep.equal([ { externalId: DETAIL_URL, title: "Salade de pâtes aux courgettes", picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", url: DETAIL_URL, }, ]); }); it("offers a next page when hasMorePages is true, and none when false", async () => { stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true })); expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2"); stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false })); expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null; }); it("requests the given cursor's page and URL-encodes the query", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), { status: 200, }); }) as typeof fetch; await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" }); expect(requestedUrl).to.include("page=3"); expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e"); }); it("skips a list entry missing a slug or a name", async () => { stubFetchHtml( htmlWithListNextData({ list: [ { id: "1", name: "No slug", image: null }, { id: "2", slug: "no-name", image: null }, ], hasMorePages: false, }), ); const result = await mangerBougerAdapter.list({ query: "x" }); expect(result.items).to.deep.equal([]); }); it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => { stubFetchHtml("Rien ici"); const result = await mangerBougerAdapter.list({ query: "x" }); expect(result.items).to.deep.equal([]); expect(result.nextCursor).to.be.null; }); it("throws RecipeSourceFetchError on a non-2xx response", async () => { stubFetchHtml("", 500); try { await mangerBougerAdapter.list({ query: "x" }); expect.fail("expected list to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger"); } }); it("throws RecipeSourceFetchError when the network request itself fails", async () => { globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; try { await mangerBougerAdapter.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(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD)); const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL); expect(result.url).to.equal(DETAIL_URL); expect(result.html).to.include("Salade de p"); }); it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => { stubFetchHtml("", 404); try { await mangerBougerAdapter.fetchDetail(DETAIL_URL); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger"); } }); }); describe("parse", () => { it("flattens a Slate.js rich-text step into readable plain text", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), url: DETAIL_URL, }); expect(parsed.steps).to.deep.equal([ { description: "Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles", picture: null, }, ]); }); it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4), url: DETAIL_URL, }); expect(parsed.portions).to.equal(4); }); it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), url: DETAIL_URL, }); expect(parsed.portions).to.be.null; }); it("doesn't override recipeYield when the JSON-LD already states one", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4), url: DETAIL_URL, }); expect(parsed.portions).to.equal(8); }); it("leaves an already-plain-text step untouched rather than mangling it", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }], }), url: DETAIL_URL, }); expect(parsed.steps).to.deep.equal([ { description: "Faites bouillir de l'eau.", picture: null }, ]); }); it("maps name/image/ingredients end to end via the underlying generic adapter", () => { const parsed = mangerBougerAdapter.parse({ html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), url: DETAIL_URL, }); expect(parsed.name).to.equal("Salade de pâtes aux courgettes"); expect(parsed.picture).to.equal( "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", ); expect(parsed.sourceUrl).to.equal(DETAIL_URL); expect(parsed.ingredients).to.deep.equal([ { rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" }, { rawText: "4 cuillères à soupe Huile d'olive", quantity: null, unit: null, name: "4 cuillères à soupe Huile d'olive", }, ]); }); it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => { const html = "Pas de JSON-LD ici"; try { mangerBougerAdapter.parse({ html, url: DETAIL_URL }); expect.fail("expected parse to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceParseError); expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger"); } }); }); });