diff --git a/server/src/routes/bets.test.ts b/server/src/routes/bets.test.ts index 30d1629..8bb69ee 100644 --- a/server/src/routes/bets.test.ts +++ b/server/src/routes/bets.test.ts @@ -1,11 +1,57 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { Hono } from 'hono' -import { betsRouter } from './bets.js' +import { + calculateOdds, + eloProbability, + calculatePayout, + validateBet, + oddsToFractional, + oddsToAmerican, +} from '../engine/odds.js' +import { + placeBet, + settleBets, + getFightBets, + getPoolInfo, +} from '../engine/betting.js' -const app = new Hono() -app.route('/api/bets', betsRouter) +// 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' }, + }, +})) -async function placeBet(cashuToken: string) { +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' }, @@ -15,36 +61,227 @@ async function placeBet(cashuToken: string) { botId: 'test-bot', amountSats: 100, cashuToken, + ...overrides, }), }) } describe('bets cashu token validation', () => { it('rejects empty string token', async () => { - const res = await placeBet('') + 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 placeBet('not-a-valid-cashu-token!!!') + 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 () => { - // A truncated token that starts like cashu but is incomplete - const res = await placeBet('cashuAey') + 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 placeBet('eyJhbGciOiJIUzI1NiJ9') + 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(/^-/) + }) +}) diff --git a/server/src/routes/payments.test.ts b/server/src/routes/payments.test.ts new file mode 100644 index 0000000..cb4678c --- /dev/null +++ b/server/src/routes/payments.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' + +// Mock DB +vi.mock('../db/index.js', () => { + const rows: Record = {} + return { + db: { + select: vi.fn().mockImplementation((fields?: any) => ({ + from: vi.fn().mockImplementation((table: any) => ({ + where: vi.fn().mockImplementation((condition: any) => ({ + limit: vi.fn().mockImplementation((n: number) => { + // Return mock data based on what's being queried + const key = JSON.stringify(condition) + return Promise.resolve(rows[key] || []) + }), + })), + })), + })), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ run: vi.fn() }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ run: vi.fn() }), + }), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ run: vi.fn() }), + }), + }, + schema: { + bots: { id: 'id', publicKey: 'publicKey', hasWallet: 'hasWallet', zapsReceived: 'zapsReceived' }, + walletConnections: { id: 'id', botId: 'botId', method: 'method', connectionData: 'connectionData' }, + payments: { id: 'id', status: 'status', botId: 'botId', direction: 'direction', cashuToken: 'cashuToken', amountSats: 'amountSats', invoice: 'invoice', preimage: 'preimage', confirmedAt: 'confirmedAt' }, + fights: { id: 'id', winnerId: 'winnerId', status: 'status' }, + }, + } +}) + +vi.mock('../engine/payments.js', () => ({ + createEntryInvoice: vi.fn().mockResolvedValue({ bolt11: 'lnbc21test', paymentId: 'pay_123' }), + checkPaymentStatus: vi.fn().mockResolvedValue('pending'), + redeemCashuToken: vi.fn().mockResolvedValue({ paymentId: 'pay_cashu_1', valid: true }), +})) + +vi.mock('../engine/crypto.js', () => ({ + encrypt: vi.fn((data: string) => `enc_${data}`), + decrypt: vi.fn((data: string) => data.replace('enc_', '')), +})) + +vi.mock('../middleware/rate-limit.js', () => ({ + rateLimit: () => async (_c: any, next: any) => next(), +})) + +const { paymentsRouter } = await import('./payments.js') +const { db, schema } = await import('../db/index.js') +const { createEntryInvoice, checkPaymentStatus } = await import('../engine/payments.js') + +function makeApp() { + const app = new Hono() + app.route('/api/payments', paymentsRouter) + return app +} + +describe('payments routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('connect-wallet returns 400 when missing fields', async () => { + const app = makeApp() + const res = await app.request('/api/payments/connect-wallet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: 'abc' }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('Missing') + }) + + it('connect-wallet returns 404 when bot not found', async () => { + const app = makeApp() + // db.select will return empty array by default + const res = await app.request('/api/payments/connect-wallet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pubkey: 'a'.repeat(64), + method: 'nwc', + connectionData: 'nostr+walletconnect://test', + }), + }) + expect(res.status).toBe(404) + }) + + it('create-invoice returns 400 when missing botId', async () => { + const app = makeApp() + const res = await app.request('/api/payments/create-invoice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('Missing botId') + }) + + it('create-invoice returns invoice in dev mode', async () => { + const app = makeApp() + const res = await app.request('/api/payments/create-invoice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botId: 'bot_1' }), + }) + expect(res.status).toBe(200) + const json = await res.json() as { bolt11: string; paymentId: string } + expect(json.bolt11).toBe('lnbc21test') + expect(json.paymentId).toBe('pay_123') + expect(createEntryInvoice).toHaveBeenCalledWith('bot_1') + }) + + it('check/:paymentId returns 400 for invalid paymentId', async () => { + const app = makeApp() + const res = await app.request('/api/payments/check/' + 'x'.repeat(25)) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toBe('Invalid paymentId') + }) + + it('check/:paymentId returns payment status', async () => { + const app = makeApp() + const res = await app.request('/api/payments/check/pay_123') + expect(res.status).toBe(200) + const json = await res.json() as { status: string } + expect(json.status).toBe('pending') + expect(checkPaymentStatus).toHaveBeenCalledWith('pay_123') + }) + + it('confirm/:paymentId returns 404 when payment not found', async () => { + const app = makeApp() + const res = await app.request('/api/payments/confirm/pay_999', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(404) + }) + + it('submit-cashu returns 400 when missing fields', async () => { + const app = makeApp() + const res = await app.request('/api/payments/submit-cashu', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botId: 'bot_1' }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('Missing') + }) + + it('disconnect-wallet returns 400 when missing pubkey', async () => { + const app = makeApp() + const res = await app.request('/api/payments/disconnect-wallet', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it('zap returns 400 for invalid amountSats', async () => { + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'bot_1', fightId: 'f_1', amountSats: -5 }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('amountSats') + }) + + it('zap returns 400 for non-integer amountSats', async () => { + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'bot_1', fightId: 'f_1', amountSats: 3.5 }), + }) + expect(res.status).toBe(400) + }) + + it('zap returns 400 when missing winnerId or fightId', async () => { + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amountSats: 100 }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('Missing') + }) +})