import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { loadHerdApiaryRows } from '../src/compliance/herd-snapshot'; import { PrismaService } from '../src/prisma/prisma.service'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; interface NoticeBody { id: string; kind: string; status: string; totalColonies: number; sentOn: string | null; openKey: string | null; existingId?: string; code?: string; } describe('Authority notices (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = Date.now(); const password = 'SuperSecret123!'; const beekeeperEmail = `notices-e2e-beekeeper-${suffix}@example.com`; const staffEmail = `notices-e2e-staff-${suffix}@example.com`; let beekeeperToken: string; let staffToken: string; let noRightsToken: string; let noRightsRoleId: string; const noRightsEmail = `notices-e2e-norights-${suffix}@example.com`; let apiaryId: string; let keyCounter = 0; const server = () => app.getHttpServer(); const as = (token: string) => ({ Authorization: `Bearer ${token}` }); const nextKey = () => `notices-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); for (const [email, roleKey] of [ [beekeeperEmail, 'BEEKEEPER'], [staffEmail, 'STAFF'], ]) { await prisma.user.create({ data: { email, passwordHash, name: roleKey, role: { connect: { key: roleKey } }, }, }); } const role = await prisma.role.create({ data: { key: `NOTICES_E2E_${suffix}`, name: 'Ohne Rechte' }, }); noRightsRoleId = role.id; await prisma.user.create({ data: { email: noRightsEmail, passwordHash, name: 'NoRights', roleId: role.id, }, }); noRightsToken = (await login(app, noRightsEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; staffToken = (await login(app, staffEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Notices Stand ${suffix}` }, }); apiaryId = apiary.id; await prisma.colony.create({ data: { code: `N-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); }); beforeEach(async () => { // The test database is shared and not empty: the notices are this file's // own table to clear. await prisma.authorityNotice.deleteMany(); }); afterAll(async () => { await prisma.authorityNotice.deleteMany(); await prisma.colony.deleteMany({ where: { apiaryId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { user: { roleId: noRightsRoleId } }, }); await prisma.user.deleteMany({ where: { roleId: noRightsRoleId } }); await prisma.role.deleteMany({ where: { id: noRightsRoleId } }); await app.close(); }); async function livingCount(): Promise { const rows = await loadHerdApiaryRows(prisma); return rows.reduce((sum, row) => sum + row.colonyTypes.length, 0); } it('two parallel creates of the same draft: one 201, one 409 with existingId', async () => { const send = () => request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .set('Idempotency-Key', nextKey()) .send({ kind: 'REGISTRATION' }); const responses = await Promise.all([send(), send()]); const statuses = responses.map((response) => response.status).sort(); expect(statuses).toEqual([201, 409]); const created = responses.find((response) => response.status === 201)! .body as NoticeBody; const conflict = responses.find((response) => response.status === 409)! .body as NoticeBody; expect(conflict.code).toBe('DRAFT_EXISTS'); expect(conflict.existingId).toBe(created.id); expect(created).toMatchObject({ status: 'DRAFT', openKey: 'REGISTRATION', }); expect(created.totalColonies).toBeGreaterThan(0); expect(await prisma.authorityNotice.count()).toBe(1); }); it('a repeated POST with the same Idempotency-Key returns the stored answer', async () => { const key = nextKey(); const send = () => request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .set('Idempotency-Key', key) .send({ kind: 'REGISTRATION' }); const first = await send(); const second = await send(); expect(first.status).toBe(201); expect(second.status).toBe(201); expect((second.body as NoticeBody).id).toBe((first.body as NoticeBody).id); expect(await prisma.authorityNotice.count()).toBe(1); }); it('BEEKEEPER manages notices, STAFF may read but gets 403 on POST', async () => { const created = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'REGISTRATION' }) .expect(201); const id = (created.body as NoticeBody).id; await request(server()) .post('/api/v1/authority-notices') .set(as(staffToken)) .send({ kind: 'TSK_REPORT', year: 2027 }) .expect(403); await request(server()) .post(`/api/v1/authority-notices/${id}/cancel`) .set(as(staffToken)) .expect(403); await request(server()) .patch('/api/v1/compliance/settings') .set(as(staffToken)) .send({ noticeOnApiaryChange: false }) .expect(403); const list = await request(server()) .get('/api/v1/authority-notices?kind=REGISTRATION') .set(as(staffToken)) .expect(200); expect((list.body as NoticeBody[]).map((notice) => notice.id)).toContain( id, ); await request(server()) .get(`/api/v1/authority-notices/${id}`) .set(as(staffToken)) .expect(200); await request(server()) .get('/api/v1/compliance/settings') .set(as(staffToken)) .expect(200); }); it('a SENT registration with sentOn in the past is the baseline of the next herd change', async () => { await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'HERD_CHANGE' }) .expect(409); const registration = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'REGISTRATION', sentOn: '2026-01-10' }) .expect(201); expect(registration.body).toMatchObject({ status: 'SENT', openKey: null, sentOn: '2026-01-10T00:00:00.000Z', }); await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'REGISTRATION' }) .expect(409); // The shared database may change meanwhile: the count lies between the // counts taken before and after the request. const before = await livingCount(); const change = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'HERD_CHANGE' }) .expect(201); const after = await livingCount(); const body = change.body as NoticeBody; expect(body).toMatchObject({ kind: 'HERD_CHANGE', status: 'DRAFT' }); expect(body.totalColonies).toBeGreaterThanOrEqual(Math.min(before, after)); expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after)); }); it('rejects a sent day in the future on create, mark-sent and patch', async () => { const message = 'Das Sendedatum darf nicht in der Zukunft liegen.'; const future = '2999-01-01'; const create = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'REGISTRATION', sentOn: future }) .expect(400); expect((create.body as { message: string }).message).toBe(message); const draft = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2031 }) .expect(201); const url = `/api/v1/authority-notices/${(draft.body as NoticeBody).id}`; const markSent = await request(server()) .post(`${url}/mark-sent`) .set(as(beekeeperToken)) .send({ sentOn: future }) .expect(400); expect((markSent.body as { message: string }).message).toBe(message); const patch = await request(server()) .patch(url) .set(as(beekeeperToken)) .send({ sentOn: future }) .expect(400); expect((patch.body as { message: string }).message).toBe(message); const unchanged = await request(server()) .get(url) .set(as(beekeeperToken)) .expect(200); expect(unchanged.body).toMatchObject({ status: 'DRAFT', sentOn: null }); }); it('walks a draft through mark-sent, reopen and cancel', async () => { const created = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2027 }) .expect(201); const id = (created.body as NoticeBody).id; const url = `/api/v1/authority-notices/${id}`; await request(server()) .post(`${url}/mark-sent`) .set(as(beekeeperToken)) .send({ sentOn: '2026-02-01', sentVia: 'E-Mail' }) .expect(201); await request(server()) .post(`${url}/recount`) .set(as(beekeeperToken)) .expect(409); await request(server()) .patch(url) .set(as(beekeeperToken)) .send({ totalColonies: 42 }) .expect(200); const reopened = await request(server()) .post(`${url}/reopen`) .set(as(beekeeperToken)) .expect(201); expect(reopened.body).toMatchObject({ status: 'DRAFT', totalColonies: 42, openKey: 'TSK_REPORT:2027', }); await request(server()) .post(`${url}/cancel`) .set(as(beekeeperToken)) .expect(201); await request(server()) .post(`${url}/mark-sent`) .set(as(beekeeperToken)) .send({}) .expect(409); await request(server()) .patch(url) .set(as(beekeeperToken)) .send({ notes: 'x' }) .expect(409); const audit = await prisma.auditLog.findMany({ where: { entityType: 'AuthorityNotice', entityId: id }, }); expect(audit.map((entry) => entry.action).sort()).toEqual([ 'CREATE', 'UPDATE', 'UPDATE', 'UPDATE', 'UPDATE', ]); }); it('reopen answers 409 while another draft of the kind exists', async () => { const sent = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' }) .expect(201); const draft = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2027 }) .expect(201); const reopen = await request(server()) .post(`/api/v1/authority-notices/${(sent.body as NoticeBody).id}/reopen`) .set(as(beekeeperToken)) .expect(409); expect((reopen.body as NoticeBody).existingId).toBe( (draft.body as NoticeBody).id, ); }); it('a notice recorded as sent is created although a draft is open', async () => { await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2027 }) .expect(201); await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' }) .expect(201); }); it('a user without compliance rights sees neither notices nor addresses', async () => { const created = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .send({ kind: 'REGISTRATION' }) .expect(201); const id = (created.body as NoticeBody).id; await request(server()) .get('/api/v1/authority-notices') .set(as(noRightsToken)) .expect(403); await request(server()) .get(`/api/v1/authority-notices/${id}`) .set(as(noRightsToken)) .expect(403); await request(server()) .get('/api/v1/compliance/settings') .set(as(noRightsToken)) .expect(403); }); it('validates the compliance settings', async () => { await request(server()) .patch('/api/v1/compliance/settings') .set(as(beekeeperToken)) .send({ tskReferenceDay: '02-29' }) .expect(400); await request(server()) .patch('/api/v1/compliance/settings') .set(as(beekeeperToken)) .send({ noticeOnApiaryChange: null }) .expect(400); const settings = await request(server()) .patch('/api/v1/compliance/settings') .set(as(beekeeperToken)) .send({ tskReferenceDay: '04-15', tskReportDueDay: null }) .expect(200); expect(settings.body).toMatchObject({ tskReferenceDay: '04-15', tskReportDueDay: null, }); await request(server()) .patch('/api/v1/compliance/settings') .set(as(beekeeperToken)) .send({ tskReferenceDay: '01-01' }) .expect(200); }); it('GET /authority-notices/:id/pdf answers application/pdf with Cache-Control no-store and the file name anzeige-.pdf', async () => { const created = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .set('Idempotency-Key', nextKey()) .send({ kind: 'TSK_REPORT', year: 2027 }) .expect(201); const id = (created.body as NoticeBody).id; const response = await request(server()) .get(`/api/v1/authority-notices/${id}/pdf`) .set(as(staffToken)) .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => callback(null, Buffer.concat(chunks))); }) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); expect(response.headers['cache-control']).toContain('no-store'); expect(response.headers['content-disposition']).toBe( `attachment; filename="anzeige-${id}.pdf"`, ); expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( '%PDF', ); await request(server()) .get('/api/v1/authority-notices/does-not-exist/pdf') .set(as(staffToken)) .expect(404); }); it('the pdf route needs compliance:read', async () => { const created = await request(server()) .post('/api/v1/authority-notices') .set(as(beekeeperToken)) .set('Idempotency-Key', nextKey()) .send({ kind: 'REGISTRATION' }) .expect(201); const id = (created.body as NoticeBody).id; await request(server()) .get(`/api/v1/authority-notices/${id}/pdf`) .set(as(noRightsToken)) .expect(403); await request(server()) .get(`/api/v1/authority-notices/${id}/pdf`) .expect(401); }); });