import { expect } from "chai"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../../src/lib/recipe-sources/recipe-source-errors.js"; import { sevenFiftyGAdapter } from "../../src/sources/750g.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; } const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm"; /** * A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately * reproducing two real 750g.com bugs verified live on the recipe this test's * URL/content is modeled after: * - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid * JSON as-is — this is exactly what {@link sanitizeJsonLdBlocks} in the * adapter under test has to repair before `JSON.parse` can succeed); * - `Préparez` — a real "é" that went through 750g's own * HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to * fully resolve it back to "é"). * Plus a plain `'` apostrophe entity in an ingredient line, the more * common single-encoding case. */ const RAW_RECIPE_JSON_LD = `{ "@context": "https://schema.org", "@type": "Recipe", "name": "Poulet au vin jaune et aux morilles", "description": "Une recette de f\\u00eate.", "image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"}, "recipeYield": "6 personnes", "recipeIngredient": ["1 poulet fermier", "Sel 'fin'"], "recipeInstructions": [ {"@type": "HowToStep", "text": "Préparez les morilles :\r\nFendez-les en deux."} ], "url": "${RECIPE_URL}" }`; function htmlWithRawJsonLd(rawJson: string): string { return ``; } describe("sevenFiftyGAdapter", () => { 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(sevenFiftyGAdapter.key).to.equal("750g"); expect(sevenFiftyGAdapter.name).to.equal("750g"); expect(sevenFiftyGAdapter.official).to.equal(false); expect(sevenFiftyGAdapter.iconUrl).to.be.a("string"); expect(sevenFiftyGAdapter.locale).to.equal("fr"); }); describe("list", () => { /** * Models the real shape found live: a card's own `` sits * immediately before its ``, but the fragment also * carries decorative images that belong to no card at all (verified * live: 28 `` tags against 23 real cards for one sample query) — * an image search that isn't "nearest preceding, not naive same-index * zip" would misattribute every card after the first stray image. */ const CARDS_HTML = `
Tarte Tarte aux pommes
Gratin Gratin dauphinois
Pain perdu
`; it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => { stubFetchHtml(CARDS_HTML); const result = await sevenFiftyGAdapter.list({ query: "tarte" }); expect(result.items).to.deep.equal([ { externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm", title: "Tarte aux pommes", picture: "https://static.750g.com/images/x/tarte.jpg", url: "https://www.750g.com/tarte-aux-pommes-r1.htm", }, { externalId: "https://www.750g.com/gratin-dauphinois-r2.htm", title: "Gratin dauphinois", picture: "https://static.750g.com/images/x/gratin.jpg", url: "https://www.750g.com/gratin-dauphinois-r2.htm", }, { externalId: "https://www.750g.com/pain-perdu-r3.htm", title: "Pain perdu", picture: null, url: "https://www.750g.com/pain-perdu-r3.htm", }, ]); }); it("decodes HTML entities in a card's title", async () => { stubFetchHtml( `Tarte aux pommes 'reinettes'`, ); const result = await sevenFiftyGAdapter.list({ query: "tarte" }); expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'"); }); it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => { stubFetchHtml(CARDS_HTML); const result = await sevenFiftyGAdapter.list({ query: "tarte" }); expect(result.nextCursor).to.be.null; }); it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response("", { status: 200 }); }) as typeof fetch; await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" }); expect(requestedUrl).to.include("page=1"); expect(requestedUrl).not.to.include("page=7"); }); it("URL-encodes the query", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response("", { status: 200 }); }) as typeof fetch; await sevenFiftyGAdapter.list({ query: "tarte aux pommes" }); expect(requestedUrl).to.include("query=tarte%20aux%20pommes"); }); describe("empty/omitted query (browsing with no filter)", () => { it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response(CARDS_HTML, { status: 200 }); }) as typeof fetch; const result = await sevenFiftyGAdapter.list({}); expect(requestedUrl).to.include("dernieres-recettes.htm"); expect(requestedUrl).not.to.include("genius/query"); expect(result.items).to.have.length(3); }); it("also browses for an explicitly empty query string, not just an omitted one", async () => { stubFetchHtml(CARDS_HTML); const result = await sevenFiftyGAdapter.list({ query: "" }); expect(result.items).to.have.length(3); }); it("requests the given cursor's page", async () => { let requestedUrl: string | undefined; globalThis.fetch = (async (url: string) => { requestedUrl = url; return new Response(CARDS_HTML, { status: 200 }); }) as typeof fetch; await sevenFiftyGAdapter.list({ cursor: "5" }); expect(requestedUrl).to.include("page=5"); }); it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => { stubFetchHtml(CARDS_HTML); const withItems = await sevenFiftyGAdapter.list({ cursor: "2" }); expect(withItems.nextCursor).to.equal("3"); stubFetchHtml("Plus rien ici"); const empty = await sevenFiftyGAdapter.list({ cursor: "50" }); expect(empty.nextCursor).to.be.null; expect(empty.items).to.deep.equal([]); }); }); it("throws RecipeSourceFetchError on a non-2xx response", async () => { stubFetchHtml("", 500); try { await sevenFiftyGAdapter.list({ query: "x" }); expect.fail("expected list to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g"); } }); it("throws RecipeSourceFetchError when the network request itself fails", async () => { globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; try { await sevenFiftyGAdapter.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(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD)); const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL); expect(result.url).to.equal(RECIPE_URL); expect(result.html).to.include("Poulet au vin jaune"); }); it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => { stubFetchHtml("", 404); try { await sevenFiftyGAdapter.fetchDetail(RECIPE_URL); expect.fail("expected fetchDetail to throw"); } catch (err) { expect(err).to.be.instanceOf(RecipeSourceFetchError); expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g"); } }); }); describe("parse", () => { it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => { const parsed = sevenFiftyGAdapter.parse({ html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), url: RECIPE_URL, }); expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles"); expect(parsed.steps).to.deep.equal([ { description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null }, ]); }); it("decodes a double HTML-entity-encoded accented character (é -> é -> é)", () => { const parsed = sevenFiftyGAdapter.parse({ html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), url: RECIPE_URL, }); expect(parsed.steps[0]?.description).to.include("Préparez"); }); it("decodes a plain numeric apostrophe entity in ingredient text", () => { const parsed = sevenFiftyGAdapter.parse({ html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), url: RECIPE_URL, }); expect(parsed.ingredients).to.deep.equal([ { rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" }, { rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" }, ]); }); it("leaves picture/sourceUrl untouched by entity decoding", () => { const parsed = sevenFiftyGAdapter.parse({ html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), url: RECIPE_URL, }); expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg"); expect(parsed.sourceUrl).to.equal(RECIPE_URL); }); it("maps a recipe with no quirks end to end, same as the generic adapter would", () => { const clean = { "@context": "https://schema.org", "@type": "Recipe", name: "Tarte aux pommes", description: "Une tarte classique.", image: "https://static.750g.com/images/tarte.jpg", recipeYield: 6, recipeIngredient: ["3 pommes", "1 pâte brisée"], recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."], }; const html = ``; const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL }); expect(parsed.name).to.equal("Tarte aux pommes"); expect(parsed.portions).to.equal(6); expect(parsed.steps).to.deep.equal([ { description: "Éplucher les pommes.", picture: null }, { description: "Enfourner 30 minutes.", picture: null }, ]); }); it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => { const html = "Pas de JSON-LD ici"; try { sevenFiftyGAdapter.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("750g"); } }); }); });