Some checks failed
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>
256 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|