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>
177 lines
6.4 KiB
TypeScript
177 lines
6.4 KiB
TypeScript
import {
|
|
INestApplication,
|
|
UnauthorizedException,
|
|
ValidationPipe,
|
|
VersioningType,
|
|
} from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { Test } from '@nestjs/testing';
|
|
import * as request from 'supertest';
|
|
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
|
import validationOptions from '../utils/validation-options';
|
|
import { CashboxExportController } from './cashbox-export.controller';
|
|
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
|
|
|
describe('cashbox export HTTP boundary', () => {
|
|
let app: INestApplication;
|
|
const service = {
|
|
exportForUser: jest.fn(),
|
|
};
|
|
const subscriptionService = {
|
|
getSubscription: jest.fn(),
|
|
upsertSubscription: jest.fn(),
|
|
};
|
|
const scheduler = { runDueSubscriptions: jest.fn() };
|
|
|
|
beforeAll(async () => {
|
|
const module = await Test.createTestingModule({
|
|
controllers: [CashboxExportController],
|
|
providers: [
|
|
{ provide: CashboxExportService, useValue: service },
|
|
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
|
{ provide: CashboxExportScheduler, useValue: scheduler },
|
|
],
|
|
})
|
|
.overrideGuard(AuthGuard('jwt'))
|
|
.useValue({
|
|
canActivate(context) {
|
|
const httpRequest = context.switchToHttp().getRequest();
|
|
if (httpRequest.headers.authorization !== 'Bearer user') {
|
|
throw new UnauthorizedException();
|
|
}
|
|
httpRequest.user = { id: 42, role: { id: 2 } };
|
|
return true;
|
|
},
|
|
})
|
|
.compile();
|
|
app = module.createNestApplication();
|
|
app.setGlobalPrefix('api');
|
|
app.enableVersioning({ type: VersioningType.URI });
|
|
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(() => app.close());
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('requires authentication', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
|
.expect(401);
|
|
});
|
|
|
|
it('rejects an invalid format', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(422);
|
|
expect(service.exportForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => {
|
|
await request(app.getHttpServer())
|
|
.get(
|
|
'/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv',
|
|
)
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(422);
|
|
expect(service.exportForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a malformed date string for to', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(422);
|
|
expect(service.exportForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a CSV file with correct headers and content', async () => {
|
|
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
|
|
service.exportForUser.mockResolvedValue({
|
|
buffer: csvBuffer,
|
|
contentType: 'text/csv; charset=utf-8',
|
|
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200);
|
|
|
|
expect(response.headers['content-type']).toContain('text/csv');
|
|
expect(response.headers['content-disposition']).toContain(
|
|
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
|
);
|
|
expect(response.text).toBe(csvBuffer.toString('utf-8'));
|
|
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
|
});
|
|
|
|
it('returns a PDF file with correct headers and binary content', async () => {
|
|
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
|
|
service.exportForUser.mockResolvedValue({
|
|
buffer: pdfBuffer,
|
|
contentType: 'application/pdf',
|
|
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200);
|
|
|
|
expect(response.headers['content-type']).toBe('application/pdf');
|
|
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
|
|
});
|
|
|
|
it('reads the current subscription', async () => {
|
|
const subscription = {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
};
|
|
subscriptionService.getSubscription.mockResolvedValue(subscription);
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200)
|
|
.expect(subscription);
|
|
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
|
|
});
|
|
|
|
it('rejects an invalid recipient email on upsert', async () => {
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
|
|
.expect(422);
|
|
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a valid subscription upsert', async () => {
|
|
const subscription = {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
};
|
|
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
|
|
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
|
|
.expect(200)
|
|
.expect(subscription);
|
|
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: 'monthly',
|
|
active: true,
|
|
});
|
|
});
|
|
});
|