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