// 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(); }); });