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:
1 parent
e4d5af5fa7
commit
4a9fdd5c71
16 files changed
+728
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
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 DocumentBody {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
describe('Documents (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const beekeeperEmail = `documents-e2e-beekeeper-${Date.now()}@example.com`;
|
||||
const readonlyEmail = `documents-e2e-readonly-${Date.now()}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
let beekeeperId: string;
|
||||
let readonlyId: string;
|
||||
let beekeeperToken: string;
|
||||
let readonlyToken: string;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let documentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const beekeeper = await prisma.user.create({
|
||||
data: {
|
||||
email: beekeeperEmail,
|
||||
passwordHash,
|
||||
name: 'Documents E2E Beekeeper',
|
||||
role: 'BEEKEEPER',
|
||||
},
|
||||
});
|
||||
beekeeperId = beekeeper.id;
|
||||
const readonly = await prisma.user.create({
|
||||
data: {
|
||||
email: readonlyEmail,
|
||||
passwordHash,
|
||||
name: 'Documents E2E Readonly',
|
||||
role: 'READONLY',
|
||||
},
|
||||
});
|
||||
readonlyId = readonly.id;
|
||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Dokumente-Stand ${Date.now()}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `V-DOC-${Date.now()}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.document.deleteMany({
|
||||
where: { entityType: 'COLONY', entityId: colonyId },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, readonlyId] } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, readonlyId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [beekeeperId, readonlyId] } },
|
||||
});
|
||||
await fs.rm('./test-uploads', { recursive: true, force: true });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects an upload from a role without write access', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/documents')
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.field('entityType', 'COLONY')
|
||||
.field('entityId', colonyId)
|
||||
.attach('file', Buffer.from('fake image bytes'), {
|
||||
filename: 'bild.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('rejects an unsupported mime type', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/documents')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.field('entityType', 'COLONY')
|
||||
.field('entityId', colonyId)
|
||||
.attach('file', Buffer.from('not an image'), {
|
||||
filename: 'script.exe',
|
||||
contentType: 'application/octet-stream',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects an upload for a non-existent entity', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/documents')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.field('entityType', 'COLONY')
|
||||
.field('entityId', 'does-not-exist')
|
||||
.attach('file', Buffer.from('fake image bytes'), {
|
||||
filename: 'bild.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('lets a beekeeper upload a document and omits storagePath from the response', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/documents')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.field('entityType', 'COLONY')
|
||||
.field('entityId', colonyId)
|
||||
.attach('file', Buffer.from('fake image bytes'), {
|
||||
filename: 'bild.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as DocumentBody;
|
||||
documentId = body.id;
|
||||
expect(body.entityType).toBe('COLONY');
|
||||
expect(body.entityId).toBe(colonyId);
|
||||
expect(body.fileName).toBe('bild.png');
|
||||
expect(body.mimeType).toBe('image/png');
|
||||
expect(body.storagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists documents for the entity', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/documents?entityType=COLONY&entityId=${colonyId}`)
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.expect(200);
|
||||
const body = response.body as DocumentBody[];
|
||||
expect(body.some((entry) => entry.id === documentId)).toBe(true);
|
||||
});
|
||||
|
||||
it('downloads the uploaded file with matching content and headers', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/documents/${documentId}/download`)
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.expect(200);
|
||||
expect(response.headers['content-type']).toContain('image/png');
|
||||
expect(response.headers['content-disposition']).toContain('bild.png');
|
||||
const downloaded = response.body as Buffer;
|
||||
expect(downloaded.toString('utf-8')).toBe('fake image bytes');
|
||||
});
|
||||
|
||||
it('rejects a delete from a role without write access', () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v1/documents/${documentId}`)
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('lets a beekeeper delete the document', () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v1/documents/${documentId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(204);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user