Files
botfights/server/src/routes/fights.integration.test.ts
T
DorianandClaude Opus 4.6 5e40221a2f test: SSE, polling, human, and concurrent fight integration tests
14 integration tests covering full fight lifecycle with real in-memory
DB: SSE event ordering, polling bot challenge/response flow, human
player response submission, and 3 concurrent fights without interference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:10:27 +00:00

606 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from '../db/schema.js'
import { eq } from 'drizzle-orm'
// Create a real in-memory DB for integration testing
let testSqlite: ReturnType<typeof Database>
let testDb: ReturnType<typeof drizzle>
function setupTestDb() {
testSqlite = new Database(':memory:')
testSqlite.pragma('journal_mode = WAL')
testSqlite.pragma('foreign_keys = ON')
testSqlite.exec(`
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
webhook_url TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
archetype TEXT NOT NULL DEFAULT 'standard',
secret_hash TEXT NOT NULL,
public_key TEXT,
profile_pic_url TEXT,
elo_rating REAL NOT NULL DEFAULT 1200,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
win_streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
tier INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_fight_at TEXT,
consecutive_errors INTEGER NOT NULL DEFAULT 0,
last_error_at TEXT,
customization TEXT,
sats_won INTEGER NOT NULL DEFAULT 0,
sats_wagered INTEGER NOT NULL DEFAULT 0,
has_wallet INTEGER NOT NULL DEFAULT 0,
zaps_received INTEGER NOT NULL DEFAULT 0,
bot_type TEXT NOT NULL DEFAULT 'regular',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS fights (
id TEXT PRIMARY KEY,
bot_a_id TEXT NOT NULL REFERENCES bots(id),
bot_b_id TEXT NOT NULL REFERENCES bots(id),
arena TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
winner_id TEXT REFERENCES bots(id),
bot_a_hp INTEGER NOT NULL DEFAULT 200,
bot_b_hp INTEGER NOT NULL DEFAULT 200,
total_rounds INTEGER NOT NULL DEFAULT 0,
scheduled_at TEXT,
started_at TEXT,
ended_at TEXT,
mode TEXT NOT NULL DEFAULT 'free',
pot_sats INTEGER NOT NULL DEFAULT 0,
payout_status TEXT,
current_season TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rounds (
id TEXT PRIMARY KEY,
fight_id TEXT NOT NULL REFERENCES fights(id),
round_number INTEGER NOT NULL,
challenge_type TEXT NOT NULL,
challenge_data TEXT NOT NULL,
bot_a_response TEXT,
bot_a_time_ms INTEGER,
bot_a_score REAL,
bot_b_response TEXT,
bot_b_time_ms INTEGER,
bot_b_score REAL,
winner_id TEXT REFERENCES bots(id),
narration TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
fight_id TEXT REFERENCES fights(id),
bot_id TEXT NOT NULL REFERENCES bots(id),
direction TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
method TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
invoice TEXT,
preimage TEXT,
cashu_token TEXT,
error_reason TEXT,
created_at TEXT NOT NULL,
confirmed_at TEXT,
refunded_at TEXT
);
CREATE TABLE IF NOT EXISTS wallet_connections (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL UNIQUE REFERENCES bots(id),
method TEXT NOT NULL,
connection_data TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS bets (
id TEXT PRIMARY KEY,
fight_id TEXT NOT NULL REFERENCES fights(id),
bettor_pubkey TEXT NOT NULL,
bot_id TEXT NOT NULL REFERENCES bots(id),
amount_sats INTEGER NOT NULL,
odds_at_placement REAL NOT NULL,
cashu_token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payout_sats INTEGER,
payout_token TEXT,
created_at TEXT NOT NULL,
settled_at TEXT
);
CREATE TABLE IF NOT EXISTS tournaments (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
format TEXT NOT NULL DEFAULT 'single_elim',
size INTEGER NOT NULL,
entry_sats INTEGER NOT NULL DEFAULT 0,
prize_sats INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
current_round INTEGER NOT NULL DEFAULT 0,
season_id TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS tournament_entries (
id TEXT PRIMARY KEY,
tournament_id TEXT NOT NULL REFERENCES tournaments(id),
bot_id TEXT NOT NULL REFERENCES bots(id),
seed INTEGER NOT NULL DEFAULT 0,
eliminated INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tournament_matches (
id TEXT PRIMARY KEY,
tournament_id TEXT NOT NULL REFERENCES tournaments(id),
round INTEGER NOT NULL,
match_index INTEGER NOT NULL,
bot_a_id TEXT REFERENCES bots(id),
bot_b_id TEXT REFERENCES bots(id),
fight_id TEXT REFERENCES fights(id),
winner_id TEXT REFERENCES bots(id),
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE IF NOT EXISTS analytics (
date TEXT NOT NULL,
metric TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_analytics_date_metric ON analytics(date, metric);
`)
testDb = drizzle(testSqlite, { schema })
}
// Mock the DB module with our real test DB
vi.mock('../db/index.js', () => {
return {
get db() { return testDb },
get sqlite() { return testSqlite },
schema,
}
})
// Mock external side effects
vi.mock('../engine/betting.js', () => ({
lockBets: vi.fn(),
settleBets: vi.fn(),
}))
vi.mock('../engine/payments.js', () => ({
payWinner: vi.fn(),
refundEntry: vi.fn(),
ENTRY_FEE_SATS: 21,
}))
vi.mock('../engine/nostr-publish.js', () => ({
publishFightResult: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../engine/queue.js', () => ({
setCooldown: vi.fn(),
}))
vi.mock('../engine/analytics.js', () => ({
trackFightCompleted: vi.fn(),
trackBotActive: vi.fn(),
trackMetric: vi.fn(),
}))
vi.mock('../routes/bots.js', () => ({
invalidateLeaderboardCache: vi.fn(),
}))
vi.mock('../engine/tournaments.js', () => ({
onFightFinished: vi.fn(),
}))
// Import after mocks
const { runFight, isInFight } = await import('../engine/orchestrator.js')
function insertBot(id: string, name: string, elo = 1200, webhookUrl = 'http://mock.local') {
testDb.insert(schema.bots).values({
id,
name,
webhookUrl,
avatarSeed: 'test',
archetype: 'standard',
secretHash: 'testhash',
eloRating: elo,
createdAt: new Date().toISOString(),
}).run()
}
describe('fights integration — full fight flow with real DB', () => {
beforeEach(() => {
setupTestDb()
})
it('two mock bots fight to completion — winner gets ELO, loser loses ELO', async () => {
insertBot('bot-a', 'MockAlpha', 1200)
insertBot('bot-b', 'MockBeta', 1200)
await runFight('bot-a', 'bot-b', 'free')
// Verify fight record
const fights = testDb.select().from(schema.fights).all()
expect(fights).toHaveLength(1)
expect(fights[0].status).toBe('finished')
expect(fights[0].winnerId).toBeTruthy()
expect(fights[0].totalRounds).toBeGreaterThanOrEqual(1)
expect(fights[0].totalRounds).toBeLessThanOrEqual(10)
expect(fights[0].endedAt).toBeTruthy()
// Verify rounds were recorded
const rounds = testDb.select().from(schema.rounds).all()
expect(rounds.length).toBe(fights[0].totalRounds)
for (const r of rounds) {
expect(r.fightId).toBe(fights[0].id)
expect(r.challengeType).toBeTruthy()
expect(r.narration).toBeTruthy()
}
// Verify ELO updated — winner up, loser down
const botA = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'bot-a')).get()!
const botB = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'bot-b')).get()!
if (fights[0].winnerId === 'bot-a') {
expect(botA.eloRating).toBeGreaterThan(1200)
expect(botB.eloRating).toBeLessThan(1200)
expect(botA.wins).toBe(1)
expect(botB.losses).toBe(1)
} else {
expect(botB.eloRating).toBeGreaterThan(1200)
expect(botA.eloRating).toBeLessThan(1200)
expect(botB.wins).toBe(1)
expect(botA.losses).toBe(1)
}
})
it('fight produces valid HP progression — never negative', async () => {
insertBot('bot-c', 'MockCharlie', 1500)
insertBot('bot-d', 'MockDelta', 900)
await runFight('bot-c', 'bot-d', 'free')
const fight = testDb.select().from(schema.fights).all()[0]
expect(fight.botAHp).toBeGreaterThanOrEqual(0)
expect(fight.botBHp).toBeGreaterThanOrEqual(0)
// At least one bot should have HP at or below 0 (KO) or fight reached max rounds
expect(fight.botAHp <= 0 || fight.botBHp <= 0 || fight.totalRounds === 10).toBe(true)
})
it('higher ELO bot wins more often over many fights', async () => {
let highWins = 0, lowWins = 0
for (let i = 0; i < 50; i++) {
// Fresh DB per fight to avoid unique constraint on bot names
setupTestDb()
insertBot('high-elo', 'HighBot', 1800)
insertBot('low-elo', 'LowBot', 900)
await runFight('high-elo', 'low-elo', 'free')
const fight = testDb.select().from(schema.fights).all()[0]
if (fight.winnerId === 'high-elo') highWins++
else if (fight.winnerId === 'low-elo') lowWins++
}
// High ELO mock bots should win at least 40% (randomness means upsets happen)
expect(highWins).toBeGreaterThanOrEqual(20)
})
it('concurrent fight prevention — same bot cannot fight twice', async () => {
insertBot('solo-bot', 'SoloFighter', 1200)
insertBot('opponent-1', 'Opp1', 1200)
insertBot('opponent-2', 'Opp2', 1200)
// Start first fight (runs to completion since mock bots are synchronous)
await runFight('solo-bot', 'opponent-1', 'free')
// After completion, bot should not be in fight
expect(isInFight('solo-bot')).toBe(false)
// Can start another fight
await runFight('solo-bot', 'opponent-2', 'free')
const fights = testDb.select().from(schema.fights).all()
expect(fights).toHaveLength(2)
expect(fights.every(f => f.status === 'finished')).toBe(true)
})
it('round data has valid scores and challenge info', async () => {
insertBot('bot-e', 'MockEcho', 1200)
insertBot('bot-f', 'MockFoxtrot', 1200)
await runFight('bot-e', 'bot-f', 'free')
const rounds = testDb.select().from(schema.rounds).all()
expect(rounds.length).toBeGreaterThanOrEqual(1)
for (const r of rounds) {
expect(r.challengeType).toBeTruthy()
expect(r.challengeData).toBeTruthy()
// Parse challenge data
const data = JSON.parse(r.challengeData)
expect(data.prompt).toBeTruthy()
expect(data.scoring).toBeTruthy()
// Scores should be non-negative
expect(r.botAScore).toBeGreaterThanOrEqual(0)
expect(r.botBScore).toBeGreaterThanOrEqual(0)
}
})
it('ELO changes are symmetric — total ELO is conserved', async () => {
insertBot('bot-g', 'MockGolf', 1300)
insertBot('bot-h', 'MockHotel', 1100)
const totalEloBefore = 1300 + 1100
await runFight('bot-g', 'bot-h', 'free')
const botG = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'bot-g')).get()!
const botH = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'bot-h')).get()!
const totalEloAfter = botG.eloRating + botH.eloRating
// ELO should be approximately conserved (rounding may cause ±1)
expect(Math.abs(totalEloAfter - totalEloBefore)).toBeLessThanOrEqual(1)
})
it('fight status transitions: live → finished', async () => {
insertBot('bot-i', 'MockIndia', 1200)
insertBot('bot-j', 'MockJuliet', 1200)
await runFight('bot-i', 'bot-j', 'free')
const fight = testDb.select().from(schema.fights).all()[0]
expect(fight.status).toBe('finished')
expect(fight.startedAt).toBeTruthy()
expect(fight.endedAt).toBeTruthy()
// endedAt should be after startedAt
expect(new Date(fight.endedAt!).getTime()).toBeGreaterThanOrEqual(new Date(fight.startedAt!).getTime())
})
it('win streak increments correctly across multiple fights', async () => {
// This test checks if winning multiple fights in a row increments streak
setupTestDb()
insertBot('streak-bot', 'StreakMaster', 1800)
insertBot('weak-1', 'Weakling1', 800, 'http://mock.local/1')
insertBot('weak-2', 'Weakling2', 800, 'http://mock.local/2')
await runFight('streak-bot', 'weak-1', 'free')
const afterFirst = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'streak-bot')).get()!
await runFight('streak-bot', 'weak-2', 'free')
const afterSecond = testDb.select().from(schema.bots).where(eq(schema.bots.id, 'streak-bot')).get()!
const fight1 = testDb.select().from(schema.fights).all()[0]
const fight2 = testDb.select().from(schema.fights).all()[1]
// If streak-bot won both (highly likely at 1800 vs 800)
if (fight1.winnerId === 'streak-bot' && fight2.winnerId === 'streak-bot') {
expect(afterSecond.winStreak).toBe(2)
expect(afterSecond.wins).toBe(2)
}
})
})
describe('SSE event delivery integration', () => {
beforeEach(() => {
setupTestDb()
})
it('fight emits events in correct order: fight_start → round_start → round_end × N → fight_end', async () => {
const { fightEvents } = await import('../engine/events.js')
insertBot('sse-a', 'SSE_Alpha', 1200)
insertBot('sse-b', 'SSE_Beta', 1200)
const events: { type: string; data: Record<string, unknown> }[] = []
// Start fight (it runs synchronously for mock bots)
const fightPromise = runFight('sse-a', 'sse-b', 'free')
// Listen for events on all fights (we don't know fightId yet)
const unsub = fightEvents.onAll((event) => {
events.push({ type: event.type, data: event.data })
})
await fightPromise
unsub()
// Verify event ordering
expect(events.length).toBeGreaterThanOrEqual(4) // fight_start + at least 1 round (start+end) + fight_end
// First event is fight_start
expect(events[0].type).toBe('fight_start')
expect(events[0].data.botA).toBeTruthy()
expect(events[0].data.botB).toBeTruthy()
expect(events[0].data.arena).toBeTruthy()
// Last event is fight_end
const lastEvent = events[events.length - 1]
expect(lastEvent.type).toBe('fight_end')
expect(lastEvent.data.winnerId).toBeTruthy()
expect(lastEvent.data.finalHp).toBeTruthy()
// All round_start events are followed by round_end events
const roundStarts = events.filter(e => e.type === 'round_start')
const roundEnds = events.filter(e => e.type === 'round_end')
expect(roundStarts.length).toBe(roundEnds.length)
expect(roundStarts.length).toBeGreaterThanOrEqual(1)
// Verify round_end payloads have expected fields
for (const re of roundEnds) {
expect(re.data.round).toBeTruthy()
expect(re.data.result).toBeTruthy()
expect(re.data.hp).toBeTruthy()
expect(re.data.combo).toBeTruthy()
}
})
it('events contain valid bot info and arena data', async () => {
const { fightEvents } = await import('../engine/events.js')
insertBot('info-a', 'InfoAlpha', 1400)
insertBot('info-b', 'InfoBeta', 1100)
let fightStartData: Record<string, unknown> | null = null
const unsub = fightEvents.onAll((event) => {
if (event.type === 'fight_start') fightStartData = event.data
})
await runFight('info-a', 'info-b', 'free')
unsub()
expect(fightStartData).toBeTruthy()
const botA = fightStartData!.botA as { id: string; name: string; elo: number }
const botB = fightStartData!.botB as { id: string; name: string; elo: number }
expect(botA.id).toBe('info-a')
expect(botA.name).toBe('InfoAlpha')
expect(botA.elo).toBe(1400)
expect(botB.id).toBe('info-b')
expect(botB.name).toBe('InfoBeta')
expect(botB.elo).toBe(1100)
const arena = fightStartData!.arena as { id: string; name: string }
expect(arena.id).toBeTruthy()
expect(arena.name).toBeTruthy()
})
})
describe('polling bot integration', () => {
beforeEach(() => {
setupTestDb()
})
it('polling bot can be fought — submitting responses via poll API', async () => {
const { getPendingPollChallenge, submitPollResponse } = await import('../engine/poll-responses.js')
insertBot('poll-bot', 'PollBot', 1200, 'http://poll.local/')
insertBot('mock-opp', 'MockOpp', 1200)
// Start fight in background
const fightPromise = runFight('poll-bot', 'mock-opp', 'free')
// Poll bot needs to respond to challenges as they come in
// The fight orchestrator waits for poll responses with a timeout
// We need to repeatedly check for pending challenges and submit answers
const pollInterval = setInterval(() => {
const challenge = getPendingPollChallenge('poll-bot')
if (challenge) {
// Submit a correct answer if we can determine it
const answer = 'poll-answer'
submitPollResponse('poll-bot', answer)
}
}, 50)
await fightPromise
clearInterval(pollInterval)
// Verify fight completed
const fights = testDb.select().from(schema.fights).all()
expect(fights).toHaveLength(1)
expect(fights[0].status).toBe('finished')
})
// Skipped: polling bot timeout test takes 120s+ (18s per round × 6+ rounds)
// The poll-with-responses test above validates the flow works correctly
})
describe('human fight integration', () => {
beforeEach(() => {
setupTestDb()
})
it('human player fight with submitted responses completes', async () => {
const { submitHumanResponse } = await import('../engine/human-responses.js')
const { fightEvents } = await import('../engine/events.js')
insertBot('human-bot', 'HumanPlayer', 1200, 'http://human.local/')
insertBot('ai-opp', 'AIOpp', 1200)
let currentFightId = ''
// Listen for fight_start to get fightId
const unsub = fightEvents.onAll((event) => {
if (event.type === 'fight_start') {
currentFightId = event.fightId
}
// When human_challenge arrives, submit an answer
if (event.type === 'human_challenge' && event.data.botId === 'human-bot') {
setTimeout(() => {
submitHumanResponse(event.fightId, 'human-bot', 'my answer')
}, 10)
}
})
await runFight('human-bot', 'ai-opp', 'free')
unsub()
const fights = testDb.select().from(schema.fights).all()
expect(fights).toHaveLength(1)
expect(fights[0].status).toBe('finished')
expect(fights[0].totalRounds).toBeGreaterThanOrEqual(1)
})
})
describe('concurrent fights integration', () => {
beforeEach(() => {
setupTestDb()
})
it('three simultaneous mock fights complete without interference', async () => {
insertBot('sim-a1', 'SimA1', 1200)
insertBot('sim-a2', 'SimA2', 1200)
insertBot('sim-b1', 'SimB1', 1300)
insertBot('sim-b2', 'SimB2', 1300)
insertBot('sim-c1', 'SimC1', 1400)
insertBot('sim-c2', 'SimC2', 1400)
// Run 3 fights concurrently
const [r1, r2, r3] = await Promise.all([
runFight('sim-a1', 'sim-a2', 'free'),
runFight('sim-b1', 'sim-b2', 'free'),
runFight('sim-c1', 'sim-c2', 'free'),
])
// All 3 should complete
const fights = testDb.select().from(schema.fights).all()
expect(fights).toHaveLength(3)
expect(fights.every(f => f.status === 'finished')).toBe(true)
expect(fights.every(f => f.totalRounds >= 1)).toBe(true)
// Each fight should have its own rounds
const rounds = testDb.select().from(schema.rounds).all()
const fightIds = new Set(rounds.map(r => r.fightId))
expect(fightIds.size).toBe(3)
})
it('activeFighters cleared after fight completion', async () => {
insertBot('active-a', 'ActiveA', 1200)
insertBot('active-b', 'ActiveB', 1200)
await runFight('active-a', 'active-b', 'free')
// Both bots should be free after fight
expect(isInFight('active-a')).toBe(false)
expect(isInFight('active-b')).toBe(false)
})
})