const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); const mockCache = { current: new Map() }; jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => mockCache.current.get(k) ?? null, cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; }, cacheDel: async (k) => { mockCache.current.delete(k); return true; }, })); jest.mock('../../src/utils/rateLimiter', () => ({ createLimiter: () => ({ waitForToken: async () => true, snapshot: () => ({}) }), createCircuitBreaker: () => ({ call: async (fn) => fn(), snapshot: () => ({}) }), })); const cache = require('../../src/services/intelligence/teamStatsCache'); beforeEach(() => { mockAxiosGet.mockReset(); mockCache.current.clear(); }); describe('teamStatsCache', () => { test('refreshTeamStats walks team list and writes cache per team', async () => { mockAxiosGet // teams list .mockResolvedValueOnce({ status: 200, data: { sports: [{ leagues: [{ teams: [ { team: { id: 1, abbreviation: 'NYK', displayName: 'Knicks' } }, { team: { id: 2, abbreviation: 'BOS', displayName: 'Celtics' } }, ] }] }], }, }) // team 1 stats .mockResolvedValueOnce({ status: 200, data: { results: { stats: [{ stats: [ { name: 'offensiveRating', value: 118.5 }, { name: 'defensiveRating', value: 110.2 }, { name: 'pace', value: 100.4 }, ] }] } }, }) // team 2 stats .mockResolvedValueOnce({ status: 200, data: { results: { stats: [{ stats: [ { name: 'offensiveRating', value: 120.0 }, { name: 'defensiveRating', value: 108.0 }, ] }] } }, }); const summary = await cache.refreshTeamStats('nba'); expect(summary.captured).toBe(2); expect(summary.total).toBe(2); const nyk = await cache.getTeamStats('nba', 'NYK'); expect(nyk).toMatchObject({ offensive_rating: 118.5, defensive_rating: 110.2, pace: 100.4 }); const bos = await cache.getTeamStats('nba', 'BOS'); expect(bos).toMatchObject({ offensive_rating: 120.0, defensive_rating: 108.0 }); }); test('getOpponentRank returns the normalized 0..1 rank baked at refresh time', async () => { // The normalized value is set during refreshTeamStats; reads use it // directly. A solo-team cache entry without the field returns null. mockCache.current.set('team_stats:nba:NYK', { defensive_rating: 110.2, defensive_rank_normalized: 0.45, }); expect(await cache.getOpponentRank('nba', 'NYK', 'points')).toBe(0.45); }); test('getOpponentRank returns null when cache predates the normalization upgrade', async () => { mockCache.current.set('team_stats:nba:OLD', { defensive_rating: 110.2 }); expect(await cache.getOpponentRank('nba', 'OLD', 'points')).toBeNull(); }); test('refreshTeamStats normalizes defensive_rank_normalized across the league', async () => { mockAxiosGet .mockResolvedValueOnce({ status: 200, data: { sports: [{ leagues: [{ teams: [ { team: { id: 1, abbreviation: 'BEST', displayName: 'Best D' } }, { team: { id: 2, abbreviation: 'MID', displayName: 'Middle' } }, { team: { id: 3, abbreviation: 'WORST', displayName: 'Worst D' } }, ] }] }], }, }) .mockResolvedValueOnce({ status: 200, data: { results: { stats: [{ stats: [{ name: 'defensiveRating', value: 105 }] }] } } }) .mockResolvedValueOnce({ status: 200, data: { results: { stats: [{ stats: [{ name: 'defensiveRating', value: 112 }] }] } } }) .mockResolvedValueOnce({ status: 200, data: { results: { stats: [{ stats: [{ name: 'defensiveRating', value: 118 }] }] } } }); await cache.refreshTeamStats('nba'); expect(await cache.getOpponentRank('nba', 'BEST', 'points')).toBe(0); expect(await cache.getOpponentRank('nba', 'MID', 'points')).toBeCloseTo(0.5, 5); expect(await cache.getOpponentRank('nba', 'WORST', 'points')).toBe(1); }); test('getTeamStats returns null when nothing cached', async () => { expect(await cache.getTeamStats('nba', 'GHOST')).toBeNull(); }); test('refreshTeamStats skips unsupported sport gracefully', async () => { const summary = await cache.refreshTeamStats('curling'); // listTeams returns [] for unsupported sport, so total = 0. expect(summary).toMatchObject({ captured: 0, errored: 0, total: 0 }); }); });