Files
ApiCore/ApiCore-Backend/src/autopilot/autopilot.handlers.spec.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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]>
2026-10-14 21:13:23 +02:00

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');
});
});
});