- 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]>
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import type { DbClient } from '../prisma/db-client';
|
||
import type { Prisma } from '../../generated/prisma';
|
||
import type { NotificationsService } from '../notifications/notifications.service';
|
||
import {
|
||
WithdrawalCheckService,
|
||
type WithdrawalConflict,
|
||
} from './withdrawal-check.service';
|
||
|
||
interface TreatmentRow {
|
||
id: string;
|
||
colonyId: string;
|
||
product: string;
|
||
date: Date;
|
||
startDate: Date | null;
|
||
endDate: Date | null;
|
||
withdrawalPeriodDays: number | null;
|
||
}
|
||
|
||
const treatment = (overrides: Partial<TreatmentRow>): TreatmentRow => ({
|
||
id: 't1',
|
||
colonyId: 'c1',
|
||
product: 'Oxuvar',
|
||
date: new Date('2026-07-31T00:00:00.000Z'),
|
||
startDate: null,
|
||
endDate: null,
|
||
withdrawalPeriodDays: 20,
|
||
...overrides,
|
||
});
|
||
|
||
const conflict = (
|
||
overrides: Partial<WithdrawalConflict> = {},
|
||
): WithdrawalConflict => ({
|
||
harvestId: 'h1',
|
||
harvestedOn: '2026-08-10',
|
||
colonyId: 'c1',
|
||
treatmentId: 't1',
|
||
product: 'Oxuvar',
|
||
untilDay: '2026-08-20',
|
||
...overrides,
|
||
});
|
||
|
||
describe('WithdrawalCheckService', () => {
|
||
// 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 db: {
|
||
harvest: { findMany: jest.Mock };
|
||
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
|
||
colony: { findMany: jest.Mock };
|
||
honeyBatch: { findUnique: jest.Mock };
|
||
extractionHarvest: { findMany: jest.Mock };
|
||
bottlingBatch: { findMany: jest.Mock };
|
||
};
|
||
let notifications: { recordForPermission: jest.Mock };
|
||
let service: WithdrawalCheckService;
|
||
|
||
beforeEach(() => {
|
||
db = {
|
||
harvest: { findMany: jest.fn().mockResolvedValue([]) },
|
||
treatment: {
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
findUnique: jest.fn().mockResolvedValue(null),
|
||
},
|
||
colony: {
|
||
findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]),
|
||
},
|
||
honeyBatch: { findUnique: jest.fn() },
|
||
extractionHarvest: { findMany: jest.fn() },
|
||
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
|
||
};
|
||
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
|
||
service = new WithdrawalCheckService(
|
||
notifications as unknown as NotificationsService,
|
||
);
|
||
});
|
||
|
||
const asDb = () => db as unknown as DbClient;
|
||
const harvestOn = (id: string, day: string, colonyIds: string[]) => ({
|
||
id,
|
||
date: new Date(`${day}T00:00:00.000Z`),
|
||
colonies: colonyIds.map((colonyId) => ({ colonyId })),
|
||
});
|
||
|
||
describe('conflictsForHarvests', () => {
|
||
it('finds the treatment whose window includes the harvest date per colony', async () => {
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-08-10', ['c1', 'c2']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([
|
||
treatment({ id: 't1', colonyId: 'c1' }),
|
||
// Window ended before the harvest: no conflict for c2.
|
||
treatment({
|
||
id: 't2',
|
||
colonyId: 'c2',
|
||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||
withdrawalPeriodDays: 10,
|
||
}),
|
||
]);
|
||
|
||
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
|
||
|
||
expect(conflicts).toEqual([conflict()]);
|
||
});
|
||
|
||
it('treats the first harvestable day as free and the treatment day as covered', async () => {
|
||
// t1: 2026-07-31 + 20 days = first harvestable day 2026-08-20.
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h-start', '2026-07-31', ['c1']),
|
||
harvestOn('h-last', '2026-08-19', ['c1']),
|
||
harvestOn('h-free', '2026-08-20', ['c1']),
|
||
harvestOn('h-before', '2026-07-30', ['c1']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||
|
||
const conflicts = await service.conflictsForHarvests(asDb(), [
|
||
'h-start',
|
||
'h-last',
|
||
'h-free',
|
||
'h-before',
|
||
]);
|
||
|
||
expect(conflicts.map((c) => c.harvestId)).toEqual(['h-start', 'h-last']);
|
||
});
|
||
|
||
it('starts the window at startDate when it lies before the treatment date', async () => {
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-07-25', ['c1']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([
|
||
treatment({ startDate: new Date('2026-07-24T00:00:00.000Z') }),
|
||
]);
|
||
|
||
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
|
||
|
||
expect(conflicts).toHaveLength(1);
|
||
});
|
||
|
||
it('reports one conflict per harvest and colony, the one that lasts longest', async () => {
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-08-10', ['c1']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([
|
||
treatment({ id: 't-short', withdrawalPeriodDays: 12 }),
|
||
treatment({
|
||
id: 't-long',
|
||
withdrawalPeriodDays: 30,
|
||
product: 'Apivar',
|
||
}),
|
||
]);
|
||
|
||
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
|
||
|
||
expect(conflicts).toEqual([
|
||
conflict({
|
||
treatmentId: 't-long',
|
||
product: 'Apivar',
|
||
untilDay: '2026-08-30',
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('ignores treatments without a withdrawal period and colonies without any', async () => {
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-08-10', ['c1']),
|
||
harvestOn('h2', '2026-08-10', []),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([
|
||
treatment({ withdrawalPeriodDays: null }),
|
||
]);
|
||
|
||
expect(await service.conflictsForHarvests(asDb(), ['h1', 'h2'])).toEqual(
|
||
[],
|
||
);
|
||
});
|
||
|
||
it('answers at once without ids and does not read anything', async () => {
|
||
expect(await service.conflictsForHarvests(asDb(), [])).toEqual([]);
|
||
expect(db.harvest.findMany).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('compares the Berlin calendar day of the harvest', async () => {
|
||
// 2026-08-19T22:30Z is 00:30 on 2026-08-20 in Berlin: already free.
|
||
db.harvest.findMany.mockResolvedValue([
|
||
{
|
||
id: 'h1',
|
||
date: new Date('2026-08-19T22:30:00.000Z'),
|
||
colonies: [{ colonyId: 'c1' }],
|
||
},
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||
|
||
expect(await service.conflictsForHarvests(asDb(), ['h1'])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('conflictsForTreatment', () => {
|
||
it('finds the harvests of the colony inside the window, entered before the treatment', async () => {
|
||
db.treatment.findUnique.mockResolvedValue(treatment({}));
|
||
// Candidates (date range) first, then the harvests re-read for the check.
|
||
db.harvest.findMany
|
||
.mockResolvedValueOnce([
|
||
{ id: 'h-in', date: new Date('2026-08-10T00:00:00.000Z') },
|
||
{ id: 'h-free', date: new Date('2026-08-20T00:00:00.000Z') },
|
||
{ id: 'h-before', date: new Date('2026-07-30T00:00:00.000Z') },
|
||
])
|
||
.mockResolvedValueOnce([harvestOn('h-in', '2026-08-10', ['c1', 'c2'])]);
|
||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||
|
||
const conflicts = await service.conflictsForTreatment(asDb(), 't1');
|
||
|
||
expect(db.harvest.findMany).toHaveBeenNthCalledWith(
|
||
1,
|
||
expect.objectContaining({
|
||
where: {
|
||
colonies: { some: { colonyId: 'c1' } },
|
||
date: {
|
||
gte: new Date('2026-07-30T00:00:00.000Z'),
|
||
lt: new Date('2026-08-21T00:00:00.000Z'),
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
expect(db.harvest.findMany).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.objectContaining({ where: { id: { in: ['h-in'] } } }),
|
||
);
|
||
// Only the colony of the treatment.
|
||
expect(conflicts).toEqual([conflict({ harvestId: 'h-in' })]);
|
||
});
|
||
|
||
it('answers nothing without a withdrawal span or for an unknown treatment', async () => {
|
||
db.treatment.findUnique.mockResolvedValueOnce(null);
|
||
expect(await service.conflictsForTreatment(asDb(), 'x')).toEqual([]);
|
||
db.treatment.findUnique.mockResolvedValueOnce(
|
||
treatment({ withdrawalPeriodDays: null }),
|
||
);
|
||
expect(await service.conflictsForTreatment(asDb(), 't1')).toEqual([]);
|
||
expect(db.harvest.findMany).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('conflictsForHoneyBatch', () => {
|
||
it('follows batch, extraction and harvests', async () => {
|
||
db.honeyBatch.findUnique.mockResolvedValue({
|
||
extraction: { harvests: [{ harvestId: 'h1' }, { harvestId: 'h2' }] },
|
||
});
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-08-10', ['c1']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||
|
||
const conflicts = await service.conflictsForHoneyBatch(asDb(), 'b1');
|
||
|
||
expect(db.harvest.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { id: { in: ['h1', 'h2'] } },
|
||
}),
|
||
);
|
||
expect(conflicts).toEqual([conflict()]);
|
||
});
|
||
|
||
it('answers nothing for an unknown batch', async () => {
|
||
db.honeyBatch.findUnique.mockResolvedValue(null);
|
||
|
||
expect(await service.conflictsForHoneyBatch(asDb(), 'nope')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('conflictsForExtraction', () => {
|
||
it('checks the harvests of the extraction', async () => {
|
||
db.extractionHarvest.findMany.mockResolvedValue([
|
||
{ harvestId: 'h1' },
|
||
{ harvestId: 'h2' },
|
||
]);
|
||
db.harvest.findMany.mockResolvedValue([
|
||
harvestOn('h1', '2026-08-10', ['c1']),
|
||
]);
|
||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||
|
||
const conflicts = await service.conflictsForExtraction(asDb(), 'e1');
|
||
|
||
expect(db.extractionHarvest.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { extractionId: 'e1' } }),
|
||
);
|
||
expect(db.harvest.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } } }),
|
||
);
|
||
expect(conflicts).toEqual([conflict()]);
|
||
});
|
||
|
||
it('answers nothing for an extraction without harvests', async () => {
|
||
db.extractionHarvest.findMany.mockResolvedValue([]);
|
||
|
||
expect(await service.conflictsForExtraction(asDb(), 'e1')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('warningsFor', () => {
|
||
it('words the warning with colony code, product and the end day', async () => {
|
||
const warnings = await service.warningsFor(asDb(), [conflict()]);
|
||
|
||
expect(warnings).toEqual([
|
||
{
|
||
code: 'WITHDRAWAL_ACTIVE',
|
||
severity: 'WARNUNG',
|
||
message:
|
||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||
refs: [
|
||
{ type: 'Colony', id: 'c1' },
|
||
{ type: 'Treatment', id: 't1' },
|
||
{ type: 'Harvest', id: 'h1' },
|
||
],
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('warns once per colony and treatment when several harvests feed the batch', async () => {
|
||
const warnings = await service.warningsFor(asDb(), [
|
||
conflict({ harvestId: 'h1' }),
|
||
conflict({ harvestId: 'h2' }),
|
||
conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }),
|
||
]);
|
||
|
||
expect(warnings.map((w) => w.message)).toEqual([
|
||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||
'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||
]);
|
||
expect(warnings[0].refs).toEqual([
|
||
{ type: 'Colony', id: 'c1' },
|
||
{ type: 'Treatment', id: 't1' },
|
||
{ type: 'Harvest', id: 'h1' },
|
||
{ type: 'Harvest', id: 'h2' },
|
||
]);
|
||
});
|
||
|
||
it('answers nothing without reading when there is no conflict', async () => {
|
||
expect(await service.warningsFor(asDb(), [])).toEqual([]);
|
||
expect(db.colony.findMany).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('notifyConflictsIn', () => {
|
||
const tx = () => db as unknown as Prisma.TransactionClient;
|
||
|
||
it('notifies readers of harvests and treatments once per harvest and colony', async () => {
|
||
notifications.recordForPermission.mockResolvedValue([{ id: 'n1' }]);
|
||
|
||
const count = await service.notifyConflictsIn(tx(), [conflict()]);
|
||
|
||
expect(count).toBe(1);
|
||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||
['harvests:read', 'treatments:read'],
|
||
{
|
||
priority: 'WARNUNG',
|
||
category: 'WITHDRAWAL',
|
||
message:
|
||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||
relatedType: 'Harvest',
|
||
relatedId: 'h1',
|
||
link: '/harvests/h1',
|
||
dedupeKeyPrefix: 'withdrawal-conflict:h1:c1',
|
||
},
|
||
db,
|
||
);
|
||
});
|
||
|
||
it('names the bottling lots when the harvest was already bottled', async () => {
|
||
db.bottlingBatch.findMany.mockResolvedValue([
|
||
{
|
||
lotNumber: 'L-2',
|
||
honeyBatch: { extraction: { harvests: [{ harvestId: 'h1' }] } },
|
||
},
|
||
{
|
||
lotNumber: 'L-1',
|
||
honeyBatch: {
|
||
extraction: {
|
||
harvests: [{ harvestId: 'h1' }, { harvestId: 'h9' }],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
lotNumber: 'L-other',
|
||
honeyBatch: { extraction: { harvests: [{ harvestId: 'h9' }] } },
|
||
},
|
||
]);
|
||
|
||
await service.notifyConflictsIn(tx(), [conflict()]);
|
||
|
||
const [, input] = notifications.recordForPermission.mock.calls[0] as [
|
||
unknown,
|
||
{ message: string; dedupeKeyPrefix: string },
|
||
];
|
||
expect(input.message).toBe(
|
||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.',
|
||
);
|
||
// Same key as without the lots: one notification per harvest and colony.
|
||
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
|
||
});
|
||
|
||
it('counts only the rows that were really written', async () => {
|
||
notifications.recordForPermission
|
||
.mockResolvedValueOnce([])
|
||
.mockResolvedValueOnce([{ id: 'n1' }, { id: 'n2' }]);
|
||
|
||
const count = await service.notifyConflictsIn(tx(), [
|
||
conflict(),
|
||
conflict({ harvestId: 'h2' }),
|
||
]);
|
||
|
||
expect(count).toBe(2);
|
||
});
|
||
|
||
it('does nothing without conflicts', async () => {
|
||
expect(await service.notifyConflictsIn(tx(), [])).toBe(0);
|
||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('refuses the root client', async () => {
|
||
const root = {
|
||
$connect: jest.fn(),
|
||
} as unknown as Prisma.TransactionClient;
|
||
|
||
await expect(
|
||
service.notifyConflictsIn(root, [conflict()]),
|
||
).rejects.toThrow('needs the transaction client');
|
||
});
|
||
});
|
||
});
|