feat(F-143): completed feature

This commit is contained in:
chattie
2026-08-22 11:43:42 +02:00
parent fb015932b2
commit 3cc51477aa
22 changed files with 1328 additions and 127 deletions

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

View File

@@ -0,0 +1,62 @@
import type { FastifyInstance, FastifySchema } from 'fastify';
import type { Authenticate } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { REPORTING_FILTER_META, comparisonRange, reportingFiltersSchema } from '../application/filters.js';
import { requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
export interface ReportingRoutesDeps {
authenticate: Authenticate;
}
/**
* Reporting routes (F-143).
*
* Backend-only contracts + RBAC foundation. NO report data is read here
* (F-144+ owns report data). `ReportingFilterMeta.dataAvailability` is metadata
* only — it never converts an unavailable metric into a value.
*/
export async function registerReportingRoutes(
app: FastifyInstance,
deps: ReportingRoutesDeps,
): Promise<void> {
const filtersSchemaRoute: FastifySchema = {
tags: ['Reporting'],
summary: 'Reporting filter schema + RBAC contract',
description:
"Returns the shared reporting filter schema, comparison contract, data-availability metadata and the caller's reporting permissions. Requires REPORTING_VIEW.",
querystring: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema },
};
// No 200 response schema: the DTO is dynamic metadata; let it pass through
// unmodified (matches security.routes.ts pattern, avoids field stripping).
app.get('/reporting/filters/schema', { schema: filtersSchemaRoute }, async (request, reply) => {
const user = await deps.authenticate(request);
requireReportingPermission(user, 'REPORTING_VIEW');
return reply.send({
filterSchema: REPORTING_FILTER_META,
comparison: REPORTING_FILTER_META.comparison,
dataAvailability: REPORTING_FILTER_META.dataAvailability,
permissions: {
role: user.role,
grants: userReportingPermissions(user),
},
});
});
const validateSchemaRoute: FastifySchema = {
tags: ['Reporting'],
summary: 'Validate reporting filters',
description:
'Parses, validates and normalizes a reporting filter query and computes the comparison range. Requires REPORTING_SALES.',
querystring: { type: 'object' },
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
};
app.get('/reporting/filters/validate', { schema: validateSchemaRoute }, async (request, reply) => {
const user = await deps.authenticate(request);
requireReportingPermission(user, 'REPORTING_SALES');
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
const comparison = comparisonRange(filters.range, filters.compare);
return reply.send({ ok: true, filters, comparison: { range: comparison } });
});
}

View File

@@ -0,0 +1,289 @@
import { z } from 'zod';
import type {
ComparisonMode,
ComparisonRange,
DateRange,
ReportingFilterMeta,
ReportingFilters,
} from '../domain/filters.js';
import { REPORTING_CHANNELS, REPORTING_COMPARISON, REPORTING_GROUP_BY } from '../domain/filters.js';
/** Server-side safety cap for page size (F-142 §7/§11). */
export const REPORTING_PAGE_SIZE_MAX = 200;
/** Default page size when omitted. */
export const REPORTING_PAGE_SIZE_DEFAULT = 50;
/**
* Accepts a single ISO datetime string or an array (Fastify querystring yields
* string vs array); missing -> undefined. Used for repeatable UUID params.
*/
function repeatableUuid() {
return z.preprocess(
(val: unknown) => (val === undefined ? undefined : Array.isArray(val) ? val : [val]),
z.array(z.uuid()).optional(),
);
}
/** Repeatable free-text enum (e.g. order `state`). */
function repeatableString() {
return z.preprocess(
(val: unknown) => (val === undefined ? undefined : Array.isArray(val) ? val : [val]),
z.array(z.string().min(1).max(40)).optional(),
);
}
/**
* Shared reporting filter schema (F-142 §6). Validates + normalizes raw query
* params into a `ReportingFilters` value. `from`/`to` use `[from,to)` semantics
* (inclusive start, exclusive end); `from<to` is enforced.
*/
export const reportingFiltersSchema = z
.object({
from: z
.string()
.refine((s) => !Number.isNaN(Date.parse(s)), { message: 'Invalid ISO 8601 datetime' }),
to: z
.string()
.refine((s) => !Number.isNaN(Date.parse(s)), { message: 'Invalid ISO 8601 datetime' }),
compare: z.enum(REPORTING_COMPARISON).default('none'),
channel: z.enum(REPORTING_CHANNELS).default('all'),
storeId: repeatableUuid(),
terminalId: repeatableUuid(),
cashierId: repeatableUuid(),
paymentMethodId: repeatableUuid(),
productId: repeatableUuid(),
categoryId: repeatableUuid(),
brandId: repeatableUuid(),
customerId: z.uuid().optional(),
state: repeatableString(),
groupBy: z.enum(REPORTING_GROUP_BY).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z
.coerce.number()
.int()
.min(1)
.max(REPORTING_PAGE_SIZE_MAX)
.default(REPORTING_PAGE_SIZE_DEFAULT),
sort: z.string().min(1).max(200).optional(),
})
.refine((v) => new Date(v.from) < new Date(v.to), {
message: 'from must be before to',
})
.transform((v): ReportingFilters => ({
range: { from: v.from, to: v.to },
compare: v.compare,
channel: v.channel,
storeIds: v.storeId ?? [],
terminalIds: v.terminalId ?? [],
cashierIds: v.cashierId ?? [],
paymentMethodIds: v.paymentMethodId ?? [],
productIds: v.productId ?? [],
categoryIds: v.categoryId ?? [],
brandIds: v.brandId ?? [],
customerId: v.customerId,
state: v.state ?? [],
groupBy: v.groupBy,
page: v.page,
pageSize: v.pageSize,
sort: v.sort,
}));
export type ReportingFiltersValue = z.output<typeof reportingFiltersSchema>;
/** Parse + validate raw query into ReportingFilters (throws ZodError). */
export function parseReportingFilters(query: unknown): ReportingFilters {
return reportingFiltersSchema.parse(query);
}
/**
* Previous-period range for a comparison mode (`[from,to)` semantics).
* `null` for `none`. `previous_equal` mirrors the exact duration; `previous_calendar`
* shifts back by the calendar days spanned, UTC-aligned.
*/
export function comparisonRange(
range: DateRange,
mode: ComparisonMode,
): ComparisonRange | null {
if (mode === 'none') return null;
const start = new Date(range.from).getTime();
const end = new Date(range.to).getTime();
const duration = end - start;
if (mode === 'previous_equal') {
return {
from: new Date(start - duration).toISOString(),
to: new Date(start).toISOString(),
};
}
// previous_calendar: shift back by the whole calendar days spanned, UTC-aligned,
// so a calendar period maps to the prior same-length calendar period.
const spanDays = Math.max(1, Math.ceil(duration / (24 * 60 * 60 * 1000)));
const prevStart = new Date(start - spanDays * 24 * 60 * 60 * 1000);
prevStart.setUTCHours(0, 0, 0, 0);
const prevEnd = new Date(prevStart);
prevEnd.setUTCDate(prevEnd.getUTCDate() + spanDays);
return { from: prevStart.toISOString(), to: prevEnd.toISOString() };
}
/** Single source of truth for the filter metadata served to clients. */
export const REPORTING_FILTER_META: ReportingFilterMeta = {
filters: [
{
name: 'from',
type: 'date',
required: true,
repeatable: false,
description: 'Inclusive start datetime (ISO 8601 with UTC offset).',
},
{
name: 'to',
type: 'date',
required: true,
repeatable: false,
description: 'Exclusive end datetime (ISO 8601 with UTC offset).',
},
{
name: 'compare',
type: 'enum',
required: false,
repeatable: false,
options: [...REPORTING_COMPARISON],
description: 'Date-comparison mode against a previous period.',
},
{
name: 'channel',
type: 'enum',
required: false,
repeatable: false,
options: [...REPORTING_CHANNELS],
description: 'Sales channel filter.',
},
{
name: 'storeId',
type: 'uuid',
required: false,
repeatable: true,
description: 'One or more store UUIDs.',
},
{
name: 'terminalId',
type: 'uuid',
required: false,
repeatable: true,
description: 'One or more POS terminal UUIDs.',
},
{
name: 'cashierId',
type: 'uuid',
required: false,
repeatable: true,
description: 'Backoffice user (cashier) UUIDs.',
},
{
name: 'paymentMethodId',
type: 'uuid',
required: false,
repeatable: true,
description: 'Payment method UUIDs.',
},
{
name: 'productId',
type: 'uuid',
required: false,
repeatable: true,
description: 'Catalog product UUIDs.',
},
{
name: 'categoryId',
type: 'uuid',
required: false,
repeatable: true,
description: 'Category UUIDs (resolved against the current catalog).',
},
{
name: 'brandId',
type: 'uuid',
required: false,
repeatable: true,
description: 'Brand UUIDs.',
},
{
name: 'customerId',
type: 'uuid',
required: false,
repeatable: false,
description: 'Single customer UUID (walk-in POS has no customer).',
},
{
name: 'state',
type: 'string',
required: false,
repeatable: true,
options: [
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'COMPLETED',
'PARTIALLY_REFUNDED',
'REFUNDED',
'CANCELLED',
],
description: 'Order states to include.',
},
{
name: 'groupBy',
type: 'enum',
required: false,
repeatable: false,
options: [...REPORTING_GROUP_BY],
description: 'Dimension to group report rows by.',
},
{
name: 'page',
type: 'string',
required: false,
repeatable: false,
description: '1-indexed page number (integer).',
},
{
name: 'pageSize',
type: 'string',
required: false,
repeatable: false,
description: `Page size (1..${REPORTING_PAGE_SIZE_MAX}).`,
},
{
name: 'sort',
type: 'string',
required: false,
repeatable: false,
description: 'Sort expression, e.g. -revenue.',
},
],
comparison: {
modes: [...REPORTING_COMPARISON],
rangeBounds: 'inclusive_start_exclusive_end',
},
groupBy: [...REPORTING_GROUP_BY],
pagination: {
pageMin: 1,
pageSizeMin: 1,
pageSizeMax: REPORTING_PAGE_SIZE_MAX,
},
// F-142 §4 baseline. Future reports flip these to 'available' as the model supports them.
dataAvailability: {
grossSales: 'available',
netSales: 'unavailable',
discounts: 'available',
tax: 'available',
unitsSold: 'available',
orders: 'available',
customers: 'available',
margin: 'unavailable',
paymentMethod: 'unavailable',
refunds: 'unavailable',
shipping: 'unavailable',
},
};

View File

@@ -0,0 +1,103 @@
/**
* Reporting filter contract — pure TypeScript domain types (F-143).
* No third-party imports, no module dependencies. Only the shared reporting
* vocabulary consumed by the application layer and the route layer.
*/
/** Date-comparison modes for the `compare` query param. */
export const REPORTING_COMPARISON = ['none', 'previous_equal', 'previous_calendar'] as const;
export type ComparisonMode = (typeof REPORTING_COMPARISON)[number];
/** Sales channels a report can be scoped to. */
export const REPORTING_CHANNELS = ['all', 'ecommerce', 'pos', 'admin'] as const;
export type ReportingChannel = (typeof REPORTING_CHANNELS)[number];
/** Dimensions a report can be grouped by. */
export const REPORTING_GROUP_BY = [
'day',
'week',
'month',
'hour',
'store',
'channel',
'terminal',
'cashier',
'payment',
] as const;
export type GroupBy = (typeof REPORTING_GROUP_BY)[number];
/** Inclusive-start, exclusive-end date window. `[from, to)` avoids double counting. */
export interface DateRange {
from: string;
to: string;
}
/** Previous-period range produced by a comparison mode. `null` for `none`. */
export interface ComparisonRange {
from: string;
to: string;
}
/** Availability of a metric under the current data model (F-142 §4 baseline). */
export type Availability = 'available' | 'unavailable';
/** Metadata describing a single filter field, for client-side UI generation. */
export interface FilterFieldMeta {
name: string;
type: 'uuid' | 'string' | 'enum' | 'date';
required: boolean;
repeatable: boolean;
options?: readonly string[];
description: string;
}
/** Normalized reporting filters after parsing + validation. */
export interface ReportingFilters {
range: DateRange;
compare: ComparisonMode;
channel: ReportingChannel;
storeIds: string[];
terminalIds: string[];
cashierIds: string[];
paymentMethodIds: string[];
productIds: string[];
categoryIds: string[];
brandIds: string[];
customerId: string | undefined;
state: string[];
groupBy: GroupBy | undefined;
page: number;
pageSize: number;
sort: string | undefined;
}
/** Which metrics can be computed from the current model. */
export interface DataAvailability {
grossSales: Availability;
netSales: Availability;
discounts: Availability;
tax: Availability;
unitsSold: Availability;
orders: Availability;
customers: Availability;
margin: Availability;
paymentMethod: Availability;
refunds: Availability;
shipping: Availability;
}
/** The shared metadata block returned by `GET /reporting/filters/schema`. */
export interface ReportingFilterMeta {
filters: FilterFieldMeta[];
comparison: {
modes: ComparisonMode[];
rangeBounds: 'inclusive_start_exclusive_end';
};
groupBy: GroupBy[];
pagination: {
pageMin: number;
pageSizeMin: number;
pageSizeMax: number;
};
dataAvailability: DataAvailability;
}

View File

@@ -0,0 +1,72 @@
/**
* Reporting RBAC (F-142 §9).
*
* The codebase has no permission table, so permissions are a role -> permission
* map. The `requireReportingPermission(user, perm)` call-site signature is the
* single seam a future `backoffice_permissions` migration must preserve.
*/
import type { CurrentUser, Role } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
export type ReportingPermission =
| 'REPORTING_VIEW'
| 'REPORTING_SALES'
| 'REPORTING_PRODUCTS'
| 'REPORTING_CUSTOMERS'
| 'REPORTING_INVENTORY'
| 'REPORTING_PAYMENTS'
| 'REPORTING_CASH'
| 'REPORTING_DISCOUNTS'
| 'REPORTING_REFUNDS'
| 'REPORTING_TAXES'
| 'REPORTING_FINANCIAL'
| 'REPORTING_EXPORT'
| 'REPORTING_ADMIN';
/** Permissions granted to every authenticated report reader. */
const REPORTING_VIEW_BASE: ReportingPermission[] = [
'REPORTING_VIEW',
'REPORTING_SALES',
'REPORTING_PRODUCTS',
'REPORTING_CUSTOMERS',
'REPORTING_INVENTORY',
'REPORTING_DISCOUNTS',
'REPORTING_REFUNDS',
'REPORTING_TAXES',
];
/** Role -> granted reporting permissions ("compatibilidad inicial", F-142 §9). */
export const REPORTING_ROLE_PERMISSIONS: Record<Role, ReportingPermission[]> = {
admin: [
...REPORTING_VIEW_BASE,
'REPORTING_PAYMENTS',
'REPORTING_CASH',
'REPORTING_FINANCIAL',
'REPORTING_EXPORT',
'REPORTING_ADMIN',
],
editor: REPORTING_VIEW_BASE,
pos_manager: [
'REPORTING_VIEW',
'REPORTING_SALES',
'REPORTING_PAYMENTS',
'REPORTING_CASH',
],
pos_cashier: ['REPORTING_VIEW', 'REPORTING_SALES'],
customer: [],
};
/** Permissions granted to a current user under the reporting RBAC matrix. */
export function userReportingPermissions(user: CurrentUser): ReportingPermission[] {
return REPORTING_ROLE_PERMISSIONS[user.role] ?? [];
}
/** Throws AppError(403) unless the user holds the given reporting permission. */
export function requireReportingPermission(
user: CurrentUser,
permission: ReportingPermission,
): void {
if (!userReportingPermissions(user).includes(permission)) {
throw new AppError(403, 'FORBIDDEN', 'Insufficient reporting permission');
}
}

View File

@@ -0,0 +1,32 @@
/**
* Public surface for the reporting module (F-143).
* Re-exports the routes registrar and the contract/permission helpers so the
* composition root (build-app.ts) and tests import only this index.
*/
export { registerReportingRoutes } from './api/reporting.routes.js';
export type { ReportingRoutesDeps } from './api/reporting.routes.js';
export {
REPORTING_FILTER_META,
comparisonRange,
parseReportingFilters,
reportingFiltersSchema,
REPORTING_PAGE_SIZE_DEFAULT,
REPORTING_PAGE_SIZE_MAX,
} from './application/filters.js';
export { REPORTING_ROLE_PERMISSIONS, requireReportingPermission, userReportingPermissions } from './domain/permissions.js';
export type { ReportingPermission } from './domain/permissions.js';
export {
REPORTING_CHANNELS,
REPORTING_COMPARISON,
REPORTING_GROUP_BY,
type ComparisonMode,
type ComparisonRange,
type DateRange,
type FilterFieldMeta,
type GroupBy,
type ReportingChannel,
type ReportingFilterMeta,
type ReportingFilters,
type DataAvailability,
type Availability,
} from './domain/filters.js';

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
REPORTING_PAGE_SIZE_MAX,
comparisonRange,
parseReportingFilters,
} from '../application/filters.js';
const FROM = '2026-08-01T00:00:00Z';
const TO = '2026-08-31T23:59:59Z';
const UUID_A = '11111111-1111-4111-8111-111111111001';
const UUID_B = '22222222-2222-4222-8222-222222222002';
describe('reportingFiltersSchema / parseReportingFilters', () => {
it('parses a valid query and applies defaults (AC1)', () => {
const f = parseReportingFilters({ from: FROM, to: TO });
expect(f.range).toEqual({ from: FROM, to: TO });
expect(f.compare).toBe('none');
expect(f.channel).toBe('all');
expect(f.storeIds).toEqual([]);
expect(f.page).toBe(1);
expect(f.pageSize).toBe(50);
});
it('accepts repeated and single storeId as a UUID array', () => {
expect(parseReportingFilters({ from: FROM, to: TO, storeId: [UUID_A, UUID_B] }).storeIds).toEqual([
UUID_A,
UUID_B,
]);
expect(parseReportingFilters({ from: FROM, to: TO, storeId: UUID_A }).storeIds).toEqual([
UUID_A,
]);
});
it('rejects inverted range (from >= to) with a validation error (AC1)', () => {
const same = { from: FROM, to: FROM };
expect(() => parseReportingFilters(same)).toThrow();
expect(() => parseReportingFilters({ from: TO, to: FROM })).toThrow();
});
it('rejects invalid UUIDs in repeatable fields (AC4)', () => {
expect(() => parseReportingFilters({ from: FROM, to: TO, storeId: 'not-a-uuid' })).toThrow();
});
it('rejects non-ISO datetimes and out-of-range page sizes', () => {
expect(() => parseReportingFilters({ from: 'nope', to: TO })).toThrow();
expect(() => parseReportingFilters({ from: FROM, to: TO, pageSize: 9999 })).toThrow();
});
it('enforces pageSize ceiling via REPORTING_PAGE_SIZE_MAX', () => {
expect(REPORTING_PAGE_SIZE_MAX).toBe(200);
expect(() => parseReportingFilters({ from: FROM, to: TO, pageSize: 201 })).toThrow();
});
});
describe('comparisonRange', () => {
it('returns null for "none"', () => {
expect(comparisonRange({ from: FROM, to: TO }, 'none')).toBeNull();
});
it('"previous_equal" mirrors the exact duration, ending at the range start (AC3)', () => {
const prev = comparisonRange({ from: FROM, to: TO }, 'previous_equal');
expect(prev).not.toBeNull();
const start = new Date(FROM).getTime();
const end = new Date(TO).getTime();
const duration = end - start;
expect(prev!.to).toBe(new Date(start).toISOString());
expect(prev!.from).toBe(new Date(start - duration).toISOString());
expect(new Date(prev!.from).getTime()).toBeLessThan(start);
expect(new Date(prev!.to).getTime()).toBe(start);
});
it('"previous_calendar" is UTC-aligned and ends at the range start (AC4)', () => {
const prev = comparisonRange({ from: FROM, to: TO }, 'previous_calendar');
expect(prev).not.toBeNull();
const thisStart = new Date(FROM).getTime();
expect(prev!.to).toBe(new Date(thisStart).toISOString());
expect(new Date(prev!.from).getTime()).toBeLessThan(thisStart);
expect(new Date(prev!.to).getTime()).toBe(thisStart);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import type { Role } from '../../../shared/auth.js';
import { REPORTING_ROLE_PERMISSIONS, requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
function user(role: Role) {
return { id: 'u1', email: 'u@example.com', role } as const;
}
describe('reporting RBAC matrix (F-142 §9)', () => {
it('admin holds every reporting permission', () => {
const grants = userReportingPermissions(user('admin'));
expect(grants).toContain('REPORTING_VIEW');
expect(grants).toContain('REPORTING_SALES');
expect(grants).toContain('REPORTING_EXPORT');
expect(grants).toContain('REPORTING_ADMIN');
});
it('pos_cashier only sees VIEW + SALES (least privilege)', () => {
expect(userReportingPermissions(user('pos_cashier'))).toEqual([
'REPORTING_VIEW',
'REPORTING_SALES',
]);
});
it('customers never get reporting permissions', () => {
expect(userReportingPermissions(user('customer'))).toEqual([]);
});
it('requireReportingPermission throws AppError(403) for denied perms', () => {
// customer has no grants -> SALES denied
expect(() => requireReportingPermission(user('customer'), 'REPORTING_SALES')).toThrow();
// editor lacks EXPORT
expect(() => requireReportingPermission(user('editor'), 'REPORTING_EXPORT')).toThrow();
// admin has everything -> no throw
expect(() => requireReportingPermission(user('admin'), 'REPORTING_VIEW')).not.toThrow();
});
it('REPORTING_FINANCIAL is admin-only (AC6)', () => {
expect(() => requireReportingPermission(user('editor'), 'REPORTING_FINANCIAL')).toThrow();
expect(() => requireReportingPermission(user('pos_manager'), 'REPORTING_FINANCIAL')).toThrow();
expect(() => requireReportingPermission(user('pos_cashier'), 'REPORTING_FINANCIAL')).toThrow();
expect(() => requireReportingPermission(user('admin'), 'REPORTING_FINANCIAL')).not.toThrow();
});
it('the matrix covers every role (no missing keys)', () => {
for (const role of ['admin', 'editor', 'pos_manager', 'pos_cashier', 'customer'] as const) {
expect(REPORTING_ROLE_PERMISSIONS[role]).toBeTypeOf('object');
}
});
});