18 fichiers à plat -> recipe-matching/ (ingredient-matcher, recipe-
translation, tech-step-matcher — miroir de lib/recipe-matching/),
recipe-sources/ (json-ld-recipe, recipe-source, recipe-source-sync,
the-meal-db — miroir de lib/recipe-sources/), sources/ (sources,
sources-index — module + registration src/sources/index.ts).
Les tests par domaine API sans regroupement naturel (auth, health,
house, logger.service, planning, preferences, profile, recipe,
reference) restent à la racine de test/, un fichier par domaine — même
logique que jwt.ts/safe-profile.ts restés à la racine de lib/.
Chemins relatifs corrigés (../src/ -> ../../src/, ../test-support/ ->
../../test-support/ dans les fichiers déplacés qui appellent
resetDatabase). .mocharc.json ("test/**/*.test.ts") couvre déjà les
sous-dossiers, aucun changement de config nécessaire.
Vérifié : biome check, 303 tests API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
7.1 KiB
TypeScript
210 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-sources/recipe-source-adapter.js";
|
|
import {
|
|
clearRecipeSources,
|
|
registerRecipeSource,
|
|
} from "../../src/lib/recipe-sources/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);
|
|
});
|
|
});
|
|
});
|