feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- 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]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -0,0 +1,376 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user