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

254 lines
9.3 KiB
TypeScript

import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } 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 { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../src/lib/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.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, real English-content fake adapter — `parse()` deliberately
* mixes one ingredient that resolves against the real seeded catalog
* ("onion") with one that doesn't ("mystery paste"), and a step whose
* text matches a real seeded English tech-step mapping ("chop") — same
* "exercise the real catalog, not a mock of it" approach the ingredient/
* tech-step matcher tests already use.
*/
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake Source",
official: true,
iconUrl: null,
locale: "en",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" },
{ externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
if (externalId === "missing") {
throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`);
}
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Fake recipe ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "1 onion", quantity: null, unit: null, name: "onion" },
// No leading number and no recognizable unit word — exercises
// quantity/unit staying null alongside the ingredient itself not
// resolving, not just the ingredient.
{ rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" },
],
steps: [{ description: "Chop the onions finely", picture: null }],
};
},
};
}
describe("Sources", () => {
const app = createApp();
/** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */
async function signupWithHouse(): Promise<{
agent: ReturnType<typeof request.agent>;
houseId: number;
}> {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
return { agent, houseId: houseRes.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
clearRecipeSources();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /sources/:sourceKey/browse", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/sources/fakeSource/browse");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
const res = await agent.get("/sources/unknown/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => {
const { agent, houseId } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const importedRecipe = await prisma.recipe.create({
data: {
name: "Already imported",
authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(200);
expect(res.body.nextCursor).to.equal(null);
expect(res.body.items).to.deep.equal([
{
externalId: "1",
title: "Onion soup",
picture: null,
url: "https://fake.test/1",
alreadyImported: true,
recipeId: importedRecipe.id,
},
{
externalId: "2",
title: "Mystery stew",
picture: null,
url: "https://fake.test/2",
alreadyImported: false,
recipeId: null,
},
]);
});
});
describe("GET /sources/:sourceKey/preview/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent };
}
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => {
const { agent } = await enableFakeSource();
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body).to.deep.include({
sourceKey: "fakeSource",
externalId: "1",
name: "Fake recipe 1",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://fake.test/1",
});
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("1 onion");
expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(unresolved.rawText).to.equal("some mystery paste");
expect(unresolved.ingredient).to.equal(null);
expect(unresolved.unit).to.equal(null);
expect(unresolved.quantity).to.equal(null);
expect(res.body.steps).to.have.length(1);
const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1);
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop");
});
it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => {
const { agent } = await enableFakeSource();
const res = await agent.get("/sources/fakeSource/preview/missing");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
});