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
|
||||
/// 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[]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TRawDetail = unknown> {
|
|||
* 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<RecipeSourceListResult>;
|
||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||
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
|
||||
// `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" },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
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 {
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
official: false,
|
||||
iconUrl: null,
|
||||
async list() {
|
||||
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 {
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
official: false,
|
||||
iconUrl: null,
|
||||
async list() {
|
||||
return { items: [], nextCursor: null };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
|
|||
key,
|
||||
name: "Fake Source",
|
||||
official: false,
|
||||
iconUrl: null,
|
||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||
const start = params.cursor ? Number(params.cursor) : 0;
|
||||
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 { 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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
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.
|
||||
*
|
||||
* 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
|
||||
* `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 && <img src={source.iconUrl} alt="" className="source-select__icon" />}
|
||||
{source.name}
|
||||
<span className={`source-select__badge ${source.official ? "is-official" : ""}`}>
|
||||
{t(source.official ? "household.sources.official" : "household.sources.unofficial")}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.<domain>.<key>` — 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue