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:
Kev
2026-03-22 05:11:42 -04:00
parent 2366660f5e
commit ed6502a880
12 changed files with 1310 additions and 37 deletions
+312
View File
@@ -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();
});
});