Files
ApiCore/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- 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]>
2026-10-14 20:00:46 +02:00

345 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});
});