batchCooking/apps/api/test/recipe-source-sync.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.1 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 { findImportedRecipeIds, 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,
locale: "fr",
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("findImportedRecipeIds", () => {
it("returns an empty map for a sourceKey with no matching Source row", async () => {
expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map());
});
it("returns an empty map for an empty externalIds list", async () => {
expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map());
});
it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", async () => {
const { profileId } = await signup();
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const imported = 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 findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]);
expect(result).to.deep.equal(new Map([["1", imported.id]]));
});
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 findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map());
});
});
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);
});
});
});