import { afterEach, describe, expect, it, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import { AppError, errorEnvelope } from '../../../shared/errors.js'; import { registerReportingRoutes } from './reporting.routes.js'; import type { ReportingRoutesDeps } from '../index.js'; const created: FastifyInstance[] = []; const ADMIN = { id: 'a1', email: 'ana@example.com', role: 'admin' }; const CUSTOMER = { id: 'c1', email: 'c1@example.com', role: 'customer' }; /** * Minimal app: reporting routes + mocked authenticator (no DB). * Mirrors build-app.ts error mapping + serializer so AppError(400/403) are * surfaced with their real status codes (routes throw AppError; build-app maps it). */ async function buildApp(authenticatedUser: unknown) { const app = Fastify(); created.push(app); app.setSerializerCompiler(() => (payload: unknown) => JSON.stringify(payload)); app.setErrorHandler(async (err, _request, reply) => { if (err instanceof AppError) { return reply .code(err.statusCode) .send( errorEnvelope(err.statusCode, err.code, err.message, 'test-request-id', err.details), ); } return reply .code(500) .send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id')); }); const deps = { authenticate: vi.fn().mockResolvedValue(authenticatedUser), } as unknown as ReportingRoutesDeps; await registerReportingRoutes(app, deps); await app.ready(); return { app }; } afterEach(async () => { for (const app of created) { try { await app.close(); } catch { /* ignore */ } } created.length = 0; }); describe('GET /reporting/filters/schema (REPORTING_VIEW)', () => { it('admin receives the schema contract + own grants (AC2)', async () => { const { app } = await buildApp(ADMIN); const res = await app.inject({ method: 'GET', url: '/reporting/filters/schema' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body ?? '') as { filterSchema: { comparison: { modes: string[] }; dataAvailability: Record }; permissions: { role: string; grants: string[] }; }; expect(body.filterSchema.comparison.modes).toEqual([ 'none', 'previous_equal', 'previous_calendar', ]); expect(body.filterSchema.dataAvailability.grossSales).toBe('available'); expect(body.filterSchema.dataAvailability.netSales).toBe('unavailable'); expect(body.permissions.role).toBe('admin'); expect(body.permissions.grants).toContain('REPORTING_VIEW'); expect(body.permissions.grants).toContain('REPORTING_SALES'); }); it('customer is forbidden (403) — reporting is backoffice-only', async () => { const { app } = await buildApp(CUSTOMER); const res = await app.inject({ method: 'GET', url: '/reporting/filters/schema' }); expect(res.statusCode).toBe(403); }); }); describe('GET /reporting/filters/validate (REPORTING_SALES)', () => { const BASE = '/reporting/filters/validate?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z'; it('validates a real query and computes the comparison range (AC3)', async () => { const { app } = await buildApp(ADMIN); const res = await app.inject({ method: 'GET', url: `${BASE}&compare=previous_equal` }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body ?? '') as { filters: { range: { from: string; to: string }; compare: string }; comparison: { range: { from: string; to: string } | null }; }; const start = new Date(body.filters.range.from).getTime(); const end = new Date(body.filters.range.to).getTime(); expect(body.filters.compare).toBe('previous_equal'); expect(body.comparison.range).not.toBeNull(); // previous_equal: prior window ends exactly at this range's start (same duration). expect(new Date(body.comparison.range!.to).getTime()).toBe(start); expect(new Date(body.comparison.range!.from).getTime()).toBeLessThan(start); expect(end - start).toBe( new Date(body.comparison.range!.to).getTime() - new Date(body.comparison.range!.from).getTime(), ); }); it('defaults to compare=none and a null range when omitted (AC1/AC3)', async () => { const { app } = await buildApp(ADMIN); const res = await app.inject({ method: 'GET', url: BASE }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body ?? '') as { filters: { compare: string }; comparison: { range: unknown }; }; expect(body.filters.compare).toBe('none'); expect(body.comparison.range).toBeNull(); }); it('returns 400 on inverted range (AC1)', async () => { const { app } = await buildApp(ADMIN); const res = await app.inject({ method: 'GET', url: '/reporting/filters/validate?from=2026-08-31T00:00:00Z&to=2026-08-01T00:00:00Z', }); expect(res.statusCode).toBe(400); }); it('returns 400 on missing required from/to (AC1)', async () => { const { app } = await buildApp(ADMIN); const res = await app.inject({ method: 'GET', url: '/reporting/filters/validate' }); expect(res.statusCode).toBe(400); }); it('customer is forbidden even with a valid query (RBAC enforced via HTTP, AC5)', async () => { const { app } = await buildApp(CUSTOMER); const res = await app.inject({ method: 'GET', url: BASE }); expect(res.statusCode).toBe(403); }); });