import { expect } from "chai"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../src/lib/recipe-sources/recipe-source-errors.js"; import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js"; /** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */ function stubFetchHtml(html: string, status = 200) { globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch; } /** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one ``) .join("\n"); return `${scripts}`; } const RECIPE_URL = "https://example.test/recipes/apple-pie"; const baseRecipe = { "@context": "https://schema.org", "@type": "Recipe", name: "Apple Pie", description: "A classic apple pie.", image: "https://example.test/apple-pie.jpg", recipeYield: 8, recipeIngredient: ["6 apples, peeled", "200g flour", " "], recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."], }; describe("jsonLdRecipeAdapter", () => { let originalFetch: typeof fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("declares itself as an unofficial, iconless, browse-less source", () => { expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe"); expect(jsonLdRecipeAdapter.official).to.equal(false); expect(jsonLdRecipeAdapter.iconUrl).to.be.null; }); describe("list", () => { it("always returns an empty page — this source has no catalog of its own", async () => { const result = await jsonLdRecipeAdapter.list({ query: "anything" }); expect(result).to.deep.equal({ items: [], nextCursor: null }); }); }); describe("fetchDetail", () => { it("fetches the given URL and returns its html alongside the url", async () => { stubFetchHtml(htmlWithJsonLd(baseRecipe)); const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); expect(result.url).to.equal(RECIPE_URL); expect(result.html).to.include("Apple Pie"); }); it("throws RecipeSourceFetchError on a non-2xx response", async () => { stubFetchHtml("", 404); try { await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); expect.fail("expected fetchDetail 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 jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); } }); }); describe("parse", () => { function parse(html: string, url = RECIPE_URL) { return jsonLdRecipeAdapter.parse({ html, url }); } it("maps a straightforward JSON-LD Recipe end to end", () => { const parsed = parse(htmlWithJsonLd(baseRecipe)); expect(parsed.name).to.equal("Apple Pie"); expect(parsed.description).to.equal("A classic apple pie."); expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg"); expect(parsed.portions).to.equal(8); expect(parsed.sourceUrl).to.equal(RECIPE_URL); expect(parsed.steps).to.deep.equal([ { description: "Peel the apples.", picture: null }, { description: "Bake at 180°C for 40 minutes.", picture: null }, ]); }); it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => { const parsed = parse(htmlWithJsonLd(baseRecipe)); expect(parsed.ingredients).to.deep.equal([ { rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" }, { rawText: "200g flour", quantity: null, unit: null, name: "200g flour" }, ]); }); it("prefers the JSON-LD's own url over the fetched url, when present", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }), ); expect(parsed.sourceUrl).to.equal("https://example.test/canonical"); }); it("accepts @type as an array containing Recipe", () => { const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] })); expect(parsed.name).to.equal("Apple Pie"); }); it("finds the Recipe nested under @graph", () => { const graphPayload = { "@context": "https://schema.org", "@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe], }; const parsed = parse(htmlWithJsonLd(graphPayload)); expect(parsed.name).to.equal("Apple Pie"); }); it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => { const html = htmlWithJsonLd( { "@type": "BreadcrumbList", itemListElement: [] }, "{ this is not valid json", JSON.stringify(baseRecipe), ); const parsed = parse(html); expect(parsed.name).to.equal("Apple Pie"); }); describe("recipeInstructions shapes", () => { it("splits a single free-text string on line breaks", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, recipeInstructions: "Step one.\nStep two.\n\nStep three.", }), ); expect(parsed.steps).to.deep.equal([ { description: "Step one.", picture: null }, { description: "Step two.", picture: null }, { description: "Step three.", picture: null }, ]); }); it("reads HowToStep objects' text field", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [ { "@type": "HowToStep", text: "Peel the apples." }, { "@type": "HowToStep", text: "Bake." }, ], }), ); expect(parsed.steps).to.deep.equal([ { description: "Peel the apples.", picture: null }, { description: "Bake.", picture: null }, ]); }); it("falls back to a HowToStep's name when it has no text", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }], }), ); expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]); }); it("flattens HowToSections into their nested steps, dropping the section name", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [ { "@type": "HowToSection", name: "Filling", itemListElement: [ { "@type": "HowToStep", text: "Peel the apples." }, { "@type": "HowToStep", text: "Slice them." }, ], }, { "@type": "HowToSection", name: "Baking", itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }], }, ], }), ); expect(parsed.steps).to.deep.equal([ { description: "Peel the apples.", picture: null }, { description: "Slice them.", picture: null }, { description: "Bake at 180°C.", picture: null }, ]); }); it("throws RecipeSourceParseError when there are no usable instructions", () => { expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw( RecipeSourceParseError, ); expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })), ).to.throw(RecipeSourceParseError); }); }); describe("image shapes", () => { it("reads a bare string image", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }), ); expect(parsed.picture).to.equal("https://example.test/a.jpg"); }); it("reads the first entry of an array of ImageObjects", () => { const parsed = parse( htmlWithJsonLd({ ...baseRecipe, image: [ { "@type": "ImageObject", url: "https://example.test/large.jpg" }, { "@type": "ImageObject", url: "https://example.test/small.jpg" }, ], }), ); expect(parsed.picture).to.equal("https://example.test/large.jpg"); }); it("is null when there's no image at all", () => { const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined })); expect(parsed.picture).to.be.null; }); }); describe("recipeYield shapes", () => { it("accepts a plain number", () => { expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4); }); it("extracts the leading integer from a free-text string", () => { expect( parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions, ).to.equal(4); }); it("reads the first entry of an array", () => { expect( parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions, ).to.equal(6); }); it("is null when absent or unparseable", () => { expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be .null; expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null; }); }); describe("failure cases", () => { it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => { expect(() => parse("No structured data here")).to.throw( RecipeSourceParseError, ); }); it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => { const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] }); expect(() => parse(html)).to.throw(RecipeSourceParseError); }); it("throws RecipeSourceParseError when the Recipe has no name", () => { const html = htmlWithJsonLd({ ...baseRecipe, name: undefined }); expect(() => parse(html)).to.throw(RecipeSourceParseError); }); }); }); });