- 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]>
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
|
import type { DbClient } from '../prisma/db-client';
|
|
import { SeasonService } from './season.service';
|
|
|
|
describe('SeasonService', () => {
|
|
const findUnique = jest.fn();
|
|
const findMany = jest.fn();
|
|
const db = {
|
|
automationRule: { findUnique },
|
|
apiaryPhenology: { findMany },
|
|
} as unknown as DbClient;
|
|
const service = new SeasonService();
|
|
|
|
beforeEach(() => {
|
|
findUnique.mockReset().mockResolvedValue(null);
|
|
findMany.mockReset().mockResolvedValue([]);
|
|
});
|
|
|
|
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
|
|
const states = await service.statesFor(
|
|
db,
|
|
['apiary-1', 'apiary-2', 'apiary-1'],
|
|
'2026-05-12',
|
|
);
|
|
|
|
expect([...states.keys()]).toEqual(['apiary-1', 'apiary-2']);
|
|
expect(states.get('apiary-2')).toMatchObject({
|
|
apiaryId: 'apiary-2',
|
|
phaseSource: 'CALENDAR',
|
|
segment: 'SCHWARMZEIT',
|
|
});
|
|
expect(findUnique).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reads no rule without apiaries', async () => {
|
|
expect((await service.statesFor(db, [], '2026-05-12')).size).toBe(0);
|
|
expect(findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('follows the stored calendar of the PHENOLOGY rule', async () => {
|
|
findUnique.mockResolvedValue({
|
|
id: 'rule-1',
|
|
active: true,
|
|
actionTemplate: null,
|
|
config: {
|
|
...DEFAULT_PHENOLOGY_CONFIG,
|
|
calendarFallback: {
|
|
...DEFAULT_PHENOLOGY_CONFIG.calendarFallback,
|
|
SALWEIDE: '03-01',
|
|
KIRSCHE: '04-01',
|
|
},
|
|
},
|
|
});
|
|
|
|
const states = await service.statesFor(db, ['apiary-1'], '2026-04-05');
|
|
|
|
expect(states.get('apiary-1')).toMatchObject({
|
|
segment: 'SCHWARMZEIT',
|
|
segmentSince: '2026-04-01',
|
|
springSince: '2026-03-01',
|
|
});
|
|
});
|
|
|
|
describe('with stored phenology', () => {
|
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
|
const row = (overrides: Record<string, unknown> = {}) => ({
|
|
apiaryId: 'apiary-1',
|
|
year: 2026,
|
|
gts: 412.5,
|
|
gtsThroughDay: d('2026-05-11'),
|
|
missingDays: 0,
|
|
estimatedPhase: 'KIRSCHE',
|
|
reportedPhase: 'APFEL',
|
|
effectivePhase: 'APFEL',
|
|
phaseSource: 'REPORT',
|
|
segment: 'SCHWARMZEIT',
|
|
segmentSince: d('2026-05-02'),
|
|
updatedAt: d('2026-05-12'),
|
|
apiary: { latitude: 48.1, longitude: 9.9 },
|
|
...overrides,
|
|
});
|
|
|
|
it('prefers the row of the current year over the calendar', async () => {
|
|
findMany.mockResolvedValue([row()]);
|
|
|
|
const states = await service.statesFor(
|
|
db,
|
|
['apiary-1', 'apiary-2'],
|
|
'2026-05-12',
|
|
);
|
|
|
|
expect(states.get('apiary-1')).toEqual({
|
|
apiaryId: 'apiary-1',
|
|
year: 2026,
|
|
phase: 'APFEL',
|
|
phaseSource: 'REPORT',
|
|
reportedOn: null,
|
|
segment: 'SCHWARMZEIT',
|
|
segmentSince: '2026-05-02',
|
|
springSince: '2026-03-20',
|
|
gts: 412.5,
|
|
gtsThroughDay: '2026-05-11',
|
|
note: null,
|
|
});
|
|
expect(states.get('apiary-2')).toMatchObject({ phaseSource: 'CALENDAR' });
|
|
expect(findMany).toHaveBeenCalledTimes(1);
|
|
expect(findUnique).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('asks for the rows of the year of today', async () => {
|
|
await service.statesFor(db, ['apiary-1'], '2026-05-12');
|
|
|
|
expect(findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { apiaryId: { in: ['apiary-1'] }, year: 2026 },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('takes the segment start as spring start while in FRUEHJAHR', async () => {
|
|
findMany.mockResolvedValue([
|
|
row({ segment: 'FRUEHJAHR', segmentSince: d('2026-03-28') }),
|
|
]);
|
|
|
|
const states = await service.statesFor(db, ['apiary-1'], '2026-04-01');
|
|
|
|
expect(states.get('apiary-1')?.springSince).toBe('2026-03-28');
|
|
});
|
|
|
|
it('has no spring start before the spring', async () => {
|
|
findMany.mockResolvedValue([
|
|
row({
|
|
segment: 'WINTERRUHE',
|
|
effectivePhase: null,
|
|
phaseSource: 'CALENDAR',
|
|
segmentSince: d('2026-01-01'),
|
|
gts: 60,
|
|
}),
|
|
]);
|
|
|
|
const states = await service.statesFor(db, ['apiary-1'], '2026-02-10');
|
|
|
|
expect(states.get('apiary-1')?.springSince).toBeNull();
|
|
});
|
|
|
|
it('explains why the weather was not used', async () => {
|
|
findMany.mockResolvedValue([
|
|
row({ gts: null, gtsThroughDay: d('2026-05-02') }),
|
|
row({
|
|
apiaryId: 'apiary-2',
|
|
apiary: { latitude: null, longitude: null },
|
|
gts: null,
|
|
gtsThroughDay: null,
|
|
}),
|
|
row({ apiaryId: 'apiary-3', gts: null }),
|
|
]);
|
|
|
|
const states = await service.statesFor(
|
|
db,
|
|
['apiary-1', 'apiary-2', 'apiary-3'],
|
|
'2026-05-12',
|
|
);
|
|
|
|
expect(states.get('apiary-1')?.note).toBe('WEATHER_STALE');
|
|
expect(states.get('apiary-2')?.note).toBe('NO_COORDINATES');
|
|
expect(states.get('apiary-3')?.note).toBe('WEATHER_GAPS');
|
|
});
|
|
|
|
it('uses a row updated 2 days ago but not one updated 3 days ago', async () => {
|
|
findMany.mockResolvedValue([
|
|
row({ updatedAt: new Date('2026-05-10T04:31:00.000Z') }),
|
|
row({
|
|
apiaryId: 'apiary-2',
|
|
updatedAt: new Date('2026-05-09T04:31:00.000Z'),
|
|
}),
|
|
]);
|
|
|
|
const states = await service.statesFor(
|
|
db,
|
|
['apiary-1', 'apiary-2'],
|
|
'2026-05-12',
|
|
);
|
|
|
|
expect(states.get('apiary-1')?.phaseSource).toBe('REPORT');
|
|
// The job missed its runs: back to the calendar.
|
|
expect(states.get('apiary-2')?.phaseSource).toBe('CALENDAR');
|
|
});
|
|
});
|
|
});
|