import { expect } from "chai"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../../src/lib/recipe-sources/recipe-source-errors.js"; import { ohMonBentoAdapter } from "../../src/sources/ohmonbento.js"; /** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`quitoque.test.ts`. */ function stubFetchHtml(html: string, status = 200) { globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch; } /** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s search/pagination URL. */ function stubFetchHtmlCapturing(html: string): { calls: string[] } { const calls: string[] = []; globalThis.fetch = (async (input: string | URL | Request) => { calls.push(typeof input === "string" ? input : input.toString()); return new Response(html, { status: 200 }); }) as typeof fetch; return { calls }; } /** A search-results page with `cards` `
` blocks, each an `` wrapping one ``. */ function searchHtml(cards: { slug: string; title: string; image: string }[]): string { const blocks = cards .map( (card) => `
` + `${card.title}
`, ) .join("\n"); return `
${blocks}
`; } /** A recipe page: a WP Recipe Maker `Recipe` inside a Yoast `@graph` array. */ function detailHtml(): string { const payload = { "@context": "https://schema.org", "@graph": [ { "@type": "WebSite", name: "Oh mon Bento" }, { "@type": "BreadcrumbList" }, { "@type": "Recipe", name: "Kamameshi", description: "Riz japonais mijoté en cocotte.", image: "https://ohmonbento.com/wp-content/uploads/kamameshi.jpg", recipeYield: ["2", "2 portions"], recipeIngredient: ["300 g de riz rond japonais (Japonica)", "1 c. à soupe de sauce soja"], recipeInstructions: [ { "@type": "HowToStep", name: "Rincer le riz", text: "Rincez le riz à l'eau claire." }, { "@type": "HowToStep", name: "Cuire", text: "Faites cuire à couvert 12 minutes." }, ], }, ], }; return ``; } describe("ohMonBentoAdapter", () => { 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(ohMonBentoAdapter.key).to.equal("ohMonBento"); expect(ohMonBentoAdapter.name).to.equal("Oh mon Bento"); expect(ohMonBentoAdapter.official).to.equal(false); expect(ohMonBentoAdapter.iconUrl).to.be.a("string"); expect(ohMonBentoAdapter.locale).to.equal("fr"); }); describe("list", () => { it("scrapes each entry-thumb card into a RecipeSourceListItem and offers a next page", async () => { stubFetchHtml( searchHtml([ { slug: "kamameshi", title: "Kamameshi", image: "https://img.test/1.jpg" }, { slug: "onigiri", title: "Onigiri", image: "https://img.test/2.jpg" }, ]), ); const result = await ohMonBentoAdapter.list({}); expect(result.items).to.deep.equal([ { externalId: "https://ohmonbento.com/kamameshi/", title: "Kamameshi", picture: "https://img.test/1.jpg", url: "https://ohmonbento.com/kamameshi/", }, { externalId: "https://ohmonbento.com/onigiri/", title: "Onigiri", picture: "https://img.test/2.jpg", url: "https://ohmonbento.com/onigiri/", }, ]); expect(result.nextCursor).to.equal("2"); }); it("treats a 404 (past the last page) as the clean end of the list", async () => { stubFetchHtml("", 404); const result = await ohMonBentoAdapter.list({ cursor: "99" }); expect(result.items).to.deep.equal([]); expect(result.nextCursor).to.be.null; }); it("uses the same `/?s=` search URL for an empty query, with no `paged` on page 1", async () => { const { calls } = stubFetchHtmlCapturing( searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]), ); await ohMonBentoAdapter.list({}); expect(calls).to.deep.equal(["https://ohmonbento.com/?s="]); }); it("passes the query and page through to `/?s={query}&paged={n}`", async () => { const { calls } = stubFetchHtmlCapturing( searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]), ); await ohMonBentoAdapter.list({ query: "bento box", cursor: "3" }); expect(calls).to.deep.equal(["https://ohmonbento.com/?s=bento%20box&paged=3"]); }); it("throws RecipeSourceFetchError keyed 'ohMonBento' on a non-404 non-2xx response", async () => { stubFetchHtml("", 500); try { await ohMonBentoAdapter.list({}); expect.fail("expected list to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento"); } }); }); describe("fetchDetail / parse", () => { it("parses a Yoast @graph-wrapped WPRM Recipe into a ParsedRecipe", async () => { stubFetchHtml(detailHtml()); const raw = await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/kamameshi/"); const parsed = ohMonBentoAdapter.parse(raw); expect(parsed.name).to.equal("Kamameshi"); // recipeYield: ["2", "2 portions"] -> first element, regex \d+ -> 2. expect(parsed.portions).to.equal(2); expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([ "300 g de riz rond japonais (Japonica)", "1 c. à soupe de sauce soja", ]); expect(parsed.steps.map((s) => s.description)).to.deep.equal([ "Rincez le riz à l'eau claire.", "Faites cuire à couvert 12 minutes.", ]); }); it("re-keys a delegated RecipeSourceFetchError to 'ohMonBento' when the detail page 404s", async () => { stubFetchHtml("", 404); try { await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/gone/"); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento"); } }); it("re-keys a delegated RecipeSourceParseError to 'ohMonBento' when a page has no Recipe JSON-LD", () => { try { ohMonBentoAdapter.parse({ html: "rien", url: "https://ohmonbento.com/vide/", }); expect.fail("expected parse to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceParseError); expect((err as RecipeSourceParseError).sourceKey).to.equal("ohMonBento"); } }); }); });