All checks were successful
Le parcours « tout voir » (filtre texte vide) de la source Manger Bouger s'arrêtait après la 1re page (9 recettes) sur le serveur déployé. Cause : `list()` ajoutait toujours un `&query=` final, même vide. Sur le serveur déployé, `…/recettes?diet=ALL&page=N&query=` renvoyait la page 1 pour tout `N`, et le garde-fou anti-boucle de #38 (« 0 nouvel externalId ⇒ fin de liste ») coupait le défilement. La requête catalogue nue `?diet=ALL&page=N` pagine correctement. - `manger-bouger.ts` `list()` : n'ajoute `&query=` que si le terme de recherche est non vide (après `trim`), comme les adaptateurs voisins (marmiton / quitoque / ohmonbento). Le chemin filtré ne change pas. - Commentaires `LIST_URL` / `hasMorePages` corrigés : la pagination « vérifiée en direct » ne valait que pour le chemin avec `query` texte. - Tests `manger-bouger.test.ts` : `list({})` construit une URL sans paramètre `query` ; un `query` blanc = pas de filtre ; le catalogue nu pagine quand même (`nextCursor`) ; `list({ query: salade })` ajoute toujours `&query=salade` (non-régression). Closes #78. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
354 lines
13 KiB
TypeScript
354 lines
13 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("omits the query param entirely on the catalogue-browse path (no filter) — issue #78", async () => {
|
|
let requestedUrl: string | undefined;
|
|
globalThis.fetch = (async (url: string) => {
|
|
requestedUrl = url;
|
|
return new Response(htmlWithListNextData({ list: [], hasMorePages: true }), {
|
|
status: 200,
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
await mangerBougerAdapter.list({});
|
|
|
|
expect(requestedUrl).to.equal(
|
|
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes?diet=ALL&page=1",
|
|
);
|
|
expect(requestedUrl).not.to.include("query=");
|
|
});
|
|
|
|
it("treats a blank/whitespace query the same as no filter (no query param)", 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: " " });
|
|
|
|
expect(requestedUrl).not.to.include("query=");
|
|
});
|
|
|
|
it("still paginates the catalogue-browse path (nextCursor set when hasMorePages)", async () => {
|
|
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
|
|
|
|
const result = await mangerBougerAdapter.list({});
|
|
|
|
expect(result.nextCursor).to.equal("2");
|
|
});
|
|
|
|
it("still appends the query param when a real search term is given", 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: "salade" });
|
|
|
|
expect(requestedUrl).to.include("&query=salade");
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
});
|