batchCooking/apps/api/test/recipe-source.test.ts
Nicolas 766d48eaa5 feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
Répond à deux besoins : permettre à chaque foyer de choisir quelles
sources apparaissent dans ses onglets de recettes, et distinguer les
sources à API officielle des sources scrapées.

- RecipeSourceAdapter.official (booléen, sans défaut — chaque
  adaptateur doit le déclarer explicitement) synchronisé sur
  Source.official par syncRecipeSources.
- HouseSource : table de jointure opt-in (House <-> Source) — aucune
  ligne = source masquée. Un foyer nouvellement créé ne voit aucune
  source tant qu'il ne les active pas explicitement.
- GET /reference/sources (catalogue des sources implémentées, avec le
  flag officiel).
- GET/PATCH /house/current/sources (lecture/remplacement complet des
  sources activées par le foyer courant).
- recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS
  les onglets (perso/foyer/publique/favoris) — une recette sans
  source reste toujours visible ; une recette importée ne l'est que
  si sa source est activée pour le foyer du viewer. Un viewer sans
  foyer ne voit aucune recette sourcée.

Côté web :
- Nouvelle étape /onboarding/sources dans le wizard d'inscription,
  atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on
  saute direct aux allergènes) ; s'auto-saute aussi si aucune source
  n'est encore implémentée (catalogue vide aujourd'hui).
- Nouvelle section « Sources de recettes » dans /parametres/foyer
  (masquée dans les mêmes conditions), avec sauvegarde à la volée
  (même pattern que les autres préférences hot-saved).
- SourceSelect (features/house/), grille de cases à cocher avec badge
  officiel/non-officielle, sur le même principe qu'AllergySelect.

172 tests backend passent (dont 25 nouveaux). Build et lint propres.
Vérifié manuellement en navigateur : le parcours d'onboarding saute
bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un
foyer a été créé ; la section paramètres reste invisible tant
qu'aucune source n'existe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:22:54 +02:00

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