import { describe, it, expect, vi, beforeEach } from 'vitest' import { Hono } from 'hono' import { calculateOdds, eloProbability, calculatePayout, validateBet, oddsToFractional, oddsToAmerican, } from '../engine/odds.js' import { placeBet, settleBets, getFightBets, getPoolInfo, } from '../engine/betting.js' // Mock DB for route tests vi.mock('../db/index.js', () => ({ db: { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), orderBy: vi.fn().mockResolvedValue([]), }), orderBy: vi.fn().mockResolvedValue([]), }), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ run: vi.fn() }), }), }, schema: { bots: { id: 'id', eloRating: 'eloRating', winStreak: 'winStreak' }, fights: { id: 'id', status: 'status', botAId: 'botAId', botBId: 'botBId' }, bets: { id: 'id', fightId: 'fightId', bettorPubkey: 'bettorPubkey', botId: 'botId', amountSats: 'amountSats', oddsAtPlacement: 'oddsAtPlacement', cashuToken: 'cashuToken', status: 'status', createdAt: 'createdAt', settledAt: 'settledAt', payoutSats: 'payoutSats' }, }, })) vi.mock('../middleware/rate-limit.js', () => ({ rateLimit: () => async (_c: any, next: any) => next(), })) const { betsRouter } = await import('./bets.js') function makeApp() { const app = new Hono() app.route('/api/bets', betsRouter) return app } async function placeBetReq(cashuToken: string, overrides: Record = {}) { const app = makeApp() return app.request('/api/bets/place', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fightId: 'test-fight', pubkey: 'a'.repeat(64), botId: 'test-bot', amountSats: 100, cashuToken, ...overrides, }), }) } describe('bets cashu token validation', () => { it('rejects empty string token', async () => { const res = await placeBetReq('') // Empty string fails the required fields check first expect(res.status).toBe(400) }) it('rejects non-base64 garbage token', async () => { const res = await placeBetReq('not-a-valid-cashu-token!!!') expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toBe('Invalid Cashu token format.') }) it('rejects truncated token', async () => { const res = await placeBetReq('cashuAey') expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toBe('Invalid Cashu token format.') }) it('rejects random base64 that is not cashu format', async () => { const res = await placeBetReq('eyJhbGciOiJIUzI1NiJ9') expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toBe('Invalid Cashu token format.') }) }) describe('bets route validation', () => { it('rejects missing required fields', async () => { const app = makeApp() const res = await app.request('/api/bets/place', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fightId: 'f1' }), }) expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toContain('Missing required fields') }) it('rejects negative amountSats', async () => { const res = await placeBetReq('cashuAvalidtoken', { amountSats: -100 }) expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toContain('amountSats') }) it('rejects zero amountSats', async () => { const res = await placeBetReq('cashuAvalidtoken', { amountSats: 0 }) expect(res.status).toBe(400) }) it('rejects non-integer amountSats', async () => { const res = await placeBetReq('cashuAvalidtoken', { amountSats: 50.5 }) expect(res.status).toBe(400) }) it('rejects amountSats over 1M', async () => { const res = await placeBetReq('cashuAvalidtoken', { amountSats: 1_000_001 }) expect(res.status).toBe(400) }) it('deposit rejects missing fields', async () => { const app = makeApp() const res = await app.request('/api/bets/deposit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(400) }) it('deposit rejects amount below 100', async () => { const app = makeApp() const res = await app.request('/api/bets/deposit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amountSats: 50, pubkey: 'abc' }), }) expect(res.status).toBe(400) const json = await res.json() as { error: string } expect(json.error).toContain('100') }) it('withdraw rejects missing fields', async () => { const app = makeApp() const res = await app.request('/api/bets/withdraw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(400) }) }) describe('odds calculation', () => { it('equal ELO gives ~50% probability', () => { const prob = eloProbability(1200, 1200) expect(prob).toBeCloseTo(0.5, 5) }) it('higher ELO gives higher probability', () => { const prob = eloProbability(1600, 1200) expect(prob).toBeGreaterThan(0.9) }) it('calculateOdds returns valid structure', () => { const odds = calculateOdds(1200, 1200) expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2) expect(odds.botAPayoutMultiplier).toBeGreaterThan(0) expect(odds.botBPayoutMultiplier).toBeGreaterThan(0) expect(odds.spread).toBe(0) }) it('house edge reduces payout multiplier below fair odds', () => { const odds = calculateOdds(1200, 1200) // Fair odds for 50/50 would be 2.0, with 3% edge it should be ~1.94 expect(odds.botAPayoutMultiplier).toBeLessThan(2.0) expect(odds.botAPayoutMultiplier).toBeGreaterThan(1.8) }) it('streak adjusts probability', () => { const noStreak = calculateOdds(1200, 1200) const withStreak = calculateOdds(1200, 1200, { streakA: 5 }) expect(withStreak.botAWinProb).toBeGreaterThan(noStreak.botAWinProb) }) it('payout calculation floors to integer', () => { expect(calculatePayout(100, 1.97)).toBe(197) expect(calculatePayout(100, 1.555)).toBe(155) }) }) describe('bet validation (engine)', () => { it('rejects non-integer amounts', () => { expect(validateBet(50.5).valid).toBe(false) }) it('rejects below minimum (100 sats)', () => { const result = validateBet(50) expect(result.valid).toBe(false) expect(result.error).toContain('Minimum') }) it('rejects above maximum (100k sats)', () => { const result = validateBet(200_000) expect(result.valid).toBe(false) expect(result.error).toContain('Maximum') }) it('accepts valid amounts', () => { expect(validateBet(100).valid).toBe(true) expect(validateBet(1000).valid).toBe(true) expect(validateBet(100_000).valid).toBe(true) }) }) describe('betting engine escrow + settlement', () => { it('placeBet creates bet with correct odds', async () => { const bet = await placeBet('fight_1', 'pub1', 'botA', 1000, 'cashuA_test_token', 1200, 1200, 'botA') expect(bet.fightId).toBe('fight_1') expect(bet.amountSats).toBe(1000) expect(bet.oddsAtPlacement).toBeGreaterThan(0) expect(bet.potentialPayout).toBeGreaterThan(0) }) it('settleBets — winner gets payout, loser gets nothing', async () => { const fightId = 'fight_settle_' + Date.now() await placeBet(fightId, 'pub1', 'botA', 500, 'cashuA_tok_1', 1200, 1200, 'botA') await placeBet(fightId, 'pub2', 'botB', 500, 'cashuA_tok_2', 1200, 1200, 'botA') const settlements = await settleBets(fightId, 'botA') expect(settlements).toHaveLength(2) const winner = settlements.find(s => s.won) const loser = settlements.find(s => !s.won) expect(winner).toBeDefined() expect(winner!.payoutSats).toBeGreaterThan(0) expect(winner!.payoutToken).toBeTruthy() expect(loser).toBeDefined() expect(loser!.payoutSats).toBe(0) expect(loser!.payoutToken).toBeNull() }) it('settleBets — draw refunds all bets', async () => { const fightId = 'fight_draw_' + Date.now() await placeBet(fightId, 'pub1', 'botA', 300, 'cashuA_tok_3', 1200, 1200, 'botA') const settlements = await settleBets(fightId, null) expect(settlements).toHaveLength(1) expect(settlements[0].payoutSats).toBe(300) // refund original amount expect(settlements[0].payoutToken).toBeTruthy() }) it('getFightBets returns empty for unknown fight', () => { expect(getFightBets('nonexistent')).toEqual([]) }) it('getPoolInfo returns null for unknown fight', () => { expect(getPoolInfo('nonexistent')).toBeNull() }) }) describe('odds display conversion', () => { it('fractional odds for even money', () => { expect(oddsToFractional(2.0)).toBe('1/1') }) it('american odds for underdog', () => { expect(oddsToAmerican(3.0)).toBe('+200') }) it('american odds for favorite', () => { const result = oddsToAmerican(1.5) expect(result).toMatch(/^-/) }) })