- 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]>
233 lines
7.4 KiB
TypeScript
233 lines
7.4 KiB
TypeScript
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 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(() => {
|
|
order = [];
|
|
tx = {
|
|
$queryRaw: jest.fn().mockImplementation(() => {
|
|
order.push('lock');
|
|
return Promise.resolve([{ id: 'apiary-1' }]);
|
|
}),
|
|
apiary: {
|
|
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(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
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('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',
|
|
});
|
|
|
|
const result = await service.deactivate('apiary-1', 'user-1');
|
|
|
|
expect(result.status).toBe('INACTIVE');
|
|
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 () => {
|
|
tx.colony.count.mockResolvedValue(2);
|
|
|
|
await expect(
|
|
service.deactivate('apiary-1', 'user-1'),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
expect(tx.apiary.update).not.toHaveBeenCalled();
|
|
expect(events.record).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reactivate throws ConflictException when already active', async () => {
|
|
await expect(
|
|
service.reactivate('apiary-1', 'user-1'),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
expect(events.record).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
|
tx.apiary.findUnique.mockResolvedValue({
|
|
...existingApiary,
|
|
status: 'INACTIVE',
|
|
deactivatedAt: new Date(),
|
|
});
|
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
|
|
|
const result = await service.reactivate('apiary-1', 'user-1');
|
|
|
|
expect(result.status).toBe('ACTIVE');
|
|
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']);
|
|
});
|
|
});
|