Suit le même schéma que marmitonAdapter/sevenFiftyGAdapter (construit sur jsonLdRecipeAdapter), avec des différences propres à mangerbouger.fr (« La Fabrique à Menus », Santé publique France) : - list() n'utilise pas de JSON-LD du tout — la page de résultats (une app Next.js) n'embarque aucun ItemList. Elle est cependant rendue côté serveur et expose le même state Redux que le client hydrate, via un <script id="__NEXT_DATA__">, qui contient déjà tout ce dont list() a besoin (slug/nom/image, pagination). Vérifié en direct : ?query=<texte libre> filtre bien côté serveur, et hasMorePages donne un signal de fin de pagination plus propre que le 404 de Marmiton ou l'absence de vraie pagination de 750g. - parse() délègue à jsonLdRecipeAdapter mais corrige deux lacunes réelles et systématiques de son propre JSON-LD (vérifiées sur 9 recettes, 72 étapes) : recipeInstructions[].text est un document Slate.js sérialisé en JSON (pas du texte) plutôt qu'être aplati ; recipeYield est absent partout alors que le nombre de portions existe bien côté site (__NEXT_DATA__) — les deux sont corrigés par un patch structuré (parse → mutation → réécriture) avant délégation, pas une réimplémentation. Enregistre mangerBougerAdapter dans registerAllRecipeSources (sources/index.ts) et complète sources-index.test.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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 `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
|
|
payload,
|
|
)}</script></body></html>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
recipeJsonLd,
|
|
)}</script></head><body>${nextData}</body></html>`;
|
|
}
|
|
|
|
function htmlWithDetailNextDataScript(portions: number): string {
|
|
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
|
|
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
|
|
}
|
|
|
|
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("<!doctype html><html><body>Rien ici</body></html>");
|
|
|
|
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 = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
});
|