Trek_CN/server/tests/unit/services/weatherService.test.ts

720 lines
26 KiB
TypeScript
Raw Normal View History

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-07 02:06:46 +08:00
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
// Prevent the module-level setInterval from running during tests
vi.useFakeTimers();
refactor(server): replace node-fetch with native fetch + undici, fix photo integrations Replace node-fetch v2 with Node 22's built-in fetch API across the entire server. Add undici as an explicit dependency to provide the dispatcher API needed for DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files that used a plain `import fetch from 'node-fetch'` are updated to use the global. The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher using an undici Agent, with correct handling of the `all: true` lookup callback required by Node 18+. The collabService dynamic require() and notifications agent option are updated to use the dispatcher pattern. Test mocks are migrated from vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are updated to use Web ReadableStream instead of Node Readable. Fix several bugs in the Synology and Immich photo integrations: - pipeAsset: guard against setting headers after stream has already started - _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null instead of propagating success(null) downstream - _requestSynologyApi: return retrySession error (not stale session) on retry failure; also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119 - searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no total); hasMore correctly uses allItems.length === limit - _splitPackedSynologyId: validate cache_key format before use; callers return 400 - getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as a missing-credentials condition rather than casting null to string - Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
2026-04-06 03:11:43 +08:00
// Prevent real HTTP requests
vi.stubGlobal('fetch', vi.fn());
afterAll(() => vi.unstubAllGlobals());
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-07 02:06:46 +08:00
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');
});
});
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-07 02:06:46 +08:00
// ── 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 forecast-type WeatherResult from the archive API', async () => {
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-07 02:06:46 +08:00
const date = dateOffset(-5); // 5 days in the past
const archiveBody = {
daily: {
time: [date],
temperature_2m_max: [18],
temperature_2m_min: [10],
weathercode: [2],
precipitation_sum: [0],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(archiveBody));
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-07 02:06:46 +08:00
const result = await getWeather('14.00', '24.00', date, 'en');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(14);
expect(result.temp_max).toBe(18);
expect(result.temp_min).toBe(10);
expect(fetch).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com');
});
it('returns no_forecast error when archive has no data for the date', async () => {
const date = dateOffset(-5);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }));
const result = await getWeather('14.01', '24.01', date, 'en');
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-07 02:06:46 +08:00
expect(result.error).toBe('no_forecast');
});
});
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');
});
});
});