Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// Session 16 — soccer weather wiring. The feature extractor fetches
|
||||
// Open-Meteo for outdoor WC venues. Dome venues skip the fetch
|
||||
// (operators close the roof); unknown venues skip silently.
|
||||
|
||||
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 mockWeather = jest.fn();
|
||||
jest.mock('../../src/services/weatherService', () => ({
|
||||
getWeather: (...a) => mockWeather(...a),
|
||||
}));
|
||||
|
||||
const { extractSoccerFeatures } = require('../../src/services/intelligence/soccerFeatureExtractor');
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
|
||||
beforeEach(() => {
|
||||
mockCache.clear();
|
||||
mockWeather.mockReset();
|
||||
});
|
||||
|
||||
function primePlayerAndMatch(player, team, opts = {}) {
|
||||
mockCache.set(`soccer:player:${normalizeName(player)}`, {
|
||||
team, goals_per_90: 0.5,
|
||||
});
|
||||
mockCache.set(`soccer:nextmatch:${team}`, {
|
||||
opponent: opts.opponent || 'X',
|
||||
venue: opts.venue || 'MetLife Stadium',
|
||||
isHome: opts.isHome ?? true,
|
||||
referee: opts.referee || null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('soccer weather wiring (Session 16)', () => {
|
||||
test('outdoor WC venue → weather features populated', async () => {
|
||||
primePlayerAndMatch('Harry Kane', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockResolvedValueOnce({
|
||||
temp_f: 81.2, wind_mph: 9.4, wind_dir: 220, precip_mm: 0,
|
||||
});
|
||||
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
|
||||
expect(mockWeather).toHaveBeenCalledTimes(1);
|
||||
expect(r.features.weather_temp_f).toBeCloseTo(81.2);
|
||||
expect(r.features.weather_wind_mph).toBeCloseTo(9.4);
|
||||
expect(r.features.weather_wind_dir).toBe(220);
|
||||
expect(r.features.weather_precip_mm).toBe(0);
|
||||
});
|
||||
|
||||
test('dome WC venue (BC Place) → weather fetch skipped, fields null', async () => {
|
||||
primePlayerAndMatch('Sub Player', 'Canada', { venue: 'BC Place' });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Sub Player', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
expect(r.features.weather_wind_mph).toBeNull();
|
||||
});
|
||||
|
||||
test('Estadio Azteca (open-air, high-altitude) → weather fetched + altitude_impact still high', async () => {
|
||||
primePlayerAndMatch('Forward', 'Mexico', { venue: 'Estadio Azteca' });
|
||||
mockWeather.mockResolvedValueOnce({ temp_f: 68, wind_mph: 5, wind_dir: 90, precip_mm: 0 });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.weather_temp_f).toBe(68);
|
||||
expect(r.features.altitude_impact).toBe('high');
|
||||
});
|
||||
|
||||
test('venue not in the WC index → weather fetch skipped', async () => {
|
||||
primePlayerAndMatch('X', 'Y', { venue: 'Random Stadium' });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
});
|
||||
|
||||
test('weather service returns null → feature fields stay null (no throw)', async () => {
|
||||
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockResolvedValueOnce(null);
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).toHaveBeenCalledTimes(1);
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
expect(r.features.weather_wind_mph).toBeNull();
|
||||
});
|
||||
|
||||
test('weather service throws → graceful degrade, grade still produced', async () => {
|
||||
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockRejectedValueOnce(new Error('timeout'));
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
// Other features (goals_per_90 etc.) still populated.
|
||||
expect(r.features.goals_per_90).toBe(0.5);
|
||||
});
|
||||
|
||||
test('no venue resolved → weather skipped entirely (no fetch attempt)', async () => {
|
||||
mockCache.set(`soccer:player:${normalizeName('Solo')}`, { team: 'England', goals_per_90: 0.5 });
|
||||
// No nextmatch entry → venueName is null.
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Solo', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Session 16 — sport-specific market scoping in oddsService.
|
||||
// Replaces a runtime axios interceptor (NODE_OPTIONS --require) that
|
||||
// had been deployed to filter out cross-sport markets the upstream
|
||||
// odds-api 422s on. These tests pin the contract so the runtime hack
|
||||
// can be retired safely.
|
||||
|
||||
const { SPORT_MARKETS, getMarketsForSport } = require('../../src/services/oddsService');
|
||||
|
||||
describe('SPORT_MARKETS — isolation', () => {
|
||||
test('NBA market list contains no soccer markets', () => {
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_goals\b/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_shots_on_target/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_tackles/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_cards/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/team_clean_sheet/);
|
||||
});
|
||||
|
||||
test('NBA market list contains no MLB markets', () => {
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/batter_/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/pitcher_/);
|
||||
});
|
||||
|
||||
test('NBA market list does contain canonical NBA markets', () => {
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_rebounds/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_assists/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_threes/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/spreads/);
|
||||
});
|
||||
|
||||
test('WNBA market list is NBA-shaped minus PRA combo', () => {
|
||||
expect(SPORT_MARKETS.wnba).toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.wnba).toMatch(/player_rebounds/);
|
||||
// WNBA odds-api doesn't expose the PRA combo today.
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/points_rebounds_assists/);
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/batter_/);
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/player_goals\b/);
|
||||
});
|
||||
|
||||
test('MLB market list contains batter + pitcher markets, no basketball', () => {
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/batter_home_runs/);
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/batter_hits/);
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/pitcher_strikeouts/);
|
||||
expect(SPORT_MARKETS.mlb).not.toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.mlb).not.toMatch(/player_goals\b/);
|
||||
});
|
||||
|
||||
test('every soccer league shares the same market list', () => {
|
||||
const soccerKeys = Object.keys(SPORT_MARKETS).filter((k) => k.startsWith('soccer_'));
|
||||
expect(soccerKeys.length).toBeGreaterThanOrEqual(9);
|
||||
const first = SPORT_MARKETS[soccerKeys[0]];
|
||||
for (const k of soccerKeys) {
|
||||
expect(SPORT_MARKETS[k]).toBe(first);
|
||||
}
|
||||
});
|
||||
|
||||
test('soccer market list contains soccer-only markets, no basketball/baseball', () => {
|
||||
const wc = SPORT_MARKETS.soccer_wc;
|
||||
expect(wc).toMatch(/player_goals/);
|
||||
expect(wc).toMatch(/player_shots_on_target/);
|
||||
expect(wc).toMatch(/player_cards/);
|
||||
expect(wc).toMatch(/team_clean_sheet/);
|
||||
expect(wc).not.toMatch(/player_points/);
|
||||
expect(wc).not.toMatch(/batter_/);
|
||||
});
|
||||
|
||||
test('every market list ends with `spreads`', () => {
|
||||
for (const list of Object.values(SPORT_MARKETS)) {
|
||||
// We don't require spreads to be the literal final segment,
|
||||
// only that it's present in the comma-separated list.
|
||||
expect(list.split(',')).toContain('spreads');
|
||||
}
|
||||
});
|
||||
|
||||
test('SPORT_MARKETS is frozen at the top level', () => {
|
||||
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarketsForSport', () => {
|
||||
test('returns the NBA list for nba', () => {
|
||||
expect(getMarketsForSport('nba')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
test('returns the soccer_wc list for soccer_wc', () => {
|
||||
expect(getMarketsForSport('soccer_wc')).toBe(SPORT_MARKETS.soccer_wc);
|
||||
});
|
||||
test('unknown sport falls back to NBA (safe default)', () => {
|
||||
expect(getMarketsForSport('cricket')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
test('null / undefined / empty fall back to NBA', () => {
|
||||
expect(getMarketsForSport(null)).toBe(SPORT_MARKETS.nba);
|
||||
expect(getMarketsForSport(undefined)).toBe(SPORT_MARKETS.nba);
|
||||
expect(getMarketsForSport('')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEventOddsFromApi uses sport-scoped markets', () => {
|
||||
// Mock axios so the test doesn't hit the network.
|
||||
jest.resetModules();
|
||||
const mockGet = jest.fn(() => Promise.resolve({ data: {}, headers: {} }));
|
||||
jest.doMock('axios', () => ({ get: mockGet }));
|
||||
const { fetchEventOddsFromApi, SPORT_MARKETS: SM } = require('../../src/services/oddsService');
|
||||
|
||||
beforeEach(() => mockGet.mockClear());
|
||||
|
||||
test('NBA fetch sends NBA markets only', async () => {
|
||||
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key', 'nba');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.nba);
|
||||
});
|
||||
|
||||
test('MLB fetch sends MLB markets only', async () => {
|
||||
await fetchEventOddsFromApi('baseball_mlb', 'evt1', 'key', 'mlb');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.mlb);
|
||||
});
|
||||
|
||||
test('soccer fetch sends soccer markets only', async () => {
|
||||
await fetchEventOddsFromApi('soccer_fifa_world_cup', 'evt1', 'key', 'soccer_wc');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.soccer_wc);
|
||||
});
|
||||
|
||||
test('omitted sport arg falls back to NBA markets (legacy callers)', async () => {
|
||||
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.nba);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user