batchCooking/apps/api/test/recipe-source.test.ts
Nicolas 44ef5e071f 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>
2026-08-20 15:51:16 +02:00

273 lines
9.1 KiB
TypeScript

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",
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);
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;
});
});
});