feat(F-143): completed feature
This commit is contained in:
139
project/src/modules/reporting/api/reporting.routes.test.ts
Normal file
139
project/src/modules/reporting/api/reporting.routes.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user