Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user