Files
TREK/server/tests/unit/services/weatherService.test.ts
T
jubnl b4922322ae test: expand test suite to 87.3% backend coverage
Add new integration test files covering previously untested routes:
- categories.test.ts — GET /api/categories
- oidc.test.ts — full OIDC login flow (callback, state, errors)
- settings.test.ts — GET/PUT /api/settings, bulk save
- tags.test.ts — CRUD for trip tags
- todo.test.ts — todo items CRUD and reorder

Add new unit test files covering service-layer logic:
- adminService.test.ts — user/invite management, packing templates, OIDC settings
- atlasService.test.ts — atlas search and place enrichment
- authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA)
- backupService.test.ts — export/import/restore logic
- categoryService.test.ts — category CRUD
- dayService.test.ts — day management and accommodation helpers
- mapsService.test.ts — route/directions helpers
- oidcService.test.ts — OIDC state, auth code, role resolution, user upsert
- packingService.test.ts — packing item/bag/template operations
- placeService.test.ts — place CRUD and tag attachment
- settingsService.test.ts — settings get/set/bulk
- tagService.test.ts — tag CRUD
- todoService.test.ts — todo CRUD and reorder
- tripService.test.ts — trip CRUD, member management, archiving
- vacayService.test.ts — vacay integration helpers
- tripAccess.test.ts (middleware) — requireTripAccess middleware

Expand existing integration and unit test files with additional cases
across admin, atlas, auth, backup, collab, days, files, maps, memories
(Immich/Synology), notifications, places, reservations, share, vacay,
weather, auth middleware, ephemeral tokens, notification preferences,
permissions, SSRF guard, and WebSocket connection tests.

Update test helpers (factories.ts, test-db.ts) with new factory
functions and seed data required by the expanded suite.

Fix minor issues in server/src/routes/reservations.ts and
server/src/services/atlasService.ts surfaced by new test coverage.

Update sonar-project.properties to reflect new coverage thresholds.
2026-04-06 20:08:30 +02:00

697 lines
26 KiB
TypeScript

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