batchCooking/apps/api/test/the-meal-db.test.ts
Nicolas 4db8e1369f refactor(api): regroupe lib/ par sous-domaine au lieu d'un dossier à plat
9 fichiers à plat -> recipe-sources/ (recipe-source-adapter, recipe-source-
errors, recipe-source-registry) et recipe-matching/ (recipe-translation,
ingredient-matcher, tech-step-matcher). jwt.ts, safe-profile.ts et
logger.service.ts restent à la racine de lib/ (pas de sous-domaine
partagé avec les autres).

Chemins relatifs corrigés dans les fichiers déplacés (profondeur +1 vers
db/) et chez tous leurs importeurs (modules/sources, modules/recipe,
sources/*, db/recipe-source-sync.ts, 12 fichiers de test), doc mise à
jour (specs/backend-architecture.md, specs/batch-cooking-architecture.md).

Vérifié : tsc --noEmit, biome check, build complet, 303 tests API.

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

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