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:
Kev
2026-06-11 16:21:18 -04:00
parent f5d79cf70d
commit 167996d99a
20 changed files with 1550 additions and 28 deletions
+6
View File
@@ -43,6 +43,12 @@ jest.mock('axios');
process.env.ODDS_API_KEY = 'test';
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
// Session 15 — PRICE_MAP was hardened to drop the fake-string
// fallbacks (would 400 from Stripe in production). The integration
// tests need real-looking Stripe price IDs in env so getPriceId
// doesn't return the unconfigured sentinel and 503 the happy path.
process.env.STRIPE_PRICE_ANALYST = process.env.STRIPE_PRICE_ANALYST || 'price_test_analyst';
process.env.STRIPE_PRICE_DESK = process.env.STRIPE_PRICE_DESK || 'price_test_desk';
const app = require('../../src/app');
+11 -2
View File
@@ -133,7 +133,7 @@ describe('computeFeaturesForProp — graceful degradation', () => {
expect(out.meta.gameId).toBeNull();
});
test('feature fetch throws → empty features + error noted, no crash', async () => {
test('feature fetch throws → ESPN fields empty, but static-context augmentation still surfaces (Session 15)', async () => {
mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' };
mockAxiosGet.mockResolvedValue(nbaScoreboard([game('e2', 'NYK', 'BOS')]));
mockFeatures.throws = true;
@@ -141,7 +141,16 @@ describe('computeFeaturesForProp — graceful degradation', () => {
player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.meta.errors).toContain('no_features_computed');
expect(out.features).toEqual({});
// Session 15 — static lookups (pace factor, park factor) populate
// regardless of ESPN fetch state. The contract used to be
// "features is empty when ESPN fails"; the contract is now
// "features may contain static context even when ESPN fails."
// ESPN-derived fields (l5_avg, opp_rank_stat, ...) ARE absent.
expect(out.features.l5_avg).toBeUndefined();
expect(out.features.opp_rank_stat).toBeUndefined();
// Pace factor lookup is static and stable for known team codes.
expect(out.features.pace_factor).toBe(95); // NYK pace
expect(out.features.opp_pace_factor).toBe(99); // BOS pace
expect(out.trap).toBeDefined();
});
+89
View File
@@ -0,0 +1,89 @@
// MLB context helpers (Session 15) — pitcher handedness + lineup PA.
const { platoonAdvantage, projectedPA, __internals } = require('../../src/services/intelligence/mlbContext');
describe('platoonAdvantage', () => {
test('LHP vs RHB → batter has advantage', () => {
expect(platoonAdvantage('L', 'R')).toBe(true);
});
test('RHP vs LHB → batter has advantage', () => {
expect(platoonAdvantage('R', 'L')).toBe(true);
});
test('LHP vs LHB → pitcher has advantage', () => {
expect(platoonAdvantage('L', 'L')).toBe(false);
});
test('RHP vs RHB → pitcher has advantage', () => {
expect(platoonAdvantage('R', 'R')).toBe(false);
});
test('switch hitter ALWAYS has the edge (vs LHP)', () => {
expect(platoonAdvantage('L', 'S')).toBe(true);
});
test('switch hitter ALWAYS has the edge (vs RHP)', () => {
expect(platoonAdvantage('R', 'S')).toBe(true);
});
test('null pitcher → null', () => {
expect(platoonAdvantage(null, 'R')).toBeNull();
});
test('null batter → null', () => {
expect(platoonAdvantage('L', null)).toBeNull();
});
test('invalid input → null (does not throw)', () => {
expect(platoonAdvantage('Z', 'R')).toBeNull();
expect(platoonAdvantage('L', 'X')).toBeNull();
});
test('case-insensitive', () => {
expect(platoonAdvantage('l', 'r')).toBe(true);
expect(platoonAdvantage('Right', 'Left')).toBe(true);
});
test('whitespace tolerant', () => {
expect(platoonAdvantage(' L ', ' R ')).toBe(true);
});
});
describe('projectedPA', () => {
test('leadoff (1) sees the most PAs (~4.7)', () => {
expect(projectedPA(1)).toBeGreaterThan(4.6);
expect(projectedPA(1)).toBeLessThan(4.8);
});
test('9-hole sees the fewest PAs (~3.7)', () => {
expect(projectedPA(9)).toBeGreaterThan(3.6);
expect(projectedPA(9)).toBeLessThan(3.8);
});
test('PA decreases monotonically by slot', () => {
let prev = Infinity;
for (let i = 1; i <= 9; i += 1) {
const pa = projectedPA(i);
expect(pa).toBeLessThan(prev);
prev = pa;
}
});
test('numeric string input is accepted', () => {
expect(projectedPA('3')).toBeCloseTo(4.43, 2);
});
test('out-of-range returns null', () => {
expect(projectedPA(0)).toBeNull();
expect(projectedPA(10)).toBeNull();
expect(projectedPA(-1)).toBeNull();
});
test('null / undefined / non-numeric → null', () => {
expect(projectedPA(null)).toBeNull();
expect(projectedPA(undefined)).toBeNull();
expect(projectedPA('NaN')).toBeNull();
});
test('non-integer (e.g. 5.5) → null', () => {
expect(projectedPA(5.5)).toBeNull();
});
});
describe('normalizeHand', () => {
test('accepts L, R, S in upper/lower case', () => {
expect(__internals.normalizeHand('l')).toBe('L');
expect(__internals.normalizeHand('R')).toBe('R');
expect(__internals.normalizeHand('Switch')).toBe('S');
});
test('rejects unknown letters', () => {
expect(__internals.normalizeHand('X')).toBeNull();
expect(__internals.normalizeHand('')).toBeNull();
expect(__internals.normalizeHand(null)).toBeNull();
});
});
+70
View File
@@ -0,0 +1,70 @@
// NBA pace factors (Session 15) — static lookup table tests.
const pace = require('../../src/data/paceFactors');
describe('PACE_FACTORS', () => {
test('covers all 30 NBA teams', () => {
expect(Object.keys(pace.PACE_FACTORS).length).toBe(30);
});
test('every entry is a finite integer near 100', () => {
for (const [code, val] of Object.entries(pace.PACE_FACTORS)) {
expect(typeof val).toBe('number');
expect(Number.isFinite(val)).toBe(true);
expect(val).toBeGreaterThanOrEqual(90);
expect(val).toBeLessThanOrEqual(110);
expect(code).toMatch(/^[A-Z]{2,3}$/);
}
});
test('Indiana / Sacramento / Atlanta are at the fast end', () => {
expect(pace.PACE_FACTORS.IND).toBeGreaterThanOrEqual(103);
expect(pace.PACE_FACTORS.SAC).toBeGreaterThanOrEqual(103);
expect(pace.PACE_FACTORS.ATL).toBeGreaterThanOrEqual(102);
});
test('Orlando / New York / Cleveland are at the slow end', () => {
expect(pace.PACE_FACTORS.ORL).toBeLessThanOrEqual(96);
expect(pace.PACE_FACTORS.NYK).toBeLessThanOrEqual(96);
expect(pace.PACE_FACTORS.CLE).toBeLessThanOrEqual(98);
});
});
describe('getPaceFactor', () => {
test('returns the value for known teams', () => {
expect(pace.getPaceFactor('IND')).toBe(pace.PACE_FACTORS.IND);
});
test('case-insensitive + trimmed', () => {
expect(pace.getPaceFactor(' ind ')).toBe(pace.PACE_FACTORS.IND);
expect(pace.getPaceFactor('sac')).toBe(pace.PACE_FACTORS.SAC);
});
test('null for unknown', () => {
expect(pace.getPaceFactor('XYZ')).toBeNull();
expect(pace.getPaceFactor('')).toBeNull();
expect(pace.getPaceFactor(null)).toBeNull();
});
describe('legacy team aliases', () => {
test('NJN resolves to BKN', () => {
expect(pace.getPaceFactor('NJN')).toBe(pace.PACE_FACTORS.BKN);
});
test('NOH resolves to NOP', () => {
expect(pace.getPaceFactor('NOH')).toBe(pace.PACE_FACTORS.NOP);
});
test('SEA resolves to OKC', () => {
expect(pace.getPaceFactor('SEA')).toBe(pace.PACE_FACTORS.OKC);
});
test('CHO resolves to CHA', () => {
expect(pace.getPaceFactor('CHO')).toBe(pace.PACE_FACTORS.CHA);
});
});
});
describe('immutability', () => {
test('PACE_FACTORS frozen', () => {
expect(Object.isFrozen(pace.PACE_FACTORS)).toBe(true);
});
test('ALIASES frozen', () => {
expect(Object.isFrozen(pace.ALIASES)).toBe(true);
});
});
+95
View File
@@ -0,0 +1,95 @@
// MLB park factors (Session 15) — pin the membership list + assert
// the expected magnitude of the headline parks. Park factors shift
// year-to-year but the directional signal (Coors hot, Oracle cold)
// is stable; the test catches obvious typos.
const pf = require('../../src/data/parkFactors');
describe('parkFactors', () => {
test('covers all 30 MLB teams', () => {
const codes = Object.keys(pf.PARK_FACTORS);
expect(codes.length).toBe(30);
});
test('every entry has hr/h/r as finite numbers', () => {
for (const [code, vals] of Object.entries(pf.PARK_FACTORS)) {
expect(typeof code).toBe('string');
expect(code).toMatch(/^[A-Z]{2,3}$/);
expect(Number.isFinite(vals.hr)).toBe(true);
expect(Number.isFinite(vals.h)).toBe(true);
expect(Number.isFinite(vals.r)).toBe(true);
}
});
test('Coors Field is the most extreme HR park', () => {
const hrs = Object.values(pf.PARK_FACTORS).map((p) => p.hr);
const maxHr = Math.max(...hrs);
expect(pf.PARK_FACTORS.COL.hr).toBe(maxHr);
expect(pf.PARK_FACTORS.COL.hr).toBeGreaterThan(120);
});
test('Oracle Park (SF) suppresses HRs heavily', () => {
expect(pf.PARK_FACTORS.SF.hr).toBeLessThan(95);
});
test('Coors also boosts hits and runs', () => {
expect(pf.PARK_FACTORS.COL.h).toBeGreaterThan(100);
expect(pf.PARK_FACTORS.COL.r).toBeGreaterThan(100);
});
test('most parks land within ±15% of neutral', () => {
// Coors + SF are deliberate outliers; check the rest cluster.
const codes = Object.keys(pf.PARK_FACTORS).filter((c) => c !== 'COL' && c !== 'SF');
for (const code of codes) {
const { hr, h, r } = pf.PARK_FACTORS[code];
expect(hr).toBeGreaterThanOrEqual(85);
expect(hr).toBeLessThanOrEqual(115);
expect(h).toBeGreaterThanOrEqual(90);
expect(h).toBeLessThanOrEqual(110);
expect(r).toBeGreaterThanOrEqual(90);
expect(r).toBeLessThanOrEqual(110);
}
});
});
describe('getParkFactor', () => {
test('returns the row for known teams', () => {
expect(pf.getParkFactor('NYY').hr).toBe(pf.PARK_FACTORS.NYY.hr);
expect(pf.getParkFactor('COL').hr).toBeGreaterThan(120);
});
test('case-insensitive', () => {
expect(pf.getParkFactor('nyy').hr).toBeGreaterThan(0);
expect(pf.getParkFactor('Col')).toBe(pf.PARK_FACTORS.COL);
});
test('whitespace-tolerant', () => {
expect(pf.getParkFactor(' COL ')).toBeTruthy();
});
test('returns null for unknown / empty', () => {
expect(pf.getParkFactor('XYZ')).toBeNull();
expect(pf.getParkFactor('')).toBeNull();
expect(pf.getParkFactor(null)).toBeNull();
expect(pf.getParkFactor(undefined)).toBeNull();
});
});
describe('getParkFactorOrNeutral', () => {
test('returns the row when known', () => {
expect(pf.getParkFactorOrNeutral('NYY')).toBe(pf.PARK_FACTORS.NYY);
});
test('falls back to neutral 100s when unknown', () => {
expect(pf.getParkFactorOrNeutral('XYZ')).toEqual({ hr: 100, h: 100, r: 100 });
expect(pf.getParkFactorOrNeutral(null)).toEqual({ hr: 100, h: 100, r: 100 });
});
});
describe('immutability', () => {
test('PARK_FACTORS is frozen at the top level', () => {
expect(Object.isFrozen(pf.PARK_FACTORS)).toBe(true);
});
test('NEUTRAL is frozen', () => {
expect(Object.isFrozen(pf.NEUTRAL)).toBe(true);
});
});
+9
View File
@@ -1,4 +1,13 @@
process.env.STRIPE_SECRET_KEY = 'sk_test_dummy';
// Session 15 — the production fallback strings ('price_analyst_monthly'
// etc.) were dropped because they'd 400 from Stripe in live mode. Tests
// that assert getPriceId returns a string containing 'analyst' /
// 'founder' must now provide the env values BEFORE requiring the
// module (PRICE_MAP is frozen at require time).
process.env.STRIPE_PRICE_ANALYST = 'price_test_analyst_monthly';
process.env.STRIPE_PRICE_ANALYST_FOUNDER = 'price_test_analyst_founder';
process.env.STRIPE_PRICE_DESK = 'price_test_desk_monthly';
process.env.STRIPE_PRICE_DESK_FOUNDER = 'price_test_desk_founder';
// Default mock for the founder-code / price-id tests (no DB interaction).
// Webhook tests below replace the implementation per-test.
+161
View File
@@ -0,0 +1,161 @@
// Tank01 daily prefetch (Session 15) — orchestrator that pulls the
// Redis cache keys session 14's augmentor reads. Tests cover:
// - graceful skip when adapter has no API key
// - budget cap respected
// - dry-run suppresses adapter calls
// - final-status box scores get pulled, non-final skipped
// - empty slate doesn't crash
const mockNbaGames = jest.fn();
const mockNbaBox = jest.fn();
const mockNbaOdds = jest.fn();
const mockNbaHasKey = jest.fn(() => true);
jest.mock('../../src/services/adapters/tank01NbaAdapter', () => ({
getNBAGamesForDate: (...a) => mockNbaGames(...a),
getNBABoxScore: (...a) => mockNbaBox(...a),
getNBABettingOdds: (...a) => mockNbaOdds(...a),
hasApiKey: (...a) => mockNbaHasKey(...a),
}));
const mockMlbSlate = jest.fn();
const mockMlbBox = jest.fn();
const mockMlbBvp = jest.fn();
const mockMlbHasKey = jest.fn(() => true);
jest.mock('../../src/services/adapters/tank01MlbAdapter', () => ({
getMLBDailyScoreboard: (...a) => mockMlbSlate(...a),
getMLBBoxScore: (...a) => mockMlbBox(...a),
getMLBBatterVsPitcher: (...a) => mockMlbBvp(...a),
hasApiKey: (...a) => mockMlbHasKey(...a),
}));
const prefetch = require('../../scripts/tank01-prefetch');
beforeEach(() => {
mockNbaGames.mockReset();
mockNbaBox.mockReset();
mockNbaOdds.mockReset();
mockNbaHasKey.mockReset().mockReturnValue(true);
mockMlbSlate.mockReset();
mockMlbBox.mockReset();
mockMlbBvp.mockReset();
mockMlbHasKey.mockReset().mockReturnValue(true);
});
describe('parseArgs', () => {
test('defaults', () => {
const a = prefetch.__internals.parseArgs(['node', 'script']);
expect(a.maxRequests).toBe(prefetch.__internals.DEFAULT_BUDGET);
expect(a.dryRun).toBe(false);
expect(a.sports).toEqual(['nba', 'mlb']);
});
test('--max=N', () => {
expect(prefetch.__internals.parseArgs(['node', 's', '--max=25']).maxRequests).toBe(25);
});
test('--max ignores non-numeric / non-positive', () => {
expect(prefetch.__internals.parseArgs(['node', 's', '--max=0']).maxRequests).toBe(80);
expect(prefetch.__internals.parseArgs(['node', 's', '--max=foo']).maxRequests).toBe(80);
});
test('--dry-run', () => {
expect(prefetch.__internals.parseArgs(['node', 's', '--dry-run']).dryRun).toBe(true);
});
test('--sports filter', () => {
expect(prefetch.__internals.parseArgs(['node', 's', '--sports=mlb']).sports).toEqual(['mlb']);
});
});
describe('budget tracker', () => {
test('counts spend, refuses past cap', () => {
const b = prefetch.__internals.makeBudget(3);
expect(b.canSpend()).toBe(true);
b.spend(); b.spend(); b.spend();
expect(b.canSpend()).toBe(false);
expect(b.spent()).toBe(3);
});
});
describe('main — NBA path', () => {
test('skips entirely when adapter has no API key', async () => {
mockNbaHasKey.mockReturnValueOnce(false);
mockMlbHasKey.mockReturnValueOnce(false);
const r = await prefetch.main(['node', 'script', '--sports=nba']);
expect(r.nba.skipped).toBe('no_key');
expect(mockNbaGames).not.toHaveBeenCalled();
});
test('pulls slate, then box score per FINAL game, plus odds once', async () => {
mockNbaGames.mockResolvedValueOnce([
{ gameId: 'G1', gameStatus: 'Final' },
{ gameId: 'G2', gameStatus: 'InProgress' }, // skip
{ gameId: 'G3', gameStatus: 'Final' },
]);
mockNbaBox.mockResolvedValue({});
mockNbaOdds.mockResolvedValueOnce({});
const r = await prefetch.main(['node', 'script', '--sports=nba']);
expect(r.nba.games).toBe(3);
expect(r.nba.boxscores).toBe(2); // only the Finals
expect(r.nba.odds).toBe(true);
expect(mockNbaBox).toHaveBeenCalledTimes(2);
});
test('budget cap stops fetches mid-loop', async () => {
mockNbaGames.mockResolvedValueOnce(
Array.from({ length: 10 }, (_, i) => ({ gameId: `G${i}`, gameStatus: 'Final' })),
);
mockNbaBox.mockResolvedValue({});
mockNbaOdds.mockResolvedValueOnce({});
// Budget = 4: one for getNBAGamesForDate, leaves 3 box-score
// slots (no odds — budget exhausted first).
const r = await prefetch.main(['node', 'script', '--sports=nba', '--max=4']);
expect(mockNbaGames).toHaveBeenCalledTimes(1);
expect(mockNbaBox).toHaveBeenCalledTimes(3);
expect(mockNbaOdds).not.toHaveBeenCalled();
expect(r.requestsSpent).toBe(4);
});
test('dry-run skips ALL adapter calls', async () => {
const r = await prefetch.main(['node', 'script', '--dry-run']);
expect(mockNbaGames).not.toHaveBeenCalled();
expect(mockNbaBox).not.toHaveBeenCalled();
expect(mockNbaOdds).not.toHaveBeenCalled();
expect(r.nba.skipped).toBe('dry_run');
expect(r.mlb.skipped).toBe('dry_run');
});
test('empty slate is not an error', async () => {
mockNbaGames.mockResolvedValueOnce([]);
mockNbaOdds.mockResolvedValueOnce({});
const r = await prefetch.main(['node', 'script', '--sports=nba']);
expect(r.nba.games).toBe(0);
expect(r.nba.boxscores).toBe(0);
// Odds still pulled — it's a single daily call, not per-game.
expect(r.nba.odds).toBe(true);
});
test('null slate (adapter returned null) is not an error', async () => {
mockNbaGames.mockResolvedValueOnce(null);
const r = await prefetch.main(['node', 'script', '--sports=nba']);
expect(r.nba.games).toBe(0);
expect(r.nba.boxscores).toBe(0);
});
});
describe('main — MLB path', () => {
test('skips when adapter has no API key', async () => {
mockMlbHasKey.mockReturnValueOnce(false);
const r = await prefetch.main(['node', 'script', '--sports=mlb']);
expect(r.mlb.skipped).toBe('no_key');
});
test('pulls scoreboard + box scores for Finals/Completed', async () => {
mockMlbSlate.mockResolvedValueOnce([
{ gameId: 'M1', gameStatus: 'Final' },
{ gameId: 'M2', gameStatus: 'In Progress' }, // skip
{ gameId: 'M3', gameStatus: 'Completed' },
]);
mockMlbBox.mockResolvedValue({});
const r = await prefetch.main(['node', 'script', '--sports=mlb']);
expect(r.mlb.games).toBe(3);
expect(r.mlb.boxscores).toBe(2);
expect(r.mlb.bvp_skipped_reason).toMatch(/scoreboard/i);
});
});
+156
View File
@@ -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();
});
});