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>
202 lines
7.5 KiB
TypeScript
202 lines
7.5 KiB
TypeScript
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,
|
|
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(14);
|
|
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 });
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(await prisma.techStepMapping.count()).to.equal(26);
|
|
});
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
});
|
|
});
|