feat(recipes): première source concrète (TheMealDB) + icône de source
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 <noreply@anthropic.com>
This commit is contained in:
parent
b060fc47c3
commit
b725ec1016
19 changed files with 457 additions and 17 deletions
|
|
@ -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;
|
||||||
|
|
@ -237,6 +237,11 @@ model Source {
|
||||||
/// sources to enable (see `HouseSource`) so scraped content is never
|
/// sources to enable (see `HouseSource`) so scraped content is never
|
||||||
/// mistaken for an official feed.
|
/// mistaken for an official feed.
|
||||||
official Boolean
|
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[]
|
recipes Recipe[]
|
||||||
enabledHouses HouseSource[]
|
enabledHouses HouseSource[]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.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
|
// 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
|
// 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
|
// `prisma migrate reset`. The actual data/logic lives in
|
||||||
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
registerAllRecipeSources();
|
||||||
|
|
||||||
seedReferenceData(prisma)
|
seedReferenceData(prisma)
|
||||||
.then(() => syncRecipeSources(prisma))
|
.then(() => syncRecipeSources(prisma))
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,23 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
||||||
* out from under it (see `onDelete: SetNull` on `Recipe.source` in
|
* out from under it (see `onDelete: SetNull` on `Recipe.source` in
|
||||||
* schema.prisma, which is what *would* happen on an actual delete).
|
* schema.prisma, which is what *would* happen on an actual delete).
|
||||||
*
|
*
|
||||||
* Safe to call with an empty registry — currently always the case, since
|
* Safe to call with an empty registry (leaves the `sources` table
|
||||||
* no concrete adapter exists yet (see recipe-source-adapter.ts) — leaves
|
* untouched) — the case whenever nothing has called `registerRecipeSource`
|
||||||
* the `sources` table untouched.
|
* 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<void> {
|
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||||
for (const adapter of listRecipeSources()) {
|
for (const adapter of listRecipeSources()) {
|
||||||
await prisma.source.upsert({
|
await prisma.source.upsert({
|
||||||
where: { key: adapter.key },
|
where: { key: adapter.key },
|
||||||
update: { 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 },
|
create: {
|
||||||
|
key: adapter.key,
|
||||||
|
name: adapter.name,
|
||||||
|
official: adapter.official,
|
||||||
|
iconUrl: adapter.iconUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export interface ParsedRecipe {
|
||||||
* key: "someRecipeSite",
|
* key: "someRecipeSite",
|
||||||
* name: "Some Recipe Site",
|
* name: "Some Recipe Site",
|
||||||
* official: false,
|
* official: false,
|
||||||
|
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
||||||
* async list(params) { ... },
|
* async list(params) { ... },
|
||||||
* async fetchDetail(externalId) { ... },
|
* async fetchDetail(externalId) { ... },
|
||||||
* parse(raw) { ... },
|
* parse(raw) { ... },
|
||||||
|
|
@ -168,6 +169,8 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
||||||
* inheriting a guess.
|
* inheriting a guess.
|
||||||
*/
|
*/
|
||||||
official: boolean;
|
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<RecipeSourceListResult>;
|
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
parse(raw: TRawDetail): ParsedRecipe;
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export async function getSources(): Promise<SourceView[]> {
|
||||||
// `SourceView` yet, so it must not leak into the response the way a bare
|
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||||
// `findMany()` would let it.
|
// `findMany()` would let it.
|
||||||
return prisma.source.findMany({
|
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" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { createServer } from "./app.js";
|
import { createServer } from "./app.js";
|
||||||
import { env } from "./config/env.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();
|
const server = createServer();
|
||||||
|
|
||||||
|
|
|
||||||
24
apps/api/src/sources/index.ts
Normal file
24
apps/api/src/sources/index.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
151
apps/api/src/sources/the-meal-db.ts
Normal file
151
apps/api/src/sources/the-meal-db.ts
Normal file
|
|
@ -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<T>(path: string): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TheMealDbMeal> = {
|
||||||
|
key: SOURCE_KEY,
|
||||||
|
name: "TheMealDB",
|
||||||
|
official: true,
|
||||||
|
iconUrl: "https://www.themealdb.com/images/logo.svg",
|
||||||
|
|
||||||
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
|
const query = params.query ?? "";
|
||||||
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
||||||
|
`/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<TheMealDbMeal> {
|
||||||
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
|
iconUrl: null,
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
|
iconUrl: null,
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
|
||||||
key,
|
key,
|
||||||
name: "Fake Source",
|
name: "Fake Source",
|
||||||
official: false,
|
official: false,
|
||||||
|
iconUrl: null,
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
const start = params.cursor ? Number(params.cursor) : 0;
|
const start = params.cursor ? Number(params.cursor) : 0;
|
||||||
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
||||||
|
|
|
||||||
|
|
@ -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 { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.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 {
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
|
iconUrl: null,
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.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, official: boolean): RecipeSourceAdapter {
|
function buildFakeAdapter(
|
||||||
|
key: string,
|
||||||
|
name: string,
|
||||||
|
official: boolean,
|
||||||
|
iconUrl: string | null = null,
|
||||||
|
): RecipeSourceAdapter {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
official,
|
official,
|
||||||
|
iconUrl,
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
@ -159,19 +165,28 @@ describe("Reference data", () => {
|
||||||
expect(res.body).to.deep.equal([]);
|
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("fakeSource", "Fake Source", false));
|
||||||
registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true));
|
registerRecipeSource(
|
||||||
|
buildFakeAdapter(
|
||||||
|
"officialSource",
|
||||||
|
"Official Source",
|
||||||
|
true,
|
||||||
|
"https://example.test/icon.svg",
|
||||||
|
),
|
||||||
|
);
|
||||||
await syncRecipeSources(prisma);
|
await syncRecipeSources(prisma);
|
||||||
|
|
||||||
const res = await request(app).get("/reference/sources");
|
const res = await request(app).get("/reference/sources");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
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);
|
const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key);
|
||||||
expect(byKey("fakeSource").official).to.equal(false);
|
expect(byKey("fakeSource").official).to.equal(false);
|
||||||
|
expect(byKey("fakeSource").iconUrl).to.equal(null);
|
||||||
expect(byKey("officialSource").official).to.equal(true);
|
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 () => {
|
it("orders sources alphabetically by name", async () => {
|
||||||
|
|
|
||||||
27
apps/api/test/sources-index.test.ts
Normal file
27
apps/api/test/sources-index.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
175
apps/api/test/the-meal-db.test.ts
Normal file
175
apps/api/test/the-meal-db.test.ts
Normal file
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,11 +20,13 @@ interface SourceSelectProps {
|
||||||
* (opt-in — see `HouseSource` in schema.prisma), not an incomplete one.
|
* (opt-in — see `HouseSource` in schema.prisma), not an incomplete one.
|
||||||
*
|
*
|
||||||
* Unlike `AllergySelect`, a source's display text is its own `name`
|
* 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.<domain>.<key>` i18n — nothing to translate. The
|
* through `catalog.<domain>.<key>` i18n — nothing to translate. The
|
||||||
* `official`/`unofficial` badge next to it is what *is* translated, so a
|
* `official`/`unofficial` badge next to it is what *is* translated, so a
|
||||||
* household can tell an official API apart from a scraped site before
|
* 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) {
|
export function SourceSelect({ legend, sources, value, onChange }: SourceSelectProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -45,6 +47,7 @@ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectP
|
||||||
onChange={() => toggle(source.id)}
|
onChange={() => toggle(source.id)}
|
||||||
className="source-select__option"
|
className="source-select__option"
|
||||||
>
|
>
|
||||||
|
{source.iconUrl && <img src={source.iconUrl} alt="" className="source-select__icon" />}
|
||||||
{source.name}
|
{source.name}
|
||||||
<span className={`source-select__badge ${source.official ? "is-official" : ""}`}>
|
<span className={`source-select__badge ${source.official ? "is-official" : ""}`}>
|
||||||
{t(source.official ? "household.sources.official" : "household.sources.unofficial")}
|
{t(source.official ? "household.sources.official" : "household.sources.unofficial")}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@
|
||||||
font-size: var(--font-size-base);
|
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
|
// Official/unofficial marker — same "small pill" language as
|
||||||
// `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by
|
// `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by
|
||||||
// default (unofficial/scraped) and tinted primary once official.
|
// default (unofficial/scraped) and tinted primary once official.
|
||||||
|
|
|
||||||
|
|
@ -219,18 +219,21 @@ export interface TechStepView {
|
||||||
* the API — only a real adapter being registered in code adds a row).
|
* the API — only a real adapter being registered in code adds a row).
|
||||||
*
|
*
|
||||||
* Unlike `DietView`/`TechStepView`, `name` is the actual display string
|
* 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.<domain>.<key>` — a source's name is a proper noun/brand,
|
* `catalog.<domain>.<key>` — a source's name is a proper noun/brand,
|
||||||
* nothing to translate. `official` distinguishes a source backed by an
|
* nothing to translate. `official` distinguishes a source backed by an
|
||||||
* official API from one built by scraping HTML the site never committed to
|
* 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
|
* a stable shape — surfaced so households can make an informed choice when
|
||||||
* picking which sources to enable (see `HouseSource` in schema.prisma).
|
* 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 {
|
export interface SourceView {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
official: boolean;
|
official: boolean;
|
||||||
|
iconUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue