const { determineSharpIndicator } = require('../../src/services/lineMovementService'); // Mock Redis and Supabase for the service-level tests const mockRedis = { get: jest.fn(), set: jest.fn() }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); const mockSupabaseFrom = jest.fn(); jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }), })); const { processNewOdds, detectMovements, captureBaseline } = require('../../src/services/lineMovementService'); function makeProp(overrides = {}) { return { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, ...overrides, }; } beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); }); describe('lineMovementService', () => { describe('processNewOdds', () => { test('first fetch of the day captures baseline, returns no movements', async () => { mockRedis.get.mockResolvedValue(null); // no baseline flag mockSupabaseFrom.mockReturnValue({ upsert: () => Promise.resolve({ data: [], error: null }), delete: () => ({ lt: () => Promise.resolve({ error: null }) }), }); const result = await processNewOdds('nba', [makeProp()]); expect(result.isBaselineCapture).toBe(true); expect(result.movements).toEqual([]); }); test('subsequent fetch detects movement >= 0.5', async () => { mockRedis.get.mockResolvedValue('1'); // baseline exists const baselines = [ { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 }, ]; mockSupabaseFrom.mockImplementation((table) => { if (table === 'line_baselines') { return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; } if (table === 'line_movements') { return { insert: () => Promise.resolve({ error: null }) }; } return {}; }); const result = await processNewOdds('nba', [makeProp({ line: 27.5 })]); expect(result.isBaselineCapture).toBe(false); expect(result.movements).toHaveLength(1); expect(result.movements[0].movement).toBe(1.0); expect(result.movements[0].direction).toBe('up'); }); test('movement < 0.5 is not flagged', async () => { mockRedis.get.mockResolvedValue('1'); const baselines = [ { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 }, ]; mockSupabaseFrom.mockImplementation((table) => { if (table === 'line_baselines') { return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; } if (table === 'line_movements') { return { insert: () => Promise.resolve({ error: null }) }; } return {}; }); const result = await processNewOdds('nba', [makeProp({ line: 26.8 })]); expect(result.movements).toHaveLength(0); }); test('direction correctly identified (down)', async () => { mockRedis.get.mockResolvedValue('1'); const baselines = [ { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 27.0 }, ]; mockSupabaseFrom.mockImplementation((table) => { if (table === 'line_baselines') { return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; } if (table === 'line_movements') { return { insert: () => Promise.resolve({ error: null }) }; } return {}; }); const result = await processNewOdds('nba', [makeProp({ line: 26.0 })]); expect(result.movements[0].direction).toBe('down'); }); }); describe('determineSharpIndicator', () => { test('line up + over expensive = sharp_action', () => { expect(determineSharpIndicator(26.5, 27.0, -120, -100, 'up')).toBe('sharp_action'); }); test('line up + under expensive = public_action', () => { expect(determineSharpIndicator(26.5, 27.0, -100, -120, 'up')).toBe('public_action'); }); test('line down + under expensive = sharp_action', () => { expect(determineSharpIndicator(27.0, 26.5, -100, -120, 'down')).toBe('sharp_action'); }); test('no odds = unknown', () => { expect(determineSharpIndicator(26.5, 27.0, null, null, 'up')).toBe('unknown'); }); }); });