batchCooking/apps/api/test/recipe-sources/json-ld-recipe.test.ts
Nicolas 82c09331dc refactor(api): regroupe test/ par sous-domaine, miroir de src/lib/
18 fichiers à plat -> recipe-matching/ (ingredient-matcher, recipe-
translation, tech-step-matcher — miroir de lib/recipe-matching/),
recipe-sources/ (json-ld-recipe, recipe-source, recipe-source-sync,
the-meal-db — miroir de lib/recipe-sources/), sources/ (sources,
sources-index — module + registration src/sources/index.ts).

Les tests par domaine API sans regroupement naturel (auth, health,
house, logger.service, planning, preferences, profile, recipe,
reference) restent à la racine de test/, un fichier par domaine — même
logique que jwt.ts/safe-profile.ts restés à la racine de lib/.

Chemins relatifs corrigés (../src/ -> ../../src/, ../test-support/ ->
../../test-support/ dans les fichiers déplacés qui appellent
resetDatabase). .mocharc.json ("test/**/*.test.ts") couvre déjà les
sous-dossiers, aucun changement de config nécessaire.

Vérifié : biome check, 303 tests API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:27:06 +02:00

301 lines
11 KiB
TypeScript

import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/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);
});
});
});
});