Files
ApiCore/ApiCore-Backend/src/withdrawal/withdrawal-check.service.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

434 lines
14 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 { 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');
});
});
});