feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)

Première étape du chantier "onglet Sources" (parcourir toutes les
recettes externes des sources activées par le foyer, importées ou non,
et déclencher leur import à l'ajout au planning) — celle-ci pose les
endpoints backend de lecture seule, rien n'est encore sauvegardé.

- RecipeSourceAdapter gagne `locale` (theMealDbAdapter: "en") — nécessaire
  pour que translateRecipe/matchTechStepSpans sachent contre quel jeu de
  TechStepMapping/labels d'ingrédients traduire une source donnée.
- findImportedExternalIds (recipe-source-sync.ts) devient
  findImportedRecipeIds : renvoie une Map<externalId, recipeId> au lieu
  d'un simple Set — son premier vrai appelant (le parcours) a besoin de
  l'id réel pour naviguer directement vers la recette déjà importée, pas
  seulement savoir qu'elle l'est.
- Nouveau module apps/api/src/modules/sources/ :
  - GET /sources/:sourceKey/browse — appelle list() de l'adaptateur,
    flague chaque item alreadyImported/recipeId. Restreint aux sources
    activées par le foyer courant (HouseSource) ; 404 SOURCE_NOT_FOUND
    sinon, même si la source existe (même posture que la visibilité des
    recettes : "pas trouvée" plutôt que "pas autorisée").
  - GET /sources/:sourceKey/preview/:externalId — fetchDetail + parse +
    résolution complète (translateRecipeIngredients, matchTechStepSpans
    avec spans réels) contre la locale de la source, sans rien
    sauvegarder. Ingrédients non résolus → null plutôt qu'une erreur.
- Nouveaux types partagés (packages/shared/src/types/sources.ts) :
  BrowsableSourceItemView, RecipeImportDraftView (+ Draft*View).

Vérifié en conditions réelles contre TheMealDB (recette "Chicken Handi") :
ingrédients résolus avec la bonne quantité/unité (1.2 kg de poulet, 8
gousses d'ail...), non-résolus corrects (huile végétale, piment vert),
et chaque étape avec ses techniques détectées et leurs spans exacts
(cook/fry/plate/setAside sur la même phrase, etc.).

Tests : 276 passing (+8 nouveaux, sources.test.ts). Étape suivante (2/4) :
l'UI de parcours (onglet Sources) — voir le plan de session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 15:51:16 +02:00
parent 73206c442b
commit 44ef5e071f
16 changed files with 628 additions and 24 deletions

View file

@ -10,6 +10,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js";
/**
* Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -34,6 +35,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after

View file

@ -41,28 +41,33 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
/**
* 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.
* registered under `sourceKey`, mapped to that `Recipe`'s id the
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
* `.keys()`) rather than querying itself. The id (not just membership) is
* what `sources.service.ts`'s browse endpoint needs to link an
* already-imported item straight to its real `Recipe`, instead of a
* caller having to look it up again. Returns an empty map (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(
export async function findImportedRecipeIds(
prisma: PrismaClient,
sourceKey: string,
externalIds: string[],
): Promise<Set<string>> {
if (externalIds.length === 0) return new Set();
): Promise<Map<string, number>> {
if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source) return new Set();
if (!source) return new Map();
const imported = await prisma.recipe.findMany({
where: { sourceId: source.id, externalId: { in: externalIds } },
select: { externalId: true },
select: { id: true, externalId: true },
});
return new Set(
imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])),
return new Map(
imported.flatMap((recipe) =>
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
),
);
}

View file

@ -146,6 +146,7 @@ export interface ParsedRecipe {
* name: "Some Recipe Site",
* official: false,
* iconUrl: "https://somerecipesite.example/favicon.svg",
* locale: "fr",
* async list(params) { ... },
* async fetchDetail(externalId) { ... },
* parse(raw) { ... },
@ -171,6 +172,16 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
official: boolean;
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
iconUrl: string | null;
/**
* Language of the text this source produces (`ParsedRecipe.description`/
* `steps[].description`/`ingredients[].name`) e.g. `"en"` for
* TheMealDB. Not a user preference: the language the source's own
* content is actually written in, regardless of who's browsing it.
* Determines which `TechStepMapping`/ingredient-label locale
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
* recipes against when previewing/importing one.
*/
locale: string;
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe;

View file

@ -0,0 +1,51 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, browseSourceSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { browseSource, previewSourceItem } from "./sources.service.js";
/**
* Router mounted at `/sources` in app.ts browsing/previewing a
* household's *enabled* external recipe sources (see `sources.service.ts`).
* Every route requires a session, same posture as `/recipes`/`/house`: this
* is app content scoped to the viewer's household, not signup-time
* reference data (contrast `/reference/sources`, which just lists what
* exists, public, no auth needed).
*/
export const sourcesRouter = Router();
/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */
function requireParam(value: string | undefined): string {
if (value === undefined) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
}
return value;
}
sourcesRouter.get(
"/:sourceKey/browse",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = browseSourceSchema.parse(req.query);
const { houseId } = res.locals.userProfile;
res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input));
}),
);
sourcesRouter.get(
"/:sourceKey/preview/:externalId",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const { houseId } = res.locals.userProfile;
res
.status(200)
.json(
await previewSourceItem(
requireParam(req.params.sourceKey),
requireParam(req.params.externalId),
houseId,
),
);
}),
);

View file

@ -0,0 +1,191 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type BrowsableSourceItemView,
type DraftRecipeIngredientView,
type DraftRecipeStepView,
ErrorCode,
type RecipeImportDraftView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
} from "../../lib/ingredient-matcher.js";
import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-source-registry.js";
import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js";
/**
* Browsing and previewing a household's *enabled* external recipe sources
* (`HouseSource`) the read-only half of the "onglet Sources" feature
* (see the project plan). Neither function here saves anything: browsing
* lists what a source offers (`RecipeSourceAdapter.list()`), previewing
* fully translates one item (`translateRecipeIngredients`,
* `matchTechStepSpans` same building blocks `recipe.service.ts` uses at
* actual save time) without persisting it. Turning a preview into a real
* `Recipe` (with unresolved ingredients reviewed/fixed up first) is a
* later stage of the same plan, not built here.
*/
/**
* `sourceKey` must both exist as a `Source` (household-enabled, via
* `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
* the two can drift apart (a `Source` row outlives its adapter being
* unregistered, exactly what `jsonLdRecipe` was cleaned up from see
* `sources/index.ts`), so both are checked. Either failure looks like "this
* source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
* "don't distinguish not-found from not-visible" posture `recipe.service.ts`
* takes for a recipe the viewer can't see.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
async function assertSourceEnabled(
houseId: number | null,
sourceKey: string,
): Promise<RecipeSourceAdapter> {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source || !enabledSourceIds.includes(source.id)) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" is not enabled for this household`,
);
}
const adapter = getRecipeSource(sourceKey);
if (!adapter) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" has no registered adapter`,
);
}
return adapter;
}
/**
* One page of `sourceKey`'s own catalog, each item flagged with whether
* it's already been imported (and, if so, its real `Recipe` id see
* `findImportedRecipeIds`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
export async function browseSource(
sourceKey: string,
houseId: number | null,
params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const adapter = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({ query: params.query, cursor: params.cursor });
const importedRecipeIds = await findImportedRecipeIds(
prisma,
sourceKey,
result.items.map((item) => item.externalId),
);
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
return {
items: marked.map((item) => ({
externalId: item.externalId,
title: item.title,
picture: item.picture,
url: item.url,
alreadyImported: item.alreadyImported,
recipeId: importedRecipeIds.get(item.externalId) ?? null,
})),
nextCursor: result.nextCursor,
};
}
/**
* Fully translates one source item into an unsaved {@link RecipeImportDraftView}
* fetches + parses it (`fetchDetail`/`parse`), then resolves its
* ingredients/units (`translateRecipeIngredients`) and detects each step's
* techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching itself only has English data today (see `ingredient-matcher.ts`);
* a non-English-locale source simply gets `ingredient`/`unit: null` on
* every line, the same graceful "no matching-language data" degradation
* `translateRecipe` already has.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` `recipe-source-errors.ts` from the adapter).
*/
export async function previewSourceItem(
sourceKey: string,
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
const adapter = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
try {
const raw = await adapter.fetchDetail(externalId);
parsed = adapter.parse(raw);
} catch (err) {
if (err instanceof RecipeSourceError) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
}
throw err;
}
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
loadTechStepMappingRules(adapter.locale),
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
const translatedIngredients = translateRecipeIngredients(
parsed.ingredients,
ingredientCatalog,
unitCatalog,
);
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
const unitById = new Map(unitViews.map((view) => [view.id, view]));
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
rawText: ingredient.rawText,
quantity: ingredient.quantity,
ingredient:
ingredient.ingredientId !== null
? (ingredientById.get(ingredient.ingredientId) ?? null)
: null,
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
}));
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
description: step.description,
picture: step.picture,
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
const techStep = techStepById.get(match.techStepId);
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
}),
}));
return {
sourceKey,
externalId,
name: parsed.name,
description: parsed.description,
picture: parsed.picture,
portions: parsed.portions,
sourceUrl: parsed.sourceUrl,
ingredients,
steps,
};
}

View file

@ -170,6 +170,13 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
name: "Import générique (JSON-LD)",
official: false,
iconUrl: null,
// Genuinely varies per scraped site (this adapter has no fixed content
// language of its own) — "fr" as a placeholder since it's never actually
// consulted: this adapter isn't registered into the source registry (see
// sources/index.ts), so nothing calls translateRecipe against it today.
// A concrete per-site adapter built on top of this one would declare its
// own real locale.
locale: "fr",
async list() {
return { items: [], nextCursor: null };

View file

@ -76,6 +76,10 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
name: "TheMealDB",
official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg",
// TheMealDB's content (names, ingredients, instructions) is English —
// determines which locale translateRecipe (recipe-translation.ts)
// resolves this source's recipes against when previewing/importing one.
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const query = params.query ?? "";

View file

@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},

View file

@ -4,7 +4,7 @@ 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 { findImportedRecipeIds, 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";
@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},
@ -108,22 +109,22 @@ describe("recipe-source-sync", () => {
});
});
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());
describe("findImportedRecipeIds", () => {
it("returns an empty map for a sourceKey with no matching Source row", async () => {
expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map());
});
it("returns an empty set for an empty externalIds list", async () => {
expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set());
it("returns an empty map for an empty externalIds list", async () => {
expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map());
});
it("returns exactly the externalIds already imported from that source", async () => {
it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", 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({
const imported = await prisma.recipe.create({
data: {
name: "Tarte",
authorId: profileId,
@ -135,8 +136,8 @@ describe("recipe-source-sync", () => {
// 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"]));
const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]);
expect(result).to.deep.equal(new Map([["1", imported.id]]));
});
it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => {
@ -156,7 +157,7 @@ describe("recipe-source-sync", () => {
},
});
expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set());
expect(await findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map());
});
});

View file

@ -60,6 +60,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
name: "Fake Source",
official: false,
iconUrl: null,
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const start = params.cursor ? Number(params.cursor) : 0;
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);

View file

@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},

View file

@ -20,6 +20,7 @@ function buildFakeAdapter(
name,
official,
iconUrl,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},

View file

@ -0,0 +1,254 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } 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 { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../src/lib/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.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, real English-content fake adapter `parse()` deliberately
* mixes one ingredient that resolves against the real seeded catalog
* ("onion") with one that doesn't ("mystery paste"), and a step whose
* text matches a real seeded English tech-step mapping ("chop") same
* "exercise the real catalog, not a mock of it" approach the ingredient/
* tech-step matcher tests already use.
*/
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake Source",
official: true,
iconUrl: null,
locale: "en",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" },
{ externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
if (externalId === "missing") {
throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`);
}
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Fake recipe ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "1 onion", quantity: null, unit: null, name: "onion" },
// No leading number and no recognizable unit word — exercises
// quantity/unit staying null alongside the ingredient itself not
// resolving, not just the ingredient.
{ rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" },
],
steps: [{ description: "Chop the onions finely", picture: null }],
};
},
};
}
describe("Sources", () => {
const app = createApp();
/** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */
async function signupWithHouse(): Promise<{
agent: ReturnType<typeof request.agent>;
houseId: number;
}> {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
return { agent, houseId: houseRes.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
clearRecipeSources();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /sources/:sourceKey/browse", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/sources/fakeSource/browse");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
const res = await agent.get("/sources/unknown/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => {
const { agent, houseId } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const importedRecipe = await prisma.recipe.create({
data: {
name: "Already imported",
authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(200);
expect(res.body.nextCursor).to.equal(null);
expect(res.body.items).to.deep.equal([
{
externalId: "1",
title: "Onion soup",
picture: null,
url: "https://fake.test/1",
alreadyImported: true,
recipeId: importedRecipe.id,
},
{
externalId: "2",
title: "Mystery stew",
picture: null,
url: "https://fake.test/2",
alreadyImported: false,
recipeId: null,
},
]);
});
});
describe("GET /sources/:sourceKey/preview/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent };
}
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => {
const { agent } = await enableFakeSource();
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body).to.deep.include({
sourceKey: "fakeSource",
externalId: "1",
name: "Fake recipe 1",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://fake.test/1",
});
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("1 onion");
expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(unresolved.rawText).to.equal("some mystery paste");
expect(unresolved.ingredient).to.equal(null);
expect(unresolved.unit).to.equal(null);
expect(unresolved.quantity).to.equal(null);
expect(res.body.steps).to.have.length(1);
const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1);
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop");
});
it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => {
const { agent } = await enableFakeSource();
const res = await agent.get("/sources/fakeSource/preview/missing");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
});

View file

@ -12,10 +12,12 @@ export * from "./schemas/planning.js";
export * from "./schemas/preferences.js";
export * from "./schemas/profile.js";
export * from "./schemas/recipe.js";
export * from "./schemas/sources.js";
export * from "./tools/assert-is-never.js";
export * from "./types/household.js";
export * from "./types/planning.js";
export * from "./types/preferences.js";
export * from "./types/recipe.js";
export * from "./types/reference.js";
export * from "./types/sources.js";
export * from "./types/user-profile.js";

View file

@ -0,0 +1,9 @@
import { z } from "zod";
/** Payload accepted by `GET /sources/:sourceKey/browse`'s query params — `query` free-text searches the source's own catalog (support varies by adapter, see `RecipeSourceListParams`), `cursor` continues a previous page (`RecipeSourceListResult.nextCursor`), omitted starts from the first page. */
export const browseSourceSchema = z.object({
query: z.string().trim().min(1).optional(),
cursor: z.string().trim().min(1).optional(),
});
/** Inferred TS type for {@link browseSourceSchema}'s validated output. */
export type BrowseSourceInput = z.infer<typeof browseSourceSchema>;

View file

@ -0,0 +1,63 @@
import type { StepTechStepView } from "./recipe.js";
import type { IngredientView, UnitView } from "./reference.js";
/**
* One item from a source's own catalog (`RecipeSourceAdapter.list()`),
* browsable regardless of whether it's already been imported
* `GET /sources/:sourceKey/browse`. Mirrors `BrowsableRecipeItem`
* (apps/api's `recipe-source-adapter.ts`), plus `recipeId`: the already-
* imported `Recipe`'s id when `alreadyImported` is true, so a caller (the
* browse UI) can navigate straight to it without a second lookup `null`
* otherwise.
*/
export interface BrowsableSourceItemView {
externalId: string;
title: string;
picture: string | null;
url: string;
alreadyImported: boolean;
recipeId: number | null;
}
/**
* One ingredient line of an unsaved import draft (`RecipeImportDraftView`)
* same spirit as `RecipeIngredientView`, but `ingredient`/`unit` can be
* `null` (nothing in the catalog matched see `ingredient-matcher.ts`) and
* `quantity` can be missing entirely, since this hasn't been reviewed/fixed
* up by a person yet.
*/
export interface DraftRecipeIngredientView {
/** Exactly what the source wrote for this line — kept even once ingredient/unit resolve, so a review screen can show what the match was made from. */
rawText: string;
quantity: number | null;
ingredient: IngredientView | null;
unit: UnitView | null;
}
/** One step of an unsaved import draft — `techSteps` is detected the same way a real save computes it (`matchTechStepSpans`), just not persisted yet. */
export interface DraftRecipeStepView {
description: string;
picture: string | null;
techSteps: StepTechStepView[];
}
/**
* An unsaved preview of one source item, fully translated (ingredients/
* units/techniques resolved against our catalogs where possible)
* `GET /sources/:sourceKey/preview/:externalId`. What a future import
* review screen pre-fills its form from. Deliberately distinct from
* `RecipeView`: nothing here has an id (nothing is saved), and ingredient/
* unit resolution can be incomplete nothing about this type assumes it's
* ready to persist as-is.
*/
export interface RecipeImportDraftView {
sourceKey: string;
externalId: string;
name: string;
description: string | null;
picture: string | null;
portions: number | null;
sourceUrl: string;
ingredients: DraftRecipeIngredientView[];
steps: DraftRecipeStepView[];
}