batchCooking/apps/api/test/json-ld-recipe.test.ts
Nicolas 8ab652206d feat(recipes): source générique JSON-LD (schema.org/Recipe)
Deuxième adaptateur concret, cette fois générique plutôt que lié à un
site précis : la plupart des sites de recettes embarquent des
données structurées JSON-LD (schema.org/Recipe) pour le SEO/Google
Rich Results — un seul adaptateur peut donc couvrir une grande partie
des sites, sans scraper le DOM site par site.

- apps/api/src/sources/json-ld-recipe.ts : official: false (on lit du
  HTML arbitraire, pas une API dédiée maintenue par l'éditeur), pas de
  catalogue à parcourir (list() renvoie toujours vide) — fetchDetail()
  prend directement une URL comme externalId, prête pour un futur
  flux "importer depuis une URL".
- Extraction JSON-LD par regex (pas de nouvelle dépendance — un tag
  <script> ne contient jamais de HTML imbriqué, donc pas besoin d'un
  vrai parseur DOM), tolérante aux blocs multiples et aux JSON
  malformés (ignorés plutôt que de faire échouer toute la page).
  Gère les variantes réelles de schema.org : @type en tableau, @graph,
  recipeInstructions en string/HowToStep[]/HowToSection imbriquées,
  image en string/ImageObject/tableau, recipeYield en nombre/texte/
  tableau.

Vérifié contre une vraie page (bbcgoodfood.com, HTML téléchargé +
fetch live) : nom, description, image, portions, 8 ingrédients et 2
étapes correctement extraits de bout en bout.

26 nouveaux tests (tous avec fetch stubbé, aucun appel réseau réel
dans la suite automatisée). 221 tests passent au total. Build et
lint propres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:17:49 +02:00

298 lines
11 KiB
TypeScript

import { expect } from "chai";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one `<script type="application/ld+json">` block, optionally alongside `extraBlocks` (e.g. an unrelated `BreadcrumbList`, or deliberately malformed JSON). */
function htmlWithJsonLd(payload: unknown, ...extraBlocks: string[]): string {
const scripts = [JSON.stringify(payload), ...extraBlocks]
.map((json) => `<script type="application/ld+json">${json}</script>`)
.join("\n");
return `<!doctype html><html><head>${scripts}</head><body></body></html>`;
}
const RECIPE_URL = "https://example.test/recipes/apple-pie";
const baseRecipe = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Apple Pie",
description: "A classic apple pie.",
image: "https://example.test/apple-pie.jpg",
recipeYield: 8,
recipeIngredient: ["6 apples, peeled", "200g flour", " "],
recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."],
};
describe("jsonLdRecipeAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, iconless, browse-less source", () => {
expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe");
expect(jsonLdRecipeAdapter.official).to.equal(false);
expect(jsonLdRecipeAdapter.iconUrl).to.be.null;
});
describe("list", () => {
it("always returns an empty page — this source has no catalog of its own", async () => {
const result = await jsonLdRecipeAdapter.list({ query: "anything" });
expect(result).to.deep.equal({ items: [], nextCursor: null });
});
});
describe("fetchDetail", () => {
it("fetches the given URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithJsonLd(baseRecipe));
const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Apple Pie");
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 404);
try {
await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail 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 jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("parse", () => {
function parse(html: string, url = RECIPE_URL) {
return jsonLdRecipeAdapter.parse({ html, url });
}
it("maps a straightforward JSON-LD Recipe end to end", () => {
const parsed = parse(htmlWithJsonLd(baseRecipe));
expect(parsed.name).to.equal("Apple Pie");
expect(parsed.description).to.equal("A classic apple pie.");
expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg");
expect(parsed.portions).to.equal(8);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Bake at 180°C for 40 minutes.", picture: null },
]);
});
it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => {
const parsed = parse(htmlWithJsonLd(baseRecipe));
expect(parsed.ingredients).to.deep.equal([
{ rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" },
{ rawText: "200g flour", quantity: null, unit: null, name: "200g flour" },
]);
});
it("prefers the JSON-LD's own url over the fetched url, when present", () => {
const parsed = parse(
htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }),
);
expect(parsed.sourceUrl).to.equal("https://example.test/canonical");
});
it("accepts @type as an array containing Recipe", () => {
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] }));
expect(parsed.name).to.equal("Apple Pie");
});
it("finds the Recipe nested under @graph", () => {
const graphPayload = {
"@context": "https://schema.org",
"@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe],
};
const parsed = parse(htmlWithJsonLd(graphPayload));
expect(parsed.name).to.equal("Apple Pie");
});
it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => {
const html = htmlWithJsonLd(
{ "@type": "BreadcrumbList", itemListElement: [] },
"{ this is not valid json",
JSON.stringify(baseRecipe),
);
const parsed = parse(html);
expect(parsed.name).to.equal("Apple Pie");
});
describe("recipeInstructions shapes", () => {
it("splits a single free-text string on line breaks", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: "Step one.\nStep two.\n\nStep three.",
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Step one.", picture: null },
{ description: "Step two.", picture: null },
{ description: "Step three.", picture: null },
]);
});
it("reads HowToStep objects' text field", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [
{ "@type": "HowToStep", text: "Peel the apples." },
{ "@type": "HowToStep", text: "Bake." },
],
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Bake.", picture: null },
]);
});
it("falls back to a HowToStep's name when it has no text", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }],
}),
);
expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]);
});
it("flattens HowToSections into their nested steps, dropping the section name", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
recipeInstructions: [
{
"@type": "HowToSection",
name: "Filling",
itemListElement: [
{ "@type": "HowToStep", text: "Peel the apples." },
{ "@type": "HowToStep", text: "Slice them." },
],
},
{
"@type": "HowToSection",
name: "Baking",
itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }],
},
],
}),
);
expect(parsed.steps).to.deep.equal([
{ description: "Peel the apples.", picture: null },
{ description: "Slice them.", picture: null },
{ description: "Bake at 180°C.", picture: null },
]);
});
it("throws RecipeSourceParseError when there are no usable instructions", () => {
expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw(
RecipeSourceParseError,
);
expect(() =>
parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })),
).to.throw(RecipeSourceParseError);
});
});
describe("image shapes", () => {
it("reads a bare string image", () => {
const parsed = parse(
htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }),
);
expect(parsed.picture).to.equal("https://example.test/a.jpg");
});
it("reads the first entry of an array of ImageObjects", () => {
const parsed = parse(
htmlWithJsonLd({
...baseRecipe,
image: [
{ "@type": "ImageObject", url: "https://example.test/large.jpg" },
{ "@type": "ImageObject", url: "https://example.test/small.jpg" },
],
}),
);
expect(parsed.picture).to.equal("https://example.test/large.jpg");
});
it("is null when there's no image at all", () => {
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined }));
expect(parsed.picture).to.be.null;
});
});
describe("recipeYield shapes", () => {
it("accepts a plain number", () => {
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4);
});
it("extracts the leading integer from a free-text string", () => {
expect(
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions,
).to.equal(4);
});
it("reads the first entry of an array", () => {
expect(
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions,
).to.equal(6);
});
it("is null when absent or unparseable", () => {
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be
.null;
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null;
});
});
describe("failure cases", () => {
it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => {
expect(() => parse("<html><body>No structured data here</body></html>")).to.throw(
RecipeSourceParseError,
);
});
it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => {
const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] });
expect(() => parse(html)).to.throw(RecipeSourceParseError);
});
it("throws RecipeSourceParseError when the Recipe has no name", () => {
const html = htmlWithJsonLd({ ...baseRecipe, name: undefined });
expect(() => parse(html)).to.throw(RecipeSourceParseError);
});
});
});
});