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