feat: Feature 2.1 — Parlay Scan with correlation detection + monetization
POST /api/scan/parlay — authenticated parlay analysis: - Supabase JWT auth middleware (auth.getUser verification) - 5 correlation types detected between legs (same_game, same_team, same_player_conflicting, positive_correlation, blowout_cascade) - Overall parlay grading (A/B/C/D) with correlation penalty adjustments - Free tier: 5 scans/month, atomic scan count increment - Scan 5: full analysis + personalized upgrade pitch - Scan 6+: 403 block with upgrade pitch - Pitch personalization from scan history (top stats, grades, tier rec) - DB writes: picks + scan_sessions per scan 30 new tests, 158 total (131 Node.js + 27 Python), all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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 Supabase
|
||||
const mockSupabaseFrom = jest.fn();
|
||||
const mockSupabaseAuth = {
|
||||
getUser: jest.fn(),
|
||||
};
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
||||
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
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_USER_FREE = {
|
||||
id: 'user-free-1',
|
||||
email: 'free@test.com',
|
||||
tier: 'free',
|
||||
scan_count: 0,
|
||||
scan_reset_date: '2026-04-01',
|
||||
founder_status: false,
|
||||
};
|
||||
|
||||
const MOCK_USER_PAID = {
|
||||
id: 'user-paid-1',
|
||||
email: 'paid@test.com',
|
||||
tier: 'analyst',
|
||||
scan_count: 100,
|
||||
scan_reset_date: '2026-04-01',
|
||||
founder_status: true,
|
||||
};
|
||||
|
||||
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_RESPONSE = {
|
||||
...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 },
|
||||
{ name: 'Over', description: 'LeBron James', price: -115, point: 25.5 },
|
||||
{ name: 'Under', description: 'LeBron James', price: -105, point: 25.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'player_rebounds', last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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_SPLITS = {
|
||||
splits: { home: { avg: 27.8, games: 33 }, away: { avg: 24.9, games: 32 } },
|
||||
};
|
||||
const MOCK_REST = {
|
||||
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 = {
|
||||
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 setupAllMocks(user = MOCK_USER_FREE) {
|
||||
// Auth
|
||||
mockSupabaseAuth.getUser.mockResolvedValue({
|
||||
data: { user: { id: user.id, email: user.email } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Supabase from() calls
|
||||
mockSupabaseFrom.mockImplementation((table) => {
|
||||
if (table === 'users') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({ data: user, error: null }),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
eq: (col, val) => ({
|
||||
eq: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({ data: { scan_count: user.scan_count + 1 }, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'picks') {
|
||||
return {
|
||||
insert: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({ data: { id: 'pick-' + Math.random().toString(36).slice(2) }, error: null }),
|
||||
}),
|
||||
}),
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
limit: () => Promise.resolve({ data: [], error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'scan_sessions') {
|
||||
return {
|
||||
insert: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({ data: { id: 'session-1' }, error: null }),
|
||||
}),
|
||||
}),
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
limit: () => Promise.resolve({ data: [], error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Default for other tables (e.g., scan history queries in upgradePitch)
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
limit: () => Promise.resolve({ data: [], error: null }),
|
||||
}),
|
||||
single: () => Promise.resolve({ data: null, error: null }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Redis
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
|
||||
// Axios (Odds API + NBA stats service)
|
||||
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: MOCK_ODDS_RESPONSE, 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 === 'rest_days') return Promise.resolve({ data: MOCK_REST });
|
||||
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
||||
return Promise.resolve({ data: MOCK_SPLITS });
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_PARLAY = {
|
||||
legs: [
|
||||
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' },
|
||||
{ player: 'LeBron James', stat_type: 'rebounds', line: 8.5, direction: 'over', book: 'fanduel' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /api/scan/parlay', () => {
|
||||
test('returns 401 without auth token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(401);
|
||||
expect(res.body.error).toContain('Authentication required');
|
||||
});
|
||||
|
||||
test('returns 401 with invalid token', async () => {
|
||||
mockSupabaseAuth.getUser.mockResolvedValue({ data: { user: null }, error: { message: 'invalid' } });
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer bad-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(401);
|
||||
expect(res.body.error).toContain('Invalid');
|
||||
});
|
||||
|
||||
test('returns 400 for fewer than 2 legs', async () => {
|
||||
setupAllMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ legs: [{ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' }] })
|
||||
.expect(400);
|
||||
expect(res.body.error).toContain('at least 2');
|
||||
});
|
||||
|
||||
test('returns 422 for more than 12 legs', async () => {
|
||||
setupAllMocks();
|
||||
const legs = Array(13).fill({ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' });
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ legs })
|
||||
.expect(422);
|
||||
expect(res.body.error).toContain('12 legs');
|
||||
});
|
||||
|
||||
test('full scan: returns complete response with grades + correlations', async () => {
|
||||
setupAllMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.scan_id).toBeDefined();
|
||||
expect(res.body.parlay_grade).toMatch(/^[ABCD]$/);
|
||||
expect(typeof res.body.parlay_confidence).toBe('number');
|
||||
expect(Array.isArray(res.body.correlation_flags)).toBe(true);
|
||||
expect(Array.isArray(res.body.legs)).toBe(true);
|
||||
expect(res.body.legs).toHaveLength(2);
|
||||
expect(res.body.legs[0]).toHaveProperty('grade');
|
||||
expect(res.body.legs[0]).toHaveProperty('confidence');
|
||||
expect(res.body.legs[0]).toHaveProperty('edge_pct');
|
||||
expect(res.body.legs[0]).toHaveProperty('reasoning_summary');
|
||||
expect(typeof res.body.scan_count).toBe('number');
|
||||
});
|
||||
|
||||
test('paid user: unlimited scans, no pitch', async () => {
|
||||
setupAllMocks(MOCK_USER_PAID);
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.upgrade_pitch).toBeNull();
|
||||
expect(res.body.scans_remaining).toBeNull();
|
||||
});
|
||||
|
||||
test('scan 5: returns analysis WITH upgrade pitch', async () => {
|
||||
const user5 = { ...MOCK_USER_FREE, scan_count: 4 };
|
||||
setupAllMocks(user5);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.upgrade_pitch).not.toBeNull();
|
||||
expect(res.body.upgrade_pitch.hook).toBeDefined();
|
||||
expect(res.body.upgrade_pitch.cta).toContain('founder rate');
|
||||
expect(res.body.upgrade_pitch.tier_recommended).toBeDefined();
|
||||
expect(res.body.scans_remaining).toBe(0);
|
||||
});
|
||||
|
||||
test('scan 6+: returns 403 with upgrade pitch, no analysis', async () => {
|
||||
const user6 = { ...MOCK_USER_FREE, scan_count: 5 };
|
||||
setupAllMocks(user6);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(403);
|
||||
|
||||
expect(res.body.error).toBe('scan_limit_reached');
|
||||
expect(res.body.upgrade_pitch).toBeDefined();
|
||||
expect(res.body.upgrade_pitch.cta).toContain('founder rate');
|
||||
// No legs in the response since analysis was blocked
|
||||
expect(res.body.legs).toBeUndefined();
|
||||
});
|
||||
|
||||
test('database: picks and scan_sessions are written', async () => {
|
||||
setupAllMocks();
|
||||
|
||||
await request(app)
|
||||
.post('/api/scan/parlay')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY)
|
||||
.expect(200);
|
||||
|
||||
// Verify picks insert was called (2 legs = 2 picks)
|
||||
const pickInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'picks');
|
||||
expect(pickInserts.length).toBe(2);
|
||||
|
||||
// Verify scan_sessions insert was called once
|
||||
const sessionInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'scan_sessions');
|
||||
expect(sessionInserts.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
const { detectCorrelations } = require('../../src/services/correlationEngine');
|
||||
|
||||
function makeLeg(overrides = {}) {
|
||||
return {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
direction: 'over',
|
||||
line: 26.5,
|
||||
grade: 'B',
|
||||
confidence: 70,
|
||||
_team: 'DEN',
|
||||
_gameTime: '2026-03-21T19:00:00Z',
|
||||
reasoning: { steps: { season_avg: { value: 26.3 } } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('correlationEngine', () => {
|
||||
test('returns empty array when no correlations exist', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: '2026-03-21T19:00:00Z' }),
|
||||
makeLeg({ player: 'Jayson Tatum', _team: 'BOS', _gameTime: '2026-03-21T20:00:00Z' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toEqual([]);
|
||||
});
|
||||
|
||||
test('detects same_game_opposing_players', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
|
||||
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toHaveLength(1);
|
||||
expect(flags[0].type).toBe('same_game_opposing_players');
|
||||
expect(flags[0].impact).toBe('minor_negative');
|
||||
});
|
||||
|
||||
test('detects same_game_same_team', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1' }),
|
||||
makeLeg({ player: 'Anthony Davis', _team: 'LAL', _gameTime: 'game1' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toHaveLength(1);
|
||||
expect(flags[0].type).toBe('same_game_same_team');
|
||||
expect(flags[0].impact).toBe('minor_negative');
|
||||
});
|
||||
|
||||
test('detects same_player_conflicting (opposite directions)', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'under' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toHaveLength(1);
|
||||
expect(flags[0].type).toBe('same_player_conflicting');
|
||||
expect(flags[0].impact).toBe('major_negative');
|
||||
});
|
||||
|
||||
test('detects positive_correlation (same player, complementary same direction)', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'rebounds', direction: 'over' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toHaveLength(1);
|
||||
expect(flags[0].type).toBe('positive_correlation');
|
||||
expect(flags[0].impact).toBe('positive');
|
||||
});
|
||||
|
||||
test('detects blowout_cascade (2+ legs, high spread)', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: 'game1' }),
|
||||
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1', stat_type: 'rebounds' }),
|
||||
];
|
||||
const spreads = [
|
||||
{ home_team: 'DEN', away_team: 'LAL', game_time: 'game1', home_spread: -12, book: 'draftkings' },
|
||||
];
|
||||
const flags = detectCorrelations(legs, spreads);
|
||||
const cascade = flags.find((f) => f.type === 'blowout_cascade');
|
||||
expect(cascade).toBeDefined();
|
||||
expect(cascade.impact).toBe('major_negative');
|
||||
});
|
||||
|
||||
test('handles single leg (no correlations possible)', () => {
|
||||
const legs = [makeLeg()];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
expect(flags).toEqual([]);
|
||||
});
|
||||
|
||||
test('multiple correlations can fire simultaneously', () => {
|
||||
const legs = [
|
||||
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
|
||||
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
|
||||
makeLeg({ player: 'Anthony Davis', stat_type: 'rebounds', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
|
||||
];
|
||||
const flags = detectCorrelations(legs, []);
|
||||
// Jokic vs LeBron = same_game_opposing_players, LeBron vs AD = same_game_same_team
|
||||
expect(flags.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const { gradeParlayFromLegs } = require('../../src/services/parlayGrader');
|
||||
|
||||
function makeLeg(grade, confidence, composite) {
|
||||
return { grade, confidence, _composite: composite };
|
||||
}
|
||||
|
||||
describe('parlayGrader', () => {
|
||||
test('grade A: high composite, no D legs, no major_negative', () => {
|
||||
const legs = [makeLeg('A', 85, 3.5), makeLeg('A', 90, 3.0), makeLeg('B', 72, 2.0)];
|
||||
const result = gradeParlayFromLegs(legs, []);
|
||||
expect(result.grade).toBe('A');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
|
||||
test('grade B: moderate composite, at most 1 D leg', () => {
|
||||
const legs = [makeLeg('B', 70, 2.5), makeLeg('B', 65, 2.0), makeLeg('D', 35, 0.3)];
|
||||
const result = gradeParlayFromLegs(legs, []);
|
||||
expect(result.grade).toBe('B');
|
||||
});
|
||||
|
||||
test('grade C: low positive composite', () => {
|
||||
const legs = [makeLeg('C', 55, 0.8), makeLeg('C', 50, 0.6)];
|
||||
const result = gradeParlayFromLegs(legs, []);
|
||||
expect(result.grade).toBe('C');
|
||||
});
|
||||
|
||||
test('grade D: negative composite', () => {
|
||||
const legs = [makeLeg('D', 35, 0.1), makeLeg('D', 30, -0.5)];
|
||||
const result = gradeParlayFromLegs(legs, []);
|
||||
expect(result.grade).toBe('D');
|
||||
});
|
||||
|
||||
test('2+ D legs forces grade D', () => {
|
||||
const legs = [makeLeg('A', 90, 4.0), makeLeg('D', 35, 0.2), makeLeg('D', 30, 0.1)];
|
||||
const result = gradeParlayFromLegs(legs, []);
|
||||
expect(result.grade).toBe('D');
|
||||
});
|
||||
|
||||
test('major_negative caps grade at B', () => {
|
||||
const legs = [makeLeg('A', 90, 4.0), makeLeg('A', 85, 3.5)];
|
||||
const flags = [{ type: 'same_player_conflicting', legs: [0, 1], impact: 'major_negative' }];
|
||||
const result = gradeParlayFromLegs(legs, flags);
|
||||
expect(['B', 'C', 'D']).toContain(result.grade);
|
||||
});
|
||||
|
||||
test('minor_negative reduces composite by 0.3 each', () => {
|
||||
const legs = [makeLeg('B', 70, 2.0), makeLeg('B', 68, 1.8)];
|
||||
const withoutFlags = gradeParlayFromLegs(legs, []);
|
||||
const withFlags = gradeParlayFromLegs(legs, [
|
||||
{ type: 'same_game_same_team', legs: [0, 1], impact: 'minor_negative' },
|
||||
]);
|
||||
expect(withFlags.composite).toBeCloseTo(withoutFlags.composite - 0.3, 1);
|
||||
});
|
||||
|
||||
test('confidence adjusted: -5 per minor, -15 per major', () => {
|
||||
const legs = [makeLeg('B', 80, 2.0), makeLeg('B', 80, 2.0)];
|
||||
const noFlags = gradeParlayFromLegs(legs, []);
|
||||
const minorFlag = gradeParlayFromLegs(legs, [
|
||||
{ type: 'test', impact: 'minor_negative' },
|
||||
]);
|
||||
const majorFlag = gradeParlayFromLegs(legs, [
|
||||
{ type: 'test', impact: 'major_negative' },
|
||||
]);
|
||||
expect(minorFlag.confidence).toBe(noFlags.confidence - 5);
|
||||
expect(majorFlag.confidence).toBe(noFlags.confidence - 15);
|
||||
});
|
||||
|
||||
test('confidence clamped to 30-95', () => {
|
||||
const legs = [makeLeg('D', 35, 0.1)];
|
||||
const manyFlags = Array(5).fill({ type: 'test', impact: 'major_negative' });
|
||||
const result = gradeParlayFromLegs(legs, manyFlags);
|
||||
expect(result.confidence).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
const { generateUpgradePitch } = require('../../src/services/upgradePitch');
|
||||
|
||||
function makeMockSupabase(sessions = [], picks = []) {
|
||||
return {
|
||||
from: (table) => ({
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
limit: () => Promise.resolve({ data: table === 'scan_sessions' ? sessions : picks }),
|
||||
}),
|
||||
single: () => Promise.resolve({ data: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('upgradePitch', () => {
|
||||
test('generates pitch with correct scan count', async () => {
|
||||
const sessions = [
|
||||
{ id: '1', final_grade: 'A', legs: ['a', 'b'], created_at: '2026-03-20' },
|
||||
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
|
||||
{ id: '3', final_grade: 'D', legs: ['e'], created_at: '2026-03-18' },
|
||||
{ id: '4', final_grade: 'C', legs: ['f', 'g'], created_at: '2026-03-17' },
|
||||
];
|
||||
const picks = [
|
||||
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
|
||||
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
|
||||
{ stat_type: 'rebounds', direction: 'over', grade: 'C', player: 'LeBron' },
|
||||
];
|
||||
|
||||
const supabase = makeMockSupabase(sessions, picks);
|
||||
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'B', legs: [] });
|
||||
|
||||
expect(result.hook).toContain('5'); // 4 prior + 1 current
|
||||
expect(result.insight).toContain('points');
|
||||
expect(result.cta).toContain('founder rate');
|
||||
expect(result.tier_recommended).toBeDefined();
|
||||
expect(result.founder_price).toBeDefined();
|
||||
});
|
||||
|
||||
test('recommends analyst for casual bettors (avg <= 4 legs)', async () => {
|
||||
const sessions = [
|
||||
{ id: '1', final_grade: 'B', legs: ['a', 'b'], created_at: '2026-03-20' },
|
||||
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
|
||||
];
|
||||
const picks = [
|
||||
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
|
||||
];
|
||||
|
||||
const supabase = makeMockSupabase(sessions, picks);
|
||||
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'A', legs: [{ stat_type: 'points' }] });
|
||||
|
||||
expect(result.tier_recommended).toBe('analyst');
|
||||
expect(result.founder_price).toBe('$14.99/mo');
|
||||
});
|
||||
|
||||
test('recommends desk for power users (avg > 4 legs)', async () => {
|
||||
const sessions = [
|
||||
{ id: '1', final_grade: 'B', legs: ['a', 'b', 'c', 'd', 'e'], created_at: '2026-03-20' },
|
||||
{ id: '2', final_grade: 'A', legs: ['f', 'g', 'h', 'i', 'j', 'k'], created_at: '2026-03-19' },
|
||||
];
|
||||
const picks = [
|
||||
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
|
||||
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'LeBron' },
|
||||
{ stat_type: 'rebounds', direction: 'over', grade: 'A', player: 'Giannis' },
|
||||
{ stat_type: 'assists', direction: 'over', grade: 'B', player: 'Trae' },
|
||||
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Luka' },
|
||||
];
|
||||
|
||||
const supabase = makeMockSupabase(sessions, picks);
|
||||
const result = await generateUpgradePitch(supabase, 'user-1', {
|
||||
grade: 'B',
|
||||
legs: [{ stat_type: 'points' }, { stat_type: 'rebounds' }, { stat_type: 'assists' }, { stat_type: 'points' }, { stat_type: 'steals' }],
|
||||
});
|
||||
|
||||
expect(result.tier_recommended).toBe('desk');
|
||||
expect(result.founder_price).toBe('$34.99/mo');
|
||||
});
|
||||
|
||||
test('handles no prior scans gracefully', async () => {
|
||||
const supabase = makeMockSupabase([], []);
|
||||
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'C', legs: [{ stat_type: 'points' }] });
|
||||
|
||||
expect(result.hook).toContain('1'); // just the current scan
|
||||
expect(result.tier_recommended).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user