batchCooking/apps/api/test/admin-catalog.test.ts
Nicolas b49ef5bb40
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
feat(recipes): signaler une technique absente du catalogue (#130)
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00

256 lines
8.8 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/api/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/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
const get = await request(app).get("/admin/api/catalog/placeholders");
expect(get.status).to.equal(401);
const patch = await request(app)
.patch("/admin/api/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [1] });
expect(patch.status).to.equal(401);
const post = await request(app).post("/admin/api/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/api/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/api/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/api/catalog/placeholders");
expect(pending.body).to.have.length(0);
const reviewed = await agent.get("/admin/api/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/api/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);
});
});
/**
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
* behind `requireAdmin`.
*/
describe("Admin catalog — missing techniques", () => {
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/api/auth/login").send({ email, password });
return agent;
}
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
async function seedPlaceholderTechnique(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const techStep = await prisma.techStep.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,
visibility: "PUBLIC",
steps: {
create: [
{
description: "Une étape.",
order: 0,
techSteps: {
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
},
},
],
},
},
});
return techStep.id;
}
it("rejects both routes without an admin session", async () => {
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
401,
);
expect(
(
await request(app)
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [1] })
).status,
).to.equal(401);
});
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
await seedPlaceholderTechnique("Flamber au chalumeau");
await seedPlaceholderTechnique("Confire à basse température");
const agent = await adminAgent();
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
"Flamber au chalumeau",
"Confire à basse température",
]);
expect(res.body[0].recipeCount).to.equal(1);
expect(res.body[0].reviewedAt).to.equal(null);
});
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
const id = await seedPlaceholderTechnique("Snacker à la plancha");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
const reviewed = await agent
.get("/admin/api/catalog/tech-step-placeholders")
.query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
});
});