Global admins couldn't see the app's event log (no read endpoint or UI existed for it) and had no way to clean up old entries or re-run a scheduled job without touching the database or server directly. Backend: - LoggingService.findLogs() + admin-only LogsController (GET admin/logs) with level/event/date-range/search filtering and pagination, mirroring AdminUsersService.findPlayers(). - LogRetentionScheduler deletes log entries older than LOG_RETENTION_DAYS (default 365, via app.config.ts), following the existing @Cron scheduler pattern. - Admin-only POST admin/run endpoints on CashboxExportController and RecurringTransactionsController that invoke the existing schedulers' public run methods on demand - both are safe to re-run since their "due" queries advance nextRunDate only after a successful run. Frontend: - New /logs page (global-admin gated, same pattern as /users): AG-Grid infinite-scroll table with level/event/date-range/search filters, plus buttons to trigger the two jobs now and see the result land in the grid immediately. - LogsApi, and triggerRunNow() added to the existing CashboxExportApi and RecurringTransactionApi. - Discoverability link from /users to /logs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
import { LoggingService } from './logging.service';
|
|
|
|
describe('LoggingService', () => {
|
|
it('can persist an event through the caller transaction manager', async () => {
|
|
const defaultRepository = { save: jest.fn() };
|
|
const transactionRepository = { save: jest.fn() };
|
|
const manager = {
|
|
getRepository: jest.fn(() => transactionRepository),
|
|
} as any;
|
|
const service = new LoggingService(defaultRepository as any);
|
|
const event = {
|
|
event: 'admin_user_profile_update' as const,
|
|
details: 'targetUserId=2',
|
|
userId: 1,
|
|
};
|
|
|
|
await service.info(event, manager);
|
|
|
|
expect(transactionRepository.save).toHaveBeenCalledWith({
|
|
...event,
|
|
level: 'INFO',
|
|
});
|
|
expect(defaultRepository.save).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('LoggingService.findLogs', () => {
|
|
let rows: any[];
|
|
let total: number;
|
|
let query: any;
|
|
let repository: any;
|
|
let service: LoggingService;
|
|
|
|
beforeEach(() => {
|
|
rows = [];
|
|
total = 0;
|
|
query = chain({
|
|
getMany: jest.fn(() => rows),
|
|
getCount: jest.fn(() => total),
|
|
});
|
|
repository = {
|
|
createQueryBuilder: jest.fn(() => query),
|
|
};
|
|
service = new LoggingService(repository);
|
|
});
|
|
|
|
it('returns a paginated page with data, total and hasNextPage', async () => {
|
|
rows = [
|
|
{ id: 1, level: 'INFO', event: 'team_create', details: 'teamId=5', userId: 3, createdAt: new Date('2026-08-01') },
|
|
];
|
|
total = 21;
|
|
|
|
const result = await service.findLogs({ page: 1, limit: 20 });
|
|
|
|
expect(result).toEqual({ data: rows, page: 1, limit: 20, total: 21, hasNextPage: true });
|
|
expect(query.orderBy).toHaveBeenCalledWith('log.createdAt', 'DESC');
|
|
expect(query.offset).toHaveBeenCalledWith(0);
|
|
expect(query.limit).toHaveBeenCalledWith(20);
|
|
});
|
|
|
|
it('reports hasNextPage=false on the last page', async () => {
|
|
total = 20;
|
|
|
|
const result = await service.findLogs({ page: 1, limit: 20 });
|
|
|
|
expect(result.hasNextPage).toBe(false);
|
|
});
|
|
|
|
it('offsets by (page - 1) * limit', async () => {
|
|
await service.findLogs({ page: 3, limit: 10 });
|
|
|
|
expect(query.offset).toHaveBeenCalledWith(20);
|
|
});
|
|
|
|
it('filters by level and event when provided', async () => {
|
|
await service.findLogs({ page: 1, limit: 20, level: 'ERROR', event: 'cashbox_export_subscription_run_fail' });
|
|
|
|
expect(query.andWhere).toHaveBeenCalledWith('log.level = :level', { level: 'ERROR' });
|
|
expect(query.andWhere).toHaveBeenCalledWith('log.event = :event', {
|
|
event: 'cashbox_export_subscription_run_fail',
|
|
});
|
|
});
|
|
|
|
it('filters by an inclusive date range when from/to are provided', async () => {
|
|
await service.findLogs({ page: 1, limit: 20, from: '2026-01-01', to: '2026-01-31' });
|
|
|
|
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt >= :from', { from: '2026-01-01' });
|
|
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', { to: '2026-01-31' });
|
|
});
|
|
|
|
it('does not add level/event/date filters when omitted', async () => {
|
|
await service.findLogs({ page: 1, limit: 20 });
|
|
|
|
expect(query.andWhere).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('filters details by a case-insensitive search term', async () => {
|
|
await service.findLogs({ page: 1, limit: 20, search: ' TeamId=5 ' });
|
|
|
|
expect(query.andWhere).toHaveBeenCalledWith('LOWER(log.details) LIKE :search', {
|
|
search: '%teamid=5%',
|
|
});
|
|
});
|
|
|
|
it('ignores a blank search term', async () => {
|
|
await service.findLogs({ page: 1, limit: 20, search: ' ' });
|
|
|
|
expect(query.andWhere).not.toHaveBeenCalled();
|
|
});
|
|
|
|
function chain(overrides: Record<string, jest.Mock>) {
|
|
const builder: Record<string, jest.Mock> = {};
|
|
['andWhere', 'orderBy', 'offset', 'limit'].forEach((method) => {
|
|
builder[method] = jest.fn(() => builder);
|
|
});
|
|
return Object.assign(builder, overrides);
|
|
}
|
|
});
|