- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
465 lines
15 KiB
TypeScript
465 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';
|
|
|
|
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')}`;
|
|
|
|
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<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);
|
|
});
|
|
});
|