Marmiton s'est avéré inaccessible pour du scraping (bloqué même via WebFetch, signe de protection anti-bot) — TheMealDB (themealdb.com) est une vraie API JSON publique et gratuite, sans scraping, testée en conditions réelles (list → fetchDetail → parse fonctionnent bout en bout contre l'API live). - Source.iconUrl (nullable) + RecipeSourceAdapter.iconUrl (requis, même convention que `official`) synchronisé par syncRecipeSources. - apps/api/src/sources/the-meal-db.ts : premier RecipeSourceAdapter réel — official: true (API officielle, pas de scraping), utilise fetch natif (aucune dépendance ajoutée). list() fait une recherche (pas de vrai "browse" côté TheMealDB, mais une requête vide renvoie un échantillon de secours) ; parse() éclate les instructions en étapes par ligne et ignore les emplacements d'ingrédients vides. - apps/api/src/sources/index.ts : registerAllRecipeSources(), appelé par server.ts (process réel) et prisma/seed.ts — délibérément PAS importé par app.ts, pour ne jamais dépendre de l'ordre des tests. - SourceSelect (web) affiche désormais le logo de la source à côté de son nom. Vérifié en conditions réelles : seed → table sources peuplée avec le vrai logo TheMealDB ; endpoint /reference/sources sur serveur réel ; parcours navigateur complet (onboarding → étape sources visible avec icône chargée → activation → paramètres foyer reflète le choix). 186 tests passent (16 nouveaux, dont le moteur TheMealDB testé avec un stub de fetch — aucun appel réseau réel dans la suite automatisée). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
272 lines
9.1 KiB
TypeScript
272 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,
|
|
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;
|
|
});
|
|
});
|
|
});
|