From 009aae1f2b17a0be1a71bb205fd4a60a155d2b3c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 21:37:14 +0200 Subject: [PATCH] feat: add cashbox export download endpoint --- .../cashbox-export.controller.ts | 45 +++++++++ .../cashbox-export.http.spec.ts | 98 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts create mode 100644 myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts new file mode 100644 index 0000000..d5ddb71 --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + Query, + Request, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto'; +import { CashboxExportService } from './cashbox-export.service'; + +type AuthenticatedRequest = { user: { id: number } }; + +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller({ path: 'cashbox-export', version: '1' }) +export class CashboxExportController { + constructor(private readonly service: CashboxExportService) {} + + @Get(':teamId') + async exportCashbox( + @Request() request: AuthenticatedRequest, + @Param('teamId', ParseIntPipe) teamId: number, + @Query() query: CashboxExportQueryDto, + @Res({ passthrough: false }) res: Response, + ): Promise { + const { buffer, contentType, filename } = await this.service.exportForUser( + teamId, + request.user.id, + query.from, + query.to, + query.format, + ); + res.set({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + res.send(buffer); + } +} diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts new file mode 100644 index 0000000..c89e4be --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts @@ -0,0 +1,98 @@ +import { + INestApplication, + UnauthorizedException, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import validationOptions from '../utils/validation-options'; +import { CashboxExportController } from './cashbox-export.controller'; +import { CashboxExportService } from './cashbox-export.service'; + +describe('cashbox export HTTP boundary', () => { + let app: INestApplication; + const service = { + exportForUser: jest.fn(), + }; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [CashboxExportController], + providers: [{ provide: CashboxExportService, useValue: service }], + }) + .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('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); + }); +});