From dd747af70f838180c97e14f520e63247df33ed02 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:24:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20distingue=20les=20recettes=20d?= =?UTF-8?q?=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");