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>
321 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|
|
});
|