Marmiton s'est avéré inaccessible pour du scraping (bloqué même via WebFetch, signe de protection anti-bot) — TheMealDB (themealdb.com) est une vraie API JSON publique et gratuite, sans scraping, testée en conditions réelles (list → fetchDetail → parse fonctionnent bout en bout contre l'API live). - Source.iconUrl (nullable) + RecipeSourceAdapter.iconUrl (requis, même convention que `official`) synchronisé par syncRecipeSources. - apps/api/src/sources/the-meal-db.ts : premier RecipeSourceAdapter réel — official: true (API officielle, pas de scraping), utilise fetch natif (aucune dépendance ajoutée). list() fait une recherche (pas de vrai "browse" côté TheMealDB, mais une requête vide renvoie un échantillon de secours) ; parse() éclate les instructions en étapes par ligne et ignore les emplacements d'ingrédients vides. - apps/api/src/sources/index.ts : registerAllRecipeSources(), appelé par server.ts (process réel) et prisma/seed.ts — délibérément PAS importé par app.ts, pour ne jamais dépendre de l'ordre des tests. - SourceSelect (web) affiche désormais le logo de la source à côté de son nom. Vérifié en conditions réelles : seed → table sources peuplée avec le vrai logo TheMealDB ; endpoint /reference/sources sur serveur réel ; parcours navigateur complet (onboarding → étape sources visible avec icône chargée → activation → paramètres foyer reflète le choix). 186 tests passent (16 nouveaux, dont le moteur TheMealDB testé avec un stub de fetch — aucun appel réseau réel dans la suite automatisée). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
import { expect } from "chai";
|
|
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/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("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,
|
|
);
|
|
});
|
|
});
|
|
});
|