- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
345 lines
10 KiB
TypeScript
345 lines
10 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 type { NotificationsService } from '../notifications/notifications.service';
|
||
import type {
|
||
WithdrawalCheckService,
|
||
WithdrawalConflict,
|
||
} from './withdrawal-check.service';
|
||
import { WithdrawalEventHandlers } from './withdrawal.handlers';
|
||
|
||
const conflict: WithdrawalConflict = {
|
||
harvestId: 'h1',
|
||
harvestedOn: '2026-08-10',
|
||
colonyId: 'c1',
|
||
treatmentId: 't1',
|
||
product: 'Oxuvar',
|
||
untilDay: '2026-08-20',
|
||
};
|
||
|
||
const envelope = <T extends DomainEventType>(
|
||
type: T,
|
||
aggregateId: string,
|
||
payload: Record<string, unknown>,
|
||
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
|
||
|
||
const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
|
||
id: 't1',
|
||
colonyId: 'c1',
|
||
product: 'Oxuvar',
|
||
date: new Date('2026-07-31T00:00:00.000Z'),
|
||
startDate: null,
|
||
endDate: null,
|
||
withdrawalPeriodDays: 20,
|
||
colony: { code: 'B3', status: 'ACTIVE' },
|
||
...overrides,
|
||
});
|
||
|
||
describe('WithdrawalEventHandlers', () => {
|
||
// The texts add the year outside the current one: pin 'today'.
|
||
beforeAll(() => {
|
||
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
|
||
});
|
||
afterAll(() => {
|
||
jest.useRealTimers();
|
||
});
|
||
|
||
let check: {
|
||
conflictsForHarvests: jest.Mock;
|
||
conflictsForHoneyBatch: jest.Mock;
|
||
conflictsForExtraction: jest.Mock;
|
||
conflictsForTreatment: jest.Mock;
|
||
notifyConflictsIn: jest.Mock;
|
||
};
|
||
let notifications: { recordForPermission: jest.Mock };
|
||
let tx: {
|
||
treatment: { findUnique: jest.Mock; findMany: jest.Mock };
|
||
automationRule: { findUnique: jest.Mock };
|
||
};
|
||
let handlers: WithdrawalEventHandlers;
|
||
|
||
beforeEach(() => {
|
||
check = {
|
||
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
|
||
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
|
||
conflictsForExtraction: jest.fn().mockResolvedValue([conflict]),
|
||
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
|
||
notifyConflictsIn: jest.fn().mockResolvedValue(1),
|
||
};
|
||
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
|
||
tx = {
|
||
treatment: {
|
||
findUnique: jest.fn().mockResolvedValue(treatmentRow()),
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
},
|
||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||
};
|
||
handlers = new WithdrawalEventHandlers(
|
||
check as unknown as WithdrawalCheckService,
|
||
notifications as unknown as NotificationsService,
|
||
);
|
||
});
|
||
|
||
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||
|
||
it('registers the three handlers under their ids', () => {
|
||
const entries = (method: keyof WithdrawalEventHandlers) =>
|
||
domainEventHandlerMetadata(
|
||
Object.getOwnPropertyDescriptor(
|
||
WithdrawalEventHandlers.prototype,
|
||
method,
|
||
)?.value as object,
|
||
);
|
||
|
||
expect(entries('harvestCheck')).toEqual([
|
||
{
|
||
types: ['HarvestRecorded', 'HarvestCorrected'],
|
||
handlerId: 'withdrawal.harvest-check',
|
||
},
|
||
]);
|
||
expect(entries('bottlingCheck')).toEqual([
|
||
{
|
||
types: ['BatchBottled', 'BottlingCorrected'],
|
||
handlerId: 'withdrawal.bottling-check',
|
||
},
|
||
]);
|
||
expect(entries('treatmentCheck')).toEqual([
|
||
{
|
||
types: ['TreatmentRecorded', 'TreatmentCorrected'],
|
||
handlerId: 'withdrawal.treatment-check',
|
||
},
|
||
]);
|
||
expect(entries('withdrawalEnded')).toEqual([
|
||
{
|
||
types: ['WithdrawalEnded'],
|
||
handlerId: 'notifications.withdrawal-ended',
|
||
},
|
||
]);
|
||
});
|
||
|
||
describe('harvest check', () => {
|
||
it.each(['HarvestRecorded', 'HarvestCorrected'] as const)(
|
||
'notifies the conflicts of the harvest on %s',
|
||
async (type) => {
|
||
await handlers.harvestCheck(
|
||
envelope(type, 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.conflictsForHarvests).toHaveBeenCalledWith(tx, ['h1']);
|
||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||
},
|
||
);
|
||
});
|
||
|
||
describe('extraction check', () => {
|
||
it('registers under its id', () => {
|
||
expect(
|
||
domainEventHandlerMetadata(
|
||
Object.getOwnPropertyDescriptor(
|
||
WithdrawalEventHandlers.prototype,
|
||
'extractionCheck',
|
||
)?.value as object,
|
||
),
|
||
).toEqual([
|
||
{
|
||
types: ['ExtractionRecorded', 'ExtractionCorrected'],
|
||
handlerId: 'withdrawal.extraction-check',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it.each(['ExtractionRecorded', 'ExtractionCorrected'] as const)(
|
||
'notifies the conflicts of the extraction on %s',
|
||
async (type) => {
|
||
await handlers.extractionCheck(
|
||
envelope(type, 'e1', { harvestIds: ['h1'] }),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.conflictsForExtraction).toHaveBeenCalledWith(tx, 'e1');
|
||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||
},
|
||
);
|
||
});
|
||
|
||
describe('treatment check', () => {
|
||
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
|
||
'notifies harvests that already lie in the window of the treatment on %s',
|
||
async (type) => {
|
||
await handlers.treatmentCheck(
|
||
envelope(type, 't1', { colonyId: 'c1' }),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.conflictsForTreatment).toHaveBeenCalledWith(tx, 't1');
|
||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||
},
|
||
);
|
||
});
|
||
|
||
describe('bottling check', () => {
|
||
it('checks the harvests behind the honey batch on BatchBottled', async () => {
|
||
await handlers.bottlingCheck(
|
||
envelope('BatchBottled', 'bb1', { honeyBatchId: 'hb1' }),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.conflictsForHoneyBatch).toHaveBeenCalledWith(tx, 'hb1');
|
||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||
});
|
||
|
||
it('checks on a corrected bottling too', async () => {
|
||
await handlers.bottlingCheck(
|
||
envelope('BottlingCorrected', 'bb1', {
|
||
honeyBatchId: 'hb1',
|
||
removed: false,
|
||
}),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.notifyConflictsIn).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('does nothing for a removed bottling', async () => {
|
||
await handlers.bottlingCheck(
|
||
envelope('BottlingCorrected', 'bb1', {
|
||
honeyBatchId: 'hb1',
|
||
removed: true,
|
||
}),
|
||
asTx(),
|
||
);
|
||
|
||
expect(check.conflictsForHoneyBatch).not.toHaveBeenCalled();
|
||
expect(check.notifyConflictsIn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('withdrawal ended', () => {
|
||
const ended = () =>
|
||
envelope('WithdrawalEnded', 't1', {
|
||
colonyId: 'c1',
|
||
endedOn: '2026-08-20',
|
||
});
|
||
|
||
it('tells readiness to harvest once, with colony, product and end day', async () => {
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||
['harvests:create', 'treatments:read'],
|
||
{
|
||
priority: 'INFO',
|
||
category: 'WITHDRAWAL',
|
||
message:
|
||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||
relatedType: 'Colony',
|
||
relatedId: 'c1',
|
||
link: '/colonies/c1',
|
||
dedupeKeyPrefix: 'withdrawal-ended:t1:2026-08-20',
|
||
},
|
||
tx,
|
||
);
|
||
});
|
||
|
||
it('stays quiet while another window of the colony still runs', async () => {
|
||
tx.treatment.findMany.mockResolvedValue([
|
||
treatmentRow({
|
||
id: 't2',
|
||
date: new Date('2026-08-10T00:00:00.000Z'),
|
||
withdrawalPeriodDays: 14,
|
||
}),
|
||
]);
|
||
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ignores a window of another treatment that has ended or not begun', async () => {
|
||
tx.treatment.findMany.mockResolvedValue([
|
||
// Ended on 2026-08-20 itself: the day is free again.
|
||
treatmentRow({
|
||
id: 't2',
|
||
date: new Date('2026-07-01T00:00:00.000Z'),
|
||
withdrawalPeriodDays: 50,
|
||
}),
|
||
// Starts after the end day.
|
||
treatmentRow({
|
||
id: 't3',
|
||
date: new Date('2026-08-21T00:00:00.000Z'),
|
||
withdrawalPeriodDays: 10,
|
||
}),
|
||
]);
|
||
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('reads the other treatments of the colony without the ended one', async () => {
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(tx.treatment.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: {
|
||
colonyId: 'c1',
|
||
id: { not: 't1' },
|
||
withdrawalPeriodDays: { not: null },
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('is silent when the rule is switched off', async () => {
|
||
tx.automationRule.findUnique.mockResolvedValue({
|
||
id: 'r1',
|
||
active: false,
|
||
config: { notify: true },
|
||
actionTemplate: null,
|
||
});
|
||
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('is silent when notify is off', async () => {
|
||
tx.automationRule.findUnique.mockResolvedValue({
|
||
id: 'r1',
|
||
active: true,
|
||
config: { notify: false },
|
||
actionTemplate: null,
|
||
});
|
||
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('is silent for a colony that no longer exists as a living unit', async () => {
|
||
tx.treatment.findUnique.mockResolvedValue(
|
||
treatmentRow({ colony: { code: 'B3', status: 'DISSOLVED' } }),
|
||
);
|
||
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('is silent when the treatment is gone or its window moved since the event', async () => {
|
||
tx.treatment.findUnique.mockResolvedValueOnce(null);
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
tx.treatment.findUnique.mockResolvedValueOnce(
|
||
treatmentRow({ withdrawalPeriodDays: 25 }),
|
||
);
|
||
await handlers.withdrawalEnded(ended(), asTx());
|
||
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|