diff --git a/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql b/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql new file mode 100644 index 0000000..877c7a4 --- /dev/null +++ b/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql @@ -0,0 +1,24 @@ +-- Adds `Source.key` (`key String @unique`) — the catalog of implemented +-- recipe sources is now kept in sync with the adapter registry +-- (recipe-source-registry.ts) by key, same "stable English camelCase uid" +-- convention as Diet/Unit/TechStep, rather than hand-maintained. `sources` +-- has never been seeded (no rows exist pre-launch), so a plain NOT NULL +-- column with no backfill is safe. +-- +-- Adds `Recipe.external_id` — the item's identifier on `source`, `null` +-- for a manually-authored recipe. `@@unique([sourceId, externalId])` +-- prevents importing the same source recipe twice; Postgres treats each +-- NULL as distinct, so manually-authored recipes (both columns null) never +-- collide with each other or with one another here. + +-- AlterTable +ALTER TABLE "sources" ADD COLUMN "key" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "sources_key_key" ON "sources"("key"); + +-- AlterTable +ALTER TABLE "recipe" ADD COLUMN "external_id" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "recipe_source_id_external_id_key" ON "recipe"("source_id", "external_id"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9eddd26..12849fa 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -211,8 +211,20 @@ model PlanningItem { // Recipes // ----------------------------------------------------------------------------- +/// Catalog of implemented recipe sources (specific websites/APIs the +/// import pipeline knows how to talk to) — one row per adapter registered +/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by +/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather +/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`): +/// the adapter registry is the actual source of truth for "which sources +/// exist", this table just mirrors it so `Recipe.sourceId` has something +/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable +/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`. +/// Empty until a concrete adapter is registered (none exists yet, see +/// recipe-source-adapter.ts). model Source { id Int @id @default(autoincrement()) + key String @unique name String url String? @@ -239,6 +251,17 @@ model Recipe { id Int @id @default(autoincrement()) name String sourceId Int? @map("source_id") + /// The item's identifier on `source` (`RecipeSourceListItem.externalId`, + /// recipe-source-adapter.ts) — `null` for a manually-authored recipe, + /// alongside `sourceId` being `null`. Together with `sourceId`, this is + /// what `findImportedExternalIds` (recipe-source-sync.ts) checks against + /// to tell an already-imported source item apart from a new one when + /// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the + /// `@@unique([sourceId, externalId])` below is what actually prevents + /// importing the same source recipe twice (Postgres treats each `NULL` + /// as distinct, so manually-authored recipes never collide with each + /// other here). + externalId String? @map("external_id") description String? picture String? /// How many portions this recipe yields as written (its ingredient @@ -267,6 +290,7 @@ model Recipe { favoritedBy RecipeFavorite[] diets RecipeDiet[] + @@unique([sourceId, externalId]) @@map("recipe") } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 87534e6..6bc8ea6 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,4 +1,5 @@ import { PrismaClient } from "@prisma/client"; +import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js"; // Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in @@ -10,6 +11,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; const prisma = new PrismaClient(); seedReferenceData(prisma) + .then(() => syncRecipeSources(prisma)) .then(() => prisma.$disconnect()) .catch(async (err) => { console.error(err); diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts new file mode 100644 index 0000000..3ff0eb2 --- /dev/null +++ b/apps/api/src/db/recipe-source-sync.ts @@ -0,0 +1,61 @@ +import type { PrismaClient } from "@prisma/client"; +import { listRecipeSources } from "../lib/recipe-source-registry.js"; + +/** + * Upserts one `Source` row (schema.prisma) per adapter currently in + * `recipe-source-registry.ts`, keyed by `adapter.key` — keeps the + * `sources` catalog an exact mirror of "which sources are actually + * implemented in code", rather than a hand-maintained list that can drift + * out of sync the way `DIETS`/`UNITS` (`reference-seed-data.ts`) would if + * copy-pasted here. Call once at startup (`prisma/seed.ts`) and in test + * setup (`test-support/reset-db.ts`), the same place `seedReferenceData` + * runs — kept as its own function rather than folded into that one, since + * it reads from the adapter registry instead of a static array. + * + * Never deletes a `Source` row whose key fell out of the registry (e.g. an + * adapter temporarily removed from code) — a recipe already imported from + * it should keep citing it rather than having `sourceId` silently nulled + * 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. + */ +export async function syncRecipeSources(prisma: PrismaClient): Promise { + for (const adapter of listRecipeSources()) { + await prisma.source.upsert({ + where: { key: adapter.key }, + update: { name: adapter.name }, + create: { key: adapter.key, name: adapter.name }, + }); + } +} + +/** + * Which of `externalIds` already have a `Recipe` imported from the source + * registered under `sourceKey` — the DB-touching counterpart to + * `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and + * takes this set as a plain argument rather than querying itself. Returns + * an empty set (not an error) for a `sourceKey` with no matching `Source` + * row — nothing can have been imported from a source we don't even have a + * catalog entry for. + */ +export async function findImportedExternalIds( + prisma: PrismaClient, + sourceKey: string, + externalIds: string[], +): Promise> { + if (externalIds.length === 0) return new Set(); + + const source = await prisma.source.findUnique({ where: { key: sourceKey } }); + if (!source) return new Set(); + + const imported = await prisma.recipe.findMany({ + where: { sourceId: source.id, externalId: { in: externalIds } }, + select: { externalId: true }, + }); + return new Set( + imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])), + ); +} diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts new file mode 100644 index 0000000..2475ce8 --- /dev/null +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -0,0 +1,162 @@ +/** + * The generic contract every recipe source (a specific website, an API, …) + * implements — groundwork for the "Import d'une recette" pipeline described + * in specs/batch-cooking-architecture.md (import depuis source → traduction + * en étapes → sauvegarde). This file only defines the shapes; no concrete + * source exists yet (see `recipe-source-registry.ts` for where one would be + * registered) and nothing here talks to the database or an HTTP route — + * that wiring (persisting an imported recipe, resolving `sourceId`) is + * deliberately out of scope until a real source needs it. + * + * The flow a caller drives against one adapter: + * 1. `list()` — browse what's available from the source (paginated, + * optionally filtered by `query`), like flipping through a catalog. + * 2. {@link markAlreadyImported} — flag which of those items we've + * already imported, so browsing a source doesn't dangle recipes the + * user has already brought in as if they were new. A separate, pure + * step rather than something `list()` itself does: an adapter only + * knows its source, never our database — same reasoning as + * `tech-step-matcher.ts`'s split between pure `matchTechStep` and its + * DB-touching `loadTechStepMappingRules`. Whichever future layer + * queries "which externalIds from this source do we already have" + * (not yet decided — it needs a place to persist that link, + * see {@link RecipeSourceListItem.externalId}) calls this to annotate + * the page before returning it. + * 3. `fetchDetail(externalId)` — once the user picks one item from that + * list, fetch its full raw content. + * 4. `parse(raw)` — turn that raw content into a {@link ParsedRecipe}, + * pure and synchronous so it's unit-testable without any network + * access (same split as step 2 above). + */ + +/** Search/pagination input for {@link RecipeSourceAdapter.list}. */ +export interface RecipeSourceListParams { + /** Free-text search, if the source supports it. Omitted means "browse everything". */ + query?: string; + /** + * Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor} + * — omitted (or `null`) means "start from the first page". Deliberately + * opaque (not a page number) so an adapter can back it with whatever its + * source actually supports (page number, offset, an API-provided token). + */ + cursor?: string | null; +} + +/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */ +export interface RecipeSourceListItem { + /** + * Source-specific identifier, opaque to callers — passed back verbatim + * to {@link RecipeSourceAdapter.fetchDetail}, and the key + * {@link markAlreadyImported} matches against to tell an already-imported + * item apart from a new one. + */ + externalId: string; + title: string; + picture: string | null; + /** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */ + url: string; +} + +export interface RecipeSourceListResult { + items: RecipeSourceListItem[]; + /** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */ + nextCursor: string | null; +} + +/** A browsed {@link RecipeSourceListItem}, after {@link markAlreadyImported} has flagged whether we already imported it. What a browsing UI actually renders — e.g. to grey it out or offer "already added" instead of "import". */ +export interface BrowsableRecipeItem extends RecipeSourceListItem { + alreadyImported: boolean; +} + +/** + * Splits a page of {@link RecipeSourceListItem}s into already-imported vs. + * new, purely by checking each item's `externalId` against + * `importedExternalIds` — no I/O here, the caller is responsible for + * gathering that set (from wherever we end up persisting the link between + * an imported `Recipe` and the source item it came from) before calling + * this. Kept as a tiny, dedicated, easily-testable step rather than folded + * into `list()` itself, so an adapter never needs to know our database + * exists. + */ +export function markAlreadyImported( + items: RecipeSourceListItem[], + importedExternalIds: ReadonlySet, +): BrowsableRecipeItem[] { + return items.map((item) => ({ + ...item, + alreadyImported: importedExternalIds.has(item.externalId), + })); +} + +/** + * One ingredient line as lifted from a source, before it's resolved against + * our own `Ingredient`/`Unit` reference catalogs (that resolution — + * matching free text to a `key`, the way `tech-step-matcher.ts` matches + * step text to a `TechStep` — is a separate, not-yet-built concern; this + * type only carries what a source's raw text actually says). `rawText` is + * kept alongside the (best-effort) parsed fields so a failed/partial parse + * is still traceable back to what the source originally wrote. + */ +export interface ParsedRecipeIngredient { + rawText: string; + quantity: number | null; + /** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */ + unit: string | null; + /** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */ + name: string; +} + +export interface ParsedRecipeStep { + description: string; + picture: string | null; +} + +/** + * The normalized shape every adapter's {@link RecipeSourceAdapter.parse} + * produces, regardless of the source. Intentionally *not* + * `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients + * are still free text (no `ingredientId`/`unitId` — that catalog-matching + * step doesn't exist yet), and there's no `dietIds`/`visibility` since a + * source can't know those. Turning a `ParsedRecipe` into a saved `Recipe` + * is future work for whichever module ends up driving this pipeline. + */ +export interface ParsedRecipe { + name: string; + description: string | null; + picture: string | null; + /** `null` when the source doesn't state a serving size. */ + portions: number | null; + /** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */ + sourceUrl: string; + ingredients: ParsedRecipeIngredient[]; + steps: ParsedRecipeStep[]; +} + +/** + * A single recipe source — a specific website or API, plus the two pieces + * of source-specific logic needed to pull a recipe out of it. `TRawDetail` + * is whatever shape `fetchDetail` naturally returns for this source (an + * HTML string, a parsed JSON body, …); `parse` is the only thing that needs + * to understand it. + * + * @example + * ```ts + * const myAdapter: RecipeSourceAdapter<{ html: string }> = { + * key: "someRecipeSite", + * name: "Some Recipe Site", + * async list(params) { ... }, + * async fetchDetail(externalId) { ... }, + * parse(raw) { ... }, + * }; + * registerRecipeSource(myAdapter); + * ``` + */ +export interface RecipeSourceAdapter { + /** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */ + key: string; + /** Human-readable name, for display in a source picker. */ + name: string; + list(params: RecipeSourceListParams): Promise; + fetchDetail(externalId: string): Promise; + parse(raw: TRawDetail): ParsedRecipe; +} diff --git a/apps/api/src/lib/recipe-source-errors.ts b/apps/api/src/lib/recipe-source-errors.ts new file mode 100644 index 0000000..256c898 --- /dev/null +++ b/apps/api/src/lib/recipe-source-errors.ts @@ -0,0 +1,37 @@ +/** + * Error vocabulary a {@link RecipeSourceAdapter} (recipe-source-adapter.ts) + * implementation throws when talking to its source fails — kept separate + * from `@batch-cooking/error-tools`'s `HttpError`/`ErrorCode` (used for + * *this API's* HTTP responses) since no route drives this module yet. A + * future import route would catch these and translate them into an + * `HttpError` with a dedicated `ErrorCode` the same way any other service + * error is; this module only needs a consistent shape to throw in the + * meantime, not that translation. + */ + +/** Base class for every error a {@link RecipeSourceAdapter} can throw — lets a caller `catch (err) { if (err instanceof RecipeSourceError) ... }` regardless of which stage failed. */ +export class RecipeSourceError extends Error { + /** The failing adapter's `key` (recipe-source-adapter.ts's `RecipeSourceAdapter.key`) — which source this error came from. */ + readonly sourceKey: string; + + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(message, options); + this.sourceKey = sourceKey; + } +} + +/** The source's `list`/`fetchDetail` failed — network error, non-2xx response, source unreachable, etc. */ +export class RecipeSourceFetchError extends RecipeSourceError { + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(sourceKey, message, options); + this.name = "RecipeSourceFetchError"; + } +} + +/** The source responded, but `parse` couldn't make sense of the raw payload (unexpected shape, missing required field, …). */ +export class RecipeSourceParseError extends RecipeSourceError { + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(sourceKey, message, options); + this.name = "RecipeSourceParseError"; + } +} diff --git a/apps/api/src/lib/recipe-source-registry.ts b/apps/api/src/lib/recipe-source-registry.ts new file mode 100644 index 0000000..f12e682 --- /dev/null +++ b/apps/api/src/lib/recipe-source-registry.ts @@ -0,0 +1,56 @@ +import type { RecipeSourceAdapter } from "./recipe-source-adapter.js"; + +/** + * In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts) + * this process knows about, keyed by `adapter.key`. Deliberately not + * DB-backed — an adapter *is* code (a website's fetch/parse logic can't + * live in a database row) — but the `Source` table (schema.prisma) is kept + * in sync with it (see `syncRecipeSources`, recipe-source-sync.ts) so a + * saved `Recipe.sourceId` has a row to point at. Actually saving an + * imported recipe (setting `Recipe.sourceId`/`externalId`) is still future + * work for whichever module ends up driving the import pipeline — this + * registry only answers "which sources can we import from right now". + * + * No adapter is registered here yet — this file only provides the + * mechanism; `registerRecipeSource` is meant to be called once per adapter + * module, at whatever point a concrete source is added. + */ +const adapters = new Map(); + +/** + * Registers `adapter` under its own `key`. Throws if that key is already + * taken — two adapters silently overwriting each other would be a bug (a + * caller reaching for "marmiton" should never get a different adapter than + * the one it registered), not a case to swallow. + */ +export function registerRecipeSource(adapter: RecipeSourceAdapter): void { + if (adapters.has(adapter.key)) { + throw new Error(`Recipe source "${adapter.key}" is already registered`); + } + // `TRawDetail` only matters within one adapter's own list/fetchDetail/parse + // trio — once stored, callers look adapters up by key and drive the same + // three methods generically, so the registry itself doesn't need to know + // each adapter's raw type. This cast is the standard way to store a + // heterogeneous collection of otherwise-identically-shaped generics. + adapters.set(adapter.key, adapter as RecipeSourceAdapter); +} + +/** The adapter registered under `key`, or `undefined` if none is. */ +export function getRecipeSource(key: string): RecipeSourceAdapter | undefined { + return adapters.get(key); +} + +/** Every registered adapter — e.g. to offer a source picker. */ +export function listRecipeSources(): RecipeSourceAdapter[] { + return [...adapters.values()]; +} + +/** + * Empties the registry. Not meant for application code — `apps/api/src` + * never calls this — only for test isolation, the same role + * `test-support/reset-db.ts` plays for the database: without it, adapters + * registered by one test file would leak into the next. + */ +export function clearRecipeSources(): void { + adapters.clear(); +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 3efcc0a..81f235a 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -1,4 +1,5 @@ import { prisma } from "../src/db/prisma.js"; +import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js"; // Single TRUNCATE ... CASCADE covers FK ordering and resets identity @@ -7,6 +8,9 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; // truncating it, so every test starts from the same realistic reference // data the real app seeds (`prisma/seed.ts`) rather than empty tables — // tests exercising dietId/allergyIds/unitId need real rows to reference. +// `syncRecipeSources` runs last, for the same reason: `sources` should +// reflect whatever adapters this test run happens to have registered +// (usually none — see recipe-source-registry.ts). export async function resetDatabase() { await prisma.$executeRawUnsafe(` TRUNCATE TABLE @@ -18,4 +22,5 @@ export async function resetDatabase() { RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); + await syncRecipeSources(prisma); } diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts new file mode 100644 index 0000000..0eefee3 --- /dev/null +++ b/apps/api/test/recipe-source-sync.test.ts @@ -0,0 +1,204 @@ +import type { SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.js"; +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"; + +/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name` matter for exercising `syncRecipeSources`. */ +function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { + return { + key, + name, + async list() { + return { items: [], nextCursor: null }; + }, + async fetchDetail() { + throw new Error("not implemented"); + }, + parse() { + throw new Error("not implemented"); + }, + }; +} + +describe("recipe-source-sync", () => { + const app = createApp(); + + async function signup(): Promise<{ profileId: number }> { + const res = await request.agent(app).post("/auth/signup").send(buildSignupPayload()); + return { profileId: res.body.id }; + } + + beforeEach(async () => { + await resetDatabase(); + clearRecipeSources(); + }); + + afterEach(() => { + clearRecipeSources(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("syncRecipeSources", () => { + it("does nothing when the registry is empty", async () => { + await syncRecipeSources(prisma); + expect(await prisma.source.count()).to.equal(0); + }); + + it("creates a Source row per registered adapter", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + + await syncRecipeSources(prisma); + + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + expect(source.name).to.equal("Fake Source"); + }); + + it("is idempotent — running it twice doesn't duplicate rows", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + + await syncRecipeSources(prisma); + await syncRecipeSources(prisma); + + expect(await prisma.source.count()).to.equal(1); + }); + + it("updates the name when the adapter's own name changes between syncs", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Old Name")); + await syncRecipeSources(prisma); + clearRecipeSources(); + registerRecipeSource(buildFakeAdapter("fakeSource", "New Name")); + + await syncRecipeSources(prisma); + + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + expect(source.name).to.equal("New Name"); + }); + + it("never deletes a Source row whose key fell out of the registry", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + await syncRecipeSources(prisma); + clearRecipeSources(); + + await syncRecipeSources(prisma); + + expect(await prisma.source.count()).to.equal(1); + }); + }); + + describe("findImportedExternalIds", () => { + it("returns an empty set for a sourceKey with no matching Source row", async () => { + expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set()); + }); + + it("returns an empty set for an empty externalIds list", async () => { + expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set()); + }); + + it("returns exactly the externalIds already imported from that source", async () => { + const { profileId } = await signup(); + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + + await prisma.recipe.create({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + // Manually-authored, not tied to any source — shouldn't ever show up as "imported". + await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } }); + + const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]); + expect(result).to.deep.equal(new Set(["1"])); + }); + + it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => { + const { profileId } = await signup(); + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + registerRecipeSource(buildFakeAdapter("otherSource", "Other Source")); + await syncRecipeSources(prisma); + const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } }); + + await prisma.recipe.create({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: otherSource.id, + externalId: "1", + }, + }); + + expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set()); + }); + }); + + describe("Recipe(sourceId, externalId) uniqueness", () => { + it("rejects importing the same source recipe twice", async () => { + const { profileId } = await signup(); + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + + await prisma.recipe.create({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + + let rejected = false; + try { + await prisma.recipe.create({ + data: { + name: "Tarte (again)", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + } catch { + rejected = true; + } + expect(rejected).to.be.true; + }); + + it("allows any number of manually-authored recipes (both columns null)", async () => { + const { profileId } = await signup(); + + await prisma.recipe.create({ data: { name: "Une", authorId: profileId, portions: 4 } }); + await prisma.recipe.create({ data: { name: "Deux", authorId: profileId, portions: 4 } }); + + expect(await prisma.recipe.count()).to.equal(2); + }); + }); +}); diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts new file mode 100644 index 0000000..ff04e86 --- /dev/null +++ b/apps/api/test/recipe-source.test.ts @@ -0,0 +1,270 @@ +import { expect } from "chai"; +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListItem, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../src/lib/recipe-source-adapter.js"; +import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js"; +import { + RecipeSourceError, + RecipeSourceFetchError, + RecipeSourceParseError, +} from "../src/lib/recipe-source-errors.js"; +import { + clearRecipeSources, + getRecipeSource, + listRecipeSources, + registerRecipeSource, +} from "../src/lib/recipe-source-registry.js"; + +interface FakeRawRecipe { + externalId: string; + title: string; + servings: number; + ingredientLines: string[]; + instructionLines: string[]; +} + +const FAKE_CATALOG: FakeRawRecipe[] = [ + { + externalId: "1", + title: "Tarte aux pommes", + servings: 6, + ingredientLines: ["3 pommes", "200 g de farine"], + instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"], + }, + { + externalId: "2", + title: "Soupe de légumes", + servings: 4, + ingredientLines: ["2 carottes"], + instructionLines: ["Mijoter 20 minutes"], + }, + { + externalId: "3", + title: "Salade César", + servings: 2, + ingredientLines: ["1 salade"], + instructionLines: ["Mélanger"], + }, +]; + +const PAGE_SIZE = 2; + +/** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */ +function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { + return { + key, + name: "Fake Source", + async list(params: RecipeSourceListParams): Promise { + const start = params.cursor ? Number(params.cursor) : 0; + const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); + const nextStart = start + PAGE_SIZE; + return { + items: page.map((recipe) => ({ + externalId: recipe.externalId, + title: recipe.title, + picture: null, + url: `https://fake.test/recipes/${recipe.externalId}`, + })), + nextCursor: nextStart < FAKE_CATALOG.length ? String(nextStart) : null, + }; + }, + async fetchDetail(externalId: string): Promise { + const found = FAKE_CATALOG.find((recipe) => recipe.externalId === externalId); + if (!found) throw new RecipeSourceFetchError(key, `Unknown recipe ${externalId}`); + return found; + }, + parse(raw: FakeRawRecipe): ParsedRecipe { + return { + name: raw.title, + description: null, + picture: null, + portions: raw.servings, + sourceUrl: `https://fake.test/recipes/${raw.externalId}`, + ingredients: raw.ingredientLines.map((line) => ({ + rawText: line, + quantity: null, + unit: null, + name: line, + })), + steps: raw.instructionLines.map((line) => ({ description: line, picture: null })), + }; + }, + }; +} + +describe("recipe-source", () => { + afterEach(() => { + clearRecipeSources(); + }); + + describe("registry", () => { + it("registers and retrieves an adapter by key", () => { + const adapter = buildFakeAdapter(); + registerRecipeSource(adapter); + expect(getRecipeSource("fakeSource")).to.equal(adapter); + }); + + it("returns undefined for an unregistered key", () => { + expect(getRecipeSource("unknown")).to.be.undefined; + }); + + it("lists every registered adapter", () => { + registerRecipeSource(buildFakeAdapter("fakeSource")); + registerRecipeSource(buildFakeAdapter("otherSource")); + + expect( + listRecipeSources() + .map((adapter) => adapter.key) + .sort(), + ).to.deep.equal(["fakeSource", "otherSource"]); + }); + + it("rejects registering the same key twice", () => { + registerRecipeSource(buildFakeAdapter()); + expect(() => registerRecipeSource(buildFakeAdapter())).to.throw(/already registered/); + }); + + it("clearRecipeSources empties the registry", () => { + registerRecipeSource(buildFakeAdapter()); + clearRecipeSources(); + expect(listRecipeSources()).to.deep.equal([]); + }); + }); + + describe("adapter contract (via a fake adapter)", () => { + it("browses in pages until nextCursor is null", async () => { + const adapter = buildFakeAdapter(); + + const firstPage = await adapter.list({}); + expect(firstPage.items.map((item) => item.externalId)).to.deep.equal(["1", "2"]); + expect(firstPage.nextCursor).to.equal("2"); + + const secondPage = await adapter.list({ cursor: firstPage.nextCursor }); + expect(secondPage.items.map((item) => item.externalId)).to.deep.equal(["3"]); + expect(secondPage.nextCursor).to.be.null; + }); + + it("filters by query the same way, when the source supports it (fake adapter ignores it — only pagination is exercised here)", async () => { + const adapter = buildFakeAdapter(); + const res = await adapter.list({ query: "tarte" }); + // Documents that `query` is a valid, optional param even though this + // particular fake doesn't act on it — a real adapter would filter. + expect(res.items).to.have.length(2); + }); + + it("fetches the detail for a selected item, then parses it into a ParsedRecipe", async () => { + const adapter = buildFakeAdapter(); + + const raw = await adapter.fetchDetail("1"); + const parsed = adapter.parse(raw); + + expect(parsed.name).to.equal("Tarte aux pommes"); + expect(parsed.description).to.be.null; + expect(parsed.portions).to.equal(6); + expect(parsed.sourceUrl).to.equal("https://fake.test/recipes/1"); + expect(parsed.ingredients).to.have.length(2); + expect(parsed.ingredients[0]).to.deep.equal({ + rawText: "3 pommes", + quantity: null, + unit: null, + name: "3 pommes", + }); + expect(parsed.steps).to.deep.equal([ + { description: "Éplucher les pommes", picture: null }, + { description: "Cuire 30 minutes", picture: null }, + ]); + }); + + it("throws RecipeSourceFetchError for an unknown externalId", async () => { + const adapter = buildFakeAdapter(); + + try { + await adapter.fetchDetail("does-not-exist"); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("fakeSource"); + } + }); + }); + + describe("markAlreadyImported", () => { + const items: RecipeSourceListItem[] = [ + { + externalId: "1", + title: "Tarte aux pommes", + picture: null, + url: "https://fake.test/recipes/1", + }, + { + externalId: "2", + title: "Soupe de légumes", + picture: null, + url: "https://fake.test/recipes/2", + }, + { externalId: "3", title: "Salade César", picture: null, url: "https://fake.test/recipes/3" }, + ]; + + it("flags items whose externalId is in the imported set, leaves the rest false", () => { + const result = markAlreadyImported(items, new Set(["1", "3"])); + + expect( + result.map((item) => ({ + externalId: item.externalId, + alreadyImported: item.alreadyImported, + })), + ).to.deep.equal([ + { externalId: "1", alreadyImported: true }, + { externalId: "2", alreadyImported: false }, + { externalId: "3", alreadyImported: true }, + ]); + }); + + it("flags nothing when the imported set is empty", () => { + const result = markAlreadyImported(items, new Set()); + expect(result.every((item) => item.alreadyImported === false)).to.be.true; + }); + + it("returns an empty list unchanged", () => { + expect(markAlreadyImported([], new Set(["1"]))).to.deep.equal([]); + }); + + it("preserves every field from the original item alongside the new flag", () => { + const [first] = markAlreadyImported([items[0]], new Set(["1"])); + expect(first).to.deep.equal({ ...items[0], alreadyImported: true }); + }); + + it("doesn't mutate the input items", () => { + const snapshot = structuredClone(items); + markAlreadyImported(items, new Set(["1"])); + expect(items).to.deep.equal(snapshot); + }); + }); + + describe("RecipeSourceError hierarchy", () => { + it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => { + const cause = new Error("network down"); + const err = new RecipeSourceFetchError("fakeSource", "could not reach source", { cause }); + + expect(err).to.be.instanceOf(Error); + expect(err).to.be.instanceOf(RecipeSourceError); + expect(err.name).to.equal("RecipeSourceFetchError"); + expect(err.sourceKey).to.equal("fakeSource"); + expect(err.message).to.equal("could not reach source"); + expect(err.cause).to.equal(cause); + }); + + it("RecipeSourceParseError carries the source key and works without a cause", () => { + const err = new RecipeSourceParseError("fakeSource", "unexpected shape"); + + expect(err).to.be.instanceOf(RecipeSourceError); + expect(err.name).to.equal("RecipeSourceParseError"); + expect(err.sourceKey).to.equal("fakeSource"); + expect(err.cause).to.be.undefined; + }); + }); +});