Files
mercadodevida/project/src/modules/reporting/api/reporting.routes.test.ts
2026-08-22 11:43:42 +02:00

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);
});
});