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>
192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
import { expect } from "chai";
|
|
import {
|
|
RecipeSourceFetchError,
|
|
RecipeSourceParseError,
|
|
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
import { type TheMealDbMeal, theMealDbAdapter } from "../../src/sources/the-meal-db.js";
|
|
|
|
/**
|
|
* Stubs `globalThis.fetch` for one test — no HTTP-mocking library exists
|
|
* in this codebase yet (this is the first module that talks to a real
|
|
* external network), and a single reassignable global covers the handful
|
|
* of call shapes this adapter needs without adding a new dependency.
|
|
* Restored by the `afterEach` below regardless of which test used it.
|
|
*/
|
|
function stubFetch(body: unknown, status = 200) {
|
|
globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
|
|
}
|
|
|
|
const baseMeal: TheMealDbMeal = {
|
|
idMeal: "52795",
|
|
strMeal: "Chicken Handi",
|
|
strMealThumb: "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg",
|
|
strInstructions: "Step one.\r\nStep two.\r\n\r\nStep three.",
|
|
strIngredient1: "Chicken",
|
|
strMeasure1: "1 kg",
|
|
strIngredient2: " ",
|
|
strMeasure2: "2 tbsp",
|
|
strIngredient3: "Onion",
|
|
strMeasure3: "",
|
|
};
|
|
|
|
describe("theMealDbAdapter", () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("declares itself as an official source, with a key/name/icon", () => {
|
|
expect(theMealDbAdapter.key).to.equal("theMealDb");
|
|
expect(theMealDbAdapter.name).to.equal("TheMealDB");
|
|
expect(theMealDbAdapter.official).to.equal(true);
|
|
expect(theMealDbAdapter.iconUrl).to.be.a("string");
|
|
});
|
|
|
|
describe("list", () => {
|
|
it("maps search results into RecipeSourceListItems", async () => {
|
|
stubFetch({
|
|
meals: [
|
|
{ idMeal: "1", strMeal: "Test Meal", strMealThumb: "https://example.test/thumb.jpg" },
|
|
],
|
|
});
|
|
|
|
const result = await theMealDbAdapter.list({ query: "test" });
|
|
|
|
expect(result.items).to.deep.equal([
|
|
{
|
|
externalId: "1",
|
|
title: "Test Meal",
|
|
picture: "https://example.test/thumb.jpg",
|
|
url: "https://www.themealdb.com/meal/1",
|
|
},
|
|
]);
|
|
expect(result.nextCursor).to.be.null;
|
|
});
|
|
|
|
it("returns an empty list when the API responds with meals: null", async () => {
|
|
stubFetch({ meals: null });
|
|
|
|
const result = await theMealDbAdapter.list({ query: "doesnotexist" });
|
|
|
|
expect(result.items).to.deep.equal([]);
|
|
expect(result.nextCursor).to.be.null;
|
|
});
|
|
|
|
it("skips a meal with no name rather than surfacing a titleless item", async () => {
|
|
stubFetch({ meals: [{ idMeal: "1", strMeal: null, strMealThumb: null }] });
|
|
|
|
const result = await theMealDbAdapter.list({ query: "x" });
|
|
|
|
expect(result.items).to.deep.equal([]);
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
|
stubFetch({}, 500);
|
|
|
|
try {
|
|
await theMealDbAdapter.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 theMealDbAdapter.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("returns the first meal from the lookup response", async () => {
|
|
stubFetch({ meals: [baseMeal] });
|
|
|
|
const result = await theMealDbAdapter.fetchDetail("52795");
|
|
|
|
expect(result).to.deep.equal(baseMeal);
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError when no meal matches the id", async () => {
|
|
stubFetch({ meals: null });
|
|
|
|
try {
|
|
await theMealDbAdapter.fetchDetail("999999");
|
|
expect.fail("expected fetchDetail to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("parse", () => {
|
|
it("maps name/picture/sourceUrl and splits instructions into steps", () => {
|
|
const parsed = theMealDbAdapter.parse(baseMeal);
|
|
|
|
expect(parsed.name).to.equal("Chicken Handi");
|
|
expect(parsed.description).to.be.null;
|
|
expect(parsed.picture).to.equal(baseMeal.strMealThumb);
|
|
expect(parsed.portions).to.be.null;
|
|
expect(parsed.sourceUrl).to.equal("https://www.themealdb.com/meal/52795");
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "Step one.", picture: null },
|
|
{ description: "Step two.", picture: null },
|
|
{ description: "Step three.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("skips blank ingredient slots and keeps the measure alongside the name in rawText", () => {
|
|
const parsed = theMealDbAdapter.parse(baseMeal);
|
|
|
|
expect(parsed.ingredients).to.deep.equal([
|
|
{ rawText: "1 kg Chicken", quantity: null, unit: null, name: "Chicken" },
|
|
{ rawText: "Onion", quantity: null, unit: null, name: "Onion" },
|
|
]);
|
|
});
|
|
|
|
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
|
|
const parsed = theMealDbAdapter.parse({
|
|
...baseMeal,
|
|
strInstructions:
|
|
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
|
|
});
|
|
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "For the caramel, melt the sugar.", picture: null },
|
|
{ description: "Preheat the oven.", picture: null },
|
|
{ description: "Bake it.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when the meal has no name", () => {
|
|
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
|
|
RecipeSourceParseError,
|
|
);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when there are no usable instructions", () => {
|
|
expect(() =>
|
|
theMealDbAdapter.parse({ ...baseMeal, strInstructions: " \r\n\r\n " }),
|
|
).to.throw(RecipeSourceParseError);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when instructions are null", () => {
|
|
expect(() => theMealDbAdapter.parse({ ...baseMeal, strInstructions: null })).to.throw(
|
|
RecipeSourceParseError,
|
|
);
|
|
});
|
|
});
|
|
});
|