feat: Feature 1.5 — Bet Submission with 3 methods + performance tracking
Three submission methods: - POST /api/bets/quickslip — structured bet entry - POST /api/bets/screenshot — stub OCR with confirm flow - POST /api/bets/sync — coming soon stub Full bet lifecycle: - PATCH /api/bets/:id/settle — settle with outcome, recalculates performance - GET /api/bets — list with status/book/pagination filters - GET /api/bets/performance — ROI, win rate, profit (weekly/monthly/all_time) Payout calculator handles straight bets (American odds) and parlays (multiplied leg payouts). Performance service recalculates on each settlement and upserts into performance table. 33 new tests, 221 total (194 Node.js + 27 Python), all passing. All backend features for Phase 1 + Phase 2 now complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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 }),
|
||||
}));
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
process.env.ODDS_API_KEY = 'test-key';
|
||||
const app = require('../../src/app');
|
||||
|
||||
const MOCK_USER = {
|
||||
id: 'user-1',
|
||||
email: 'test@test.com',
|
||||
tier: 'analyst',
|
||||
scan_count: 0,
|
||||
scan_reset_date: '2026-04-01',
|
||||
};
|
||||
|
||||
const VALID_QUICKSLIP = {
|
||||
legs: [
|
||||
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', odds: -110 },
|
||||
],
|
||||
amount: 20,
|
||||
book: 'draftkings',
|
||||
bet_type: 'straight',
|
||||
};
|
||||
|
||||
const VALID_PARLAY_SLIP = {
|
||||
legs: [
|
||||
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', odds: -110 },
|
||||
{ player: 'LeBron James', stat_type: 'rebounds', line: 8.5, direction: 'over', odds: -120 },
|
||||
],
|
||||
amount: 10,
|
||||
book: 'fanduel',
|
||||
bet_type: 'parlay',
|
||||
};
|
||||
|
||||
function setupAuthMock() {
|
||||
mockSupabaseAuth.getUser.mockResolvedValue({
|
||||
data: { user: { id: MOCK_USER.id, email: MOCK_USER.email } },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
function setupFullMocks() {
|
||||
setupAuthMock();
|
||||
|
||||
mockSupabaseFrom.mockImplementation((table) => {
|
||||
if (table === 'users') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({ data: MOCK_USER, error: null }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'bets') {
|
||||
return {
|
||||
insert: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({
|
||||
data: { id: 'bet-1', status: 'pending', amount: 20, potential_payout: 38.18, bet_type: 'straight', book: 'draftkings', created_at: '2026-03-22T00:00:00Z' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
select: (sel, opts) => ({
|
||||
eq: (col, val) => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({
|
||||
data: { id: 'bet-1', user_id: 'user-1', amount: 20, potential_payout: 38.18, status: 'pending', book: 'draftkings' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
order: () => ({
|
||||
range: () => Promise.resolve({ data: [], count: 0, error: null }),
|
||||
}),
|
||||
in: () => Promise.resolve({ data: [], error: null }),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
eq: () => Promise.resolve({ error: null }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'scan_sessions') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({ data: { id: 'session-1' }, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'performance') {
|
||||
return {
|
||||
upsert: () => Promise.resolve({ error: null }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
select: () => ({ eq: () => ({ in: () => Promise.resolve({ data: [], error: null }) }) }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
describe('POST /api/bets/quickslip', () => {
|
||||
test('creates straight bet with correct payout', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/quickslip')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_QUICKSLIP)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.bet_id).toBeDefined();
|
||||
expect(res.body.status).toBe('pending');
|
||||
expect(res.body.bet_type).toBe('straight');
|
||||
});
|
||||
|
||||
test('creates parlay bet', async () => {
|
||||
setupFullMocks();
|
||||
// Override bets insert to return parlay data
|
||||
const res = await request(app)
|
||||
.post('/api/bets/quickslip')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_PARLAY_SLIP)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.bet_id).toBeDefined();
|
||||
});
|
||||
|
||||
test('returns 400 for missing legs', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/quickslip')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ amount: 20, book: 'draftkings', bet_type: 'straight' })
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toContain('legs');
|
||||
});
|
||||
|
||||
test('returns 400 for invalid bet_type', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/quickslip')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ ...VALID_QUICKSLIP, bet_type: 'invalid' })
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toContain('bet_type');
|
||||
});
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/bets/quickslip')
|
||||
.send(VALID_QUICKSLIP)
|
||||
.expect(401);
|
||||
|
||||
expect(res.body.error).toContain('Authentication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/bets/screenshot', () => {
|
||||
test('returns needs_confirmation with stub extraction', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/screenshot')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ book: 'draftkings' })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.needs_confirmation).toBe(true);
|
||||
expect(res.body.extracted).toBeDefined();
|
||||
expect(res.body.extracted.book).toBe('draftkings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/bets/screenshot/confirm', () => {
|
||||
test('saves confirmed bet', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/screenshot/confirm')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(VALID_QUICKSLIP)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.bet_id).toBeDefined();
|
||||
expect(res.body.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/bets/sync', () => {
|
||||
test('returns coming_soon stub', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.post('/api/bets/sync')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.status).toBe('coming_soon');
|
||||
expect(res.body.supported_books).toContain('draftkings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/bets/:id/settle', () => {
|
||||
test('settles pending bet as won', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.patch('/api/bets/bet-1/settle')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ status: 'won' })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.status).toBe('won');
|
||||
expect(res.body.settled_at).toBeDefined();
|
||||
expect(typeof res.body.profit).toBe('number');
|
||||
});
|
||||
|
||||
test('rejects settling already-settled bet', async () => {
|
||||
setupFullMocks();
|
||||
// Override bet fetch to return already-settled
|
||||
mockSupabaseFrom.mockImplementation((table) => {
|
||||
if (table === 'users') {
|
||||
return { select: () => ({ eq: () => ({ single: () => Promise.resolve({ data: MOCK_USER, error: null }) }) }) };
|
||||
}
|
||||
if (table === 'bets') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({
|
||||
data: { id: 'bet-1', user_id: 'user-1', amount: 20, potential_payout: 38, status: 'won' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) };
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/bets/bet-1/settle')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ status: 'lost' })
|
||||
.expect(422);
|
||||
|
||||
expect(res.body.error).toContain('already settled');
|
||||
});
|
||||
|
||||
test('returns 400 for invalid status', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.patch('/api/bets/bet-1/settle')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ status: 'invalid' })
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toContain('Invalid status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/bets', () => {
|
||||
test('returns user bets with pagination', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.get('/api/bets')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(res.body.bets)).toBe(true);
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
expect(res.body.limit).toBeDefined();
|
||||
expect(res.body.offset).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/bets/performance', () => {
|
||||
test('returns performance stats', async () => {
|
||||
setupFullMocks();
|
||||
const res = await request(app)
|
||||
.get('/api/bets/performance')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.expect(200);
|
||||
|
||||
// Performance service returns results for weekly/monthly/all_time
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
const { extractFromScreenshot } = require('../../src/services/ocrStub');
|
||||
|
||||
describe('ocrStub', () => {
|
||||
test('returns needs_confirmation: true', () => {
|
||||
const result = extractFromScreenshot('draftkings');
|
||||
expect(result.needs_confirmation).toBe(true);
|
||||
expect(result.confidence).toBe(0);
|
||||
expect(result.book).toBe('draftkings');
|
||||
});
|
||||
|
||||
test('includes message for user', () => {
|
||||
const result = extractFromScreenshot('fanduel');
|
||||
expect(result.message).toContain('screenshot');
|
||||
expect(result.book).toBe('fanduel');
|
||||
});
|
||||
|
||||
test('defaults book to unknown', () => {
|
||||
const result = extractFromScreenshot();
|
||||
expect(result.book).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const { calculatePayout, calculateStraightPayout, calculateParlayPayout } = require('../../src/services/payoutCalculator');
|
||||
|
||||
describe('payoutCalculator', () => {
|
||||
describe('straight bets', () => {
|
||||
test('negative odds: -110, $20 → $38.18', () => {
|
||||
const payout = calculatePayout(20, 'straight', [-110]);
|
||||
expect(payout).toBeCloseTo(38.18, 1);
|
||||
});
|
||||
|
||||
test('positive odds: +150, $20 → $50', () => {
|
||||
const payout = calculatePayout(20, 'straight', [150]);
|
||||
expect(payout).toBe(50);
|
||||
});
|
||||
|
||||
test('even odds: -100, $20 → $40', () => {
|
||||
const payout = calculatePayout(20, 'straight', [-100]);
|
||||
expect(payout).toBe(40);
|
||||
});
|
||||
|
||||
test('heavy favorite: -200, $20 → $30', () => {
|
||||
const payout = calculatePayout(20, 'straight', [-200]);
|
||||
expect(payout).toBe(30);
|
||||
});
|
||||
|
||||
test('big underdog: +300, $10 → $40', () => {
|
||||
const payout = calculatePayout(10, 'straight', [300]);
|
||||
expect(payout).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parlay bets', () => {
|
||||
test('2-leg parlay: -110 and -110, $20', () => {
|
||||
const payout = calculatePayout(20, 'parlay', [-110, -110]);
|
||||
// Each leg: 1 + 100/110 = 1.909..., product = 3.647, payout = 20 * 3.647 = 72.93
|
||||
expect(payout).toBeCloseTo(72.93, 0);
|
||||
});
|
||||
|
||||
test('3-leg parlay: -110, -110, +150, $10', () => {
|
||||
const payout = calculatePayout(10, 'parlay', [-110, -110, 150]);
|
||||
// 1.909 * 1.909 * 2.5 = 9.118, payout = 91.18
|
||||
expect(payout).toBeCloseTo(91.18, 0);
|
||||
});
|
||||
|
||||
test('2-leg parlay: +200 and +200, $10', () => {
|
||||
const payout = calculatePayout(10, 'parlay', [200, 200]);
|
||||
// 3 * 3 = 9, payout = 90
|
||||
expect(payout).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('empty odds returns amount', () => {
|
||||
expect(calculatePayout(20, 'straight', [])).toBe(20);
|
||||
});
|
||||
|
||||
test('single leg in parlay treated as straight', () => {
|
||||
const straight = calculatePayout(20, 'straight', [-110]);
|
||||
const parlaySingle = calculatePayout(20, 'parlay', [-110]);
|
||||
// Single leg parlay should use parlay calc but result is same as straight
|
||||
expect(parlaySingle).toBeCloseTo(straight, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const { computeStats, getWeekStart } = require('../../src/services/performanceService');
|
||||
|
||||
describe('performanceService', () => {
|
||||
describe('computeStats', () => {
|
||||
test('calculates ROI correctly from won/lost mix', () => {
|
||||
const bets = [
|
||||
{ amount: '20', potential_payout: '38.18', status: 'won', settled_at: '2026-03-21' },
|
||||
{ amount: '20', potential_payout: '38.18', status: 'lost', settled_at: '2026-03-21' },
|
||||
{ amount: '20', potential_payout: '38.18', status: 'won', settled_at: '2026-03-21' },
|
||||
];
|
||||
const stats = computeStats(bets);
|
||||
// Won: 18.18 + 18.18 = 36.36 profit, Lost: -20 = -20
|
||||
// Total profit: 16.36, Total wagered: 60
|
||||
// ROI: 16.36/60 * 100 = 27.3%
|
||||
expect(stats.roi).toBeCloseTo(27.3, 0);
|
||||
expect(stats.total_wagered).toBe(60);
|
||||
expect(stats.total_profit).toBeCloseTo(16.36, 1);
|
||||
});
|
||||
|
||||
test('win rate = won / (won + lost), excludes push', () => {
|
||||
const bets = [
|
||||
{ amount: '20', potential_payout: '38', status: 'won', settled_at: '2026-03-21' },
|
||||
{ amount: '20', potential_payout: '38', status: 'lost', settled_at: '2026-03-21' },
|
||||
{ amount: '20', potential_payout: '38', status: 'push', settled_at: '2026-03-21' },
|
||||
];
|
||||
const stats = computeStats(bets);
|
||||
expect(stats.win_rate).toBe(50);
|
||||
expect(stats.sample_size).toBe(3);
|
||||
});
|
||||
|
||||
test('empty bets returns zeroes', () => {
|
||||
const stats = computeStats([]);
|
||||
expect(stats.roi).toBe(0);
|
||||
expect(stats.win_rate).toBe(0);
|
||||
expect(stats.sample_size).toBe(0);
|
||||
expect(stats.total_wagered).toBe(0);
|
||||
expect(stats.total_profit).toBe(0);
|
||||
});
|
||||
|
||||
test('all losses gives negative ROI', () => {
|
||||
const bets = [
|
||||
{ amount: '20', potential_payout: '38', status: 'lost', settled_at: '2026-03-21' },
|
||||
{ amount: '30', potential_payout: '57', status: 'lost', settled_at: '2026-03-21' },
|
||||
];
|
||||
const stats = computeStats(bets);
|
||||
expect(stats.roi).toBe(-100);
|
||||
expect(stats.win_rate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWeekStart', () => {
|
||||
test('returns Monday for a Wednesday', () => {
|
||||
// 2026-03-18 is a Wednesday
|
||||
const ws = getWeekStart(new Date('2026-03-18T12:00:00Z'));
|
||||
expect(ws.getUTCDay()).toBe(1); // Monday
|
||||
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
|
||||
});
|
||||
|
||||
test('returns Monday for a Monday', () => {
|
||||
const ws = getWeekStart(new Date('2026-03-16T12:00:00Z'));
|
||||
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
|
||||
});
|
||||
|
||||
test('returns previous Monday for a Sunday', () => {
|
||||
// 2026-03-22 is a Sunday
|
||||
const ws = getWeekStart(new Date('2026-03-22T12:00:00Z'));
|
||||
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user