From 5ea102615171fb6f682a51a60ad0fce9361cc057 Mon Sep 17 00:00:00 2001 From: kyuno053 <31762247+kyuno053@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:01:46 +0200 Subject: [PATCH] feat(recipes): adaptateurs RecipeSourceAdapter pour Marmiton, 750g et Manger Bouger (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Marmiton É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 ` block — + * same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its + * own private copy here rather than sharing that module's export: this one + * does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever + * sees it (see {@link sanitizeJsonLdBlocks} below), a different concern + * from extracting-and-parsing blocks into objects. + */ +const JSON_LD_SCRIPT_PATTERN = + /(]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi; + +/** + * Escapes any raw (unescaped) JSON control character — U+0000–U+001F — + * found *inside* a string literal of `json`, leaving everything outside + * string literals (structural whitespace, brackets, …) untouched. Fixes a + * real bug in 750g.com's own JSON-LD generator: some `HowToStep.text` + * values contain a literal, un-escaped `\r\n` where valid JSON requires + * `\\r\\n` (verified live, e.g. + * https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and + * roughly a third of a random sample of recipe pages hit this) — + * `JSON.parse` throws "Bad control character in string literal" on these + * pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report + * "no JSON-LD Recipe found" on a page that has a perfectly good one. + * + * A blind find/replace across the whole block would be wrong: JSON also + * uses real newlines as *structural* whitespace between tokens + * (pretty-printing), where they're perfectly legal and must be left alone — + * only walking the text with string-literal awareness (tracking `"…"` + * boundaries and `\`-escapes) can tell the two apart. + */ +function escapeRawControlCharactersInStrings(json: string): string { + const SHORT_ESCAPES: Record = { + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + }; + + let result = ""; + let inString = false; + let escapedNext = false; + for (const ch of json) { + if (!inString) { + if (ch === '"') inString = true; + result += ch; + continue; + } + if (escapedNext) { + result += ch; + escapedNext = false; + continue; + } + if (ch === "\\") { + result += ch; + escapedNext = true; + continue; + } + if (ch === '"') { + inString = false; + result += ch; + continue; + } + if (ch < " ") { + result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; + continue; + } + result += ch; + } + return result; +} + +/** + * Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD + * `` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */ +const NEXT_DATA_PATTERN = /` block + * a mangerbouger.fr recipe *detail* page carries (verified live across a + * sample of 9 recipes — always exactly one, always a bare `Recipe`, never + * an `@graph`) — a much narrower pattern than `json-ld-recipe.ts`'s own + * `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the + * first/only block, to patch it — see {@link patchRecipeJsonLd}) or + * `750g.ts`'s identically-named private copy (which does its own, + * different, character-level repair over every block on the page). + */ +const JSON_LD_SCRIPT_PATTERN = + /(]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i; + +/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */ +interface SlateNode { + type?: string; + text?: string; + children?: SlateNode[]; +} + +/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */ +function flattenSlateInline(nodes: SlateNode[]): string { + return nodes + .map((node) => + typeof node.text === "string" + ? node.text + : node.children + ? flattenSlateInline(node.children) + : "", + ) + .join(""); +} + +/** + * Flattens a Slate.js document's top-level blocks into one line of plain + * text each — verified live across every recipe step sampled (72 recipes): + * only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as + * block types, so that's all this handles; any other/unrecognized block + * type still degrades reasonably (its own children read as one inline run) + * rather than being dropped outright. + */ +function flattenSlateBlocks(nodes: SlateNode[]): string[] { + const lines: string[] = []; + for (const node of nodes) { + if (node.type === "bulleted-list" && node.children) { + lines.push(...flattenSlateBlocks(node.children)); + continue; + } + if (node.type === "list-item" && node.children) { + const text = flattenSlateInline(node.children); + if (text.trim().length > 0) lines.push(`- ${text}`); + continue; + } + if (node.children) { + const text = flattenSlateInline(node.children); + if (text.trim().length > 0) lines.push(text); + continue; + } + if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text); + } + return lines; +} + +/** + * Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's + * own JSON-LD embeds this field pre-formatted for its own web app instead + * of as prose: `text` is itself a JSON-serialized Slate.js rich-text + * document (verified live: every one of 72 sampled recipe steps parses as + * one) — handing that straight to `jsonLdRecipeAdapter.parse` would surface + * the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's + * description, unusable as-is. `json` that doesn't parse as an array (a + * genuinely plain-text step, or some future/different shape) is returned + * unchanged rather than mangled. + */ +function flattenSlateDocument(json: string): string { + let doc: unknown; + try { + doc = JSON.parse(json); + } catch { + return json; + } + if (!Array.isArray(doc)) return json; + return flattenSlateBlocks(doc as SlateNode[]).join("\n"); +} + +/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */ +interface JsonLdRecipeLike { + recipeInstructions?: unknown; + recipeYield?: unknown; + [key: string]: unknown; +} + +/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */ +interface JsonLdHowToStepLike { + text?: unknown; + [key: string]: unknown; +} + +/** + * `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__` + * — the number `recipeYield` should have been (see {@link patchRecipeJsonLd}), + * read from the site's own internal state rather than left unstated. + */ +function extractPortionsFromNextData(html: string): number | null { + const data = extractNextData(html); + const portions = data?.props?.initialState?.recipe?.recipe?.portions; + return typeof portions === "number" ? portions : null; +} + +/** + * Repairs the two real gaps verified live in mangerbouger.fr's own + * recipe-detail JSON-LD, then hands the patched HTML to + * `jsonLdRecipeAdapter.parse` unmodified otherwise — same "fix what's + * actually broken, delegate the rest" shape as `750g.ts`'s + * `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse → + * mutate → re-serialize the one JSON-LD object) rather than textual, since + * both gaps need real understanding of the document, not character-level + * fixups: + * + * - `recipeInstructions[].text` is Slate.js rich text, not prose — flattened + * via {@link flattenSlateDocument}. + * - `recipeYield` is absent on every one of 9 sampled recipes (schema.org + * allows omitting it, and mangerbouger.fr's generator apparently always + * does) even though the site's own internal data has the serving count + * right there — backfilled from `__NEXT_DATA__` via + * {@link extractPortionsFromNextData} rather than left as a needless + * `portions: null` on every single imported recipe. + * + * A missing or malformed JSON-LD block is left completely untouched — + * `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block, + * skip it" handling is exactly the right behavior for that, no need to + * duplicate it here. + */ +function patchRecipeJsonLd(html: string): string { + const match = html.match(JSON_LD_SCRIPT_PATTERN); + if (!match) return html; + + let recipe: JsonLdRecipeLike; + try { + recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike; + } catch { + return html; + } + + if (Array.isArray(recipe.recipeInstructions)) { + for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) { + if (step && typeof step === "object" && typeof step.text === "string") { + step.text = flattenSlateDocument(step.text); + } + } + } + + if (recipe.recipeYield === undefined) { + const portions = extractPortionsFromNextData(html); + if (portions !== null) recipe.recipeYield = portions; + } + + const patchedJson = JSON.stringify(recipe); + return html.replace( + JSON_LD_SCRIPT_PATTERN, + (_full, openTag: string, _json: string, closeTag: string) => + `${openTag}${patchedJson}${closeTag}`, + ); +} + +/** + * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by + * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having + * come from this adapter instead (`sourceKey` `"mangerBouger"`) — same + * reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers. + */ +function rekeySourceError(err: unknown): unknown { + if (err instanceof RecipeSourceFetchError) { + return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause }); + } + if (err instanceof RecipeSourceParseError) { + return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause }); + } + return err; +} + +/** + * mangerbouger.fr ("La Fabrique à Menus") — Santé publique France's public + * nutrition site. Unofficial (`official: false`): no published API, same + * reasoning as every other adapter in this family — fetching ordinary pages + * and reading data the site never committed to a stable contract, not a + * maintained endpoint. `fetchDetail` delegates straight to + * `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd} + * (see that function's doc comment for the two real gaps it fixes). + * `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment. + */ +export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = { + key: SOURCE_KEY, + name: "Manger Bouger", + official: false, + // Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources + // de cette famille) — répond correctement sans paramètre supplémentaire. + iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico", + // Le contenu de mangerbouger.fr (noms, ingrédients, instructions) est en + // français — détermine contre quel modèle/locale d'étiquettes + // d'ingrédients translateRecipe (recipe-translation.ts) résout les + // recettes de cette source lors d'une prévisualisation/d'un import. + locale: "fr", + + async list(params: RecipeSourceListParams): Promise { + try { + const page = params.cursor ? Number(params.cursor) : 1; + const query = params.query ?? ""; + const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`; + + let response: Response; + try { + response = await fetch(listUrl); + } catch (cause) { + throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, { + cause, + }); + } + if (!response.ok) { + throw new RecipeSourceFetchError( + SOURCE_KEY, + `mangerbouger.fr responded ${response.status} (${listUrl})`, + ); + } + const html = await response.text(); + + const data = extractNextData(html); + const state = data?.props?.initialState?.recipes; + + const items: RecipeSourceListItem[] = (state?.list ?? []) + .filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } => + Boolean(entry.slug && entry.name), + ) + .map((entry) => ({ + externalId: detailUrl(entry.slug), + title: entry.name, + picture: entry.image ?? null, + url: detailUrl(entry.slug), + })); + + return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null }; + } catch (err) { + // Rethrown as-is (already keyed "mangerBouger" by whichever branch + // above threw it) — this adapter's only caller (`sources.service.ts`) + // already handles/logs failures centrally; this method just isn't + // allowed a bare `async` body without a try/catch per the repo's + // convention. Same reasoning as `marmiton.ts`/`750g.ts`. + throw err; + } + }, + + // `externalId` est directement l'URL canonique de la recette sur + // mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même + // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode + // délègue entièrement : la réparation du JSON-LD (voir + // `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici. + async fetchDetail(externalId: string): Promise<{ html: string; url: string }> { + try { + return await jsonLdRecipeAdapter.fetchDetail(externalId); + } catch (err) { + // Pas un simple re-throw : `rekeySourceError` est le traitement utile + // que ce point d'appel doit faire de l'erreur (relabelliser sa + // `sourceKey`), conformément à la convention await/try-catch du repo. + throw rekeySourceError(err); + } + }, + + parse(raw: { html: string; url: string }): ParsedRecipe { + try { + const patchedHtml = patchRecipeJsonLd(raw.html); + return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url }); + } catch (err) { + throw rekeySourceError(err); + } + }, +}; diff --git a/apps/api/src/sources/marmiton.ts b/apps/api/src/sources/marmiton.ts new file mode 100644 index 0000000..57f72fe --- /dev/null +++ b/apps/api/src/sources/marmiton.ts @@ -0,0 +1,206 @@ +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListItem, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../lib/recipe-sources/recipe-source-adapter.js"; +import { + RecipeSourceFetchError, + RecipeSourceParseError, +} from "../lib/recipe-sources/recipe-source-errors.js"; +import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js"; + +const SOURCE_KEY = "marmiton"; +const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx"; + +/** + * One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as + * JSON-LD on its search-results pages — the subset this adapter reads. Also + * what a search whose term happens to match a known ingredient (e.g. + * `aqt=poulet`) actually returns: marmiton.org silently serves its + * ingredient-index page instead of a "search results" page for those terms, + * but that page embeds the exact same `ItemList` shape, so `list()` doesn't + * need to tell the two apart. + */ +interface MarmitonListItem { + "@type"?: string; + url?: string; + name?: string; + image?: string; +} + +/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */ +interface MarmitonItemList { + "@type"?: string; + "@graph"?: unknown[]; + itemListElement?: MarmitonListItem[]; +} + +/** + * Finds the first `ItemList` node within one parsed JSON-LD block — mirrors + * `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type + * nodes, `@graph` wrapper) but looks for the results listing marmiton.org's + * search page embeds instead of a `Recipe`. + */ +function findItemListNode(node: unknown): MarmitonItemList | null { + if (node === null || typeof node !== "object") return null; + if (Array.isArray(node)) { + for (const item of node) { + const found = findItemListNode(item); + if (found) return found; + } + return null; + } + const obj = node as MarmitonItemList; + if (obj["@type"] === "ItemList") return obj; + if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]); + return null; +} + +/** + * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by + * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having + * come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/ + * `parse` below are thin wrappers around the generic adapter's own methods + * (see this module's doc comment) — but a caller catching `RecipeSourceError` + * and reading `.sourceKey` to attribute a failure to a specific `Source` + * should see "marmiton", the source it actually asked about, not the + * internal implementation detail this adapter happens to be built on. + * Anything else (a bug, an unexpected throw) is passed through unchanged — + * only the vocabulary this module documents gets relabeled. + */ +function rekeySourceError(err: unknown): unknown { + if (err instanceof RecipeSourceFetchError) { + return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause }); + } + if (err instanceof RecipeSourceParseError) { + return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause }); + } + return err; +} + +/** + * marmiton.org — France's largest recipe site. Unofficial (`official: + * false`): there's no published API, this adapter fetches ordinary pages and + * reads the schema.org structured data marmiton.org embeds for search + * engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first + * concrete, per-site adapter that generic adapter's own doc comment + * anticipated ("a concrete adapter for a specific site would use it + * internally") — `fetchDetail`/`parse` below just delegate straight to it, + * since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe` + * with nothing site-specific to handle. The only real Marmiton-specific + * logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to + * browse, but marmiton.org's search-results page embeds a browsable + * `ItemList` this adapter reads directly (see {@link findItemListNode}). + */ +export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = { + key: SOURCE_KEY, + name: "Marmiton", + official: false, + // Un chemin stable (jamais un nom de fichier avec un hash de build, comme + // les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son + // favicon à cette adresse indépendamment de tout déploiement. + iconUrl: "https://www.marmiton.org/favicon.ico", + // Le contenu de Marmiton (noms, ingrédients, instructions) est en + // français — détermine contre quel modèle/locale d'étiquettes + // d'ingrédients translateRecipe (recipe-translation.ts) résout les + // recettes de cette source lors d'une prévisualisation/d'un import. + locale: "fr", + + async list(params: RecipeSourceListParams): Promise { + try { + // Un curseur opaque qui encode simplement le numéro de page suivant — + // marmiton.org pagine sa recherche via `&page=N` (page 1 implicite + // quand le paramètre est absent), pas de token dédié à faire + // transiter. + const page = params.cursor ? Number(params.cursor) : 1; + const query = params.query ?? ""; + const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${ + page > 1 ? `&page=${page}` : "" + }`; + + let response: Response; + try { + response = await fetch(searchUrl); + } catch (cause) { + throw new RecipeSourceFetchError( + SOURCE_KEY, + `Network error searching Marmiton (${searchUrl})`, + { cause }, + ); + } + // marmiton.org répond 404 dès que `page` dépasse la dernière page de + // résultats pour cette recherche — pas un vrai échec, juste "il n'y a + // plus rien" : son `ItemList` ne porte aucun total fiable (son + // `numberOfItems` vaut toujours la taille de la page courante, jamais + // le nombre total de résultats) pour le détecter à l'avance autrement + // qu'en demandant la page suivante et en constatant qu'elle est vide. + if (response.status === 404) { + return { items: [], nextCursor: null }; + } + if (!response.ok) { + throw new RecipeSourceFetchError( + SOURCE_KEY, + `Marmiton search responded ${response.status} (${searchUrl})`, + ); + } + const html = await response.text(); + + let itemList: MarmitonItemList | null = null; + for (const block of extractJsonLdBlocks(html)) { + itemList = findItemListNode(block); + if (itemList) break; + } + + const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? []) + .filter((entry): entry is MarmitonListItem & { url: string; name: string } => + Boolean(entry.url && entry.name), + ) + .map((entry) => ({ + externalId: entry.url, + title: entry.name, + picture: entry.image ?? null, + url: entry.url, + })); + + return { + items, + // Voir le commentaire ci-dessus sur la réponse 404 : une page vide + // est elle-même le signal de fin, donc on ne propose une page + // suivante que si celle-ci en a retourné au moins un résultat. + nextCursor: items.length > 0 ? String(page + 1) : null, + }; + } catch (err) { + // Rethrown as-is (already keyed "marmiton" by whichever branch above + // threw it) — this adapter's only caller (`sources.service.ts`) + // already handles/logs failures centrally; this method just isn't + // allowed a bare `async` body without a try/catch per the repo's + // convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`. + throw err; + } + }, + + // `externalId` est directement l'URL canonique de la recette sur + // marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même + // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode + // délègue entièrement (voir le commentaire du module). + async fetchDetail(externalId: string): Promise<{ html: string; url: string }> { + try { + return await jsonLdRecipeAdapter.fetchDetail(externalId); + } catch (err) { + // Pas un simple re-throw : `rekeySourceError` est le traitement utile + // que ce point d'appel doit faire de l'erreur (relabelliser sa + // `sourceKey`), conformément à la convention await/try-catch du repo. + throw rekeySourceError(err); + } + }, + + parse(raw: { html: string; url: string }): ParsedRecipe { + try { + return jsonLdRecipeAdapter.parse(raw); + } catch (err) { + throw rekeySourceError(err); + } + }, +}; diff --git a/apps/api/test/recipe-sources/750g.test.ts b/apps/api/test/recipe-sources/750g.test.ts new file mode 100644 index 0000000..a9635da --- /dev/null +++ b/apps/api/test/recipe-sources/750g.test.ts @@ -0,0 +1,294 @@ +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&eacute;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&eacute;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 = ` + + `; + + 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 and always requests page=1 — there's never a legitimate cursor to pass back", 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"); + }); + + 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 (é -> é -> &eacute;)", () => { + 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"); + } + }); + }); +}); diff --git a/apps/api/test/recipe-sources/manger-bouger.test.ts b/apps/api/test/recipe-sources/manger-bouger.test.ts new file mode 100644 index 0000000..a5cc209 --- /dev/null +++ b/apps/api/test/recipe-sources/manger-bouger.test.ts @@ -0,0 +1,301 @@ +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"); + } + }); + }); +}); diff --git a/apps/api/test/recipe-sources/marmiton.test.ts b/apps/api/test/recipe-sources/marmiton.test.ts new file mode 100644 index 0000000..21f870c --- /dev/null +++ b/apps/api/test/recipe-sources/marmiton.test.ts @@ -0,0 +1,226 @@ +import { expect } from "chai"; +import { + RecipeSourceFetchError, + RecipeSourceParseError, +} from "../../src/lib/recipe-sources/recipe-source-errors.js"; +import { marmitonAdapter } from "../../src/sources/marmiton.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; +} + +/** + * Wraps a schema.org `ItemList` payload (already an object, not yet + * stringified) in a minimal HTML page carrying it as one + * ``; +} + +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"); + } + }); + }); +}); diff --git a/apps/api/test/sources/sources-index.test.ts b/apps/api/test/sources/sources-index.test.ts index caf8cb6..a25e9d0 100644 --- a/apps/api/test/sources/sources-index.test.ts +++ b/apps/api/test/sources/sources-index.test.ts @@ -25,6 +25,33 @@ describe("registerAllRecipeSources", () => { expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); }); + it("registers Marmiton into the shared registry", () => { + registerAllRecipeSources(); + + const marmiton = getRecipeSource("marmiton"); + expect(marmiton).to.not.be.undefined; + expect(marmiton?.name).to.equal("Marmiton"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("marmiton"); + }); + + it("registers 750g into the shared registry", () => { + registerAllRecipeSources(); + + const sevenFiftyG = getRecipeSource("750g"); + expect(sevenFiftyG).to.not.be.undefined; + expect(sevenFiftyG?.name).to.equal("750g"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g"); + }); + + it("registers Manger Bouger into the shared registry", () => { + registerAllRecipeSources(); + + const mangerBouger = getRecipeSource("mangerBouger"); + expect(mangerBouger).to.not.be.undefined; + expect(mangerBouger?.name).to.equal("Manger Bouger"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger"); + }); + it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { registerAllRecipeSources();