- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
262 lines
8.2 KiB
TypeScript
262 lines
8.2 KiB
TypeScript
import type { Prisma } from '../../generated/prisma';
|
|
import type {
|
|
AnyDomainEventEnvelope,
|
|
DomainEventType,
|
|
} from '../domain-events/domain-event-types';
|
|
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
|
import { lockRowsForUpdate } from '../prisma/row-lock';
|
|
import { AutopilotEventHandlers } from './autopilot.handlers';
|
|
import type { AutopilotService } from './autopilot.service';
|
|
|
|
jest.mock('../prisma/row-lock');
|
|
const lockRows = jest.mocked(lockRowsForUpdate);
|
|
|
|
const EXPECTED_TYPES: DomainEventType[] = [
|
|
'InspectionRecorded',
|
|
'InspectionCorrected',
|
|
'VarroaMeasured',
|
|
'VarroaCorrected',
|
|
'TreatmentRecorded',
|
|
'TreatmentCorrected',
|
|
'FeedingRecorded',
|
|
'FeedingCorrected',
|
|
'HarvestRecorded',
|
|
'HarvestCorrected',
|
|
'ColonyStatusChanged',
|
|
'ColonySplit',
|
|
'QueenAssigned',
|
|
'TaskCompleted',
|
|
'TaskUpdated',
|
|
'WithdrawalEnded',
|
|
'QueenAged',
|
|
'InspectionOverdue',
|
|
'VarroaMeasurementDue',
|
|
'WinterCheckRecorded',
|
|
];
|
|
|
|
function event(
|
|
type: DomainEventType,
|
|
aggregateId: string,
|
|
payload: object,
|
|
): AnyDomainEventEnvelope {
|
|
return {
|
|
id: 'evt-1',
|
|
type,
|
|
aggregateType: 'Colony',
|
|
aggregateId,
|
|
payload,
|
|
actorUserId: null,
|
|
correlationId: 'cor-1',
|
|
occurredAt: new Date(),
|
|
attempts: 0,
|
|
} as AnyDomainEventEnvelope;
|
|
}
|
|
|
|
describe('AutopilotEventHandlers', () => {
|
|
let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
|
|
let tx: { task: { findUnique: jest.Mock } };
|
|
let handlers: AutopilotEventHandlers;
|
|
|
|
beforeEach(() => {
|
|
lockRows.mockReset();
|
|
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
|
autopilot = {
|
|
syncColonyIn: jest.fn().mockResolvedValue({}),
|
|
syncApiaryIn: jest.fn().mockResolvedValue({}),
|
|
};
|
|
tx = { task: { findUnique: jest.fn() } };
|
|
handlers = new AutopilotEventHandlers(
|
|
autopilot as unknown as AutopilotService,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
const run = (e: AnyDomainEventEnvelope) =>
|
|
handlers.colonySync(
|
|
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
|
|
tx as unknown as Prisma.TransactionClient,
|
|
);
|
|
const synced = () =>
|
|
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
|
|
|
|
it('is registered as autopilot.colony-sync for the listed events', () => {
|
|
const entries = domainEventHandlerMetadata(
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
AutopilotEventHandlers.prototype.colonySync,
|
|
);
|
|
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
|
|
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
|
|
});
|
|
|
|
it.each([
|
|
['InspectionRecorded', { colonyId: 'c1' }],
|
|
['InspectionCorrected', { colonyId: 'c1' }],
|
|
['VarroaMeasured', { colonyId: 'c1' }],
|
|
['VarroaCorrected', { colonyId: 'c1' }],
|
|
['TreatmentRecorded', { colonyId: 'c1' }],
|
|
['TreatmentCorrected', { colonyId: 'c1' }],
|
|
['FeedingRecorded', { colonyId: 'c1' }],
|
|
['FeedingCorrected', { colonyId: 'c1' }],
|
|
['WinterCheckRecorded', { colonyId: 'c1' }],
|
|
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
|
|
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
|
|
] as [DomainEventType, object][])(
|
|
'%s syncs the colony of its payload for the Berlin day',
|
|
async (type, payload) => {
|
|
await run(event(type, 'aggregate-1', payload));
|
|
|
|
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
|
|
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
|
|
tx,
|
|
'c1',
|
|
'2026-05-12',
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
'ColonyStatusChanged',
|
|
'InspectionOverdue',
|
|
'VarroaMeasurementDue',
|
|
] as const)('%s syncs the aggregate colony', async (type) => {
|
|
await run(event(type, 'c9', {}));
|
|
|
|
expect(synced()).toEqual(['c9']);
|
|
});
|
|
|
|
it('syncs every colony of a harvest, in id order', async () => {
|
|
await run(
|
|
event('HarvestRecorded', 'h1', {
|
|
apiaryId: 'a',
|
|
colonyIds: ['c3', 'c1', 'c2', 'c1'],
|
|
}),
|
|
);
|
|
|
|
expect(synced()).toEqual(['c1', 'c2', 'c3']);
|
|
});
|
|
|
|
it('locks all colonies of an event in one statement before syncing any', async () => {
|
|
await run(
|
|
event('HarvestRecorded', 'h1', {
|
|
apiaryId: 'a',
|
|
colonyIds: ['c3', 'c1', 'c2'],
|
|
}),
|
|
);
|
|
|
|
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
|
|
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
|
|
autopilot.syncColonyIn.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('takes no extra lock for a single colony', async () => {
|
|
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
|
|
|
expect(lockRows).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('syncs the new and the parent colony of a split', async () => {
|
|
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
|
|
|
|
expect(synced()).toEqual(['mother', 'new']);
|
|
});
|
|
|
|
it('syncs the colony and the previous colony of a queen assignment', async () => {
|
|
await run(
|
|
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
|
|
);
|
|
await run(
|
|
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
|
|
);
|
|
|
|
expect(synced()).toEqual(['c1', 'c2', 'c2']);
|
|
});
|
|
|
|
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
|
|
await run(
|
|
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
|
|
);
|
|
await run(
|
|
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
|
|
);
|
|
tx.task.findUnique
|
|
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
|
|
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
|
|
.mockResolvedValueOnce(null);
|
|
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
|
|
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
|
|
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
|
|
|
|
expect(synced()).toEqual(['c1', 'c4']);
|
|
});
|
|
|
|
it('does nothing for a queen event without a colony', async () => {
|
|
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
|
|
|
|
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets errors propagate so the delivery is retried', async () => {
|
|
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
|
|
|
|
await expect(
|
|
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
|
|
).rejects.toThrow('db down');
|
|
});
|
|
|
|
describe('apiarySync', () => {
|
|
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
|
|
const entries = domainEventHandlerMetadata(
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
AutopilotEventHandlers.prototype.apiarySync,
|
|
);
|
|
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
|
|
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
|
|
});
|
|
|
|
// One event per apiary and day (dailyDedupeKey): a change later the same
|
|
// day is caught by the daily autopilot step.
|
|
it('syncs the apiary of the payload for the Berlin day', async () => {
|
|
await handlers.apiarySync(
|
|
event('SeasonPhaseChanged', 'api-1', {
|
|
apiaryId: 'api-1',
|
|
phase: 'KIRSCHE',
|
|
segment: 'SCHWARMZEIT',
|
|
previousPhase: 'SALWEIDE',
|
|
previousSegment: 'FRUEHJAHR',
|
|
source: 'REPORT',
|
|
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
|
tx as unknown as Prisma.TransactionClient,
|
|
);
|
|
|
|
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
|
|
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
|
|
tx,
|
|
'api-1',
|
|
'2026-05-12',
|
|
);
|
|
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets errors propagate so the delivery is retried', async () => {
|
|
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
|
|
|
|
await expect(
|
|
handlers.apiarySync(
|
|
event('SeasonPhaseChanged', 'api-1', {
|
|
apiaryId: 'api-1',
|
|
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
|
tx as unknown as Prisma.TransactionClient,
|
|
),
|
|
).rejects.toThrow('db down');
|
|
});
|
|
});
|
|
});
|