diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index c3c7bc7..a3aff13 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -31,6 +31,7 @@ import { StatisticsModule } from './statistics/statistics.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { DocumentsModule } from './documents/documents.module'; import { AutomationModule } from './automation/automation.module'; +import { ImportModule } from './import/import.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -67,6 +68,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; WebhooksModule, DocumentsModule, AutomationModule, + ImportModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/import/csv.spec.ts b/ApiCore-Backend/src/import/csv.spec.ts new file mode 100644 index 0000000..69de763 --- /dev/null +++ b/ApiCore-Backend/src/import/csv.spec.ts @@ -0,0 +1,41 @@ +import { parseCsv } from './csv'; + +describe('parseCsv', () => { + it('parses a simple header and data row', () => { + const result = parseCsv('name,location\nTeststand,Garten'); + expect(result).toEqual([ + ['name', 'location'], + ['Teststand', 'Garten'], + ]); + }); + + it('handles quoted fields containing commas', () => { + const result = parseCsv('name,notes\n"Stand A","Enthält, ein Komma"'); + expect(result).toEqual([ + ['name', 'notes'], + ['Stand A', 'Enthält, ein Komma'], + ]); + }); + + it('handles escaped double quotes inside quoted fields', () => { + const result = parseCsv('name\n"Der ""beste"" Stand"'); + expect(result).toEqual([['name'], ['Der "beste" Stand']]); + }); + + it('handles CRLF line endings', () => { + const result = parseCsv('a,b\r\n1,2\r\n3,4'); + expect(result).toEqual([ + ['a', 'b'], + ['1', '2'], + ['3', '4'], + ]); + }); + + it('ignores trailing blank lines', () => { + const result = parseCsv('a,b\n1,2\n\n'); + expect(result).toEqual([ + ['a', 'b'], + ['1', '2'], + ]); + }); +}); diff --git a/ApiCore-Backend/src/import/csv.ts b/ApiCore-Backend/src/import/csv.ts new file mode 100644 index 0000000..fe32089 --- /dev/null +++ b/ApiCore-Backend/src/import/csv.ts @@ -0,0 +1,46 @@ +export function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + const BOM = String.fromCharCode(0xfeff); + const normalized = text.startsWith(BOM) ? text.slice(1) : text; + + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + if (inQuotes) { + if (char === '"') { + if (normalized[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += char; + } + continue; + } + if (char === '"') { + inQuotes = true; + } else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && normalized[i + 1] === '\n') { + i++; + } + row.push(field); + rows.push(row); + row = []; + field = ''; + } else { + field += char; + } + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((line) => !(line.length === 1 && line[0] === '')); +} diff --git a/ApiCore-Backend/src/import/import.controller.ts b/ApiCore-Backend/src/import/import.controller.ts new file mode 100644 index 0000000..ed1830a --- /dev/null +++ b/ApiCore-Backend/src/import/import.controller.ts @@ -0,0 +1,53 @@ +import { + BadRequestException, + Controller, + Param, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { ImportService } from './import.service'; + +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; + +@ApiTags('import') +@ApiBearerAuth() +@Roles(Role.ADMIN) +@Controller('import') +export class ImportController { + constructor(private readonly importService: ImportService) {} + + @Post(':entity') + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_FILE_SIZE_BYTES }, + fileFilter: (_req, file, callback) => { + if (!file.originalname.toLowerCase().endsWith('.csv')) { + callback( + new BadRequestException('Nur CSV-Dateien werden unterstützt'), + false, + ); + return; + } + callback(null, true); + }, + }), + ) + import( + @Param('entity') entity: string, + @UploadedFile() file: Express.Multer.File | undefined, + @CurrentUser() actor: AuthenticatedUser, + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + return this.importService.importCsv(entity, file.buffer, actor.id); + } +} diff --git a/ApiCore-Backend/src/import/import.module.ts b/ApiCore-Backend/src/import/import.module.ts new file mode 100644 index 0000000..93df18f --- /dev/null +++ b/ApiCore-Backend/src/import/import.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; + +@Module({ + controllers: [ImportController], + providers: [ImportService], +}) +export class ImportModule {} diff --git a/ApiCore-Backend/src/import/import.service.spec.ts b/ApiCore-Backend/src/import/import.service.spec.ts new file mode 100644 index 0000000..1248070 --- /dev/null +++ b/ApiCore-Backend/src/import/import.service.spec.ts @@ -0,0 +1,177 @@ +import { BadRequestException } from '@nestjs/common'; +import { ImportService } from './import.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('ImportService', () => { + let service: ImportService; + let prisma: { + apiary: { create: jest.Mock; findFirst: jest.Mock }; + colony: { create: jest.Mock; findUnique: jest.Mock }; + queen: { create: jest.Mock; findUnique: jest.Mock }; + }; + let auditLog: { record: jest.Mock }; + + beforeEach(() => { + prisma = { + apiary: { create: jest.fn(), findFirst: jest.fn() }, + colony: { create: jest.fn(), findUnique: jest.fn() }, + queen: { create: jest.fn(), findUnique: jest.fn() }, + }; + auditLog = { record: jest.fn() }; + service = new ImportService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('rejects an unsupported entity', async () => { + await expect( + service.importCsv('unicorns', Buffer.from('a\n1'), 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an empty CSV file', async () => { + await expect( + service.importCsv('apiaries', Buffer.from(''), 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + describe('apiaries', () => { + it('creates a valid row', async () => { + prisma.apiary.create.mockResolvedValue({ id: 'apiary-1' }); + + const report = await service.importCsv( + 'apiaries', + Buffer.from('name,location\nTeststand,Garten'), + 'user-1', + ); + + expect(report.created).toBe(1); + expect(report.errors).toBe(0); + expect(prisma.apiary.create).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'Import', entityId: 'apiaries' }), + ); + }); + + it('reports a validation error for a missing name', async () => { + const report = await service.importCsv( + 'apiaries', + Buffer.from('name,location\n,Garten'), + 'user-1', + ); + + expect(report.errors).toBe(1); + expect(report.created).toBe(0); + expect(prisma.apiary.create).not.toHaveBeenCalled(); + expect(report.rows[0].message).toBeDefined(); + }); + }); + + describe('colonies', () => { + it('errors when apiaryName column is missing', async () => { + const report = await service.importCsv( + 'colonies', + Buffer.from('code,colonyType\nV-100,PRODUCTION_COLONY'), + 'user-1', + ); + + expect(report.errors).toBe(1); + expect(report.rows[0].message).toContain('apiaryName'); + }); + + it('errors when the referenced apiary does not exist', async () => { + prisma.apiary.findFirst.mockResolvedValue(null); + + const report = await service.importCsv( + 'colonies', + Buffer.from( + 'code,apiaryName,colonyType\nV-100,Unbekannt,PRODUCTION_COLONY', + ), + 'user-1', + ); + + expect(report.errors).toBe(1); + expect(report.rows[0].message).toContain('nicht gefunden'); + }); + + it('skips a row whose code already exists', async () => { + prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' }); + prisma.colony.findUnique.mockResolvedValue({ id: 'existing-colony' }); + + const report = await service.importCsv( + 'colonies', + Buffer.from( + 'code,apiaryName,colonyType\nV-100,Teststand,PRODUCTION_COLONY', + ), + 'user-1', + ); + + expect(report.skipped).toBe(1); + expect(prisma.colony.create).not.toHaveBeenCalled(); + }); + + it('creates a valid, non-duplicate row', async () => { + prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' }); + prisma.colony.findUnique.mockResolvedValue(null); + prisma.colony.create.mockResolvedValue({ id: 'colony-1' }); + + const report = await service.importCsv( + 'colonies', + Buffer.from( + 'code,apiaryName,colonyType\nV-100,Teststand,PRODUCTION_COLONY', + ), + 'user-1', + ); + + expect(report.created).toBe(1); + const [[createArg]] = prisma.colony.create.mock.calls as [ + [{ data: { apiaryId: string; code: string } }], + ]; + expect(createArg.data.apiaryId).toBe('apiary-1'); + expect(createArg.data.code).toBe('V-100'); + }); + + it('reports a validation error for an invalid colonyType', async () => { + prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' }); + + const report = await service.importCsv( + 'colonies', + Buffer.from('code,apiaryName,colonyType\nV-100,Teststand,NOT_A_TYPE'), + 'user-1', + ); + + expect(report.errors).toBe(1); + expect(prisma.colony.create).not.toHaveBeenCalled(); + }); + }); + + describe('queens', () => { + it('creates a valid row', async () => { + prisma.queen.findUnique.mockResolvedValue(null); + prisma.queen.create.mockResolvedValue({ id: 'queen-1' }); + + const report = await service.importCsv( + 'queens', + Buffer.from('code,breed\nQ-100,Carnica'), + 'user-1', + ); + + expect(report.created).toBe(1); + }); + + it('skips a duplicate queen code', async () => { + prisma.queen.findUnique.mockResolvedValue({ id: 'existing-queen' }); + + const report = await service.importCsv( + 'queens', + Buffer.from('code,breed\nQ-100,Carnica'), + 'user-1', + ); + + expect(report.skipped).toBe(1); + expect(prisma.queen.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/import/import.service.ts b/ApiCore-Backend/src/import/import.service.ts new file mode 100644 index 0000000..9a89928 --- /dev/null +++ b/ApiCore-Backend/src/import/import.service.ts @@ -0,0 +1,247 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate, ValidationError } from 'class-validator'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto'; +import { CreateColonyDto } from '../colonies/dto/create-colony.dto'; +import { CreateQueenDto } from '../queens/dto/create-queen.dto'; +import { parseCsv } from './csv'; + +const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const; +export type ImportEntity = (typeof IMPORT_ENTITIES)[number]; + +export type ImportRowStatus = 'created' | 'skipped' | 'error'; + +export interface ImportRowResult { + row: number; + status: ImportRowStatus; + message?: string; + id?: string; +} + +export interface ImportReport { + entity: ImportEntity; + totalRows: number; + created: number; + skipped: number; + errors: number; + rows: ImportRowResult[]; +} + +export function isImportEntity(value: string): value is ImportEntity { + return (IMPORT_ENTITIES as readonly string[]).includes(value); +} + +function emptyToUndefined(value: string | undefined): string | undefined { + return value === undefined || value === '' ? undefined : value; +} + +function parseNumberField(value: string | undefined): number | undefined { + return value === undefined || value === '' ? undefined : Number(value); +} + +function formatValidationErrors(errors: ValidationError[]): string { + return errors + .flatMap((error) => Object.values(error.constraints ?? {})) + .join('; '); +} + +@Injectable() +export class ImportService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async importCsv( + entity: string, + buffer: Buffer, + actingUserId: string, + ): Promise { + if (!isImportEntity(entity)) { + throw new BadRequestException(`Unsupported import entity: ${entity}`); + } + const rows = parseCsv(buffer.toString('utf-8')); + if (rows.length === 0) { + throw new BadRequestException('CSV-Datei ist leer'); + } + const [header, ...dataRows] = rows; + const columns = header.map((column) => column.trim()); + + const results: ImportRowResult[] = []; + for (let i = 0; i < dataRows.length; i++) { + const record: Record = {}; + columns.forEach((column, index) => { + record[column] = (dataRows[i][index] ?? '').trim(); + }); + const rowNumber = i + 1; + results.push(await this.importRow(entity, record, rowNumber)); + } + + const report: ImportReport = { + entity, + totalRows: dataRows.length, + created: results.filter((result) => result.status === 'created').length, + skipped: results.filter((result) => result.status === 'skipped').length, + errors: results.filter((result) => result.status === 'error').length, + rows: results, + }; + await this.auditLog.record({ + entityType: 'Import', + entityId: entity, + action: 'CREATE', + userId: actingUserId, + afterData: { + totalRows: report.totalRows, + created: report.created, + skipped: report.skipped, + errors: report.errors, + }, + }); + return report; + } + + private importRow( + entity: ImportEntity, + record: Record, + rowNumber: number, + ): Promise { + switch (entity) { + case 'apiaries': + return this.importApiaryRow(record, rowNumber); + case 'colonies': + return this.importColonyRow(record, rowNumber); + case 'queens': + return this.importQueenRow(record, rowNumber); + } + } + + private async importApiaryRow( + record: Record, + rowNumber: number, + ): Promise { + const plain = { + name: emptyToUndefined(record.name), + description: emptyToUndefined(record.description), + location: emptyToUndefined(record.location), + latitude: parseNumberField(record.latitude), + longitude: parseNumberField(record.longitude), + address: emptyToUndefined(record.address), + notes: emptyToUndefined(record.notes), + }; + const dto = plainToInstance(CreateApiaryDto, plain); + const errors = await validate(dto); + if (errors.length > 0) { + return { + row: rowNumber, + status: 'error', + message: formatValidationErrors(errors), + }; + } + const apiary = await this.prisma.apiary.create({ data: { ...dto } }); + return { row: rowNumber, status: 'created', id: apiary.id }; + } + + private async importColonyRow( + record: Record, + rowNumber: number, + ): Promise { + const apiaryName = emptyToUndefined(record.apiaryName); + if (!apiaryName) { + return { + row: rowNumber, + status: 'error', + message: 'Spalte "apiaryName" fehlt', + }; + } + const apiary = await this.prisma.apiary.findFirst({ + where: { name: { equals: apiaryName, mode: 'insensitive' } }, + }); + if (!apiary) { + return { + row: rowNumber, + status: 'error', + message: `Bienenstand "${apiaryName}" nicht gefunden`, + }; + } + + const plain = { + code: emptyToUndefined(record.code), + apiaryId: apiary.id, + colonyType: emptyToUndefined(record.colonyType), + hiveType: emptyToUndefined(record.hiveType), + originText: emptyToUndefined(record.originText), + strength: parseNumberField(record.strength), + broodStatus: emptyToUndefined(record.broodStatus), + feedStatus: emptyToUndefined(record.feedStatus), + healthStatus: emptyToUndefined(record.healthStatus), + notes: emptyToUndefined(record.notes), + }; + const dto = plainToInstance(CreateColonyDto, plain); + const errors = await validate(dto); + if (errors.length > 0) { + return { + row: rowNumber, + status: 'error', + message: formatValidationErrors(errors), + }; + } + const existing = await this.prisma.colony.findUnique({ + where: { code: dto.code }, + }); + if (existing) { + return { + row: rowNumber, + status: 'skipped', + message: `Volk mit Code "${dto.code}" existiert bereits`, + id: existing.id, + }; + } + const colony = await this.prisma.colony.create({ data: { ...dto } }); + return { row: rowNumber, status: 'created', id: colony.id }; + } + + private async importQueenRow( + record: Record, + rowNumber: number, + ): Promise { + const plain = { + code: emptyToUndefined(record.code), + hatchYear: parseNumberField(record.hatchYear), + breed: emptyToUndefined(record.breed), + origin: emptyToUndefined(record.origin), + breeder: emptyToUndefined(record.breeder), + markingColor: emptyToUndefined(record.markingColor), + markingNumber: emptyToUndefined(record.markingNumber), + matingType: emptyToUndefined(record.matingType), + matingStatus: emptyToUndefined(record.matingStatus), + inseminationData: emptyToUndefined(record.inseminationData), + motherLine: emptyToUndefined(record.motherLine), + fatherLine: emptyToUndefined(record.fatherLine), + notes: emptyToUndefined(record.notes), + }; + const dto = plainToInstance(CreateQueenDto, plain); + const errors = await validate(dto); + if (errors.length > 0) { + return { + row: rowNumber, + status: 'error', + message: formatValidationErrors(errors), + }; + } + const existing = await this.prisma.queen.findUnique({ + where: { code: dto.code }, + }); + if (existing) { + return { + row: rowNumber, + status: 'skipped', + message: `Königin mit Code "${dto.code}" existiert bereits`, + id: existing.id, + }; + } + const queen = await this.prisma.queen.create({ data: { ...dto } }); + return { row: rowNumber, status: 'created', id: queen.id }; + } +} diff --git a/ApiCore-Backend/test/import.e2e-spec.ts b/ApiCore-Backend/test/import.e2e-spec.ts new file mode 100644 index 0000000..87b3d96 --- /dev/null +++ b/ApiCore-Backend/test/import.e2e-spec.ts @@ -0,0 +1,188 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface ImportRowBody { + row: number; + status: 'created' | 'skipped' | 'error'; + message?: string; + id?: string; +} + +interface ImportReportBody { + entity: string; + totalRows: number; + created: number; + skipped: number; + errors: number; + rows: ImportRowBody[]; +} + +function csvFile(content: string) { + return Buffer.from(content, 'utf-8'); +} + +describe('Import (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const adminEmail = `import-e2e-admin-${Date.now()}@example.com`; + const staffEmail = `import-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let adminId: string; + let staffId: string; + let adminToken: string; + let staffToken: string; + let apiaryId: string; + const apiaryName = `Import-Stand-${Date.now()}`; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Import E2E Admin', + role: 'ADMIN', + }, + }); + adminId = admin.id; + const staff = await prisma.user.create({ + data: { + email: staffEmail, + passwordHash, + name: 'Import E2E Staff', + role: 'STAFF', + }, + }); + staffId = staff.id; + adminToken = (await login(app, adminEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ data: { name: apiaryName } }); + apiaryId = apiary.id; + }); + + afterAll(async () => { + await prisma.colony.deleteMany({ where: { apiaryId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.apiary.deleteMany({ + where: { name: { startsWith: 'CSV-Bienenstand-' } }, + }); + await prisma.queen.deleteMany({ + where: { code: { startsWith: 'CSV-Q-' } }, + }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); + await app.close(); + }); + + it('rejects a non-admin', () => { + return request(app.getHttpServer()) + .post('/api/v1/import/apiaries') + .set('Authorization', `Bearer ${staffToken}`) + .attach('file', csvFile('name\nTest'), { + filename: 'apiaries.csv', + contentType: 'text/csv', + }) + .expect(403); + }); + + it('rejects a file without a .csv extension', () => { + return request(app.getHttpServer()) + .post('/api/v1/import/apiaries') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile('name\nTest'), { + filename: 'apiaries.txt', + contentType: 'text/plain', + }) + .expect(400); + }); + + it('imports apiaries, reporting one created and one validation error', async () => { + const csv = `name,location\nCSV-Bienenstand-${Date.now()},Garten\n,Ohne Namen`; + const response = await request(app.getHttpServer()) + .post('/api/v1/import/apiaries') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile(csv), { + filename: 'apiaries.csv', + contentType: 'text/csv', + }) + .expect(201); + const report = response.body as ImportReportBody; + expect(report.entity).toBe('apiaries'); + expect(report.totalRows).toBe(2); + expect(report.created).toBe(1); + expect(report.errors).toBe(1); + expect(report.rows[1].status).toBe('error'); + }); + + it('imports colonies, resolving the apiary by name and skipping duplicate codes on re-import', async () => { + const code = `CSV-V-${Date.now()}`; + const csv = `code,apiaryName,colonyType\n${code},${apiaryName},PRODUCTION_COLONY`; + + const firstResponse = await request(app.getHttpServer()) + .post('/api/v1/import/colonies') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile(csv), { + filename: 'colonies.csv', + contentType: 'text/csv', + }) + .expect(201); + const firstReport = firstResponse.body as ImportReportBody; + expect(firstReport.created).toBe(1); + + const secondResponse = await request(app.getHttpServer()) + .post('/api/v1/import/colonies') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile(csv), { + filename: 'colonies.csv', + contentType: 'text/csv', + }) + .expect(201); + const secondReport = secondResponse.body as ImportReportBody; + expect(secondReport.created).toBe(0); + expect(secondReport.skipped).toBe(1); + }); + + it('reports an error when the referenced apiary is unknown', async () => { + const csv = `code,apiaryName,colonyType\nCSV-V-Unknown,Nicht Vorhanden,PRODUCTION_COLONY`; + const response = await request(app.getHttpServer()) + .post('/api/v1/import/colonies') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile(csv), { + filename: 'colonies.csv', + contentType: 'text/csv', + }) + .expect(201); + const report = response.body as ImportReportBody; + expect(report.errors).toBe(1); + expect(report.rows[0].message).toContain('nicht gefunden'); + }); + + it('imports queens', async () => { + const code = `CSV-Q-${Date.now()}`; + const csv = `code,breed\n${code},Carnica`; + const response = await request(app.getHttpServer()) + .post('/api/v1/import/queens') + .set('Authorization', `Bearer ${adminToken}`) + .attach('file', csvFile(csv), { + filename: 'queens.csv', + contentType: 'text/csv', + }) + .expect(201); + const report = response.body as ImportReportBody; + expect(report.created).toBe(1); + }); +});