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>
206 lines
7 KiB
TypeScript
206 lines
7 KiB
TypeScript
import type { SignupInput } from "@batch-cooking/shared";
|
|
import { faker } from "@faker-js/faker";
|
|
import { expect } from "chai";
|
|
import request from "supertest";
|
|
import { createApp } from "../src/app.js";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.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";
|
|
|
|
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
|
function buildSignupPayload(): SignupInput {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
password: faker.internet.password({ length: 16 }),
|
|
};
|
|
}
|
|
|
|
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official`/`iconUrl` matter for exercising `syncRecipeSources`. */
|
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|
return {
|
|
key,
|
|
name,
|
|
official: false,
|
|
iconUrl: null,
|
|
async list() {
|
|
return { items: [], nextCursor: null };
|
|
},
|
|
async fetchDetail() {
|
|
throw new Error("not implemented");
|
|
},
|
|
parse() {
|
|
throw new Error("not implemented");
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("recipe-source-sync", () => {
|
|
const app = createApp();
|
|
|
|
async function signup(): Promise<{ profileId: number }> {
|
|
const res = await request.agent(app).post("/auth/signup").send(buildSignupPayload());
|
|
return { profileId: res.body.id };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
clearRecipeSources();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearRecipeSources();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("syncRecipeSources", () => {
|
|
it("does nothing when the registry is empty", async () => {
|
|
await syncRecipeSources(prisma);
|
|
expect(await prisma.source.count()).to.equal(0);
|
|
});
|
|
|
|
it("creates a Source row per registered adapter", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
expect(source.name).to.equal("Fake Source");
|
|
});
|
|
|
|
it("is idempotent — running it twice doesn't duplicate rows", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
await syncRecipeSources(prisma);
|
|
|
|
expect(await prisma.source.count()).to.equal(1);
|
|
});
|
|
|
|
it("updates the name when the adapter's own name changes between syncs", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Old Name"));
|
|
await syncRecipeSources(prisma);
|
|
clearRecipeSources();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "New Name"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
expect(source.name).to.equal("New Name");
|
|
});
|
|
|
|
it("never deletes a Source row whose key fell out of the registry", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
clearRecipeSources();
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
expect(await prisma.source.count()).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe("findImportedExternalIds", () => {
|
|
it("returns an empty set for a sourceKey with no matching Source row", async () => {
|
|
expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set());
|
|
});
|
|
|
|
it("returns an empty set for an empty externalIds list", async () => {
|
|
expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set());
|
|
});
|
|
|
|
it("returns exactly the externalIds already imported from that source", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
// Manually-authored, not tied to any source — shouldn't ever show up as "imported".
|
|
await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } });
|
|
|
|
const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]);
|
|
expect(result).to.deep.equal(new Set(["1"]));
|
|
});
|
|
|
|
it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
|
|
await syncRecipeSources(prisma);
|
|
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: otherSource.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set());
|
|
});
|
|
});
|
|
|
|
describe("Recipe(sourceId, externalId) uniqueness", () => {
|
|
it("rejects importing the same source recipe twice", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
let rejected = false;
|
|
try {
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte (again)",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
expect(rejected).to.be.true;
|
|
});
|
|
|
|
it("allows any number of manually-authored recipes (both columns null)", async () => {
|
|
const { profileId } = await signup();
|
|
|
|
await prisma.recipe.create({ data: { name: "Une", authorId: profileId, portions: 4 } });
|
|
await prisma.recipe.create({ data: { name: "Deux", authorId: profileId, portions: 4 } });
|
|
|
|
expect(await prisma.recipe.count()).to.equal(2);
|
|
});
|
|
});
|
|
});
|