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 let testDb: ReturnType 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 }[] = [] // 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 | 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) }) })