Le tableau de bord /admin/monitoring fonctionnait en polling côté client (15 s) déclenchant une sonde active côté serveur à chaque appel — deux onglets admin ouverts = deux séries de sondes redondantes. Inverse le mécanisme : le serveur sonde une fois par minute et pousse le résultat aux clients connectés via WebSocket (socket.io, room "monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne en continu dès le démarrage, sans lien avec la présence d'un admin connecté (le plus simple des deux, coût des sondes négligeable). Backend : - ExpressServer (packages/express-tools) expose désormais son http.Server sous-jacent (créé explicitement, plus dans .listen()) — socket.io s'attache au serveur HTTP lui-même, pas à Express/un port. - MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune duplication), diffuse à la room "monitoring", rejoue le dernier résultat en cache à tout nouvel admin qui rejoint (pas d'attente jusqu'à une minute pour un onglet fraîchement ouvert). - requireAdminSocket (nouveau) : middleware de handshake socket.io, équivalent WebSocket de requireAdmin — même vérification (cookie + JWT + tokenVersion), factorisée dans resolveAdminFromToken (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent le cookie brut (un handshake socket.io ne passe pas par cookie-parser). - GET /admin/api/monitoring supprimée (route + service de test associés). - server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont pas besoin d'un serveur WebSocket. Frontend : - MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper socket.io-client, interface MonitoringSocketClientLike volontairement petite et injectable dans MonitoringPage (prop optionnelle) pour rester testable sans serveur réel. - MonitoringPage : data et connectionState séparés — une coupure de connexion après un premier chargement affiche un badge "reconnexion en cours" sans jamais effacer le dernier board connu (repose sur la reconnexion automatique de socket.io-client). Seul le tout premier chargement affiche l'état plein-écran chargement/erreur. - adminApiClient.getMonitoring() supprimée (plus utilisée). Tests : - Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion socket.io-client contre un serveur réel démarré dans le test (port 0), authentification avec un admin seedé + cookie signé, vérifie la réception de monitoring:update et le rejet sans cookie admin valide. Les tests heartbeat de l'ancien admin-monitoring.test.ts (route /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont déplacés vers tech-step-worker.routes.test.ts, leur router d'origine. - Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un client factice, vérifie rendu des cartes, badge de reconnexion sans perte du board, états chargement/erreur — pas de test e2e Gherkin contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo, fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur). - apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé par le test composant ci-dessus. Vérification : pnpm lint clean, build complet du monorepo clean, suite Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable, comme pour #33) — le nouveau test socket.io n'a donc pas pu être run-vérifié, seulement relu attentivement et vérifié par le lint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
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<number> {
|
|
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);
|
|
});
|
|
});
|
|
|
|
// Moved here from the now-deleted admin-monitoring.test.ts when that
|
|
// file's own REST route (GET /admin/api/monitoring) was replaced by a
|
|
// WebSocket push (see admin-monitoring-socket.test.ts) — this route
|
|
// (POST /internal/tech-steps/heartbeat) is unrelated to that change
|
|
// and belongs with the rest of this router's own tests. No separate
|
|
// "rejects with no secret" case here: `requireInternalWorker` above
|
|
// already proves that generically for this whole router.
|
|
describe("POST /internal/tech-steps/heartbeat", () => {
|
|
it("rejects a malformed body with 400", async () => {
|
|
const res = await withSecret(
|
|
request(app).post("/internal/tech-steps/heartbeat").send({ event: "not-a-real-event" }),
|
|
);
|
|
expect(res.status).to.equal(400);
|
|
});
|
|
|
|
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async () => {
|
|
const res = await withSecret(
|
|
request(app)
|
|
.post("/internal/tech-steps/heartbeat")
|
|
.send({
|
|
event: "job",
|
|
job: "audit-low-confidence",
|
|
ok: true,
|
|
counts: { suggestions: 3 },
|
|
}),
|
|
);
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.deep.equal({ ok: true });
|
|
|
|
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
|
where: { workerKey: "tech-step-llm-worker" },
|
|
});
|
|
expect(stored.lastRunAt).to.not.equal(null);
|
|
expect(stored.lastResult).to.deep.equal({
|
|
job: "audit-low-confidence",
|
|
ok: true,
|
|
counts: { suggestions: 3 },
|
|
});
|
|
});
|
|
|
|
it("leaves lastRunAt null for a boot/tick ping", async () => {
|
|
await withSecret(
|
|
request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }),
|
|
);
|
|
|
|
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
|
where: { workerKey: "tech-step-llm-worker" },
|
|
});
|
|
expect(stored.lastSeenAt).to.be.instanceOf(Date);
|
|
expect(stored.lastRunAt).to.equal(null);
|
|
});
|
|
});
|
|
});
|
|
});
|