feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- 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:
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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user