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:
1 parent
e051ad956b
commit
4f2f31c05f
8 files changed
+763
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user