Temperature and rain were already tracked, but wind matters just as much for whether it's safe/sensible to open a hive - bees get more defensive and frames are harder to handle in wind. Adds wind_speed_10m_max to the Open-Meteo request and a derived inspectionSuitable flag per day (>=15C, <=1mm rain, <=25km/h wind; null when a value is missing rather than guessing). The apiary weather card now shows a clear "good/not good for a Durchsicht today" banner plus wind alongside each day's forecast. Co-Authored-By: Claude Sonnet 5 <[email protected]>
290 lines
8.7 KiB
TypeScript
290 lines
8.7 KiB
TypeScript
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<typeof fetch>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|