Merge pull request #38 from kyuno053/feat/themealdb-source-icon
feat(recipes): première source concrète (TheMealDB) + icône de source
This commit is contained in:
commit
89937d7a6b
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