Merge pull request #35 from kyuno053/feat/recipe-source-adapter
feat(recipes): module générique d'adaptateurs de sources de recettes
This commit is contained in:
commit
0b1c102418
10 changed files with 845 additions and 0 deletions
|
|
@ -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");
|
||||||
|
|
@ -211,8 +211,20 @@ model PlanningItem {
|
||||||
// Recipes
|
// 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 {
|
model Source {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
name String
|
name String
|
||||||
url String?
|
url String?
|
||||||
|
|
||||||
|
|
@ -239,6 +251,17 @@ model Recipe {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
sourceId Int? @map("source_id")
|
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?
|
description String?
|
||||||
picture String?
|
picture String?
|
||||||
/// How many portions this recipe yields as written (its ingredient
|
/// How many portions this recipe yields as written (its ingredient
|
||||||
|
|
@ -267,6 +290,7 @@ model Recipe {
|
||||||
favoritedBy RecipeFavorite[]
|
favoritedBy RecipeFavorite[]
|
||||||
diets RecipeDiet[]
|
diets RecipeDiet[]
|
||||||
|
|
||||||
|
@@unique([sourceId, externalId])
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
|
|
||||||
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
||||||
|
|
@ -10,6 +11,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
seedReferenceData(prisma)
|
seedReferenceData(prisma)
|
||||||
|
.then(() => syncRecipeSources(prisma))
|
||||||
.then(() => prisma.$disconnect())
|
.then(() => prisma.$disconnect())
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
|
||||||
61
apps/api/src/db/recipe-source-sync.ts
Normal file
61
apps/api/src/db/recipe-source-sync.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<Set<string>> {
|
||||||
|
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] : [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
162
apps/api/src/lib/recipe-source-adapter.ts
Normal file
162
apps/api/src/lib/recipe-source-adapter.ts
Normal file
|
|
@ -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<string>,
|
||||||
|
): 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<TRawDetail = unknown> {
|
||||||
|
/** 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<RecipeSourceListResult>;
|
||||||
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||||
|
parse(raw: TRawDetail): ParsedRecipe;
|
||||||
|
}
|
||||||
37
apps/api/src/lib/recipe-source-errors.ts
Normal file
37
apps/api/src/lib/recipe-source-errors.ts
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/lib/recipe-source-registry.ts
Normal file
56
apps/api/src/lib/recipe-source-registry.ts
Normal file
|
|
@ -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<string, RecipeSourceAdapter>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TRawDetail>(adapter: RecipeSourceAdapter<TRawDetail>): 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();
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { prisma } from "../src/db/prisma.js";
|
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";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
|
|
||||||
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity
|
// 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
|
// truncating it, so every test starts from the same realistic reference
|
||||||
// data the real app seeds (`prisma/seed.ts`) rather than empty tables —
|
// data the real app seeds (`prisma/seed.ts`) rather than empty tables —
|
||||||
// tests exercising dietId/allergyIds/unitId need real rows to reference.
|
// 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() {
|
export async function resetDatabase() {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
TRUNCATE TABLE
|
TRUNCATE TABLE
|
||||||
|
|
@ -18,4 +22,5 @@ export async function resetDatabase() {
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
`);
|
`);
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
apps/api/test/recipe-source-sync.test.ts
Normal file
204
apps/api/test/recipe-source-sync.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
270
apps/api/test/recipe-source.test.ts
Normal file
270
apps/api/test/recipe-source.test.ts
Normal file
|
|
@ -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<FakeRawRecipe> {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name: "Fake Source",
|
||||||
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
|
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<FakeRawRecipe> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue