- 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]>
696 lines
22 KiB
TypeScript
696 lines
22 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { addDays, berlinToday, dayToDate } from '../src/common/dates';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { lockRowsForShare } from '../src/prisma/row-lock';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { login } from './utils/api';
|
|
|
|
interface ColonyBody {
|
|
id: string;
|
|
apiaryId: string;
|
|
notes: string | null;
|
|
}
|
|
|
|
interface RestrictedBody {
|
|
code: string;
|
|
message: string;
|
|
restrictionIds: string[];
|
|
restrictions: {
|
|
id: string;
|
|
apiaryId: string;
|
|
apiaryName: string;
|
|
disease: string;
|
|
startsOn: string;
|
|
}[];
|
|
}
|
|
|
|
interface RestrictionBody {
|
|
id: string;
|
|
apiaryId: string;
|
|
endsOn: string | null;
|
|
}
|
|
|
|
const LIFECYCLE_PERMISSIONS = [
|
|
'colonies:read',
|
|
'colonies:create',
|
|
'colonies:update',
|
|
'colonies:manage-lifecycle',
|
|
'apiaries:read',
|
|
];
|
|
const REASON = 'Amtstierarzt hat die Verbringung am Telefon genehmigt';
|
|
const DISEASE = 'Amerikanische Faulbrut';
|
|
|
|
describe('Restricted zone (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = Date.now();
|
|
const password = 'SuperSecret123!';
|
|
const userIds: string[] = [];
|
|
const roleIds: string[] = [];
|
|
let beekeeperToken: string;
|
|
let lifecycleToken: string;
|
|
let staffToken: string;
|
|
let readonlyToken: string;
|
|
let apiariesOnlyToken: string;
|
|
let apiaryRestrictedId: string;
|
|
let apiaryFreeId: string;
|
|
let apiaryOtherId: string;
|
|
const colonyIds: string[] = [];
|
|
|
|
async function user(
|
|
label: string,
|
|
role: { connect: { key: string } } | { connect: { id: string } },
|
|
): Promise<string> {
|
|
const email = `restricted-zone-${label}-${suffix}@example.com`;
|
|
const created = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, 12),
|
|
name: label,
|
|
role,
|
|
},
|
|
});
|
|
userIds.push(created.id);
|
|
return (await login(app, email, password)).accessToken;
|
|
}
|
|
|
|
async function colonyIn(apiaryId: string): Promise<string> {
|
|
const colony = await prisma.colony.create({
|
|
data: {
|
|
code: `RZ-${suffix}-${colonyIds.length}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
});
|
|
colonyIds.push(colony.id);
|
|
return colony.id;
|
|
}
|
|
|
|
function restrict(apiaryId: string, endsOn: string | null = null) {
|
|
return prisma.apiaryRestriction.create({
|
|
data: {
|
|
apiaryId,
|
|
disease: DISEASE,
|
|
startsOn: dayToDate('2000-01-01'),
|
|
endsOn: endsOn ? dayToDate(endsOn) : null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const apiaryIds = (): string[] => [
|
|
apiaryRestrictedId,
|
|
apiaryFreeId,
|
|
apiaryOtherId,
|
|
];
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
|
|
const role = await prisma.role.create({
|
|
data: {
|
|
key: `RZ_LIFECYCLE_${suffix}`,
|
|
name: 'Voelker ohne Behoerden',
|
|
permissions: {
|
|
create: LIFECYCLE_PERMISSIONS.map((key) => ({
|
|
permission: { connect: { key } },
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
roleIds.push(role.id);
|
|
beekeeperToken = await user('beekeeper', { connect: { key: 'BEEKEEPER' } });
|
|
lifecycleToken = await user('lifecycle', { connect: { id: role.id } });
|
|
staffToken = await user('staff', { connect: { key: 'STAFF' } });
|
|
readonlyToken = await user('readonly', { connect: { key: 'READONLY' } });
|
|
const apiariesOnly = await prisma.role.create({
|
|
data: {
|
|
key: `RZ_APIARIES_ONLY_${suffix}`,
|
|
name: 'Nur Staende',
|
|
permissions: {
|
|
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
|
|
},
|
|
},
|
|
});
|
|
roleIds.push(apiariesOnly.id);
|
|
apiariesOnlyToken = await user('apiaries-only', {
|
|
connect: { id: apiariesOnly.id },
|
|
});
|
|
|
|
apiaryRestrictedId = (
|
|
await prisma.apiary.create({ data: { name: `RZ Sperre ${suffix}` } })
|
|
).id;
|
|
apiaryFreeId = (
|
|
await prisma.apiary.create({ data: { name: `RZ Frei ${suffix}` } })
|
|
).id;
|
|
apiaryOtherId = (
|
|
await prisma.apiary.create({ data: { name: `RZ Weitere ${suffix}` } })
|
|
).id;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await prisma.apiaryRestriction.deleteMany({
|
|
where: { apiaryId: { in: apiaryIds() } },
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.apiaryRestriction.deleteMany({
|
|
where: { apiaryId: { in: apiaryIds() } },
|
|
});
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
|
});
|
|
const harvests = await prisma.harvest.findMany({
|
|
where: { apiaryId: { in: apiaryIds() } },
|
|
select: { id: true },
|
|
});
|
|
const harvestIds = harvests.map((harvest) => harvest.id);
|
|
await prisma.harvestColony.deleteMany({
|
|
where: { harvestId: { in: harvestIds } },
|
|
});
|
|
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
|
await prisma.colonyLineage.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ fromColonyId: { in: colonyIds } },
|
|
{ toColonyId: { in: colonyIds } },
|
|
],
|
|
},
|
|
});
|
|
await prisma.colonyApiaryHistory.deleteMany({
|
|
where: { colonyId: { in: colonyIds } },
|
|
});
|
|
await prisma.colony.deleteMany({
|
|
where: {
|
|
OR: [{ id: { in: colonyIds } }, { apiaryId: { in: apiaryIds() } }],
|
|
},
|
|
});
|
|
await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds() } } });
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
|
await app.close();
|
|
});
|
|
|
|
it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', async () => {
|
|
await restrict(apiaryRestrictedId);
|
|
const inFree = await colonyIn(apiaryFreeId);
|
|
const inRestricted = await colonyIn(apiaryRestrictedId);
|
|
const http = () => request(app.getHttpServer());
|
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
|
|
|
const created = await http()
|
|
.post('/api/v1/colonies')
|
|
.set(auth)
|
|
.send({
|
|
code: `RZ-new-${suffix}`,
|
|
apiaryId: apiaryRestrictedId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
})
|
|
.expect(409);
|
|
const body = created.body as RestrictedBody;
|
|
expect(body.code).toBe('RESTRICTED_ZONE');
|
|
expect(body.restrictionIds).toHaveLength(1);
|
|
expect(body.restrictions[0]).toMatchObject({
|
|
apiaryId: apiaryRestrictedId,
|
|
apiaryName: `RZ Sperre ${suffix}`,
|
|
disease: DISEASE,
|
|
startsOn: '2000-01-01',
|
|
});
|
|
expect(body.message).toContain(`RZ Sperre ${suffix}`);
|
|
expect(
|
|
await prisma.colony.count({ where: { code: `RZ-new-${suffix}` } }),
|
|
).toBe(0);
|
|
|
|
await http()
|
|
.patch(`/api/v1/colonies/${inFree}`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryRestrictedId })
|
|
.expect(409);
|
|
await http()
|
|
.post(`/api/v1/colonies/${inFree}/move`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryRestrictedId })
|
|
.expect(409);
|
|
await http()
|
|
.post(`/api/v1/colonies/${inRestricted}/move`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryFreeId })
|
|
.expect(409);
|
|
await http()
|
|
.patch(`/api/v1/colonies/${inRestricted}`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryFreeId })
|
|
.expect(409);
|
|
await http()
|
|
.post(`/api/v1/colonies/${inFree}/merge`)
|
|
.set(auth)
|
|
.send({ mergedColonyIds: [inRestricted] })
|
|
.expect(409);
|
|
|
|
const after = await prisma.colony.findMany({
|
|
where: { id: { in: [inFree, inRestricted] } },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
expect(
|
|
after.map((colony) => [colony.id, colony.apiaryId, colony.status]),
|
|
).toEqual(
|
|
[
|
|
[inFree, apiaryFreeId, 'ACTIVE'],
|
|
[inRestricted, apiaryRestrictedId, 'ACTIVE'],
|
|
].sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
);
|
|
});
|
|
|
|
it('an override with compliance:manage answers 200, writes audit and the history note, and never stores the reason on the colony', async () => {
|
|
const restriction = await restrict(apiaryRestrictedId);
|
|
const colonyId = await colonyIn(apiaryFreeId);
|
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
|
|
|
const patched = await request(app.getHttpServer())
|
|
.patch(`/api/v1/colonies/${colonyId}`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
|
.expect(200);
|
|
expect((patched.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
|
const moved = await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set(auth)
|
|
.send({
|
|
apiaryId: apiaryFreeId,
|
|
notes: 'Rueckfuehrung',
|
|
restrictionOverrideReason: REASON,
|
|
})
|
|
.expect(201);
|
|
expect((moved.body as ColonyBody).apiaryId).toBe(apiaryFreeId);
|
|
|
|
const colony = await prisma.colony.findUniqueOrThrow({
|
|
where: { id: colonyId },
|
|
});
|
|
expect(JSON.stringify(colony)).not.toContain(REASON);
|
|
const history = await prisma.colonyApiaryHistory.findMany({
|
|
where: { colonyId },
|
|
orderBy: { movedAt: 'asc' },
|
|
});
|
|
expect(history.map((entry) => entry.notes)).toEqual([
|
|
`Ausnahme Sperrbezirk: ${REASON}\nKorrektur über Volk bearbeiten`,
|
|
`Ausnahme Sperrbezirk: ${REASON}\nRueckfuehrung`,
|
|
]);
|
|
const audits = await prisma.auditLog.findMany({
|
|
where: { entityType: 'Colony', entityId: colonyId, action: 'UPDATE' },
|
|
});
|
|
expect(audits).toHaveLength(2);
|
|
for (const audit of audits) {
|
|
expect(
|
|
(audit.afterData as { restrictionOverride: unknown })
|
|
.restrictionOverride,
|
|
).toEqual({ reason: REASON, restrictionIds: [restriction.id] });
|
|
}
|
|
});
|
|
|
|
it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', async () => {
|
|
await restrict(apiaryRestrictedId);
|
|
const colonyId = await colonyIn(apiaryFreeId);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set('Authorization', `Bearer ${lifecycleToken}`)
|
|
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
|
.expect(403);
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({
|
|
apiaryId: apiaryRestrictedId,
|
|
restrictionOverrideReason: 'kurz!',
|
|
})
|
|
.expect(400);
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({
|
|
apiaryId: apiaryRestrictedId,
|
|
restrictionOverrideReason: ' '.repeat(12),
|
|
})
|
|
.expect(400);
|
|
// A reason without compliance:manage is a 403 even when nothing is restricted.
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set('Authorization', `Bearer ${lifecycleToken}`)
|
|
.send({ apiaryId: apiaryOtherId, restrictionOverrideReason: REASON })
|
|
.expect(403);
|
|
expect(
|
|
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
|
|
.apiaryId,
|
|
).toBe(apiaryFreeId);
|
|
});
|
|
|
|
it('split inside the apiary and a harvest at a restricted apiary succeed', async () => {
|
|
await restrict(apiaryRestrictedId);
|
|
const colonyId = await colonyIn(apiaryRestrictedId);
|
|
|
|
const split = await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/split`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ code: `RZ-split-${suffix}`, colonyType: 'NUCLEUS_COLONY' })
|
|
.expect(201);
|
|
colonyIds.push((split.body as ColonyBody).id);
|
|
expect((split.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/harvests')
|
|
.set('Authorization', `Bearer ${staffToken}`)
|
|
.send({
|
|
apiaryId: apiaryRestrictedId,
|
|
date: new Date().toISOString(),
|
|
colonyIds: [colonyId],
|
|
netWeight: 12,
|
|
})
|
|
.expect(201);
|
|
});
|
|
|
|
it('a restriction ending yesterday no longer blocks, one ending today still does', async () => {
|
|
const today = berlinToday();
|
|
const colonyId = await colonyIn(apiaryFreeId);
|
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
|
await restrict(apiaryRestrictedId, today);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryRestrictedId })
|
|
.expect(409);
|
|
|
|
await prisma.apiaryRestriction.updateMany({
|
|
where: { apiaryId: apiaryRestrictedId },
|
|
data: { endsOn: dayToDate(addDays(today, -1)) },
|
|
});
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set(auth)
|
|
.send({ apiaryId: apiaryRestrictedId })
|
|
.expect(201);
|
|
});
|
|
|
|
it('GET /public routes never contain restriction data', async () => {
|
|
const user = await prisma.user.findFirstOrThrow({
|
|
where: { id: { in: userIds } },
|
|
});
|
|
const lotNumber = `RZ-LOT-${suffix}`;
|
|
await restrict(apiaryRestrictedId);
|
|
const colonyId = await colonyIn(apiaryRestrictedId);
|
|
const harvest = await prisma.harvest.create({
|
|
data: {
|
|
apiaryId: apiaryRestrictedId,
|
|
date: new Date('2026-08-12'),
|
|
userId: user.id,
|
|
},
|
|
});
|
|
await prisma.harvestColony.create({
|
|
data: { harvestId: harvest.id, colonyId },
|
|
});
|
|
const extraction = await prisma.extraction.create({
|
|
data: { date: new Date('2026-08-13'), userId: user.id },
|
|
});
|
|
await prisma.extractionHarvest.create({
|
|
data: { extractionId: extraction.id, harvestId: harvest.id },
|
|
});
|
|
const honeyBatch = await prisma.honeyBatch.create({
|
|
data: {
|
|
batchNumber: `RZ-HC-${suffix}`,
|
|
extractionId: extraction.id,
|
|
amount: 25,
|
|
honeyType: 'Sommerhonig',
|
|
},
|
|
});
|
|
const product = await prisma.product.create({
|
|
data: { name: `RZ Honig ${suffix}` },
|
|
});
|
|
const bottling = await prisma.bottlingBatch.create({
|
|
data: {
|
|
honeyBatchId: honeyBatch.id,
|
|
productId: product.id,
|
|
date: new Date('2026-08-20'),
|
|
jarSize: 500,
|
|
jarCount: 10,
|
|
totalAmount: 5,
|
|
bestBeforeDate: new Date('2028-08-20'),
|
|
lotNumber,
|
|
userId: user.id,
|
|
},
|
|
});
|
|
try {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/api/v1/public/batches/${lotNumber}`)
|
|
.expect(200);
|
|
const text = JSON.stringify(response.body).toLowerCase();
|
|
for (const secret of [DISEASE, 'faulbrut', 'restriction', 'sperr']) {
|
|
expect(text).not.toContain(secret.toLowerCase());
|
|
}
|
|
} finally {
|
|
await prisma.bottlingBatch.delete({ where: { id: bottling.id } });
|
|
await prisma.product.delete({ where: { id: product.id } });
|
|
await prisma.honeyBatch.delete({ where: { id: honeyBatch.id } });
|
|
await prisma.extractionHarvest.deleteMany({
|
|
where: { extractionId: extraction.id },
|
|
});
|
|
await prisma.extraction.delete({ where: { id: extraction.id } });
|
|
}
|
|
});
|
|
|
|
it('READONLY may read restrictions but not create them (403)', async () => {
|
|
const auth = { Authorization: `Bearer ${readonlyToken}` };
|
|
const restriction = await restrict(apiaryRestrictedId);
|
|
|
|
const active = await request(app.getHttpServer())
|
|
.get('/api/v1/apiary-restrictions?active=true')
|
|
.set(auth)
|
|
.expect(200);
|
|
expect(
|
|
(active.body as RestrictedBody['restrictions']).map((entry) => entry.id),
|
|
).toContain(restriction.id);
|
|
const ofApiary = await request(app.getHttpServer())
|
|
.get(`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`)
|
|
.set(auth)
|
|
.expect(200);
|
|
expect(
|
|
(ofApiary.body as RestrictionBody[]).map((entry) => entry.id),
|
|
).toEqual([restriction.id]);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set(auth)
|
|
.send({
|
|
apiaryIds: [apiaryFreeId],
|
|
disease: DISEASE,
|
|
startsOn: '2026-10-01',
|
|
})
|
|
.expect(403);
|
|
await request(app.getHttpServer())
|
|
.patch(`/api/v1/apiary-restrictions/${restriction.id}`)
|
|
.set(auth)
|
|
.send({ endsOn: '2026-12-01' })
|
|
.expect(403);
|
|
});
|
|
|
|
it('creates one restriction per apiary, ends it by endsOn and keeps the row', async () => {
|
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
|
|
|
const created = await request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set(auth)
|
|
.send({
|
|
apiaryIds: [apiaryFreeId, apiaryOtherId],
|
|
disease: DISEASE,
|
|
startsOn: '2026-10-01',
|
|
authority: 'Veterinaeramt',
|
|
})
|
|
.expect(201);
|
|
const rows = created.body as RestrictionBody[];
|
|
expect(rows.map((row) => row.apiaryId).sort()).toEqual(
|
|
[apiaryFreeId, apiaryOtherId].sort(),
|
|
);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set(auth)
|
|
.send({
|
|
apiaryIds: [apiaryFreeId, 'does-not-exist'],
|
|
disease: DISEASE,
|
|
startsOn: '2026-10-01',
|
|
})
|
|
.expect(404);
|
|
expect(
|
|
await prisma.apiaryRestriction.count({
|
|
where: { apiaryId: { in: apiaryIds() } },
|
|
}),
|
|
).toBe(2);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set(auth)
|
|
.send({
|
|
apiaryIds: [apiaryFreeId],
|
|
disease: DISEASE,
|
|
startsOn: '2026-10-05',
|
|
endsOn: '2026-10-01',
|
|
})
|
|
.expect(400);
|
|
|
|
const ended = await request(app.getHttpServer())
|
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
|
.set(auth)
|
|
.send({ endsOn: '2026-10-20' })
|
|
.expect(200);
|
|
expect((ended.body as RestrictionBody).endsOn).toContain('2026-10-20');
|
|
await request(app.getHttpServer())
|
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
|
.set(auth)
|
|
.send({ endsOn: '2026-09-01' })
|
|
.expect(400);
|
|
await request(app.getHttpServer())
|
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
|
.set(auth)
|
|
.send({ endsOn: null })
|
|
.expect(200);
|
|
expect(
|
|
await prisma.auditLog.count({
|
|
where: { entityType: 'ApiaryRestriction', entityId: rows[0].id },
|
|
}),
|
|
).toBe(3);
|
|
});
|
|
|
|
it('a restriction created while a move into that apiary is in flight: the move commits first, the restriction waits (FOR UPDATE vs FOR SHARE)', async () => {
|
|
let letMoveCommit!: () => void;
|
|
const commitSignal = new Promise<void>((resolve) => {
|
|
letMoveCommit = resolve;
|
|
});
|
|
let moveHoldsLock!: () => void;
|
|
const lockHeld = new Promise<void>((resolve) => {
|
|
moveHoldsLock = resolve;
|
|
});
|
|
// What a move into the apiary holds between its lock and its commit.
|
|
const moveInFlight = prisma.$transaction(
|
|
async (tx) => {
|
|
await lockRowsForShare(tx, 'Apiary', [apiaryFreeId]);
|
|
moveHoldsLock();
|
|
await commitSignal;
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await lockHeld;
|
|
|
|
let answered = false;
|
|
const creating = request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({
|
|
apiaryIds: [apiaryFreeId],
|
|
disease: DISEASE,
|
|
startsOn: '2026-10-01',
|
|
})
|
|
.then((response) => {
|
|
answered = true;
|
|
return response;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
expect(answered).toBe(false);
|
|
|
|
letMoveCommit();
|
|
await moveInFlight;
|
|
expect((await creating).status).toBe(201);
|
|
});
|
|
|
|
it('details (authority, reference, notes, creator) need compliance:read; apiaries:read alone gets the summary', async () => {
|
|
const restriction = await prisma.apiaryRestriction.create({
|
|
data: {
|
|
apiaryId: apiaryRestrictedId,
|
|
disease: DISEASE,
|
|
authority: 'Veterinaeramt Geheim',
|
|
reference: 'AZ-GEHEIM-1',
|
|
notes: 'interne Notiz',
|
|
startsOn: dayToDate('2000-01-01'),
|
|
createdByUserId: userIds[0],
|
|
},
|
|
});
|
|
const paths = [
|
|
'/api/v1/apiary-restrictions',
|
|
`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`,
|
|
];
|
|
for (const path of paths) {
|
|
const minimal = await request(app.getHttpServer())
|
|
.get(path)
|
|
.set('Authorization', `Bearer ${apiariesOnlyToken}`)
|
|
.expect(200);
|
|
const rows = (minimal.body as RestrictionBody[]).filter(
|
|
(row) => row.id === restriction.id,
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(Object.keys(rows[0]).sort()).toEqual([
|
|
'apiaryId',
|
|
'disease',
|
|
'endsOn',
|
|
'id',
|
|
'startsOn',
|
|
]);
|
|
const text = JSON.stringify(minimal.body);
|
|
for (const secret of ['Geheim', 'AZ-GEHEIM-1', 'interne Notiz']) {
|
|
expect(text).not.toContain(secret);
|
|
}
|
|
const full = await request(app.getHttpServer())
|
|
.get(path)
|
|
.set('Authorization', `Bearer ${readonlyToken}`)
|
|
.expect(200);
|
|
const detailed = (full.body as Record<string, unknown>[]).find(
|
|
(row) => row.id === restriction.id,
|
|
);
|
|
expect(detailed).toMatchObject({
|
|
authority: 'Veterinaeramt Geheim',
|
|
reference: 'AZ-GEHEIM-1',
|
|
notes: 'interne Notiz',
|
|
});
|
|
}
|
|
});
|
|
|
|
it('a move and a restriction on the target apiary racing each other end consistently', async () => {
|
|
for (let round = 0; round < 4; round += 1) {
|
|
await prisma.apiaryRestriction.deleteMany({
|
|
where: { apiaryId: { in: apiaryIds() } },
|
|
});
|
|
const colonyId = await colonyIn(apiaryFreeId);
|
|
const [move, created] = await Promise.all([
|
|
request(app.getHttpServer())
|
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ apiaryId: apiaryOtherId }),
|
|
request(app.getHttpServer())
|
|
.post('/api/v1/apiary-restrictions')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({
|
|
apiaryIds: [apiaryOtherId],
|
|
disease: DISEASE,
|
|
startsOn: '2000-01-01',
|
|
}),
|
|
]);
|
|
// Either the move committed first and the restriction followed, or the
|
|
// restriction was first and the move was refused - never a move that
|
|
// slipped past a restriction that was already in force.
|
|
expect(created.status).toBe(201);
|
|
expect([201, 409]).toContain(move.status);
|
|
const colony = await prisma.colony.findUniqueOrThrow({
|
|
where: { id: colonyId },
|
|
});
|
|
expect(colony.apiaryId).toBe(
|
|
move.status === 201 ? apiaryOtherId : apiaryFreeId,
|
|
);
|
|
}
|
|
});
|
|
});
|