Files
botfights/server/src/routes/fights.integration.test.ts
T
DorianandClaude Opus 4.6 41c66d7732 test: full fight flow integration test with real in-memory DB
8 tests covering: complete fight lifecycle, HP progression, ELO
updates, concurrent fight prevention, round data validity, ELO
conservation, status transitions, and win streak tracking.

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

401 lines
13 KiB
TypeScript

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)
}
})
})