feat(backend): Dokumente und Bilder mit lokalem Datei-Upload (Modul 29)

DocumentsModule (Document: entityType/entityId, Dateiname, MIME-Typ,
Groesse, Speicherpfad, Hochgeladen-von) fuer Bienenstand/Volk/Koenigin/
Durchsicht/Honigcharge. Upload als multipart/form-data (Multer, 10 MB
Limit, MIME-Allowlist jpeg/png/webp/pdf), Speicherung unter
DOCUMENTS_STORAGE_PATH (austauschbar). ARCHITECTURE.md Annahme 7
dokumentiert die Multi-Replica-Einschraenkung ohne geteiltes Volume.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:29:22 +02:00
1 parent e4d5af5fa7
commit 4a9fdd5c71
16 files changed
+728

No files matched your search

@@ -0,0 +1,170 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import { DocumentsService } from './documents.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
jest.mock('node:fs/promises');
describe('DocumentsService', () => {
let service: DocumentsService;
let prisma: {
document: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
apiary: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock };
queen: { findUnique: jest.Mock };
inspection: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
};
let auditLog: { record: jest.Mock };
const document = {
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
storagePath: '/tmp/uploads/colony/abc.jpg',
uploadedByUserId: 'user-1',
createdAt: new Date('2026-01-01'),
};
beforeEach(() => {
prisma = {
document: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
apiary: { findUnique: jest.fn() },
colony: { findUnique: jest.fn() },
queen: { findUnique: jest.fn() },
inspection: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new DocumentsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data'));
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
it('findAll strips storagePath from results', async () => {
prisma.document.findMany.mockResolvedValueOnce([document]);
const result = await service.findAll('COLONY', 'colony-1');
expect(result).toEqual([
{
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
uploadedByUserId: 'user-1',
createdAt: document.createdAt,
},
]);
});
describe('create', () => {
const file = {
buffer: Buffer.from('binary'),
originalname: 'bild.jpg',
mimetype: 'image/jpeg',
size: 1234,
} as Express.Multer.File;
it('rejects an unsupported mime type', async () => {
const badFile = {
...file,
mimetype: 'application/zip',
};
await expect(
service.create(badFile, 'COLONY', 'colony-1', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws NotFoundException when the referenced entity does not exist', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
service.create(file, 'COLONY', 'missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('writes the file to disk and persists a document row', async () => {
prisma.colony.findUnique.mockResolvedValue({ id: 'colony-1' });
prisma.document.create.mockResolvedValue(document);
const result = await service.create(file, 'COLONY', 'colony-1', 'user-1');
expect(fs.mkdir).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
const [[createArg]] = prisma.document.create.mock.calls as [
[
{
data: {
entityType: string;
entityId: string;
fileName: string;
uploadedByUserId: string;
};
},
],
];
expect(createArg.data.entityType).toBe('COLONY');
expect(createArg.data.entityId).toBe('colony-1');
expect(createArg.data.fileName).toBe('bild.jpg');
expect(createArg.data.uploadedByUserId).toBe('user-1');
expect(result).not.toHaveProperty('storagePath');
});
});
describe('readFile', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads the stored file from disk', async () => {
prisma.document.findUnique.mockResolvedValue(document);
const result = await service.readFile('doc-1');
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
expect(result.fileName).toBe('bild.jpg');
expect(result.mimeType).toBe('image/jpeg');
});
});
describe('delete', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the db row and unlinks the file', async () => {
prisma.document.findUnique.mockResolvedValue(document);
await service.delete('doc-1', 'user-1');
expect(prisma.document.delete).toHaveBeenCalledWith({
where: { id: 'doc-1' },
});
expect(fs.unlink).toHaveBeenCalledWith(document.storagePath);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Document', action: 'DELETE' }),
);
});
});
});