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>
606 lines
20 KiB
TypeScript
606 lines
20 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)
|
||
}
|
||
})
|
||
})
|
||
|
||
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)
|
||
})
|
||
})
|