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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; import { resetDatabase } from "../test-support/reset-db.js"; async function seedAdmin(): Promise<{ email: string; password: string }> { const email = faker.internet.email().toLowerCase(); const password = faker.internet.password({ length: 16 }); await prisma.adminUser.create({ data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, }); return { email, password }; } /** A recipe with one placeholder ingredient line whose `displayName` is `name`. Returns the placeholder `Ingredient` id. */ async function seedPlaceholderRecipe(name: string): Promise { const author = await prisma.userProfile.create({ data: { firstName: "T", lastName: "A", email: `${faker.string.uuid()}@example.test`, passwordHash: "x", }, }); const unit = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } }); const placeholder = await prisma.ingredient.create({ data: { key: `placeholder:${faker.string.uuid()}`, isPlaceholder: true, displayName: name, createdById: author.id, createdAt: new Date(), }, }); await prisma.recipe.create({ data: { name: faker.lorem.words(3), authorId: author.id, portions: 2, ingredients: { create: [{ ingredientId: placeholder.id, quantity: 1, unitId: unit.id }] }, }, }); return placeholder.id; } /** * `/admin/catalog/*` — the off-catalog ingredient review. Every route is * behind `requireAdmin`; the list groups placeholder rows by normalized * name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows * no recipe references any more. */ describe("Admin catalog — off-catalog ingredients", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); async function adminAgent() { const { email, password } = await seedAdmin(); const agent = request.agent(app); await agent.post("/admin/auth/login").send({ email, password }); return agent; } it("rejects every route without an admin session", async () => { const get = await request(app).get("/admin/catalog/placeholders"); expect(get.status).to.equal(401); const patch = await request(app) .patch("/admin/catalog/placeholders/mark-reviewed") .send({ ingredientIds: [1] }); expect(patch.status).to.equal(401); const post = await request(app).post("/admin/catalog/placeholders/prune-orphans"); expect(post.status).to.equal(401); }); it("groups two spellings of the same missing ingredient into one row", async () => { await seedPlaceholderRecipe("Piment d'Espelette"); await seedPlaceholderRecipe("piment d espelette"); await seedPlaceholderRecipe("Sumac"); const agent = await adminAgent(); const res = await agent.get("/admin/catalog/placeholders"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); const espelette = res.body.find( (g: { normalizedName: string }) => g.normalizedName === "piment d espelette", ); expect(espelette.recipeCount).to.equal(2); expect(espelette.ingredientIds).to.have.length(2); expect(espelette.displayNames).to.have.members(["Piment d'Espelette", "piment d espelette"]); // Impact-ordered: the 2-recipe gap before the 1-recipe one. expect(res.body[0].normalizedName).to.equal("piment d espelette"); }); it("mark-reviewed stamps reviewedAt and moves the group out of the default list", async () => { const id = await seedPlaceholderRecipe("Galanga"); const agent = await adminAgent(); const patched = await agent .patch("/admin/catalog/placeholders/mark-reviewed") .send({ ingredientIds: [id] }); expect(patched.status).to.equal(200); expect(patched.body.reviewed).to.equal(1); expect( (await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt, ).to.be.an.instanceOf(Date); const pending = await agent.get("/admin/catalog/placeholders"); expect(pending.body).to.have.length(0); const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" }); expect(reviewed.body).to.have.length(1); expect(reviewed.body[0].allReviewed).to.equal(true); }); it("prune-orphans deletes only placeholder rows with no recipe left", async () => { await seedPlaceholderRecipe("Encore utilisé"); await prisma.ingredient.create({ data: { key: `placeholder:${faker.string.uuid()}`, isPlaceholder: true, displayName: "Orphelin", createdAt: new Date(), }, }); const agent = await adminAgent(); const res = await agent.post("/admin/catalog/placeholders/prune-orphans"); expect(res.status).to.equal(200); expect(res.body.deleted).to.equal(1); expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); }); });