test: add payment/betting edge cases — concurrent bets, draw refunds, validation
17 tests covering: simultaneous bet placement, draw refund mechanics, bet validation bounds, extreme ELO odds, Cashu token rejection, and escrow lifecycle leak prevention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f15504b400
commit
27b3b89424
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
placeBet,
|
||||
settleBets,
|
||||
getFightBets,
|
||||
clearEscrow,
|
||||
getPoolInfo,
|
||||
} from './betting.js'
|
||||
import { validateBet, calculateOdds, calculatePayout } from './odds.js'
|
||||
|
||||
beforeEach(() => {
|
||||
clearEscrow()
|
||||
})
|
||||
|
||||
describe('7.3 — simultaneous bets on same fight', () => {
|
||||
it('two concurrent bets on same fight: both accepted into escrow', async () => {
|
||||
const fightId = 'fight-sim-1'
|
||||
const [bet1, bet2] = await Promise.all([
|
||||
placeBet(fightId, 'pub1', 'botA', 500, 'cashuA_test_500', 1200, 1200, 'botA'),
|
||||
placeBet(fightId, 'pub2', 'botB', 300, 'cashuA_test_300', 1200, 1200, 'botA'),
|
||||
])
|
||||
|
||||
expect(bet1.fightId).toBe(fightId)
|
||||
expect(bet2.fightId).toBe(fightId)
|
||||
|
||||
const bets = getFightBets(fightId)
|
||||
expect(bets).toHaveLength(2)
|
||||
|
||||
const pool = getPoolInfo(fightId)!
|
||||
expect(pool.totalPool).toBe(800) // 500 + 300
|
||||
expect(pool.betCount).toBe(2)
|
||||
})
|
||||
|
||||
it('settlement distributes correctly with multiple bets', async () => {
|
||||
const fightId = 'fight-sim-2'
|
||||
// Bet 1: pub1 bets on botA (winner)
|
||||
await placeBet(fightId, 'pub1', 'botA', 1000, 'cashuA_test_1000', 1200, 1200, 'botA')
|
||||
// Bet 2: pub2 bets on botB (loser)
|
||||
await placeBet(fightId, 'pub2', 'botB', 500, 'cashuA_test_500', 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.payoutSats).toBeGreaterThan(0)
|
||||
expect(winner.payoutToken).toBeTruthy()
|
||||
expect(loser.payoutSats).toBe(0)
|
||||
expect(loser.payoutToken).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('7.3 — fight cancelled/draw: bet refund', () => {
|
||||
it('draw settlement refunds all bets', async () => {
|
||||
const fightId = 'fight-draw-1'
|
||||
await placeBet(fightId, 'pub1', 'botA', 1000, 'cashuA_test_1000', 1200, 1200, 'botA')
|
||||
await placeBet(fightId, 'pub2', 'botB', 500, 'cashuA_test_500', 1200, 1200, 'botA')
|
||||
|
||||
// Draw: winnerId = null → all bets refunded
|
||||
const settlements = await settleBets(fightId, null)
|
||||
expect(settlements).toHaveLength(2)
|
||||
|
||||
for (const s of settlements) {
|
||||
expect(s.won).toBe(false)
|
||||
// Refund amount equals original bet amount
|
||||
expect(s.payoutSats).toBeGreaterThan(0) // refund, not zero
|
||||
expect(s.payoutToken).toBeTruthy() // minted refund token
|
||||
}
|
||||
|
||||
// Verify bet 1 refunded correct amount
|
||||
const bet1Settlement = settlements.find(s => s.payoutSats === 1000)
|
||||
expect(bet1Settlement).toBeDefined()
|
||||
|
||||
// Escrow cleared after settlement
|
||||
expect(getFightBets(fightId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clearEscrow returns count of orphaned fight pools', async () => {
|
||||
await placeBet('fight-a', 'pub1', 'botA', 500, 'cashuA_test', 1200, 1200, 'botA')
|
||||
await placeBet('fight-b', 'pub2', 'botB', 300, 'cashuA_test', 1200, 1200, 'botA')
|
||||
|
||||
const cleared = clearEscrow()
|
||||
expect(cleared).toBe(2)
|
||||
|
||||
// All pools gone
|
||||
expect(getFightBets('fight-a')).toHaveLength(0)
|
||||
expect(getFightBets('fight-b')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('settlement on empty fight returns empty array', async () => {
|
||||
const settlements = await settleBets('nonexistent-fight', 'botA')
|
||||
expect(settlements).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('7.3 — bet validation edge cases', () => {
|
||||
it('rejects zero-sat bet', () => {
|
||||
const result = validateBet(0)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects negative bet', () => {
|
||||
const result = validateBet(-100)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects bet above maximum (100k sats)', () => {
|
||||
const result = validateBet(100_001)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts minimum valid bet (100 sats)', () => {
|
||||
const result = validateBet(100)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts maximum valid bet (100k sats)', () => {
|
||||
const result = validateBet(100_000)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('7.3 — odds at extreme ELO gaps', () => {
|
||||
it('odds remain finite and positive at huge ELO gap', () => {
|
||||
const odds = calculateOdds(3000, 100)
|
||||
expect(odds.botAPayoutMultiplier).toBeGreaterThan(0)
|
||||
expect(odds.botBPayoutMultiplier).toBeGreaterThan(0)
|
||||
expect(isFinite(odds.botAPayoutMultiplier)).toBe(true)
|
||||
expect(isFinite(odds.botBPayoutMultiplier)).toBe(true)
|
||||
// Favorite should have lower payout multiplier
|
||||
expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botBPayoutMultiplier)
|
||||
})
|
||||
|
||||
it('equal ELO produces near-even odds', () => {
|
||||
const odds = calculateOdds(1200, 1200)
|
||||
// Should be ~2.0x for both (minus house edge)
|
||||
expect(odds.botAPayoutMultiplier).toBeCloseTo(odds.botBPayoutMultiplier, 1)
|
||||
})
|
||||
|
||||
it('payout calculation is correct', () => {
|
||||
const payout = calculatePayout(1000, 2.5)
|
||||
expect(payout).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('7.3 — Cashu token validation', () => {
|
||||
it('placeBet rejects empty token', async () => {
|
||||
await expect(
|
||||
placeBet('fight-1', 'pub1', 'botA', 500, '', 1200, 1200, 'botA')
|
||||
).rejects.toThrow(/Invalid.*Cashu/i)
|
||||
})
|
||||
|
||||
it('placeBet rejects too-short token', async () => {
|
||||
await expect(
|
||||
placeBet('fight-1', 'pub1', 'botA', 500, 'short', 1200, 1200, 'botA')
|
||||
).rejects.toThrow(/Invalid.*Cashu/i)
|
||||
})
|
||||
|
||||
it('placeBet accepts valid cashuA-prefixed token', async () => {
|
||||
const bet = await placeBet('fight-1', 'pub1', 'botA', 500, 'cashuA_valid_token_test', 1200, 1200, 'botA')
|
||||
expect(bet.id).toBeTruthy()
|
||||
expect(bet.amountSats).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('7.3 — wallet persistence (no server interaction)', () => {
|
||||
it('escrow survives multiple bet/settle cycles without leaks', async () => {
|
||||
// Cycle 1
|
||||
await placeBet('f1', 'pub1', 'botA', 500, 'cashuA_test', 1200, 1200, 'botA')
|
||||
await settleBets('f1', 'botA')
|
||||
expect(getPoolInfo('f1')).toBeNull()
|
||||
|
||||
// Cycle 2
|
||||
await placeBet('f2', 'pub1', 'botA', 300, 'cashuA_test', 1200, 1200, 'botA')
|
||||
await settleBets('f2', null) // draw refund
|
||||
expect(getPoolInfo('f2')).toBeNull()
|
||||
|
||||
// Cycle 3
|
||||
await placeBet('f3', 'pub1', 'botA', 200, 'cashuA_test', 1200, 1200, 'botA')
|
||||
await settleBets('f3', 'botB') // loss
|
||||
expect(getPoolInfo('f3')).toBeNull()
|
||||
|
||||
// No orphaned escrow
|
||||
expect(clearEscrow()).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user