Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de
l'API au lieu de la page — même chose à craindre pour tout futur /admin/*
GET sans segment supplémentaire.
Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait
l'API admin exactement sous /admin, la même racine que les pages
/admin/* d'apps/web, servies depuis la même origine/le même process en
prod (image Docker unique). Express matche par chemin, sans égard au
Accept — `GET /admin/monitoring` (route API) et la page du même nom
étaient en conflit direct ; l'API gagnait systématiquement (montée
avant serveStaticFrontend), donc chaque F5 sur cette page recevait le
JSON de l'API plutôt que index.html.
Seule /admin/monitoring collidait réellement aujourd'hui (seul
sous-routeur admin à répondre GET sur sa racine bare) mais le
mécanisme touche toute future route admin nommée comme sa page — d'où
un correctif structurel plutôt qu'un renommage ponctuel.
Correctif : l'API admin est montée sous /admin/api (app.ts), jamais
bare /admin. Toute la surface admin bouge en conséquence
(auth/metrics/monitoring/tech-steps/catalog) ; les pages front
(/admin/monitoring, /admin/catalogue, /admin/corrections...) sont
inchangées, seul admin-client.ts change de base URL. Mis à jour en
cohérence : commentaires JSDoc citant les routes (apps/api + apps/web +
packages/shared), specs/backend-architecture.md,
specs/batch-cooking-modele.md, les intercepts Cypress (globs + un
matcher regex qui avait échappé au premier passage) et les chemins
supertest du suite Mocha admin-*.test.ts.
Simplifie au passage admin-monitoring.cy.ts : le contournement
resourceType ajouté en #32 pour cette même collision n'a plus lieu
d'être une fois les chemins distincts (retour à un intercept déclaratif
simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en
commentaire pour référence future.
Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète
28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*).
Suite Mocha apps/api non exécutable cette session (Postgres/Docker
injoignable) — chemins mis à jour mécaniquement en cohérence avec le
nouveau mount point, à faire tourner en CI/à la prochaine session avec
accès DB.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
import type { SignupInput } 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 { bucketByDay } from "../src/modules/admin/admin-metrics.service.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
function buildSignupPayload(): SignupInput {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
password: faker.internet.password({ length: 16 }),
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/** Retries `check` until it stops throwing or `timeoutMs` elapses — `analytics.recordEvent` writes its row fire-and-forget, so a test observing it has to poll briefly. */
|
|
async function eventually(check: () => Promise<void>, timeoutMs = 2000): Promise<void> {
|
|
const start = Date.now();
|
|
for (;;) {
|
|
try {
|
|
await check();
|
|
return;
|
|
} catch (err) {
|
|
if (Date.now() - start > timeoutMs) throw err;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
}
|
|
|
|
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
|
|
describe("Admin metrics", () => {
|
|
describe("bucketByDay (pure)", () => {
|
|
const since = new Date("2026-08-01T00:00:00.000Z");
|
|
|
|
it("returns one zero-filled bucket per day, in date order", () => {
|
|
const result = bucketByDay([], since, 3);
|
|
expect(result).to.deep.equal([
|
|
{ date: "2026-08-01", count: 0 },
|
|
{ date: "2026-08-02", count: 0 },
|
|
{ date: "2026-08-03", count: 0 },
|
|
]);
|
|
});
|
|
|
|
it("counts dates into their UTC day and ignores dates outside the window", () => {
|
|
const result = bucketByDay(
|
|
[
|
|
new Date("2026-08-01T09:00:00Z"),
|
|
new Date("2026-08-01T23:30:00Z"),
|
|
new Date("2026-08-03T00:00:00Z"),
|
|
new Date("2026-07-31T23:59:59Z"), // before the window
|
|
new Date("2026-08-10T00:00:00Z"), // after the window
|
|
],
|
|
since,
|
|
3,
|
|
);
|
|
expect(result).to.deep.equal([
|
|
{ date: "2026-08-01", count: 2 },
|
|
{ date: "2026-08-02", count: 0 },
|
|
{ date: "2026-08-03", count: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/metrics", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it("rejects a request with no admin session with 401", async () => {
|
|
const res = await request(app).get("/admin/api/metrics");
|
|
expect(res.status).to.equal(401);
|
|
});
|
|
|
|
it("returns a snapshot reflecting seeded data, plus zero-filled series", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
const { email, password } = await seedAdmin();
|
|
|
|
// Two end users sign up (also emits `user.signup` analytics events).
|
|
const userA = request.agent(app);
|
|
const userB = request.agent(app);
|
|
await userA.post("/auth/signup").send(buildSignupPayload());
|
|
await userB.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const adminAgent = request.agent(app);
|
|
await adminAgent.post("/admin/api/auth/login").send({ email, password });
|
|
|
|
const res = await adminAgent.get("/admin/api/metrics").query({ days: 14 });
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.rangeDays).to.equal(14);
|
|
expect(res.body.snapshot.users).to.equal(2);
|
|
expect(res.body.snapshot.admins).to.equal(1);
|
|
expect(res.body.snapshot.recipes).to.equal(0);
|
|
|
|
// 14 daily buckets, each series zero-filled to that length.
|
|
expect(res.body.series.signups).to.have.length(14);
|
|
expect(
|
|
res.body.series.signups.every((b: { count: number }) => typeof b.count === "number"),
|
|
).to.equal(true);
|
|
// Two signups today → the last bucket counts them.
|
|
const signupTotal = res.body.series.signups.reduce(
|
|
(sum: number, b: { count: number }) => sum + b.count,
|
|
0,
|
|
);
|
|
expect(signupTotal).to.equal(2);
|
|
});
|
|
|
|
it("records a user.signup analytics event (fire-and-forget, never blocks signup)", async () => {
|
|
const agent = request.agent(app);
|
|
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
expect(signupRes.status).to.equal(201);
|
|
|
|
// `>= 1`, not `=== 1`: `recordEvent` is fire-and-forget, so an insert
|
|
// from an earlier test's signup could in principle land in this
|
|
// window too — the point here is that the instrumentation fires and
|
|
// the signup itself was never blocked by it.
|
|
await eventually(async () => {
|
|
const count = await prisma.analyticsEvent.count({ where: { type: "user.signup" } });
|
|
expect(count).to.be.greaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
});
|