import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; // Prevent the module-level setInterval from running during tests vi.useFakeTimers(); // Prevent real HTTP requests vi.stubGlobal('fetch', vi.fn()); afterAll(() => vi.unstubAllGlobals()); import { estimateCondition, cacheKey, getWeather, getDetailedWeather, ApiError, type WeatherResult, } from '../../../src/services/weatherService'; // ── estimateCondition ──────────────────────────────────────────────────────── describe('estimateCondition', () => { describe('heavy precipitation (precipMm > 5)', () => { it('returns Snow when temp <= 0', () => { expect(estimateCondition(0, 6)).toBe('Snow'); expect(estimateCondition(-5, 10)).toBe('Snow'); }); it('returns Rain when temp > 0', () => { expect(estimateCondition(1, 6)).toBe('Rain'); expect(estimateCondition(20, 50)).toBe('Rain'); }); it('boundary: precipMm = 5.01 and temp = 0 -> Snow', () => { expect(estimateCondition(0, 5.01)).toBe('Snow'); }); it('boundary: precipMm = 5 is NOT heavy (exactly 5, not > 5) -> falls through', () => { // precipMm = 5 fails the > 5 check, falls to > 1 check -> Snow or Drizzle expect(estimateCondition(0, 5)).toBe('Snow'); // > 1 and temp <= 0 expect(estimateCondition(5, 5)).toBe('Drizzle'); // > 1 and temp > 0 }); }); describe('moderate precipitation (precipMm > 1)', () => { it('returns Snow when temp <= 0', () => { expect(estimateCondition(0, 2)).toBe('Snow'); expect(estimateCondition(-10, 1.5)).toBe('Snow'); }); it('returns Drizzle when temp > 0', () => { expect(estimateCondition(5, 2)).toBe('Drizzle'); expect(estimateCondition(15, 3)).toBe('Drizzle'); }); }); describe('light precipitation (precipMm > 0.3)', () => { it('returns Clouds regardless of temperature', () => { expect(estimateCondition(-5, 0.5)).toBe('Clouds'); expect(estimateCondition(25, 0.5)).toBe('Clouds'); }); it('boundary: precipMm = 0.31 -> Clouds', () => { expect(estimateCondition(20, 0.31)).toBe('Clouds'); }); it('boundary: precipMm = 0.3 is NOT light precipitation -> falls through', () => { // precipMm = 0.3 fails the > 0.3 check, falls to temperature check expect(estimateCondition(20, 0.3)).toBe('Clear'); // temp > 15 expect(estimateCondition(10, 0.3)).toBe('Clouds'); // temp <= 15 }); }); describe('dry conditions (precipMm <= 0.3)', () => { it('returns Clear when temp > 15', () => { expect(estimateCondition(16, 0)).toBe('Clear'); expect(estimateCondition(30, 0.1)).toBe('Clear'); }); it('returns Clouds when temp <= 15', () => { expect(estimateCondition(15, 0)).toBe('Clouds'); expect(estimateCondition(10, 0)).toBe('Clouds'); expect(estimateCondition(-5, 0)).toBe('Clouds'); }); it('boundary: temp = 15 -> Clouds (not > 15)', () => { expect(estimateCondition(15, 0)).toBe('Clouds'); }); }); }); // ── cacheKey ───────────────────────────────────────────────────────────────── describe('cacheKey', () => { it('rounds lat and lng to 2 decimal places', () => { expect(cacheKey('48.8566', '2.3522', '2024-06-15')).toBe('48.86_2.35_2024-06-15'); }); it('uses "current" when date is undefined', () => { expect(cacheKey('10.0', '20.0')).toBe('10.00_20.00_current'); }); it('handles negative coordinates', () => { expect(cacheKey('-33.8688', '151.2093', '2024-01-01')).toBe('-33.87_151.21_2024-01-01'); }); it('pads to 2 decimal places for round numbers', () => { expect(cacheKey('48', '2', '2024-01-01')).toBe('48.00_2.00_2024-01-01'); }); it('preserves the date string as-is', () => { expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate'); }); }); // ── Helpers ─────────────────────────────────────────────────────────────────── /** Build a minimal mock Response for fetch. */ function mockResponse(body: unknown, ok = true, status = 200): Response { return { ok, status, json: vi.fn().mockResolvedValue(body), } as unknown as Response; } /** ISO date string offset by `days` from now (fake-timer "now"). */ function dateOffset(days: number): string { const d = new Date(Date.now() + days * 24 * 60 * 60 * 1000); return d.toISOString().slice(0, 10); } // ── getWeather ──────────────────────────────────────────────────────────────── describe('getWeather', () => { // Use coordinates that are unique per describe block to avoid cross-test cache // pollution. Each nested describe uses a distinct lat so the module-level Map // never returns stale data from a sibling test. beforeEach(() => { vi.mocked(fetch).mockReset(); }); describe('with date — cache hit', () => { it('returns cached result without calling fetch', async () => { const date = dateOffset(2); const forecastBody = { daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(forecastBody)); // First call populates the cache const first = await getWeather('10.00', '20.00', date, 'en'); expect(fetch).toHaveBeenCalledTimes(1); vi.mocked(fetch).mockReset(); // Second call with identical arguments should be served from cache const second = await getWeather('10.00', '20.00', date, 'en'); expect(fetch).not.toHaveBeenCalled(); expect(second).toEqual(first); }); }); describe('with date — forecast path (diffDays -1 .. +16)', () => { it('returns a forecast WeatherResult for a date 3 days away', async () => { const date = dateOffset(3); const body = { daily: { time: [date], temperature_2m_max: [25], temperature_2m_min: [15], weathercode: [1], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('11.00', '21.00', date, 'en'); expect(result.type).toBe('forecast'); expect(result.temp).toBe(20); // (25+15)/2 expect(result.temp_max).toBe(25); expect(result.temp_min).toBe(15); expect(result.main).toBe('Clear'); // WMO code 1 expect(result.description).toBe('Mainly clear'); }); it('uses German descriptions when lang is "de"', async () => { const date = dateOffset(4); const body = { daily: { time: [date], temperature_2m_max: [10], temperature_2m_min: [5], weathercode: [3], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('11.01', '21.01', date, 'de'); expect(result.description).toBe('Bewolkt'); // German for code 3 }); it('falls back to "Clouds" for an unknown WMO code', async () => { const date = dateOffset(5); const body = { daily: { time: [date], temperature_2m_max: [10], temperature_2m_min: [5], weathercode: [999], // not in WMO_MAP }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('11.02', '21.02', date, 'en'); expect(result.main).toBe('Clouds'); }); it('throws ApiError when response.ok is false', async () => { const date = dateOffset(2); const body = { reason: 'rate limited' }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429)); vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429)); await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toThrow(ApiError); await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toMatchObject({ status: 429, message: 'rate limited', }); }); it('throws ApiError when data.error is true', async () => { const date = dateOffset(2); const body = { error: true, reason: 'invalid coordinates' }; // Need a fresh coordinate to avoid the cache from the previous test failure vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200)); await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError); }); it('falls through to climate path when date is not found in forecast data', async () => { // The forecast API returns data but NOT for our target date; the code // checks idx === -1 and falls into the diffDays > -1 climate branch. const date = dateOffset(3); const forecastBody = { daily: { time: ['1970-01-01'], // deliberately wrong date temperature_2m_max: [10], temperature_2m_min: [5], weathercode: [0], }, }; // Archive response for the climate fallback const refDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); const archiveBody = { daily: { time: ['some-date'], temperature_2m_max: [18], temperature_2m_min: [8], precipitation_sum: [0], }, }; vi.mocked(fetch) .mockResolvedValueOnce(mockResponse(forecastBody)) .mockResolvedValueOnce(mockResponse(archiveBody)); const result = await getWeather('13.00', '23.00', date, 'en'); expect(result.type).toBe('climate'); expect(fetch).toHaveBeenCalledTimes(2); }); }); describe('with date — past date (diffDays < -1)', () => { it('returns no_forecast error immediately without fetching', async () => { const date = dateOffset(-5); // 5 days in the past const result = await getWeather('14.00', '24.00', date, 'en'); expect(result.error).toBe('no_forecast'); expect(fetch).not.toHaveBeenCalled(); }); }); describe('with date — climate / archive path (diffDays > 16)', () => { it('returns a climate WeatherResult for a far-future date', async () => { const date = dateOffset(20); const body = { daily: { time: ['2025-01-01', '2025-01-02'], temperature_2m_max: [22, 24], temperature_2m_min: [12, 14], precipitation_sum: [0, 0.1], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('15.00', '25.00', date, 'en'); expect(result.type).toBe('climate'); expect(result.temp).toBe(18); // avg of (22+12)/2=17 and (24+14)/2=19 -> avg 18 expect(result.temp_max).toBe(23); expect(result.temp_min).toBe(13); }); it('throws ApiError when archive API response.ok is false', async () => { const date = dateOffset(20); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500)); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500)); await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toThrow(ApiError); await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toMatchObject({ status: 500 }); }); it('returns no_forecast when archive daily data is missing', async () => { const date = dateOffset(20); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); const result = await getWeather('15.02', '25.02', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('returns no_forecast when archive daily.time is empty', async () => { const date = dateOffset(20); const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], precipitation_sum: [] } }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('15.03', '25.03', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('returns no_forecast when all temperature entries are null', async () => { const date = dateOffset(20); const body = { daily: { time: ['2025-01-01'], temperature_2m_max: [null], temperature_2m_min: [null], precipitation_sum: [0], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('15.04', '25.04', date, 'en'); expect(result.error).toBe('no_forecast'); }); }); describe('without date — current weather path', () => { it('returns current WeatherResult', async () => { const body = { current: { temperature_2m: 18.7, weathercode: 2 }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('16.00', '26.00', undefined, 'en'); expect(result.type).toBe('current'); expect(result.temp).toBe(19); // Math.round(18.7) expect(result.main).toBe('Clouds'); // WMO code 2 expect(result.description).toBe('Partly cloudy'); }); it('uses German descriptions when lang is "de"', async () => { const body = { current: { temperature_2m: 10, weathercode: 45 } }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getWeather('16.01', '26.01', undefined, 'de'); expect(result.description).toBe('Nebel'); }); it('returns cached current weather on second identical call', async () => { const body = { current: { temperature_2m: 22, weathercode: 0 } }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const first = await getWeather('16.02', '26.02', undefined, 'en'); vi.mocked(fetch).mockReset(); const second = await getWeather('16.02', '26.02', undefined, 'en'); expect(fetch).not.toHaveBeenCalled(); expect(second).toEqual(first); }); it('throws ApiError when current weather API returns error', async () => { vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400)); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400)); await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toThrow(ApiError); await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toMatchObject({ status: 400 }); }); it('throws ApiError when data.error flag is set on current weather response', async () => { const body = { error: true, reason: 'quota exceeded' }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200)); await expect(getWeather('16.04', '26.04', undefined, 'en')).rejects.toThrow(ApiError); }); }); }); // ── getDetailedWeather ──────────────────────────────────────────────────────── describe('getDetailedWeather', () => { beforeEach(() => { vi.mocked(fetch).mockReset(); }); describe('cache hit', () => { it('returns cached result without calling fetch a second time', async () => { const date = dateOffset(5); const dailyBody = { daily: { time: [date], temperature_2m_max: [28], temperature_2m_min: [18], weathercode: [0], precipitation_sum: [0], precipitation_probability_max: [0], windspeed_10m_max: [10], sunrise: [`${date}T06:00`], sunset: [`${date}T20:00`], }, hourly: { time: [], temperature_2m: [] }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(dailyBody)); const first = await getDetailedWeather('30.00', '40.00', date, 'en'); vi.mocked(fetch).mockReset(); const second = await getDetailedWeather('30.00', '40.00', date, 'en'); expect(fetch).not.toHaveBeenCalled(); expect(second).toEqual(first); }); }); describe('forecast path (diffDays <= 16)', () => { it('returns a detailed forecast WeatherResult with hourly data', async () => { const date = dateOffset(6); const body = { daily: { time: [date], temperature_2m_max: [30], temperature_2m_min: [20], weathercode: [80], precipitation_sum: [5], precipitation_probability_max: [70], windspeed_10m_max: [15], sunrise: [`${date}T05:45`], sunset: [`${date}T21:15`], }, hourly: { time: [`${date}T12:00`, `${date}T13:00`], temperature_2m: [28, 29], precipitation_probability: [60, 65], precipitation: [1.2, 0.8], weathercode: [80, 81], windspeed_10m: [12, 14], relativehumidity_2m: [70, 68], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('31.00', '41.00', date, 'en'); expect(result.type).toBe('forecast'); expect(result.temp).toBe(25); // (30+20)/2 expect(result.temp_max).toBe(30); expect(result.temp_min).toBe(20); expect(result.main).toBe('Rain'); // WMO code 80 expect(result.precipitation_sum).toBe(5); expect(result.precipitation_probability_max).toBe(70); expect(result.wind_max).toBe(15); expect(result.sunrise).toBe('05:45'); expect(result.sunset).toBe('21:15'); expect(result.hourly).toHaveLength(2); expect(result.hourly![0].temp).toBe(28); expect(result.hourly![0].precipitation_probability).toBe(60); expect(result.hourly![1].main).toBe('Rain'); // WMO code 81 }); it('returns no_forecast when daily data is missing', async () => { const date = dateOffset(7); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); const result = await getDetailedWeather('31.01', '41.01', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('returns no_forecast when daily.time is empty', async () => { const date = dateOffset(7); const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('31.02', '41.02', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('throws ApiError when forecast API returns !ok', async () => { const date = dateOffset(8); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404)); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404)); await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toThrow(ApiError); await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toMatchObject({ status: 404 }); }); it('throws ApiError when data.error flag is set', async () => { const date = dateOffset(9); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'bad coords' })); await expect(getDetailedWeather('31.04', '41.04', date, 'en')).rejects.toThrow(ApiError); }); it('handles missing hourly block gracefully', async () => { const date = dateOffset(10); const body = { daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0], precipitation_sum: [0], precipitation_probability_max: [0], windspeed_10m_max: [5], sunrise: [`${date}T06:00`], sunset: [`${date}T20:00`], }, // no hourly field }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('31.05', '41.05', date, 'en'); expect(result.type).toBe('forecast'); expect(result.hourly).toEqual([]); }); }); describe('climate / archive path (diffDays > 16)', () => { it('returns a detailed climate WeatherResult with hourly data', async () => { const date = dateOffset(20); const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); const refYear = refDate.getFullYear() - 1; const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`; const body = { daily: { time: [refDateStr], temperature_2m_max: [26], temperature_2m_min: [16], weathercode: [63], precipitation_sum: [8], windspeed_10m_max: [20], sunrise: [`${refDateStr}T06:30`], sunset: [`${refDateStr}T20:30`], }, hourly: { time: [`${refDateStr}T10:00`, `${refDateStr}T11:00`], temperature_2m: [22, 24], precipitation: [2, 1], weathercode: [63, 61], windspeed_10m: [18, 16], relativehumidity_2m: [80, 75], }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('32.00', '42.00', date, 'en'); expect(result.type).toBe('climate'); expect(result.temp).toBe(21); // (26+16)/2 expect(result.temp_max).toBe(26); expect(result.temp_min).toBe(16); expect(result.main).toBe('Rain'); // WMO code 63 expect(result.description).toBe('Rain'); // WMO_DESCRIPTION_EN[63] expect(result.precipitation_sum).toBe(8); expect(result.wind_max).toBe(20); expect(result.sunrise).toBe('06:30'); expect(result.sunset).toBe('20:30'); expect(result.hourly).toHaveLength(2); expect(result.hourly![0].temp).toBe(22); expect(result.hourly![0].precipitation).toBe(2); expect(result.hourly![1].main).toBe('Rain'); // WMO code 61 }); it('uses German descriptions when lang is "de"', async () => { const date = dateOffset(20); const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); const refYear = refDate.getFullYear() - 1; const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`; const body = { daily: { time: [refDateStr], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0], precipitation_sum: [0], windspeed_10m_max: [5], }, hourly: { time: [], temperature_2m: [] }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('32.01', '42.01', date, 'de'); expect(result.description).toBe('Klar'); // German WMO_DESCRIPTION_DE[0] }); it('returns no_forecast when archive daily data is missing', async () => { const date = dateOffset(20); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); const result = await getDetailedWeather('32.02', '42.02', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('returns no_forecast when archive daily.time is empty', async () => { const date = dateOffset(20); const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('32.03', '42.03', date, 'en'); expect(result.error).toBe('no_forecast'); }); it('throws ApiError when archive API returns !ok', async () => { const date = dateOffset(20); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503)); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503)); await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toThrow(ApiError); await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toMatchObject({ status: 503 }); }); it('throws ApiError when archive data.error flag is set', async () => { const date = dateOffset(20); vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'quota exceeded' })); await expect(getDetailedWeather('32.05', '42.05', date, 'en')).rejects.toThrow(ApiError); }); it('falls back to estimateCondition when archive weathercode is undefined', async () => { // When daily.weathercode[0] is undefined, the code falls back to // estimateCondition(avgTemp, precipitation_sum) const date = dateOffset(20); const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); const refYear = refDate.getFullYear() - 1; const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`; const body = { daily: { time: [refDateStr], temperature_2m_max: [20], temperature_2m_min: [10], // weathercode intentionally omitted — will be undefined precipitation_sum: [10], // > 5 mm and temp > 0 -> 'Rain' windspeed_10m_max: [5], }, hourly: { time: [], temperature_2m: [] }, }; vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); const result = await getDetailedWeather('32.06', '42.06', date, 'en'); // undefined code -> WMO_MAP[undefined] is undefined -> falls back to estimateCondition // avgTemp = (20+10)/2 = 15, precip = 10 > 5 and temp 15 > 0 -> 'Rain' expect(result.main).toBe('Rain'); }); }); });