From b725ec1016c4299a559d1d0ecb4790c25126f29a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 11:16:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20premi=C3=A8re=20source=20concr?= =?UTF-8?q?=C3=A8te=20(TheMealDB)=20+=20ic=C3=B4ne=20de=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 5 + apps/api/prisma/seed.ts | 2 + apps/api/src/db/recipe-source-sync.ts | 17 +- apps/api/src/lib/recipe-source-adapter.ts | 3 + .../modules/reference/reference.service.ts | 2 +- apps/api/src/server.ts | 6 + apps/api/src/sources/index.ts | 24 +++ apps/api/src/sources/the-meal-db.ts | 151 +++++++++++++++ apps/api/test/house.test.ts | 3 +- apps/api/test/recipe-source-sync.test.ts | 3 +- apps/api/test/recipe-source.test.ts | 1 + apps/api/test/recipe.test.ts | 3 +- apps/api/test/reference.test.ts | 25 ++- apps/api/test/sources-index.test.ts | 27 +++ apps/api/test/the-meal-db.test.ts | 175 ++++++++++++++++++ apps/web/src/features/house/SourceSelect.tsx | 7 +- apps/web/src/features/house/house-forms.scss | 9 + packages/shared/src/types/reference.ts | 5 +- 19 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql create mode 100644 apps/api/src/sources/index.ts create mode 100644 apps/api/src/sources/the-meal-db.ts create mode 100644 apps/api/test/sources-index.test.ts create mode 100644 apps/api/test/the-meal-db.test.ts diff --git a/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql b/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql new file mode 100644 index 0000000..2590cfd --- /dev/null +++ b/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql @@ -0,0 +1,6 @@ +-- Adds `Source.icon_url` — the source's own logo/favicon, shown next to +-- its name in the `SourceSelect` picker (apps/web). Nullable, no backfill +-- needed for existing rows (none had one to begin with). + +-- AlterTable +ALTER TABLE "sources" ADD COLUMN "icon_url" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cf35cfb..5514042 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -237,6 +237,11 @@ model Source { /// sources to enable (see `HouseSource`) so scraped content is never /// mistaken for an official feed. official Boolean + /// The source's own logo/favicon URL, shown next to its name in + /// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`, + /// synced the same way as `name`/`official`. `null` if the source has + /// none worth showing. + iconUrl String? @map("icon_url") recipes Recipe[] enabledHouses HouseSource[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 6bc8ea6..1db9bd9 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@prisma/client"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js"; +import { registerAllRecipeSources } from "../src/sources/index.js"; // Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in // the rest of the app's config/env plumbing this doesn't need), run via @@ -9,6 +10,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; // `prisma migrate reset`. The actual data/logic lives in // `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`. const prisma = new PrismaClient(); +registerAllRecipeSources(); seedReferenceData(prisma) .then(() => syncRecipeSources(prisma)) diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index 1d8f57a..f6ae4aa 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -18,16 +18,23 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js"; * out from under it (see `onDelete: SetNull` on `Recipe.source` in * schema.prisma, which is what *would* happen on an actual delete). * - * Safe to call with an empty registry — currently always the case, since - * no concrete adapter exists yet (see recipe-source-adapter.ts) — leaves - * the `sources` table untouched. + * Safe to call with an empty registry (leaves the `sources` table + * untouched) — the case whenever nothing has called `registerRecipeSource` + * yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where + * the app's own concrete adapters — currently just TheMealDB — register + * themselves at startup). */ export async function syncRecipeSources(prisma: PrismaClient): Promise { for (const adapter of listRecipeSources()) { await prisma.source.upsert({ where: { key: adapter.key }, - update: { name: adapter.name, official: adapter.official }, - create: { key: adapter.key, name: adapter.name, official: adapter.official }, + update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl }, + create: { + key: adapter.key, + name: adapter.name, + official: adapter.official, + iconUrl: adapter.iconUrl, + }, }); } } diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index d903fa9..2db6816 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -145,6 +145,7 @@ export interface ParsedRecipe { * key: "someRecipeSite", * name: "Some Recipe Site", * official: false, + * iconUrl: "https://somerecipesite.example/favicon.svg", * async list(params) { ... }, * async fetchDetail(externalId) { ... }, * parse(raw) { ... }, @@ -168,6 +169,8 @@ export interface RecipeSourceAdapter { * inheriting a guess. */ official: boolean; + /** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */ + iconUrl: string | null; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 2a933af..01baf80 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -78,7 +78,7 @@ export async function getSources(): Promise { // `SourceView` yet, so it must not leak into the response the way a bare // `findMany()` would let it. return prisma.source.findMany({ - select: { id: true, key: true, name: true, official: true }, + select: { id: true, key: true, name: true, official: true, iconUrl: true }, orderBy: { name: "asc" }, }); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f7753cc..388c3a8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,5 +1,11 @@ import { createServer } from "./app.js"; import { env } from "./config/env.js"; +import { registerAllRecipeSources } from "./sources/index.js"; + +// Populates the recipe-source registry (recipe-source-registry.ts) before +// the app starts — see registerAllRecipeSources' doc comment for why this +// doesn't happen inside app.ts/createServer() itself. +registerAllRecipeSources(); const server = createServer(); diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts new file mode 100644 index 0000000..2457d1a --- /dev/null +++ b/apps/api/src/sources/index.ts @@ -0,0 +1,24 @@ +import { registerRecipeSource } from "../lib/recipe-source-registry.js"; +import { theMealDbAdapter } from "./the-meal-db.js"; + +/** + * Registers every concrete `RecipeSourceAdapter` this app ships with into + * the shared in-memory registry (`recipe-source-registry.ts`) — currently + * just `theMealDbAdapter`. Called once, explicitly, by the two real entry + * points that need the registry populated: + * + * - `server.ts` — the running API process, before it starts listening. + * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into + * the `sources` table. + * + * Deliberately **not** imported by `app.ts`: `createApp()` is what every + * test file gets via supertest, and registering a real adapter there would + * make its presence in the registry depend on test *order* (once + * registered at module load, nothing re-registers it after a test's + * `clearRecipeSources()` clears it out) instead of each test's own + * explicit setup. Tests that need a source in the registry register their + * own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`). + */ +export function registerAllRecipeSources(): void { + registerRecipeSource(theMealDbAdapter); +} diff --git a/apps/api/src/sources/the-meal-db.ts b/apps/api/src/sources/the-meal-db.ts new file mode 100644 index 0000000..b43d6d6 --- /dev/null +++ b/apps/api/src/sources/the-meal-db.ts @@ -0,0 +1,151 @@ +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../lib/recipe-source-adapter.js"; +import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js"; + +const SOURCE_KEY = "theMealDb"; + +// TheMealDB documents "1" as a shared, public test key, free to use for +// development (https://www.themealdb.com/api.php) — a deployment serving +// real traffic is expected to use a supporter-tier key instead (paid, via +// Patreon). Configurable here via an env var without touching anything +// else in this adapter. +const API_KEY = process.env.THE_MEAL_DB_API_KEY ?? "1"; +const API_BASE = `https://www.themealdb.com/api/json/v1/${API_KEY}`; + +/** + * TheMealDB's flat meal shape — ingredients/measures are 20 numbered + * field pairs (`strIngredient1`/`strMeasure1` … `strIngredient20`/ + * `strMeasure20`), not an array, hence the string index signature rather + * than 20 explicit optional properties. + */ +export interface TheMealDbMeal { + idMeal: string; + strMeal: string | null; + strMealThumb: string | null; + strInstructions: string | null; + [key: string]: string | null | undefined; +} + +interface TheMealDbMealsResponse { + meals: TheMealDbMeal[] | null; +} + +async function fetchTheMealDb(path: string): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`); + } catch (cause) { + throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, { + cause, + }); + } + if (!response.ok) { + throw new RecipeSourceFetchError( + SOURCE_KEY, + `TheMealDB responded ${response.status} (${path})`, + ); + } + return response.json() as Promise; +} + +function detailUrl(idMeal: string): string { + return `https://www.themealdb.com/meal/${idMeal}`; +} + +/** + * TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the + * publisher's own structured JSON, hence `official: true`). The first real + * `RecipeSourceAdapter` implementation, proving the generic contract + * (recipe-source-adapter.ts) end to end against a live source. + * + * `list()` is search-only — TheMealDB has no dedicated "browse everything" + * endpoint on its free tier. An omitted `query` searches for an empty + * string, which TheMealDB happens to answer with a small default sample + * (~25 meals) rather than nothing — close enough to this contract's + * "omitted `query` means browse everything" convention + * (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed + * sample, not the whole catalog. Search isn't paginated either — one + * response holds every match, so `nextCursor` is always `null`. + */ +export const theMealDbAdapter: RecipeSourceAdapter = { + key: SOURCE_KEY, + name: "TheMealDB", + official: true, + iconUrl: "https://www.themealdb.com/images/logo.svg", + + async list(params: RecipeSourceListParams): Promise { + const query = params.query ?? ""; + const data = await fetchTheMealDb( + `/search.php?s=${encodeURIComponent(query)}`, + ); + const meals = data.meals ?? []; + return { + items: meals + .filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal)) + .map((meal) => ({ + externalId: meal.idMeal, + title: meal.strMeal, + picture: meal.strMealThumb, + url: detailUrl(meal.idMeal), + })), + nextCursor: null, + }; + }, + + async fetchDetail(externalId: string): Promise { + const data = await fetchTheMealDb(`/lookup.php?i=${externalId}`); + const meal = data.meals?.[0]; + if (!meal) { + throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`); + } + return meal; + }, + + parse(meal: TheMealDbMeal): ParsedRecipe { + if (!meal.strMeal) { + throw new RecipeSourceParseError(SOURCE_KEY, "Meal is missing its name (strMeal)"); + } + + const ingredients = []; + for (let i = 1; i <= 20; i++) { + const name = meal[`strIngredient${i}`]?.trim(); + if (!name) continue; + const measure = meal[`strMeasure${i}`]?.trim(); + ingredients.push({ + rawText: measure ? `${measure} ${name}` : name, + quantity: null, + unit: null, + name, + }); + } + + // Free-text instructions, usually one step per line — splitting on + // blank/newlines is the closest this source gets to discrete steps. + const steps = (meal.strInstructions ?? "") + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((description) => ({ description, picture: null })); + if (steps.length === 0) { + throw new RecipeSourceParseError( + SOURCE_KEY, + `Meal "${meal.strMeal}" has no usable instructions`, + ); + } + + return { + name: meal.strMeal, + description: null, + picture: meal.strMealThumb, + // TheMealDB's free API doesn't state a serving size. + portions: null, + sourceUrl: detailUrl(meal.idMeal), + ingredients, + steps, + }; + }, +}; diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index 8c2c4f5..f25fa60 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -21,12 +21,13 @@ function buildSignupPayload(): SignupInput { }; } -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts index 7b863a8..05f9c4e 100644 --- a/apps/api/test/recipe-source-sync.test.ts +++ b/apps/api/test/recipe-source-sync.test.ts @@ -21,12 +21,13 @@ function buildSignupPayload(): SignupInput { }; } -/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official` matter for exercising `syncRecipeSources`. */ +/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official`/`iconUrl` matter for exercising `syncRecipeSources`. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts index 1b91a33..45d0c36 100644 --- a/apps/api/test/recipe-source.test.ts +++ b/apps/api/test/recipe-source.test.ts @@ -59,6 +59,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { const start = params.cursor ? Number(params.cursor) : 0; const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 1f2cb8a..d3740ba 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -10,12 +10,13 @@ import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { resetDatabase } from "../test-support/reset-db.js"; -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index bdf3d09..245f508 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -8,12 +8,18 @@ import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { resetDatabase } from "../test-support/reset-db.js"; -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ -function buildFakeAdapter(key: string, name: string, official: boolean): RecipeSourceAdapter { +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ +function buildFakeAdapter( + key: string, + name: string, + official: boolean, + iconUrl: string | null = null, +): RecipeSourceAdapter { return { key, name, official, + iconUrl, async list() { return { items: [], nextCursor: null }; }, @@ -159,19 +165,28 @@ describe("Reference data", () => { expect(res.body).to.deep.equal([]); }); - it("returns every synced adapter, official flag included, no session required", async () => { + it("returns every synced adapter, official flag and icon included, no session required", async () => { registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source", false)); - registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true)); + registerRecipeSource( + buildFakeAdapter( + "officialSource", + "Official Source", + true, + "https://example.test/icon.svg", + ), + ); await syncRecipeSources(prisma); const res = await request(app).get("/reference/sources"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); - expect(res.body[0]).to.have.keys(["id", "key", "name", "official"]); + expect(res.body[0]).to.have.keys(["id", "key", "name", "official", "iconUrl"]); const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key); expect(byKey("fakeSource").official).to.equal(false); + expect(byKey("fakeSource").iconUrl).to.equal(null); expect(byKey("officialSource").official).to.equal(true); + expect(byKey("officialSource").iconUrl).to.equal("https://example.test/icon.svg"); }); it("orders sources alphabetically by name", async () => { diff --git a/apps/api/test/sources-index.test.ts b/apps/api/test/sources-index.test.ts new file mode 100644 index 0000000..a9600f2 --- /dev/null +++ b/apps/api/test/sources-index.test.ts @@ -0,0 +1,27 @@ +import { expect } from "chai"; +import { + clearRecipeSources, + getRecipeSource, + listRecipeSources, +} from "../src/lib/recipe-source-registry.js"; +import { registerAllRecipeSources } from "../src/sources/index.js"; + +// Not exercised by any other test file — `registerAllRecipeSources` is +// deliberately never imported by `app.ts` (see its own doc comment), so +// nothing else in the suite triggers it. Registers/clears explicitly here +// rather than relying on module-load order, so this test's outcome doesn't +// depend on which other test file Mocha happens to load first. +describe("registerAllRecipeSources", () => { + afterEach(() => { + clearRecipeSources(); + }); + + it("registers TheMealDB into the shared registry", () => { + registerAllRecipeSources(); + + const theMealDb = getRecipeSource("theMealDb"); + expect(theMealDb).to.not.be.undefined; + expect(theMealDb?.name).to.equal("TheMealDB"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); + }); +}); diff --git a/apps/api/test/the-meal-db.test.ts b/apps/api/test/the-meal-db.test.ts new file mode 100644 index 0000000..7427146 --- /dev/null +++ b/apps/api/test/the-meal-db.test.ts @@ -0,0 +1,175 @@ +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, + ); + }); + }); +}); diff --git a/apps/web/src/features/house/SourceSelect.tsx b/apps/web/src/features/house/SourceSelect.tsx index 65a4f52..976bb41 100644 --- a/apps/web/src/features/house/SourceSelect.tsx +++ b/apps/web/src/features/house/SourceSelect.tsx @@ -20,11 +20,13 @@ interface SourceSelectProps { * (opt-in — see `HouseSource` in schema.prisma), not an incomplete one. * * Unlike `AllergySelect`, a source's display text is its own `name` - * (`SourceView.name` — a proper noun like "Marmiton"), not resolved + * (`SourceView.name` — a proper noun like "TheMealDB"), not resolved * through `catalog..` i18n — nothing to translate. The * `official`/`unofficial` badge next to it is what *is* translated, so a * household can tell an official API apart from a scraped site before - * deciding whether to trust it. + * deciding whether to trust it. `iconUrl`, when the source has one, is + * shown as a small logo before the name — purely decorative (`alt=""`), + * the name text already carries the information. */ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectProps) { const { t } = useTranslation(); @@ -45,6 +47,7 @@ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectP onChange={() => toggle(source.id)} className="source-select__option" > + {source.iconUrl && } {source.name} {t(source.official ? "household.sources.official" : "household.sources.unofficial")} diff --git a/apps/web/src/features/house/house-forms.scss b/apps/web/src/features/house/house-forms.scss index 7425232..c9af5be 100644 --- a/apps/web/src/features/house/house-forms.scss +++ b/apps/web/src/features/house/house-forms.scss @@ -25,6 +25,15 @@ font-size: var(--font-size-base); } + // The source's own logo — small and square, never bigger than the text + // line it sits next to regardless of the source image's real dimensions. + &__icon { + width: 1.1em; + height: 1.1em; + object-fit: contain; + flex-shrink: 0; + } + // Official/unofficial marker — same "small pill" language as // `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by // default (unofficial/scraped) and tinted primary once official. diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 6dff5fd..1653b58 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -219,18 +219,21 @@ export interface TechStepView { * the API — only a real adapter being registered in code adds a row). * * Unlike `DietView`/`TechStepView`, `name` is the actual display string - * (e.g. `"Marmiton"`) rather than a `key` resolved through + * (e.g. `"TheMealDB"`) rather than a `key` resolved through * `catalog..` — a source's name is a proper noun/brand, * nothing to translate. `official` distinguishes a source backed by an * official API from one built by scraping HTML the site never committed to * a stable shape — surfaced so households can make an informed choice when * picking which sources to enable (see `HouseSource` in schema.prisma). + * `iconUrl` is the source's own logo/favicon, `null` if it has none worth + * showing — `SourceSelect` (apps/web) renders it next to `name`. */ export interface SourceView { id: number; key: string; name: string; official: boolean; + iconUrl: string | null; } /**