Répond au besoin identifié précédemment : la table `sources` devient un vrai catalogue des sources implémentées, et une recette importée pourra être reliée à l'item source dont elle provient. - Source.key (unique) — même convention que Diet.key/Unit.key/ TechStep.key. Le catalogue est désormais synchronisé depuis le registre d'adaptateurs (recipe-source-registry.ts) via syncRecipeSources() (nouveau apps/api/src/db/recipe-source-sync.ts), plutôt que maintenu à la main comme DIETS/UNITS — reste vide tant qu'aucun adaptateur concret n'est enregistré. - Recipe.externalId (nullable) — l'identifiant de la recette côté source. Contrainte @@unique([sourceId, externalId]) : empêche d'importer deux fois la même recette (les recettes manuelles, aux deux colonnes nulles, ne sont jamais en conflit entre elles). - findImportedExternalIds(prisma, sourceKey, externalIds) — le pendant DB de markAlreadyImported (recipe-source-adapter.ts), ferme la boucle commencée dans la PR précédente pour distinguer les recettes déjà intégrées lors du browse. - syncRecipeSources() appelé après seedReferenceData() dans prisma/seed.ts et test-support/reset-db.ts. Toujours pas de route HTTP ni de champ sourceId/externalId exposé dans createRecipeSchema — la sauvegarde effective d'une recette importée reste pour une PR ultérieure. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
6.9 KiB
TypeScript
204 lines
6.9 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` matter for exercising `syncRecipeSources`. */
|
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|
return {
|
|
key,
|
|
name,
|
|
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);
|
|
});
|
|
});
|
|
});
|