Files
ApiCore/ApiCore-Backend/test/authority-notices.e2e-spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 6edae3efec
Backend E2E / e2e (push) Failing after 7m26s
Backend Image / build-and-push (push) Failing after 4m20s
Frontend Image / build-and-push (push) Successful in 15m44s
test: Backend-e2e in CI stabil (eigene Jahre, Sperren, ein Server je App)
- daily-evaluation nutzt die Jahre 2011/2012 statt 2001, damit der Job nicht
  die Daten von autopilot und reminders liest und verändert.
- throttler: app.listen(0) einmal je App statt eines Servers je Anfrage
  (ECONNRESET bei 40 parallelen Anfragen).
- outbox-claims: 120 s Zeitlimit für 2000 sequentielle Anweisungen.
- migration-harness: eine `migrate deploy` zur Zeit über eine eigene
  Advisory-Sperre (P1002 bei parallelen Migrations-Suites).
- authority-notices und authority-notices-herd laufen über
  exclusive-suite nacheinander; Herd-Wiederholung räumt eigene Entwürfe.

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:55:56 +02:00

471 lines
15 KiB
TypeScript

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';
import { acquireExclusive, EXCLUSIVE_KEYS } from './utils/exclusive-suite';
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<App>;
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')}`;
let releaseExclusive: () => Promise<void>;
beforeAll(async () => {
// Suites that clear or create the global open notice drafts take turns.
releaseExclusive = await acquireExclusive(EXCLUSIVE_KEYS.authorityNotices);
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();
await releaseExclusive();
});
async function livingCount(): Promise<number> {
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-<id>.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);
});
});