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