mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
b4922322ae
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.
697 lines
26 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|