c8c0962e56
Core intelligence for BetonBLK prop analysis: - POST /api/analyze/prop — single prop analysis - POST /api/analyze/batch — multi-prop analysis for parlay scanner - 6-step pipeline: season avg → recent form → situational splits → cross-book lines → kill conditions → grade (A/B/C/D) - 6 kill conditions: low_minutes, small_sample, b2b_high_usage, blowout_risk, split_conflict, no_opponent_data - Composite scoring with confidence (30-95), bonuses, penalties - Added spreads market to Odds API fetch (zero extra credits) - Full reasoning output with step-by-step breakdown 36 new tests (unit + integration), 128 total across all features Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
397 lines
12 KiB
JavaScript
397 lines
12 KiB
JavaScript
const request = require('supertest');
|
|
|
|
// Mock Redis
|
|
const mockRedis = {
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
hset: jest.fn(),
|
|
hgetall: jest.fn(),
|
|
expire: jest.fn(),
|
|
};
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
getRedisClient: () => mockRedis,
|
|
}));
|
|
|
|
// Mock axios (used by both oddsService and nbaStatsClient)
|
|
jest.mock('axios');
|
|
const axios = require('axios');
|
|
|
|
process.env.ODDS_API_KEY = 'test-key';
|
|
process.env.NBA_SERVICE_URL = 'http://localhost:8000';
|
|
|
|
const app = require('../../src/app');
|
|
|
|
// Mock data
|
|
const MOCK_ODDS_EVENTS = [
|
|
{
|
|
id: 'game-1',
|
|
sport_key: 'basketball_nba',
|
|
home_team: 'Denver Nuggets',
|
|
away_team: 'Los Angeles Lakers',
|
|
commence_time: '2026-03-21T19:00:00Z',
|
|
},
|
|
];
|
|
|
|
const MOCK_ODDS_WITH_SPREADS = {
|
|
...MOCK_ODDS_EVENTS[0],
|
|
bookmakers: [
|
|
{
|
|
key: 'draftkings',
|
|
title: 'DraftKings',
|
|
markets: [
|
|
{
|
|
key: 'player_points',
|
|
last_update: '2026-03-21T14:28:00Z',
|
|
outcomes: [
|
|
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
|
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
|
],
|
|
},
|
|
{
|
|
key: 'spreads',
|
|
last_update: '2026-03-21T14:28:00Z',
|
|
outcomes: [
|
|
{ name: 'Denver Nuggets', price: -110, point: -5.5 },
|
|
{ name: 'Los Angeles Lakers', price: -110, point: 5.5 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'fanduel',
|
|
title: 'FanDuel',
|
|
markets: [
|
|
{
|
|
key: 'player_points',
|
|
last_update: '2026-03-21T14:30:00Z',
|
|
outcomes: [
|
|
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
|
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const MOCK_SEASON_AVG = {
|
|
player: 'Nikola Jokic',
|
|
player_id: 203999,
|
|
team: 'DEN',
|
|
season: '2025-26',
|
|
source: 'cache',
|
|
stats: {
|
|
points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1,
|
|
blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2,
|
|
games_played: 65, minutes: 34.2,
|
|
},
|
|
};
|
|
|
|
const MOCK_LAST_N = {
|
|
player: 'Nikola Jokic',
|
|
player_id: 203999,
|
|
team: 'DEN',
|
|
last_n: 10,
|
|
source: 'cache',
|
|
stats: {
|
|
points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3,
|
|
blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9,
|
|
games_played: 10, minutes: 35.1,
|
|
},
|
|
};
|
|
|
|
const MOCK_HOME_AWAY = {
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
split_type: 'home_away',
|
|
source: 'cache',
|
|
splits: {
|
|
home: { avg: 27.8, games: 33 },
|
|
away: { avg: 24.9, games: 32 },
|
|
},
|
|
};
|
|
|
|
const MOCK_REST_DAYS = {
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
split_type: 'rest_days',
|
|
source: 'cache',
|
|
splits: {
|
|
b2b: { avg: 23.1, games: 8 },
|
|
'1_day_rest': { avg: 26.5, games: 40 },
|
|
'2_plus_days_rest': { avg: 28.2, games: 17 },
|
|
},
|
|
};
|
|
|
|
const MOCK_VS_TEAM = {
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
split_type: 'vs_team',
|
|
opponent: 'LAL',
|
|
source: 'cache',
|
|
splits: {
|
|
vs_opponent: { avg: 30.5, games: 3 },
|
|
vs_all_others: { avg: 25.8, games: 62 },
|
|
},
|
|
};
|
|
|
|
const API_HEADERS = {
|
|
'x-requests-remaining': '488',
|
|
'x-requests-used': '12',
|
|
};
|
|
|
|
function setupMocks() {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockRedis.hset.mockResolvedValue(1);
|
|
mockRedis.hgetall.mockResolvedValue({});
|
|
mockRedis.expire.mockResolvedValue(1);
|
|
|
|
// Odds API: events then event odds
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes('/events') && !url.includes('/odds')) {
|
|
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
}
|
|
if (url.includes('/odds')) {
|
|
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
|
}
|
|
// NBA stats service calls
|
|
if (url.includes('/stats/season-avg')) {
|
|
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
}
|
|
if (url.includes('/stats/last-n')) {
|
|
return Promise.resolve({ data: MOCK_LAST_N });
|
|
}
|
|
if (url.includes('/stats/splits')) {
|
|
if (url.includes('split_type=home_away') || (arguments[1]?.params?.split_type === 'home_away')) {
|
|
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
}
|
|
if (url.includes('split_type=rest_days') || (arguments[1]?.params?.split_type === 'rest_days')) {
|
|
return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
}
|
|
if (url.includes('split_type=vs_team') || (arguments[1]?.params?.split_type === 'vs_team')) {
|
|
return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
}
|
|
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
}
|
|
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
|
});
|
|
}
|
|
|
|
// Better mock that checks params
|
|
function setupDetailedMocks() {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockRedis.hset.mockResolvedValue(1);
|
|
mockRedis.hgetall.mockResolvedValue({});
|
|
mockRedis.expire.mockResolvedValue(1);
|
|
|
|
axios.get.mockImplementation((url, config) => {
|
|
// Odds API
|
|
if (url.includes('the-odds-api.com') && url.includes('/events') && !url.includes('/odds')) {
|
|
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
}
|
|
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
|
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
|
}
|
|
// NBA stats service
|
|
if (url.includes('localhost:8000/stats/season-avg')) {
|
|
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
}
|
|
if (url.includes('localhost:8000/stats/last-n')) {
|
|
return Promise.resolve({ data: MOCK_LAST_N });
|
|
}
|
|
if (url.includes('localhost:8000/stats/splits')) {
|
|
const splitType = config?.params?.split_type;
|
|
if (splitType === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
if (splitType === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
if (splitType === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
}
|
|
if (url.includes('localhost:8000/players/search')) {
|
|
return Promise.resolve({ data: { results: [{ player_id: 203999, full_name: 'Nikola Jokic' }] } });
|
|
}
|
|
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('POST /api/analyze/prop', () => {
|
|
it('returns complete analysis with all fields', async () => {
|
|
setupDetailedMocks();
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 26.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body.player).toBe('Nikola Jokic');
|
|
expect(res.body.stat_type).toBe('points');
|
|
expect(res.body.grade).toMatch(/^[ABCD]$/);
|
|
expect(typeof res.body.edge_pct).toBe('number');
|
|
expect(typeof res.body.confidence).toBe('number');
|
|
expect(res.body.confidence).toBeGreaterThanOrEqual(30);
|
|
expect(res.body.confidence).toBeLessThanOrEqual(95);
|
|
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
|
expect(res.body.reasoning).toBeDefined();
|
|
expect(res.body.reasoning.summary).toBeDefined();
|
|
expect(res.body.reasoning.steps).toBeDefined();
|
|
expect(res.body.reasoning.steps.season_avg).toBeDefined();
|
|
expect(res.body.reasoning.steps.recent_form).toBeDefined();
|
|
expect(res.body.reasoning.steps.situational).toBeDefined();
|
|
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
|
|
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
|
|
expect(res.body.reasoning.steps.final_grade).toBeDefined();
|
|
});
|
|
|
|
it('returns grade A/B for player averaging above line with good recent form', async () => {
|
|
setupDetailedMocks();
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 24.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(['A', 'B']).toContain(res.body.grade);
|
|
expect(res.body.edge_pct).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns grade D for player averaging below line', async () => {
|
|
setupDetailedMocks();
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 35.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body.grade).toBe('D');
|
|
});
|
|
|
|
it('caps grade when kill conditions trigger (blowout spread)', async () => {
|
|
// Override spread to be a blowout
|
|
const bigSpreadOdds = JSON.parse(JSON.stringify(MOCK_ODDS_WITH_SPREADS));
|
|
bigSpreadOdds.bookmakers[0].markets[1].outcomes[0].point = -15;
|
|
bigSpreadOdds.bookmakers[0].markets[1].outcomes[1].point = 15;
|
|
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockRedis.hset.mockResolvedValue(1);
|
|
mockRedis.hgetall.mockResolvedValue({});
|
|
mockRedis.expire.mockResolvedValue(1);
|
|
|
|
axios.get.mockImplementation((url, config) => {
|
|
if (url.includes('the-odds-api.com') && !url.includes('/odds')) {
|
|
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
}
|
|
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
|
return Promise.resolve({ data: bigSpreadOdds, headers: API_HEADERS });
|
|
}
|
|
if (url.includes('localhost:8000/stats/season-avg')) {
|
|
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
}
|
|
if (url.includes('localhost:8000/stats/last-n')) {
|
|
return Promise.resolve({ data: MOCK_LAST_N });
|
|
}
|
|
if (url.includes('localhost:8000/stats/splits')) {
|
|
const st = config?.params?.split_type;
|
|
if (st === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
}
|
|
return Promise.reject(new Error(`Unmocked: ${url}`));
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 24.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
const codes = res.body.kill_conditions_triggered.map((k) => k.code);
|
|
expect(codes).toContain('blowout_risk');
|
|
expect(['C', 'D']).toContain(res.body.grade);
|
|
});
|
|
|
|
it('returns 400 for missing player field', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ stat_type: 'points', line: 26.5, direction: 'over' })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('player is required');
|
|
});
|
|
|
|
it('returns 400 for invalid stat_type', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ player: 'Jokic', stat_type: 'invalid', line: 26.5, direction: 'over' })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('Invalid stat_type');
|
|
});
|
|
|
|
it('returns 400 for missing direction', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ player: 'Jokic', stat_type: 'points', line: 26.5 })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('direction is required');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/analyze/batch', () => {
|
|
it('processes multiple props and returns array', async () => {
|
|
setupDetailedMocks();
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/batch')
|
|
.send({
|
|
props: [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' },
|
|
{ player: 'Nikola Jokic', stat_type: 'rebounds', line: 12.5, direction: 'over', book: 'fanduel' },
|
|
],
|
|
})
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(res.body.results)).toBe(true);
|
|
expect(res.body.results.length).toBe(2);
|
|
});
|
|
|
|
it('returns 400 for empty props array', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/batch')
|
|
.send({ props: [] })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('props array is required');
|
|
});
|
|
});
|