From 4a1e46b283f96c6b990ae7ef04157d127ab29a41 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:15:50 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(recipes):=20module=20g=C3=A9n=C3=A9riq?= =?UTF-8?q?ue=20d'adaptateurs=20de=20sources=20de=20recettes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose les bases du pipeline d'import décrit dans specs/batch-cooking-architecture.md (Import depuis source → Traduction en étapes → Sauvegarde), en commençant par le premier maillon : récupérer et parser des recettes brutes depuis une source externe, indépendamment du site/API concerné. - RecipeSourceAdapter (recipe-source-adapter.ts) : contrat générique par source — list() pour parcourir un catalogue de façon paginée (l'utilisateur "browse" les recettes disponibles), puis fetchDetail(externalId) une fois une recette sélectionnée, puis parse(raw) pour la transformer en ParsedRecipe. parse() est pure et synchrone (même séparation I/O vs pur que tech-step-matcher.ts), ce qui la rend testable sans réseau. - ParsedRecipe est volontairement distinct de CreateRecipeInput : les ingrédients restent en texte libre (pas d'ingredientId/unitId) — la résolution vers nos catalogues Ingredient/Unit est un sujet séparé, pas encore construit. - recipe-source-registry.ts : registre en mémoire des adaptateurs, identifiés par une clé stable (même convention que Diet.key/ Unit.key/TechStep.key), distinct de la table Source (schema.prisma) qui documente la provenance d'une recette déjà sauvegardée. - recipe-source-errors.ts : RecipeSourceFetchError/RecipeSourceParseError, vocabulaire d'erreur dédié en attendant qu'une route les traduise en HttpError/ErrorCode. Pas de route HTTP, pas d'écriture en base, pas d'implémentation concrète pour l'instant — uniquement le module générique, validé par un adaptateur factice dans les tests. Le câblage (endpoint, sourceId, un vrai parseur) sera une PR suivante. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/lib/recipe-source-adapter.ts | 122 ++++++++++++ apps/api/src/lib/recipe-source-errors.ts | 37 ++++ apps/api/src/lib/recipe-source-registry.ts | 57 ++++++ apps/api/test/recipe-source.test.ts | 215 +++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 apps/api/src/lib/recipe-source-adapter.ts create mode 100644 apps/api/src/lib/recipe-source-errors.ts create mode 100644 apps/api/src/lib/recipe-source-registry.ts create mode 100644 apps/api/test/recipe-source.test.ts 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..fd316fb --- /dev/null +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -0,0 +1,122 @@ +/** + * 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. `fetchDetail(externalId)` — once the user picks one item from that + * list, fetch its full raw content. + * 3. `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 `tech-step-matcher.ts`'s pure `matchTechStep` + * vs its DB-touching `loadTechStepMappingRules`). + */ + +/** 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}. */ + 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; +} + +/** + * 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..aca3b42 --- /dev/null +++ b/apps/api/src/lib/recipe-source-registry.ts @@ -0,0 +1,57 @@ +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), unlike the `Source` table in schema.prisma, + * which records *where a saved recipe came from* (a name/url pair) once + * the import pipeline actually persists one. The two are related but + * distinct: this registry is "which sources can we import from right now", + * `Source` rows are "which sources a saved recipe cites" — wiring the two + * together is future work for whichever module ends up saving imported + * recipes. + * + * 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/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts new file mode 100644 index 0000000..3267ec0 --- /dev/null +++ b/apps/api/test/recipe-source.test.ts @@ -0,0 +1,215 @@ +import { expect } from "chai"; +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} 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("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; + }); + }); +}); From dd747af70f838180c97e14f520e63247df33ed02 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:24:44 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(recipes):=20distingue=20les=20recettes?= =?UTF-8?q?=20d=C3=A9j=C3=A0=20import=C3=A9es=20lors=20du=20browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute markAlreadyImported(items, importedExternalIds) et le type BrowsableRecipeItem à recipe-source-adapter.ts : quand on parcourt le catalogue d'une source (list()), on peut désormais annoter chaque item pour savoir s'il correspond à une recette déjà intégrée dans notre base ou non. Reste une fonction pure, volontairement séparée de list() : un adaptateur ne connaît que sa source, jamais notre base — même séparation I/O/pur que tech-step-matcher.ts. La constitution du set d'externalId déjà importés (où/comment on persiste ce lien) est laissée à une future couche, pas encore décidée. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/lib/recipe-source-adapter.ts | 50 ++++++++++++++++++--- apps/api/test/recipe-source.test.ts | 55 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index fd316fb..2475ce8 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -11,12 +11,22 @@ * 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. `fetchDetail(externalId)` — once the user picks one item from that + * 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. - * 3. `parse(raw)` — turn that raw content into a {@link ParsedRecipe}, + * 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 `tech-step-matcher.ts`'s pure `matchTechStep` - * vs its DB-touching `loadTechStepMappingRules`). + * access (same split as step 2 above). */ /** Search/pagination input for {@link RecipeSourceAdapter.list}. */ @@ -34,7 +44,12 @@ export interface RecipeSourceListParams { /** 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}. */ + /** + * 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; @@ -48,6 +63,31 @@ export interface RecipeSourceListResult { 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 — diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts index 3267ec0..ff04e86 100644 --- a/apps/api/test/recipe-source.test.ts +++ b/apps/api/test/recipe-source.test.ts @@ -2,9 +2,11 @@ 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, @@ -190,6 +192,59 @@ describe("recipe-source", () => { }); }); + 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"); From aedeb257ced8807d06659667998a1d516078e5db Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:43:51 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(recipes):=20relie=20les=20recettes=20?= =?UTF-8?q?=C3=A0=20leur=20source=20(sourceId=20+=20externalId)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Répond au besoin identifié précédemment : la table `sources` devient un vrai catalogue des sources implémentées, et une recette importée pourra être reliée à l'item source dont elle provient. - Source.key (unique) — même convention que Diet.key/Unit.key/ TechStep.key. Le catalogue est désormais synchronisé depuis le registre d'adaptateurs (recipe-source-registry.ts) via syncRecipeSources() (nouveau apps/api/src/db/recipe-source-sync.ts), plutôt que maintenu à la main comme DIETS/UNITS — reste vide tant qu'aucun adaptateur concret n'est enregistré. - Recipe.externalId (nullable) — l'identifiant de la recette côté source. Contrainte @@unique([sourceId, externalId]) : empêche d'importer deux fois la même recette (les recettes manuelles, aux deux colonnes nulles, ne sont jamais en conflit entre elles). - findImportedExternalIds(prisma, sourceKey, externalIds) — le pendant DB de markAlreadyImported (recipe-source-adapter.ts), ferme la boucle commencée dans la PR précédente pour distinguer les recettes déjà intégrées lors du browse. - syncRecipeSources() appelé après seedReferenceData() dans prisma/seed.ts et test-support/reset-db.ts. Toujours pas de route HTTP ni de champ sourceId/externalId exposé dans createRecipeSchema — la sauvegarde effective d'une recette importée reste pour une PR ultérieure. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 24 +++ apps/api/prisma/schema.prisma | 24 +++ apps/api/prisma/seed.ts | 2 + apps/api/src/db/recipe-source-sync.ts | 61 ++++++ apps/api/src/lib/recipe-source-registry.ts | 13 +- apps/api/test-support/reset-db.ts | 5 + apps/api/test/recipe-source-sync.test.ts | 204 ++++++++++++++++++ 7 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql create mode 100644 apps/api/src/db/recipe-source-sync.ts create mode 100644 apps/api/test/recipe-source-sync.test.ts 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 23bfef3..91b1a52 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-registry.ts b/apps/api/src/lib/recipe-source-registry.ts index aca3b42..f12e682 100644 --- a/apps/api/src/lib/recipe-source-registry.ts +++ b/apps/api/src/lib/recipe-source-registry.ts @@ -4,13 +4,12 @@ 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), unlike the `Source` table in schema.prisma, - * which records *where a saved recipe came from* (a name/url pair) once - * the import pipeline actually persists one. The two are related but - * distinct: this registry is "which sources can we import from right now", - * `Source` rows are "which sources a saved recipe cites" — wiring the two - * together is future work for whichever module ends up saving imported - * recipes. + * 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 diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 3d6c793..52b264b 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); + }); + }); +});