batchCooking/apps/api/test/recipe-sources/the-meal-db.test.ts
Nicolas c48625cb0c
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00

321 lines
11 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;
}
/**
* Route-aware variant of {@link stubFetch} for the empty-query category
* walk, which fires several different requests (`/list.php?c=list`, then a
* `/filter.php?c=…`) within a single `list()` call. Matches by URL
* substring; an unstubbed path throws rather than silently returning junk.
* Returns the list of requested URLs, so a test can assert *which*
* endpoint was hit (e.g. category walk vs. `/search.php`).
*/
function stubFetchByUrl(routes: Record<string, unknown>, status = 200): string[] {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
calls.push(url);
const route = Object.keys(routes).find((fragment) => url.includes(fragment));
if (route === undefined) {
throw new Error(`stubFetchByUrl: no route matches ${url}`);
}
return new Response(JSON.stringify(routes[route]), { status });
}) as typeof fetch;
return calls;
}
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);
}
});
// Empty/omitted query = "browse everything". The free tier has no
// catalog-wide endpoint, so the adapter walks categories one per
// cursor step (issue #38) rather than settling for `/search.php?s=`'s
// fixed ~25-meal sample.
describe("empty query — walks categories", () => {
const CATEGORIES = {
meals: [{ strCategory: "Beef" }, { strCategory: "Chicken" }, { strCategory: "Dessert" }],
};
it("returns the first category's meals, cursor pointing at the next category", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [
{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: "https://img.test/1.jpg" },
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
],
},
});
const result = await theMealDbAdapter.list({});
expect(result.items).to.deep.equal([
{
externalId: "1",
title: "Beef Pie",
picture: "https://img.test/1.jpg",
url: "https://www.themealdb.com/meal/1",
},
{
externalId: "2",
title: "Beef Stew",
picture: null,
url: "https://www.themealdb.com/meal/2",
},
]);
expect(result.nextCursor).to.equal("1");
});
it("follows the cursor into the next category", async () => {
const calls = stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Chicken": {
meals: [{ idMeal: "3", strMeal: "Chicken Handi", strMealThumb: null }],
},
});
const result = await theMealDbAdapter.list({ cursor: "1" });
expect(calls.some((u) => u.includes("/filter.php?c=Chicken"))).to.equal(true);
expect(result.items.map((i) => i.externalId)).to.deep.equal(["3"]);
expect(result.nextCursor).to.equal("2");
});
it("stops (null cursor) on the last category", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Dessert": {
meals: [{ idMeal: "9", strMeal: "Flan", strMealThumb: null }],
},
});
const result = await theMealDbAdapter.list({ cursor: "2" });
expect(result.nextCursor).to.be.null;
});
it("returns an empty final page once the cursor is past the last category", async () => {
stubFetchByUrl({ "/list.php?c=list": CATEGORIES });
const result = await theMealDbAdapter.list({ cursor: "3" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a blank query the same as no query — category walk, never /search.php", async () => {
const calls = stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [{ idMeal: "1", strMeal: "Beef Pie", strMealThumb: null }],
},
});
await theMealDbAdapter.list({ query: " " });
expect(calls.some((u) => u.includes("/search.php"))).to.equal(false);
expect(calls.some((u) => u.includes("/list.php?c=list"))).to.equal(true);
});
it("skips a titleless meal in a category page, like search does", async () => {
stubFetchByUrl({
"/list.php?c=list": CATEGORIES,
"/filter.php?c=Beef": {
meals: [
{ idMeal: "1", strMeal: null, strMealThumb: null },
{ idMeal: "2", strMeal: "Beef Stew", strMealThumb: null },
],
},
});
const result = await theMealDbAdapter.list({});
expect(result.items.map((i) => i.externalId)).to.deep.equal(["2"]);
});
});
});
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,
);
});
});
});