Pose les bases du pipeline d'import décrit dans specs/batch-cooking-architecture.md (Import depuis source → Traduction en étapes → Sauvegarde), en commençant par le premier maillon : récupérer et parser des recettes brutes depuis une source externe, indépendamment du site/API concerné. - RecipeSourceAdapter<TRawDetail> (recipe-source-adapter.ts) : contrat générique par source — list() pour parcourir un catalogue de façon paginée (l'utilisateur "browse" les recettes disponibles), puis fetchDetail(externalId) une fois une recette sélectionnée, puis parse(raw) pour la transformer en ParsedRecipe. parse() est pure et synchrone (même séparation I/O vs pur que tech-step-matcher.ts), ce qui la rend testable sans réseau. - ParsedRecipe est volontairement distinct de CreateRecipeInput : les ingrédients restent en texte libre (pas d'ingredientId/unitId) — la résolution vers nos catalogues Ingredient/Unit est un sujet séparé, pas encore construit. - recipe-source-registry.ts : registre en mémoire des adaptateurs, identifiés par une clé stable (même convention que Diet.key/ Unit.key/TechStep.key), distinct de la table Source (schema.prisma) qui documente la provenance d'une recette déjà sauvegardée. - recipe-source-errors.ts : RecipeSourceFetchError/RecipeSourceParseError, vocabulaire d'erreur dédié en attendant qu'une route les traduise en HttpError/ErrorCode. Pas de route HTTP, pas d'écriture en base, pas d'implémentation concrète pour l'instant — uniquement le module générique, validé par un adaptateur factice dans les tests. Le câblage (endpoint, sourceId, un vrai parseur) sera une PR suivante. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
import { expect } from "chai";
|
|
import type {
|
|
ParsedRecipe,
|
|
RecipeSourceAdapter,
|
|
RecipeSourceListParams,
|
|
RecipeSourceListResult,
|
|
} from "../src/lib/recipe-source-adapter.js";
|
|
import {
|
|
RecipeSourceError,
|
|
RecipeSourceFetchError,
|
|
RecipeSourceParseError,
|
|
} from "../src/lib/recipe-source-errors.js";
|
|
import {
|
|
clearRecipeSources,
|
|
getRecipeSource,
|
|
listRecipeSources,
|
|
registerRecipeSource,
|
|
} from "../src/lib/recipe-source-registry.js";
|
|
|
|
interface FakeRawRecipe {
|
|
externalId: string;
|
|
title: string;
|
|
servings: number;
|
|
ingredientLines: string[];
|
|
instructionLines: string[];
|
|
}
|
|
|
|
const FAKE_CATALOG: FakeRawRecipe[] = [
|
|
{
|
|
externalId: "1",
|
|
title: "Tarte aux pommes",
|
|
servings: 6,
|
|
ingredientLines: ["3 pommes", "200 g de farine"],
|
|
instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"],
|
|
},
|
|
{
|
|
externalId: "2",
|
|
title: "Soupe de légumes",
|
|
servings: 4,
|
|
ingredientLines: ["2 carottes"],
|
|
instructionLines: ["Mijoter 20 minutes"],
|
|
},
|
|
{
|
|
externalId: "3",
|
|
title: "Salade César",
|
|
servings: 2,
|
|
ingredientLines: ["1 salade"],
|
|
instructionLines: ["Mélanger"],
|
|
},
|
|
];
|
|
|
|
const PAGE_SIZE = 2;
|
|
|
|
/** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */
|
|
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<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("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;
|
|
});
|
|
});
|
|
});
|