fix: address cashbox-export whole-branch review findings

- Reject dates that are not strict YYYY-MM-DD (was accepting full ISO
  datetimes, which silently produced empty exports instead of a 400)
  and reject from > to with a 400 before touching the team/DB.
- Emit the cashbox_export_download and cashbox_export_subscription_update
  audit log events that were declared but never fired, matching the
  audit trail every sibling feature already has.
- Restore full type checking on the pdfkit import via `import = require()`
  instead of an untyped require() with an eslint-disable.
- Tighten a cashbox.spec.ts assertion to check the exact dialog class
  instead of expect.anything(), so it can't pass with the wrong dialog
  wired to the Export button.
- Style and announce the export dialogs' error messages using this
  codebase's established error-message/role=alert pattern.
This commit is contained in:
Bastian Wagner
2026-08-04 09:20:44 +02:00
parent ce0b500d7a
commit da5998487a
12 changed files with 100 additions and 15 deletions

View File

@@ -6,11 +6,12 @@ import { CashboxExportSubscriptionService } from './cashbox-export-subscription.
describe('CashboxExportSubscriptionService', () => {
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportSubscriptionService;
beforeEach(() => {
jest.clearAllMocks();
service = new CashboxExportSubscriptionService(repository as any, access as any);
service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any);
});
it('returns a paused default when no subscription exists yet', async () => {
@@ -78,6 +79,11 @@ describe('CashboxExportSubscriptionService', () => {
nextRunDate: '2026-09-01T00:00:00.000Z',
}),
);
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_update',
details: 'teamId=5 active=true interval=monthly recipients=1',
userId: 42,
});
jest.useRealTimers();
});

View File

@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
@@ -20,6 +21,7 @@ export class CashboxExportSubscriptionService {
@InjectRepository(CashboxExportSubscription)
private readonly repository: Repository<CashboxExportSubscription>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getSubscription(
@@ -67,6 +69,11 @@ export class CashboxExportSubscriptionService {
}
const saved = await this.repository.save(entity);
await this.logger.info({
event: 'cashbox_export_subscription_update',
details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`,
userId,
});
return this.toResponse(saved);
}

View File

@@ -67,6 +67,24 @@ describe('cashbox export HTTP boundary', () => {
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({

View File

@@ -1,10 +1,11 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { CashboxExportService } from './cashbox-export.service';
describe('CashboxExportService', () => {
const teamRepository = { findOne: jest.fn() };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportService;
let callOrder: string[];
@@ -31,7 +32,7 @@ describe('CashboxExportService', () => {
return team;
});
service = new CashboxExportService(teamRepository as any, access as any);
service = new CashboxExportService(teamRepository as any, access as any, logger as any);
});
it('checks permission before loading data', async () => {
@@ -69,6 +70,15 @@ describe('CashboxExportService', () => {
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a range where from is after to without querying the team', async () => {
await expect(
service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'),
).rejects.toBeInstanceOf(BadRequestException);
expect(access.assertAtLeast).not.toHaveBeenCalled();
expect(teamRepository.findOne).not.toHaveBeenCalled();
});
it('builds a CSV buffer with the correct content type and filename', async () => {
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
@@ -84,4 +94,14 @@ describe('CashboxExportService', () => {
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('logs a cashbox_export_download event after a successful export', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_download',
details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31',
userId: 42,
});
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
@@ -12,6 +13,7 @@ export class CashboxExportService {
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async exportForUser(
@@ -21,6 +23,11 @@ export class CashboxExportService {
to: string,
format: 'csv' | 'pdf',
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
if (from > to) {
throw new BadRequestException(
'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.',
);
}
await this.access.assertAtLeast(
userId,
teamId,
@@ -36,16 +43,28 @@ export class CashboxExportService {
const rows = buildRows(team, from, to);
if (format === 'csv') {
return {
const result = {
buffer: Buffer.from(buildCsv(rows), 'utf-8'),
contentType: 'text/csv; charset=utf-8',
filename: `kassenbuch_${team.alias}_${from}_${to}.csv`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
return {
const result = {
buffer: await buildPdf(team, rows, from, to),
contentType: 'application/pdf',
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
}

View File

@@ -1,6 +1,5 @@
import { Team } from 'src/teams/entities/team.entity';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PDFDocument = require('pdfkit');
import PDFDocument = require('pdfkit');
export interface CashboxExportRow {
date: string;

View File

@@ -1,10 +1,10 @@
import { IsDateString, IsIn } from 'class-validator';
import { IsIn, Matches } from 'class-validator';
export class CashboxExportQueryDto {
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from: string;
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to: string;
@IsIn(['csv', 'pdf'])