batchCooking/apps/api/test/reference.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

207 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(
key: string,
name: string,
official: boolean,
iconUrl: string | null = null,
): RecipeSourceAdapter {
return {
key,
name,
official,
iconUrl,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
describe("Reference data", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /reference/diets", () => {
it("returns the seeded regimes, no session required", async () => {
const res = await request(app).get("/reference/diets");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(5);
expect(res.body.map((d: { key: string }) => d.key)).to.include("vegetarian");
expect(res.body[0]).to.have.keys(["id", "key"]);
});
});
describe("GET /reference/allergies", () => {
it("returns the seeded allergens with their key resolved, no session required", async () => {
const res = await request(app).get("/reference/allergies");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(14);
expect(res.body.map((a: { key: string }) => a.key)).to.include("peanuts");
expect(res.body[0]).to.have.keys(["id", "key", "kind"]);
});
it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => {
const res = await request(app).get("/reference/allergies");
const byKey = (key: string) => res.body.find((a: { key: string }) => a.key === key);
expect(byKey("gluten").kind).to.equal("INTOLERANCE");
expect(byKey("sulfites").kind).to.equal("INTOLERANCE");
expect(byKey("peanuts").kind).to.equal("ALLERGY");
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
});
});
describe("GET /reference/ingredients", () => {
it("returns the seeded ingredients, no session required", async () => {
const res = await request(app).get("/reference/ingredients");
expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { key: string }) => i.key)).to.include("tomato");
expect(res.body[0]).to.have.keys([
"id",
"key",
"icon",
"category",
"subcategory",
"reproducible",
"allergens",
"diets",
]);
});
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
const res = await request(app).get("/reference/ingredients");
const byKey = (key: string) => res.body.find((i: { key: string }) => i.key === key);
expect(byKey("egg").allergens.map((a: { key: string }) => a.key)).to.include("eggs");
expect(byKey("tomato").allergens).to.deep.equal([]);
});
});
describe("GET /reference/units", () => {
it("returns the seeded units, no session required", async () => {
const res = await request(app).get("/reference/units");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(17);
expect(res.body.map((u: { key: string }) => u.key)).to.include("gram");
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
});
it("resolves MASS/VOLUME toBaseFactor against their type's base unit, COUNT units all at 1", async () => {
const res = await request(app).get("/reference/units");
const byKey = (key: string) => res.body.find((u: { key: string }) => u.key === key);
expect(byKey("gram")).to.include({ type: "MASS", toBaseFactor: 1 });
expect(byKey("kilogram")).to.include({ type: "MASS", toBaseFactor: 1000 });
expect(byKey("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
expect(byKey("cup")).to.include({ type: "VOLUME", toBaseFactor: 236.5882 });
expect(byKey("ounce")).to.include({ type: "MASS", toBaseFactor: 28.3495 });
expect(byKey("pound")).to.include({ type: "MASS", toBaseFactor: 453.5924 });
});
});
describe("GET /reference/tech-steps", () => {
it("returns the seeded techniques, no session required", async () => {
const res = await request(app).get("/reference/tech-steps");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(26);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
expect(res.body[0]).to.have.keys(["id", "key"]);
});
it("orders techniques alphabetically by key", async () => {
const res = await request(app).get("/reference/tech-steps");
const keys = res.body.map((t: { key: string }) => t.key);
expect(keys).to.deep.equal([...keys].sort());
});
it("reseeding is idempotent — no duplicate techniques or mappings", async () => {
// resetDatabase already seeded once in beforeEach; seed a second time
// on top of that without truncating, the way a redeploy would.
await seedReferenceData(prisma);
const res = await request(app).get("/reference/tech-steps");
expect(res.body).to.have.length(26);
// 26 techniques × one "fr" + one "en" mapping each.
expect(await prisma.techStepMapping.count()).to.equal(52);
});
});
describe("GET /reference/sources", () => {
afterEach(() => {
clearRecipeSources();
});
it("is empty until a concrete adapter is registered", async () => {
const res = await request(app).get("/reference/sources");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("returns every synced adapter, official flag and icon included, no session required", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source", false));
registerRecipeSource(
buildFakeAdapter(
"officialSource",
"Official Source",
true,
"https://example.test/icon.svg",
),
);
await syncRecipeSources(prisma);
const res = await request(app).get("/reference/sources");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0]).to.have.keys(["id", "key", "name", "official", "iconUrl"]);
const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key);
expect(byKey("fakeSource").official).to.equal(false);
expect(byKey("fakeSource").iconUrl).to.equal(null);
expect(byKey("officialSource").official).to.equal(true);
expect(byKey("officialSource").iconUrl).to.equal("https://example.test/icon.svg");
});
it("orders sources alphabetically by name", async () => {
registerRecipeSource(buildFakeAdapter("bSource", "Bravo", false));
registerRecipeSource(buildFakeAdapter("aSource", "Alpha", false));
await syncRecipeSources(prisma);
const res = await request(app).get("/reference/sources");
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
});
});
});