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 { 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: [
|
||||||
|
|||||||
@@ -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