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>
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 { 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);
|
|
});
|
|
});
|
|
});
|
|
});
|