test: add payments.test.ts (12 cases) and expand bets.test.ts (30 cases)
Covers wallet connection, invoice creation, payment confirmation, zap validation, odds calculation, escrow settlement, bet validation, and display conversion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2576221e24
commit
c224776c90
+247
-10
@@ -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<string, any> = {}) {
|
||||
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(/^-/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, any[]> = {}
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user