import { ErrorCode } from "@batch-cooking/shared"; 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 { resetDatabase } from "../../test-support/reset-db.js"; const SECRET_HEADER = "X-Internal-Worker-Secret"; /** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */ async function techStepId(key: string): Promise { const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } }); return techStep.id; } /** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */ async function createRecipeWithStep( description = "Faire mijoter la sauce.", ): Promise<{ stepId: number; recipeId: number }> { const author = await prisma.userProfile.create({ data: { firstName: "Test", lastName: "Author", email: `${crypto.randomUUID()}@example.test`, passwordHash: "not-a-real-hash", }, }); const recipe = await prisma.recipe.create({ data: { name: "Recette", authorId: author.id, portions: 4, steps: { create: [{ description, order: 0 }] }, }, include: { steps: true }, }); const step = recipe.steps[0]; if (!step) throw new Error("expected the fixture recipe to have one step"); return { stepId: step.id, recipeId: recipe.id }; } describe("Internal tech-step worker routes", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("requireInternalWorker", () => { it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/internal/tech-steps/audit-batch"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => { const res = await request(app) .get("/internal/tech-steps/audit-batch") .set(SECRET_HEADER, "definitely-not-the-right-secret"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => { const res = await request(app) .get("/recipes") .query({ tab: "publique" }) .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); }); // Every test below needs a real configured secret to exercise the success // path — skipped (not failed) in an environment that hasn't set one, same // "optional, but the surface fails closed without it" posture // `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this // repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so // this only actually skips in an environment that deliberately diverges // from both. describe("with a configured secret", () => { before(function skipWithoutConfiguredSecret() { if (env.INTERNAL_WORKER_SECRET === undefined) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have. (this as any).skip(); } }); function withSecret(req: request.Test): request.Test { return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string); } describe("GET /internal/tech-steps/audit-batch", () => { // A true low-confidence positive case can't be asserted here without // a live trained classifier to verify the exact sentence against // first — same limitation `tech-step-eval-dataset.ts` documents for // the same reason (no local Postgres was reachable in the session // that introduced this file). This test instead covers the // deterministic negative: a step the classifier confidently resolves // (proven by `tech-step-matcher.test.ts`'s own identical-sentence // case) must produce zero audit entries — nothing here should ever // flag a confident match as worth a second opinion. it("finds nothing to audit in a step the classifier confidently resolves", async () => { await createRecipeWithStep("Faire mijoter à feu doux"); const res = await withSecret( request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }), ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal([]); }); it("finds nothing to audit in a step naming no technique at all", async () => { await createRecipeWithStep("Ranger les couverts dans le tiroir"); const res = await withSecret( request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }), ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal([]); }); it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => { const res = await withSecret( request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }), ); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); describe("GET /internal/tech-steps/pending-corrections", () => { it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => { const { stepId, recipeId } = await createRecipeWithStep(); const simmerId = await techStepId("simmer"); const author = await prisma.recipe .findUniqueOrThrow({ where: { id: recipeId } }) .then((recipe) => recipe.authorId); const older = await prisma.stepTechStepCorrection.create({ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId }, }); const consumed = await prisma.stepTechStepCorrection.create({ data: { stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId, consumedAt: new Date(), }, }); const newer = await prisma.stepTechStepCorrection.create({ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId }, }); const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections")); expect(res.status).to.equal(200); const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id); expect(ids).to.deep.equal([older.id, newer.id]); expect(ids).to.not.include(consumed.id); }); it("respects ?limit=", async () => { const { stepId, recipeId } = await createRecipeWithStep(); const simmerId = await techStepId("simmer"); const author = await prisma.recipe .findUniqueOrThrow({ where: { id: recipeId } }) .then((recipe) => recipe.authorId); await prisma.stepTechStepCorrection.createMany({ data: [ { stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId }, { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId }, ], }); const res = await withSecret( request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }), ); expect(res.status).to.equal(200); expect(res.body).to.have.length(1); }); }); describe("POST /internal/tech-steps/training-suggestions", () => { it("creates a suggestion and marks its source correction consumed", async () => { const { stepId, recipeId } = await createRecipeWithStep(); const simmerId = await techStepId("simmer"); const author = await prisma.recipe .findUniqueOrThrow({ where: { id: recipeId } }) .then((recipe) => recipe.authorId); const correction = await prisma.stepTechStepCorrection.create({ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId }, }); const res = await withSecret( request(app) .post("/internal/tech-steps/training-suggestions") .send({ suggestions: [ { techStepKey: "simmer", locale: "fr", suggestedSynonyms: ["frémissonner"], suggestedUtterances: ["laisser frémissonner à feu très doux"], sourceType: "correction", sourceCorrectionId: correction.id, }, ], }), ); expect(res.status).to.equal(201); expect(res.body).to.deep.equal({ created: 1 }); const suggestions = await prisma.techStepTrainingSuggestion.findMany({ where: { techStepId: simmerId }, }); expect(suggestions).to.have.length(1); expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id); expect(suggestions[0]?.status).to.equal("pending"); const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({ where: { id: correction.id }, }); expect(updatedCorrection.consumedAt).to.not.equal(null); }); it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => { const res = await withSecret( request(app) .post("/internal/tech-steps/training-suggestions") .send({ suggestions: [ { techStepKey: "boil", locale: "fr", suggestedSynonyms: ["bouillonner"], suggestedUtterances: [], sourceType: "llm_audit", }, ], }), ); expect(res.status).to.equal(201); expect(res.body).to.deep.equal({ created: 1 }); }); it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => { const res = await withSecret( request(app) .post("/internal/tech-steps/training-suggestions") .send({ suggestions: [ { techStepKey: "boil", locale: "fr", suggestedSynonyms: ["bouillonner"], suggestedUtterances: [], sourceType: "correction", }, ], }), ); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => { const res = await withSecret( request(app) .post("/internal/tech-steps/training-suggestions") .send({ suggestions: [ { techStepKey: "not-a-real-tech-step", locale: "fr", suggestedSynonyms: [], suggestedUtterances: [], sourceType: "llm_audit", }, ], }), ); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND); }); }); }); });