import { BadRequestException, NotFoundException } from '@nestjs/common'; import { WeatherService } from './weather.service'; import { PrismaService } from '../prisma/prisma.service'; function openMeteoResponse( days: { date: string; tempMax: number | null; tempMin: number | null; precipitation: number | null; wind?: number | null; }[], ): unknown { return { daily: { time: days.map((day) => day.date), temperature_2m_max: days.map((day) => day.tempMax), temperature_2m_min: days.map((day) => day.tempMin), precipitation_sum: days.map((day) => day.precipitation), wind_speed_10m_max: days.map((day) => day.wind ?? 10), }, }; } describe('WeatherService', () => { let weatherService: WeatherService; let prisma: { apiary: { findUnique: jest.Mock }; harvest: { findMany: jest.Mock }; }; let fetchMock: jest.SpiedFunction; beforeEach(() => { prisma = { apiary: { findUnique: jest.fn() }, harvest: { findMany: jest.fn().mockResolvedValue([]) }, }; weatherService = new WeatherService(prisma as unknown as PrismaService); fetchMock = jest.spyOn(global, 'fetch'); }); afterEach(() => { fetchMock.mockRestore(); }); describe('getWeather', () => { it('throws NotFoundException when the apiary does not exist', async () => { prisma.apiary.findUnique.mockResolvedValue(null); await expect(weatherService.getWeather('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); it('throws BadRequestException when the apiary has no coordinates', async () => { prisma.apiary.findUnique.mockResolvedValue({ latitude: null, longitude: null, }); await expect( weatherService.getWeather('apiary-1'), ).rejects.toBeInstanceOf(BadRequestException); }); it('returns daily weather mapped from the Open-Meteo response', async () => { prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue( new Response( JSON.stringify( openMeteoResponse([ { date: '2026-06-01', tempMax: 22, tempMin: 12, precipitation: 0, }, ]), ), { status: 200 }, ), ); const result = await weatherService.getWeather('apiary-1'); expect(result.daily).toEqual([ { date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0, windSpeedMaxKmh: 10, inspectionSuitable: true, }, ]); const [url] = fetchMock.mock.calls[0] as [URL]; expect(url.toString()).toContain('latitude=52.5'); expect(url.toString()).toContain('longitude=13.4'); }); it.each([ ['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }], ['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }], ['too windy', { tempMax: 20, tempMin: 12, precipitation: 0, wind: 40 }], ])('marks a day unsuitable for inspection when %s', async (_label, day) => { prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue( new Response( JSON.stringify(openMeteoResponse([{ date: '2026-06-01', ...day }])), { status: 200 }, ), ); const result = await weatherService.getWeather('apiary-1'); expect(result.daily[0].inspectionSuitable).toBe(false); }); it('leaves inspectionSuitable null when a required value is missing', async () => { prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue( new Response( JSON.stringify( openMeteoResponse([ { date: '2026-06-01', tempMax: null, tempMin: null, precipitation: 0, wind: 5, }, ]), ), { status: 200 }, ), ); const result = await weatherService.getWeather('apiary-1'); expect(result.daily[0].inspectionSuitable).toBeNull(); }); it('throws BadRequestException when Open-Meteo responds with an error status', async () => { prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue(new Response('', { status: 500 })); await expect( weatherService.getWeather('apiary-1'), ).rejects.toBeInstanceOf(BadRequestException); }); it('throws BadRequestException instead of hanging when the request times out or the network fails', async () => { prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockRejectedValue( new DOMException('The operation was aborted', 'TimeoutError'), ); await expect( weatherService.getWeather('apiary-1'), ).rejects.toBeInstanceOf(BadRequestException); }); }); describe('getTrachtprognose', () => { const realDate = Date; function mockToday(isoDate: string) { const fixed = new realDate(isoDate); jest .spyOn(global, 'Date') .mockImplementation((...args: unknown[]) => args.length ? new realDate(...(args as [string])) : fixed, ); } afterEach(() => { jest.restoreAllMocks(); }); it('returns KEINE_TRACHT outside the bloom season', async () => { mockToday('2026-01-15'); prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue( new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }), ); const result = await weatherService.getTrachtprognose('apiary-1'); expect(result.rating).toBe('KEINE_TRACHT'); expect(result.season).toBe('Winterruhe'); }); it('returns GUT for warm, dry weather during bloom season', async () => { mockToday('2026-06-10'); prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); const days = Array.from({ length: 21 }, (_, index) => ({ date: `2026-06-${String(index + 1).padStart(2, '0')}`, tempMax: 24, tempMin: 14, precipitation: 0, })); fetchMock.mockResolvedValue( new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }), ); const result = await weatherService.getTrachtprognose('apiary-1'); expect(result.rating).toBe('GUT'); expect(result.season).toBe('Sommertracht'); }); it('returns SCHWACH for cold, wet weather during bloom season', async () => { mockToday('2026-04-10'); prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); const days = Array.from({ length: 21 }, (_, index) => ({ date: `2026-04-${String(index + 1).padStart(2, '0')}`, tempMax: 8, tempMin: 2, precipitation: 10, })); fetchMock.mockResolvedValue( new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }), ); const result = await weatherService.getTrachtprognose('apiary-1'); expect(result.rating).toBe('SCHWACH'); expect(result.season).toBe('Frühtracht'); }); it('returns UNBEKANNT during bloom season when there is no usable temperature data', async () => { mockToday('2026-06-10'); prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); fetchMock.mockResolvedValue( new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }), ); const result = await weatherService.getTrachtprognose('apiary-1'); expect(result.rating).toBe('UNBEKANNT'); expect(result.season).toBe('Sommertracht'); }); it('includes the historical average yield when past harvests exist for the same month', async () => { mockToday('2026-06-10'); prisma.apiary.findUnique.mockResolvedValue({ latitude: 52.5, longitude: 13.4, }); prisma.harvest.findMany.mockResolvedValue([ { date: new realDate('2025-06-15'), netWeight: 20 }, { date: new realDate('2024-06-20'), netWeight: 30 }, { date: new realDate('2025-09-01'), netWeight: 100 }, ]); fetchMock.mockResolvedValue( new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }), ); const result = await weatherService.getTrachtprognose('apiary-1'); expect(result.historicalAverageYieldKg).toBe(25); }); }); });