- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
377 lines
12 KiB
TypeScript
377 lines
12 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('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: '2027-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: '2027-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: '2027-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);
|
|
});
|
|
});
|