From 5e40221a2f9c8a6eda9087490c8a5d1f55c5927b Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 05:10:27 +0000 Subject: [PATCH] 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 --- server/src/routes/fights.integration.test.ts | 205 +++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/server/src/routes/fights.integration.test.ts b/server/src/routes/fights.integration.test.ts index 849a6e4..7412d8c 100644 --- a/server/src/routes/fights.integration.test.ts +++ b/server/src/routes/fights.integration.test.ts @@ -398,3 +398,208 @@ describe('fights integration — full fight flow with real DB', () => { } }) }) + +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) + }) +})