// Unit: line-snapshot service (Session 28). Redis-only; mocked. const mockStore = {}; // key -> array of JSON strings (list) const mockScan = jest.fn(); const mockRedis = { rpush: jest.fn(async (k, v) => { (mockStore[k] = mockStore[k] || []).push(v); return mockStore[k].length; }), ltrim: jest.fn(async (k, start, end) => { if (mockStore[k]) mockStore[k] = mockStore[k].slice(start, end === -1 ? undefined : end + 1); return 'OK'; }), expire: jest.fn(async () => 1), lrange: jest.fn(async (k) => mockStore[k] || []), scan: mockScan, }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis, isDegraded: () => false, })); const svc = require('../../src/services/lineSnapshotService'); beforeEach(() => { for (const k of Object.keys(mockStore)) delete mockStore[k]; jest.clearAllMocks(); }); describe('lineSnapshotService — recording', () => { test('records a snapshot per prop into the right key', async () => { const n = await svc.recordSnapshots('nba', [ { gameId: 'g1', player: 'Wemby', stat: 'points', line: 28.5, book: 'dk' }, ], 1000); expect(n).toBe(1); const hist = await svc.getLineHistory('nba', 'g1', 'Wemby', 'points'); expect(hist).toEqual([{ time: 1000, line: 28.5, book: 'dk' }]); }); test('skips props missing required fields', async () => { const n = await svc.recordSnapshots('nba', [{ player: 'X' }, { gameId: 'g', player: 'Y', stat: 's', line: 'NaN' }], 1); expect(n).toBe(0); }); }); describe('lineSnapshotService — classifyMovement', () => { test('empty / single → stable, no error', () => { expect(svc.classifyMovement([]).movement).toBe('stable'); expect(svc.classifyMovement([{ line: 5 }]).movement).toBe('stable'); expect(svc.classifyMovement([{ line: 5 }]).delta).toBe(0); }); test('rising vs dropping', () => { expect(svc.classifyMovement([{ line: 26.5 }, { line: 28.5 }]).movement).toBe('rising'); expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).movement).toBe('dropping'); }); test('sharp signal at >= 1.5 point move', () => { expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).sharpSignal).toBe(true); // 2.0 expect(svc.classifyMovement([{ line: 28.5 }, { line: 27.5 }]).sharpSignal).toBe(false); // 1.0 }); test('< 0.5 move is stable', () => { expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.5 }]).movement).toBe('stable'); expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.2 }]).movement).toBe('stable'); }); }); describe('lineSnapshotService — biggest movers', () => { test('classifies + sorts by absolute delta desc', async () => { mockScan.mockResolvedValueOnce(['0', [ 'linehistory:mlb:g1:Acuna:hits', 'linehistory:mlb:g2:Soto:total_bases', ]]); mockStore['linehistory:mlb:g1:Acuna:hits'] = [ JSON.stringify({ time: 1, line: 1.5 }), JSON.stringify({ time: 2, line: 2.5 }), // +1.0 ]; mockStore['linehistory:mlb:g2:Soto:total_bases'] = [ JSON.stringify({ time: 1, line: 3.5 }), JSON.stringify({ time: 2, line: 1.0 }), // -2.5 (bigger, sharp) ]; const movers = await svc.getBiggestMovers('mlb'); expect(movers).toHaveLength(2); expect(movers[0].player).toBe('Soto'); // bigger |delta| first expect(movers[0].delta).toBe(-2.5); expect(movers[0].sharpSignal).toBe(true); expect(movers[1].player).toBe('Acuna'); }); test('filters out sub-threshold (stable) props', async () => { mockScan.mockResolvedValueOnce(['0', ['linehistory:mlb:g1:Flat:hits']]); mockStore['linehistory:mlb:g1:Flat:hits'] = [ JSON.stringify({ time: 1, line: 1.5 }), JSON.stringify({ time: 2, line: 1.5 }), ]; const movers = await svc.getBiggestMovers('mlb'); expect(movers).toEqual([]); }); test('parseKey extracts sport/game/player/stat', () => { expect(svc.__internals.parseKey('linehistory:nba:g1:LeBron James:points')).toEqual({ sport: 'nba', gameId: 'g1', player: 'LeBron James', stat: 'points', }); }); });