167996d99a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.6 KiB
JavaScript
157 lines
5.6 KiB
JavaScript
// weatherService (Session 15) — Open-Meteo proxy with cache + timeout.
|
|
|
|
const mockAxiosGet = jest.fn();
|
|
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
|
|
|
const mockCache = new Map();
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null),
|
|
cacheSet: async (k, v) => { mockCache.set(k, v); return true; },
|
|
cacheDel: async (k) => { mockCache.delete(k); return true; },
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
const ws = require('../../src/services/weatherService');
|
|
|
|
beforeEach(() => {
|
|
mockAxiosGet.mockReset();
|
|
mockCache.clear();
|
|
});
|
|
|
|
describe('weatherService.getWeather', () => {
|
|
test('invalid coordinates return null without touching the network', async () => {
|
|
expect(await ws.getWeather(null, null)).toBeNull();
|
|
expect(await ws.getWeather(NaN, 0)).toBeNull();
|
|
expect(await ws.getWeather(undefined, undefined)).toBeNull();
|
|
expect(mockAxiosGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('happy path — projects Open-Meteo current block to our flat shape', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({
|
|
data: {
|
|
current: {
|
|
temperature_2m: 78.5,
|
|
wind_speed_10m: 12.3,
|
|
wind_direction_10m: 165,
|
|
precipitation: 0,
|
|
},
|
|
},
|
|
});
|
|
const w = await ws.getWeather(39.7559, -104.9942);
|
|
expect(w).toMatchObject({
|
|
temp_f: 78.5,
|
|
wind_mph: 12.3,
|
|
wind_dir: 165,
|
|
precip_mm: 0,
|
|
});
|
|
expect(typeof w._fetched_at).toBe('string');
|
|
});
|
|
|
|
test('Fahrenheit + mph units requested explicitly', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } });
|
|
await ws.getWeather(40, -75);
|
|
const [, opts] = mockAxiosGet.mock.calls[0];
|
|
expect(opts.params.temperature_unit).toBe('fahrenheit');
|
|
expect(opts.params.wind_speed_unit).toBe('mph');
|
|
});
|
|
|
|
test('second call within the same hour hits cache', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({
|
|
data: { current: { temperature_2m: 80, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } },
|
|
});
|
|
await ws.getWeather(39.7559, -104.9942);
|
|
await ws.getWeather(39.7559, -104.9942);
|
|
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('coordinate-precision collapse — venues within ~1km share cache', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({
|
|
data: { current: { temperature_2m: 70, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } },
|
|
});
|
|
// Two lat/lon pairs that round to the same 2-decimal cache key.
|
|
await ws.getWeather(40.815, -74.075); // round to 40.82 / -74.07
|
|
await ws.getWeather(40.823, -74.078); // round to 40.82 / -74.08 — different lon key, different fetch
|
|
// 40.82/-74.07 vs 40.82/-74.08 → different keys, two fetches expected
|
|
expect(mockAxiosGet).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('upstream throw → returns null (graceful)', async () => {
|
|
mockAxiosGet.mockRejectedValueOnce(new Error('timeout'));
|
|
const w = await ws.getWeather(40, -75);
|
|
expect(w).toBeNull();
|
|
});
|
|
|
|
test('upstream returns response without `current` block → null', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
|
const w = await ws.getWeather(40, -75);
|
|
expect(w).toBeNull();
|
|
});
|
|
|
|
test('individual missing fields default to null without crashing', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({
|
|
data: {
|
|
current: { temperature_2m: 60 /* wind/precip missing */ },
|
|
},
|
|
});
|
|
const w = await ws.getWeather(40, -75);
|
|
expect(w.temp_f).toBe(60);
|
|
expect(w.wind_mph).toBeNull();
|
|
expect(w.wind_dir).toBeNull();
|
|
expect(w.precip_mm).toBeNull();
|
|
});
|
|
|
|
test('5-second timeout configured', async () => {
|
|
mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } });
|
|
await ws.getWeather(40, -75);
|
|
const [, opts] = mockAxiosGet.mock.calls[0];
|
|
expect(opts.timeout).toBe(ws.__internals.HTTP_TIMEOUT_MS);
|
|
expect(opts.timeout).toBeLessThanOrEqual(5000);
|
|
});
|
|
});
|
|
|
|
describe('venueCoordinates', () => {
|
|
const venues = require('../../src/data/venueCoordinates');
|
|
|
|
test('all 30 MLB venues defined with finite lat/lon', () => {
|
|
const codes = Object.keys(venues.MLB_VENUES);
|
|
expect(codes.length).toBe(30);
|
|
for (const [code, v] of Object.entries(venues.MLB_VENUES)) {
|
|
expect(Number.isFinite(v.lat)).toBe(true);
|
|
expect(Number.isFinite(v.lon)).toBe(true);
|
|
expect(typeof v.dome).toBe('boolean');
|
|
expect(typeof v.name).toBe('string');
|
|
// Sanity: every MLB venue is in roughly the right hemisphere.
|
|
expect(v.lat).toBeGreaterThan(20);
|
|
expect(v.lat).toBeLessThan(50);
|
|
expect(v.lon).toBeLessThan(-65);
|
|
expect(v.lon).toBeGreaterThan(-125);
|
|
expect(code).toMatch(/^[A-Z]{2,3}$/);
|
|
}
|
|
});
|
|
|
|
test('all 16 WC 2026 venues defined', () => {
|
|
expect(Object.keys(venues.WC_VENUES).length).toBe(16);
|
|
});
|
|
|
|
test('dome stadiums correctly flagged', () => {
|
|
// Tampa Bay's Tropicana is the canonical fixed-roof dome in the AL.
|
|
expect(venues.MLB_VENUES.TB.dome).toBe(true);
|
|
// Coors is open-air.
|
|
expect(venues.MLB_VENUES.COL.dome).toBe(false);
|
|
// Toronto's Rogers Centre is retractable — treated as dome.
|
|
expect(venues.MLB_VENUES.TOR.dome).toBe(true);
|
|
});
|
|
|
|
test('getMlbVenue lookup', () => {
|
|
expect(venues.getMlbVenue('NYY').name).toBe('Yankee Stadium');
|
|
expect(venues.getMlbVenue('xyz')).toBeNull();
|
|
expect(venues.getMlbVenue(null)).toBeNull();
|
|
});
|
|
|
|
test('getWcVenueCoords lookup', () => {
|
|
expect(venues.getWcVenueCoords('MetLife Stadium').dome).toBe(false);
|
|
expect(venues.getWcVenueCoords('Estadio Azteca').lat).toBeCloseTo(19.30, 1);
|
|
expect(venues.getWcVenueCoords('Bogus')).toBeNull();
|
|
});
|
|
});
|