feat: add cashbox export download endpoint
This commit is contained in:
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user