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, timeoutMs = 2000): Promise { 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/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/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/auth/login").send({ email, password }); const res = await adminAgent.get("/admin/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); }); }); }); });