Files
vyndr/tests/unit/weatherService.test.js
T

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