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]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -4,9 +4,13 @@ import type {
|
||||
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',
|
||||
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
'WinterCheckRecorded',
|
||||
];
|
||||
|
||||
function event(
|
||||
@@ -48,13 +53,17 @@ function event(
|
||||
}
|
||||
|
||||
describe('AutopilotEventHandlers', () => {
|
||||
let autopilot: { syncColonyIn: jest.Mock };
|
||||
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({}) };
|
||||
autopilot = {
|
||||
syncColonyIn: jest.fn().mockResolvedValue({}),
|
||||
syncApiaryIn: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
tx = { task: { findUnique: jest.fn() } };
|
||||
handlers = new AutopilotEventHandlers(
|
||||
autopilot as unknown as AutopilotService,
|
||||
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
|
||||
['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][])(
|
||||
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
|
||||
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' }));
|
||||
|
||||
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user