batchCooking/apps/api/test/admin-catalog.test.ts
Nicolas 16f593d5d0
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 1m55s
CI / e2e (push) Successful in 8m37s
CI / intent-service-test (push) Successful in 17m3s
CI / test (push) Failing after 3h14m19s
feat(recipes): permet d'ajouter des ingredients hors-catalogue
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:42 +02:00

141 lines
5.1 KiB
TypeScript

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