import { ErrorCode } 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 { env } from "../src/config/env.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 }; } async function techStepId(key: string): Promise { return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id; } /** A recipe + one step + one correction on it, optionally already turned into a suggestion. */ async function seedCorrectionAndSuggestion(options: { clause: string; correctedKey: string | null; withSuggestion?: { status: string; synonyms: string[] }; }) { const author = await prisma.userProfile.create({ data: { firstName: "T", lastName: "A", email: `${faker.string.uuid()}@example.test`, passwordHash: "x", }, }); const recipe = await prisma.recipe.create({ data: { name: "R", authorId: author.id, portions: 4, steps: { create: [{ description: options.clause, order: 0 }] }, }, include: { steps: true }, }); const step = recipe.steps[0]; if (!step) throw new Error("expected a step"); const correctedKey = options.correctedKey; const correction = await prisma.stepTechStepCorrection.create({ data: { stepId: step.id, correctorId: author.id, start: 0, end: options.clause.length, previousTechStepId: null, correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey), }, }); let suggestion: { id: number } | null = null; if (options.withSuggestion && correctedKey !== null) { suggestion = await prisma.techStepTrainingSuggestion.create({ data: { techStepId: await techStepId(correctedKey), locale: "fr", suggestedSynonyms: options.withSuggestion.synonyms, suggestedUtterances: [], sourceType: "correction", sourceCorrectionId: correction.id, status: options.withSuggestion.status, }, }); } return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion }; } const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; describe("Admin tech-steps triage", () => { 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 () => { for (const path of [ "/admin/tech-steps/suggestions", "/admin/tech-steps/corrections", "/admin/tech-steps/training-data-snippet?techStepKey=simmer", ]) { const res = await request(app).get(path); expect(res.status, path).to.equal(401); } const post = await request(app).post("/admin/tech-steps/retrain").send({}); expect(post.status).to.equal(401); }); describe("GET /suggestions", () => { it("groups suggestions by technique and filters by status", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer", withSuggestion: { status: "pending", synonyms: ["laisser frémir"] }, }); await seedCorrectionAndSuggestion({ clause: "Émincer les oignons", correctedKey: "chop", withSuggestion: { status: "applied", synonyms: ["ciseler"] }, }); const agent = await adminAgent(); const all = await agent.get("/admin/tech-steps/suggestions"); expect(all.status).to.equal(200); expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([ "chop", "simmer", ]); const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer"); expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter"); const pendingOnly = await agent .get("/admin/tech-steps/suggestions") .query({ status: "pending" }); expect(pendingOnly.body).to.have.length(1); expect(pendingOnly.body[0].techStepKey).to.equal("simmer"); }); }); describe("PATCH /suggestions/:id", () => { it("rejects an empty body with 400", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } const { suggestion } = await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer", withSuggestion: { status: "pending", synonyms: ["x"] }, }); const agent = await adminAgent(); const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({}); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("404s an unknown id", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } const agent = await adminAgent(); const res = await agent .patch("/admin/tech-steps/suggestions/999999") .send({ status: "applied" }); expect(res.status).to.equal(404); }); it("flips the status and edits the synonyms, reflected in a later GET", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } const { suggestion } = await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer", withSuggestion: { status: "pending", synonyms: ["frémir"] }, }); const agent = await adminAgent(); const patched = await agent .patch(`/admin/tech-steps/suggestions/${suggestion?.id}`) .send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] }); expect(patched.status).to.equal(200); expect(patched.body.status).to.equal("applied"); expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]); const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({ where: { id: suggestion?.id }, }); expect(stored.status).to.equal("applied"); }); }); describe("GET /corrections", () => { it("includes the 'no technique here' removals", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null }); await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" }); const agent = await adminAgent(); const res = await agent.get("/admin/tech-steps/corrections"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); const removals = await agent .get("/admin/tech-steps/corrections") .query({ hasCorrectedTechStep: "false" }); expect(removals.body).to.have.length(1); expect(removals.body[0].clauseText).to.equal("Rien ici"); expect(removals.body[0].correctedTechStepKey).to.equal(null); }); }); describe("GET /training-data-snippet", () => { it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer", withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] }, }); const agent = await adminAgent(); const res = await agent .get("/admin/tech-steps/training-data-snippet") .query({ techStepKey: "simmer", locale: "fr", status: "applied" }); expect(res.status).to.equal(200); expect(res.body.suggestionCount).to.equal(1); expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]); expect(res.body.snippet).to.include('"frémir"'); expect(res.body.snippet).to.include('"synonyms": ['); }); }); describe("POST /retrain", () => { it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } this.timeout(60000); const agent = await adminAgent(); const res = await agent.post("/admin/tech-steps/retrain").send({}); expect(res.status).to.equal(200); expect(res.body).to.have.keys([ "f1", "precision", "recall", "minF1", "gatePassed", "backfilled", "marked", ]); expect(res.body.minF1).to.equal(0.8); if (res.body.gatePassed) { expect(res.body.backfilled).to.have.keys(["total", "changed"]); } }); it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } this.timeout(60000); const agent = await adminAgent(); // supertest requests are lazy — they only dispatch when awaited/then'd. // Attaching the `.then` here is what actually fires the first request, // so its handler acquires the process-wide lock before the second one // (100ms later) checks it. Swallow its result/rejection — this test // only asserts on the second request. const first = agent .post("/admin/tech-steps/retrain") .send({}) .then( (res) => res, (err) => err, ); await new Promise((resolve) => setTimeout(resolve, 100)); const second = await agent.post("/admin/tech-steps/retrain").send({}); expect(second.status).to.equal(409); expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING); await first; }); }); });