// Verify computeFeaturesForProp routes soccer → soccerFeatureExtractor // and NBA → existing path. The NBA path's full behavior is covered by // computeFeatures.test.js (existing). const mockExtractSoccerFeatures = jest.fn(); jest.mock('../../src/services/intelligence/soccerFeatureExtractor', () => ({ extractSoccerFeatures: (...args) => mockExtractSoccerFeatures(...args), isSoccerSport: (s) => ['soccer', 'football'].includes(String(s || '').toLowerCase()), })); // Mock the rest of the upstream chain — none of it should be called on // the soccer branch. jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from: jest.fn() }), })); jest.mock('axios'); jest.mock('../../src/services/intelligence/featureCache', () => ({ getFeatures: jest.fn(), })); jest.mock('../../src/services/intelligence/trapDetection', () => ({ getTrapScore: jest.fn(async () => ({ composite: 0.2, signals: {}, active_count: 1, recommendation: 'caution' })), })); jest.mock('../../src/services/intelligence/consistencyScore', () => ({ getConsistency: jest.fn(), })); jest.mock('../../src/services/intelligence/gameLogService', () => ({ getGameLogs: jest.fn(async () => []), })); const { computeFeaturesForProp } = require('../../src/services/intelligence/computeFeatures'); beforeEach(() => { mockExtractSoccerFeatures.mockReset(); }); describe('computeFeaturesForProp — sport dispatch', () => { test('sport=soccer routes to soccerFeatureExtractor (NBA path NOT invoked)', async () => { mockExtractSoccerFeatures.mockResolvedValueOnce({ features: { goals_per_90: 0.4 }, trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' }, consistency: { consistency: 'unknown', score: null, games: 0 }, prop: { line: 0.5, direction: 'over' }, meta: { player: 'Test', sport: 'soccer', statType: 'goals', errors: [] }, }); const result = await computeFeaturesForProp({ player: 'Test', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', }); expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1); expect(result.features.goals_per_90).toBe(0.4); expect(result.meta.sport).toBe('soccer'); // The branch re-runs trap detection so the trap object is populated. expect(result.trap.composite).toBeGreaterThanOrEqual(0); }); test('sport=football is normalized into the soccer branch', async () => { mockExtractSoccerFeatures.mockResolvedValueOnce({ features: {}, trap: {}, consistency: {}, prop: {}, meta: { sport: 'soccer', errors: [] }, }); await computeFeaturesForProp({ player: 'X', stat_type: 'goals', line: 0.5, sport: 'football' }); expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1); }); test('sport=nba does NOT invoke the soccer extractor', async () => { const featureCache = require('../../src/services/intelligence/featureCache'); featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 28 } }); await computeFeaturesForProp({ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over', sport: 'nba', }); expect(mockExtractSoccerFeatures).not.toHaveBeenCalled(); }); test('sport omitted defaults to NBA (legacy contract)', async () => { const featureCache = require('../../src/services/intelligence/featureCache'); featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 30 } }); await computeFeaturesForProp({ player: 'A', stat_type: 'points', line: 25, direction: 'over' }); expect(mockExtractSoccerFeatures).not.toHaveBeenCalled(); }); });