É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 <script type="application/ld+json">. Enregistre marmitonAdapter dans registerAllRecipeSources (sources/index.ts) — contrairement à jsonLdRecipeAdapter lui-même, c'est un adaptateur concret par site, donc une Source household-toggleable légitime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
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
|
|
* `<script type="application/ld+json">` block — the shape marmiton.org's
|
|
* search-results page embeds `list()` reads.
|
|
*/
|
|
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
|
|
const payload = {
|
|
"@context": "https://schema.org",
|
|
"@graph": [
|
|
{ "@type": "WebSite", name: "Marmiton" },
|
|
{
|
|
"@type": "ItemList",
|
|
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
|
|
numberOfItems: itemListElement.length,
|
|
itemListElement,
|
|
},
|
|
],
|
|
};
|
|
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
payload,
|
|
)}</script></head><body></body></html>`;
|
|
}
|
|
|
|
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 `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
baseRecipeJsonLd,
|
|
)}</script></head><body></body></html>`;
|
|
}
|
|
|
|
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("<!doctype html><html><body>Rien ici</body></html>");
|
|
|
|
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 = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
});
|