Ces suites ont ete ecrites pendant le dev des features cooking / admin / hors-catalogue mais jamais executees (pas de Postgres dans ces sessions). Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas dans le code merge. - reference.test.ts : `GET /reference/ingredients` renvoie desormais `isPlaceholder` (toujours false) et `displayName` (toujours null) depuis la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])` exacte est mise a jour. - cooking-session.test.ts : la fixture "pooling merged-prep" avait 2 recettes symetriques (chop -> simmer) ; apres mise en commun du chop les deux simmer tournent dans l'unique phase de cuisson, donc aucun `background` possible (l'optimiseur est correct, cf. le test pur equivalent). « Tarte » recoit une etape active `mix` de plus pour que son simmer flotte en background pendant que « Soupe » est en hold. - admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/ `.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de 409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant l'attente de 100 ms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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<number> {
|
|
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;
|
|
});
|
|
});
|
|
});
|