feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
let order: string[];
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
colony: { count: jest.Mock };
};
let prisma: {
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
address: 'Weg 1',
location: 'Garten',
latitude: 48.1,
longitude: 11.5,
notes: null,
};
const changes = (): string[] =>
(
events.record.mock.calls as [unknown, { payload: { change: string } }][]
).map(([, event]) => event.payload.change);
beforeEach(() => {
prisma = {
order = [];
tx = {
$queryRaw: jest.fn().mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
}),
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(existingApiary);
}),
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
prisma = {
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: 'apiary-1',
actorUserId: 'user-1',
payload: { change: 'CREATED' },
});
});
it('a failing audit write rolls back the apiary change', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
auditLog.record.mockRejectedValue(new Error('audit down'));
await expect(
service.create({ name: 'Heimstand' }, 'user-1'),
).rejects.toThrow('audit down');
// Record, audit and event share one transaction callback: its rejection
// is what rolls the record back.
expect(events.record).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('update reads before after locking the Apiary row', async () => {
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
expect(order).toEqual(['lock', 'read']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: existingApiary,
}),
tx,
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
it('update of a missing apiary answers 404', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.apiary.update).not.toHaveBeenCalled();
});
it.each([
[{ address: 'Neuer Weg 2' }],
[{ location: 'Wiese' }],
[{ latitude: 50 }],
[{ longitude: 12 }],
])('update records LOCATION_CHANGED for %j', async (dto) => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
await service.update('apiary-1', dto, 'user-1');
expect(changes()).toEqual(['LOCATION_CHANGED']);
});
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
await service.update(
'apiary-1',
{ name: 'Anders', notes: 'neu' },
'user-1',
);
// The same value sent again is no change either.
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
expect(events.record).not.toHaveBeenCalled();
});
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
tx.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
);
});
it('deactivate counts every living colony, weak and queenless ones included', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue(existingApiary);
await service.deactivate('apiary-1', 'user-1');
expect(prisma.colony.count).toHaveBeenCalledWith({
expect(order).toEqual(['lock', 'read']);
expect(tx.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
tx,
);
expect(changes()).toEqual(['DEACTIVATED']);
});
it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2);
tx.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.apiary.update).not.toHaveBeenCalled();
expect(tx.apiary.update).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
tx.apiary.findUnique.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
tx.apiary.update.mockResolvedValue(existingApiary);
const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({
expect(tx.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
tx,
);
expect(changes()).toEqual(['REACTIVATED']);
});
});