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:
parent
73206c442b
commit
44ef5e071f
16 changed files with 628 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
51
apps/api/src/modules/sources/sources.routes.ts
Normal file
51
apps/api/src/modules/sources/sources.routes.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
191
apps/api/src/modules/sources/sources.service.ts
Normal file
191
apps/api/src/modules/sources/sources.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|||
name,
|
||||
official: false,
|
||||
iconUrl: null,
|
||||
locale: "fr",
|
||||
async list() {
|
||||
return { items: [], nextCursor: null };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|||
name,
|
||||
official: false,
|
||||
iconUrl: null,
|
||||
locale: "fr",
|
||||
async list() {
|
||||
return { items: [], nextCursor: null };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function buildFakeAdapter(
|
|||
name,
|
||||
official,
|
||||
iconUrl,
|
||||
locale: "fr",
|
||||
async list() {
|
||||
return { items: [], nextCursor: null };
|
||||
},
|
||||
|
|
|
|||
254
apps/api/test/sources.test.ts
Normal file
254
apps/api/test/sources.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
9
packages/shared/src/schemas/sources.ts
Normal file
9
packages/shared/src/schemas/sources.ts
Normal 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>;
|
||||
63
packages/shared/src/types/sources.ts
Normal file
63
packages/shared/src/types/sources.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in a new issue