140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
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<string, string> };
|
|
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);
|
|
});
|
|
});
|