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); }); }); });