18 fichiers à plat -> recipe-matching/ (ingredient-matcher, recipe-
translation, tech-step-matcher — miroir de lib/recipe-matching/),
recipe-sources/ (json-ld-recipe, recipe-source, recipe-source-sync,
the-meal-db — miroir de lib/recipe-sources/), sources/ (sources,
sources-index — module + registration src/sources/index.ts).
Les tests par domaine API sans regroupement naturel (auth, health,
house, logger.service, planning, preferences, profile, recipe,
reference) restent à la racine de test/, un fichier par domaine — même
logique que jwt.ts/safe-profile.ts restés à la racine de lib/.
Chemins relatifs corrigés (../src/ -> ../../src/, ../test-support/ ->
../../test-support/ dans les fichiers déplacés qui appellent
resetDatabase). .mocharc.json ("test/**/*.test.ts") couvre déjà les
sous-dossiers, aucun changement de config nécessaire.
Vérifié : biome check, 303 tests API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
9.2 KiB
TypeScript
273 lines
9.2 KiB
TypeScript
import { expect } from "chai";
|
|
import type {
|
|
ParsedRecipe,
|
|
RecipeSourceAdapter,
|
|
RecipeSourceListItem,
|
|
RecipeSourceListParams,
|
|
RecipeSourceListResult,
|
|
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
|
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
|
import {
|
|
RecipeSourceError,
|
|
RecipeSourceFetchError,
|
|
RecipeSourceParseError,
|
|
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
import {
|
|
clearRecipeSources,
|
|
getRecipeSource,
|
|
listRecipeSources,
|
|
registerRecipeSource,
|
|
} from "../../src/lib/recipe-sources/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;
|
|
});
|
|
});
|
|
});
|