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 = {}) => ({ 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'); }); }); });