import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomBytes, randomUUID } from 'node:crypto'; import { MongoClient } from 'mongodb'; import type { Db } from 'mongodb'; import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createRoomStore } from '../src/server/rooms.js'; const uri = process.env.TEST_MONGODB_URI; test('orphan friend cleanup releases its room without discarding the connected opponent result', { skip: !uri }, async () => { const client = await new MongoClient(uri!).connect(); const db = client.db(`dragon_ball_test_orphan_room_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now); const a = await store.create(randomBytes(32).toString('base64url')); const b = await store.create(randomBytes(32).toString('base64url')); const waiting = await rooms.create(a.gameSessionId, 'room'); const room = await rooms.join(b.gameSessionId, waiting.code); const request = { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend' as const, expectedPreviousSeq: 0 }; await store.register(a, request); const match = await store.register(b, request); now += 90000; await store.touchPresence(b); now += 31000; await store.cleanupDisconnectedMatches(); assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'paired', 'a connected participant keeps the room'); assert.equal((await store.get(a)).activeMatch, undefined); assert.equal((await store.get(b)).activeMatch?.matchId, match.matchId); assert.equal((await store.record(b, { ...match, outcome: 'win', reason: 'disconnect' })).status, 'applied'); assert.deepEqual((await store.get(a)).stats, a.stats); now += 90000; await store.cleanupDisconnectedMatches(); assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'closed'); } finally { await db.dropDatabase(); await client.close(); } }); test('closed browsers release waiting rooms and rooms with both results already saved', { skip: !uri }, async () => { const client = await new MongoClient(uri!).connect(); const db = client.db(`dragon_ball_test_saved_room_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now); const a = await store.create(randomBytes(32).toString('base64url')); const b = await store.create(randomBytes(32).toString('base64url')); const c = await store.create(randomBytes(32).toString('base64url')); const waiting = await rooms.create(a.gameSessionId, 'paired-room'); const room = await rooms.join(b.gameSessionId, waiting.code); const idle = await rooms.create(c.gameSessionId, 'waiting-room'); for (const session of [a, b]) { const match = await store.register(session, { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 }); await store.record(session, { ...match, outcome: 'draw', reason: 'double_timeout' }); } now += 120001; await store.cleanupDisconnectedMatches(); assert.equal((await rooms.get(room.id, a.gameSessionId)).status, 'closed'); assert.equal((await rooms.get(idle.id, c.gameSessionId)).status, 'closed'); assert.equal((await store.get(a)).stats.friend.draws, 1); } finally { await db.dropDatabase(); await client.close(); } }); test('restart grace and a reconnect during cleanup protect the current match', { skip: !uri }, async () => { const client = await new MongoClient(uri!).connect(); const db = client.db(`dragon_ball_test_reconnect_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now); const session = await store.create(randomBytes(32).toString('base64url')); const match = await store.register(session, { requestId: 'request', matchId: 'match', mode: 'bot', expectedPreviousSeq: 0 }); // Legacy registrations from before lease tracking are protected on server startup too. await db.collection('game_sessions').updateOne({ _id: session.gameSessionId as any }, { $unset: { presenceUntil: '' } }); now += 300000; let reconnect = false; const collection = db.collection('game_sessions'); const wrapped = new Proxy(db, { get(target, key) { if (key === 'collection') return (name: string) => name !== 'game_sessions' ? target.collection(name) : new Proxy(collection, { get(c, method) { if (method === 'updateOne') return async (...args: Parameters) => { if (reconnect) { reconnect = false; await store.touchPresence(session); } return c.updateOne(...args); }; const value = Reflect.get(c, method); return typeof value === 'function' ? value.bind(c) : value; } }); const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value; } }) as Db; const restarted = await createGameSessionStore(wrapped, () => now); await restarted.cleanupDisconnectedMatches(); assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId); now += 120001; reconnect = true; await restarted.cleanupDisconnectedMatches(); assert.equal(reconnect, false, 'exercise a reconnect between candidate selection and atomic cleanup'); assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId); now += 120001; await restarted.cleanupDisconnectedMatches(); assert.equal((await store.get(session)).activeMatch, undefined); } finally { await db.dropDatabase(); await client.close(); } }); test('disconnected matches expire without changing stats or cancelling a live tab', { skip: !uri }, async () => { const client = await new MongoClient(uri!).connect(); const db = client.db(`dragon_ball_test_orphans_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now); const offline = await store.create(randomBytes(32).toString('base64url'), 'same-player'); const online = await store.create(randomBytes(32).toString('base64url'), 'same-player'); const register = (auth: typeof offline) => store.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); const abandoned = await register(offline), live = await register(online); now += 90000; await store.touchPresence(online); await store.cleanupDisconnectedMatches(); assert.equal((await store.get(offline)).activeMatch?.matchId, abandoned.matchId, 'reconnection grace'); now += 31000; await store.cleanupDisconnectedMatches(); assert.equal((await store.get(offline)).activeMatch, undefined); assert.equal((await store.get(online)).activeMatch?.matchId, live.matchId); assert.deepEqual((await store.get(offline)).stats, offline.stats); assert.equal((await store.get(offline)).lastAppliedSeq, 1); assert.equal((await store.record(offline, { ...abandoned, outcome: 'win', reason: 'hit' })).status, 'stale'); assert.deepEqual((await store.get(offline)).stats, offline.stats); await store.register(offline, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 }); await store.cleanupDisconnectedMatches(); assert.ok((await store.get(offline)).activeMatch, 'a new match must not inherit an expired lease'); } finally { await db.dropDatabase(); await client.close(); } }); test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => { const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now); const creationKey = randomBytes(32).toString('base64url'); const session = await store.create(creationKey); assert.deepEqual(await store.create(creationKey), session); const auth = { gameSessionId: session.gameSessionId, token: session.token }; const registration = { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot' as const, expectedPreviousSeq: 0 }; const active = await store.register(auth, registration); assert.deepEqual(await store.register(auth, registration), active); const win = { ...active, outcome: 'win' as const, reason: 'hit' as const }; const replies = await Promise.all(Array.from({ length: 20 }, () => store.record(auth, win))); assert.equal(replies.filter(r => r.status === 'applied').length, 1); assert.equal((await store.get(auth)).stats.bot.wins, 1); assert.equal((await store.get(auth)).stats.bot.current, 1); assert.equal((await store.record(auth, win)).status, 'duplicate'); // lost response retry await assert.rejects(store.record(auth, { ...win, outcome: 'loss' }), /RESULT_CONFLICT/); const second = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 1 }); await store.record(auth, { ...second, outcome: 'loss', reason: 'hit' }); assert.equal((await store.record(auth, win)).status, 'stale'); assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 }); const registrations = await Promise.allSettled([0, 1].map(() => store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 2 }))); assert.equal(registrations.filter(r => r.status === 'fulfilled').length, 1); const third = (await store.get(auth)).activeMatch!; await store.record(auth, { ...third, outcome: 'aborted', reason: 'connection_failed' }); assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 }); await assert.rejects(store.record(auth, { ...third, seq: 9, matchId: randomUUID(), outcome: 'win', reason: 'hit' }), /NO_ACTIVE_MATCH/); await assert.rejects(store.get({ ...auth, token: randomBytes(32).toString('base64url') }), /INVALID_SESSION/); const fourth = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 3 }); await store.record(auth, { ...fourth, outcome: 'draw', reason: 'double_timeout' }); assert.equal((await store.get(auth)).stats.bot.draws, 1); now += 91 * 86400000; await assert.rejects(store.get(auth), /INVALID_SESSION/); } finally { try { await db.dropDatabase(); } finally { await client.close(); } } }); test('friend registration requires membership, but expired room cannot prevent recording', { skip: !uri }, async () => { const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); const db = client.db(`dragon_ball_test_p2p_member_${randomUUID().replaceAll('-', '')}`); try { let now = Date.now(); const store = await createGameSessionStore(db, () => now); const session = await store.create(randomBytes(32).toString('base64url')); const other = await store.create(randomBytes(32).toString('base64url')); const request = { requestId: randomUUID(), matchId: randomUUID(), roomId: randomUUID(), mode: 'friend' as const, expectedPreviousSeq: 0 }; await assert.rejects(store.register(session, request), /ROOM_NOT_FOUND/); await db.collection('rooms').insertOne({ _id: request.roomId, hostGameSessionId: session.gameSessionId, guestGameSessionId: other.gameSessionId, status: 'paired', currentMatchId: request.matchId, expiresAt: new Date(now + 1000) }); const active = await store.register(session, request); now += 2000; await store.record(session, { ...active, outcome: 'win', reason: 'hit' }); assert.equal((await store.get(session)).stats.friend.current, 1); assert.equal((await store.get(session)).stats.bot.wins, 0); } finally { try { await db.dropDatabase(); } finally { await client.close(); } } });