feat(backend): CSV-Import für Stammdaten (Modul 33)

Neues ImportModule mit einem abhängigkeitsfreien CSV-Parser
(src/import/csv.ts, RFC4180-artig: Quoting, escapte Anführungszeichen,
CRLF/LF) passend zum bereits im ExportModule verwendeten
Schreibformat, sodass exportierte CSVs unverändert wieder importierbar
sind.

Endpunkt: POST /import/:entity (multipart, nur Administrator) für
apiaries, colonies, queens. Jede Zeile wird über die exakt gleichen
class-validator-DTOs geprüft, die auch die regulären POST-Endpunkte
nutzen (CreateApiaryDto/CreateColonyDto/CreateQueenDto) – keine
duplizierte Validierungslogik.

Ergebnisbericht pro Zeile (erstellt/übersprungen/Fehler + Meldung):
- Colonies/Queens: Zeilen mit bereits existierendem Code werden
  übersprungen statt einen Fehler zu werfen (idempotenter Re-Import)
- Colonies lösen den Bienenstand über eine "apiaryName"-Spalte auf
  (case-insensitive), da eine CSV keine internen IDs kennen kann;
  unbekannter Name → Fehlerzeile statt Abbruch des gesamten Imports

Unit-Tests für den CSV-Parser (Quoting, escapte Anführungszeichen,
CRLF, leere Zeilen) und den ImportService (alle drei Entitäten,
Validierungsfehler, Duplikat-Skip, unbekannter Bienenstand). E2E-Tests
über echten multipart-Upload (Rollenprüfung, Dateiendungsprüfung, alle
drei Entitäten, Re-Import-Idempotenz).
This commit is contained in:
Nick Szaunig committed 2026-08-24 23:00:46 +02:00
1 parent e051ad956b
commit 4f2f31c05f
8 files changed
+763

No files matched your search

+2
View File
@@ -31,6 +31,7 @@ import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module'; import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module'; import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module'; import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
@@ -67,6 +68,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
WebhooksModule, WebhooksModule,
DocumentsModule, DocumentsModule,
AutomationModule, AutomationModule,
ImportModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
+41
View File
@@ -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'],
]);
});
});
+46
View File
@@ -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] === ''));
}
@@ -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);
}
}
@@ -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 {}
@@ -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();
});
});
});
@@ -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<ImportReport> {
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<string, string> = {};
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<string, string>,
rowNumber: number,
): Promise<ImportRowResult> {
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<string, string>,
rowNumber: number,
): Promise<ImportRowResult> {
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<string, string>,
rowNumber: number,
): Promise<ImportRowResult> {
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<string, string>,
rowNumber: number,
): Promise<ImportRowResult> {
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 };
}
}
+188
View File
@@ -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<App>;
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);
});
});