import assert from 'node:assert/strict'; import test from 'node:test'; import Fastify from 'fastify'; import type { Db } from 'mongodb'; import { registerGameApi, registerRoomApi } from '../src/server/game-api.js'; import type { GameSessionStore } from '../src/server/game-session-store.js'; import { createRankedService } from '../src/server/ranked-service.js'; import type { RoomStore } from '../src/server/rooms.js'; import type { Signaling } from '../src/server/signaling.js'; import { ManualClock } from './helpers.js'; function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } // Only the persistence boundary is fake. Queue ownership, serialization, pairing, // availability checks and HTTP routing all run through the production code. function memoryDatabase(now: number) { const records: Record[]> = { game_sessions: [ { _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) }, { _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) }, ], player_profiles: [], ranked_matches: [], rooms: [], }; const matches = (doc: Record, query: Record = {}) => Object.entries(query).every(([key, value]) => { if (value && typeof value === 'object' && '$gt' in value) return doc[key] > value.$gt; if (value && typeof value === 'object' && '$in' in value) { return (Array.isArray(doc[key]) ? doc[key] : [doc[key]]).some(item => value.$in.includes(item)); } return Array.isArray(doc[key]) ? doc[key].includes(value) : doc[key] === value; }); const db = { collection(name: string) { const data = records[name]; return { async createIndex() {}, async findOne(query: Record) { return data.find(doc => matches(doc, query)) ?? null; }, find(query?: Record) { const rows = data.filter(doc => matches(doc, query)); return { sort() { return this; }, limit() { return this; }, async next() { return rows[0] ?? null; }, async toArray() { return rows; }, async *[Symbol.asyncIterator]() { yield* rows; }, }; }, async updateOne(query: Record, update: Record) { const doc = data.find(row => matches(row, query)); if (doc) Object.assign(doc, update.$set ?? {}); else data.push({ ...query, ...update.$setOnInsert, ...update.$set }); }, }; } } as unknown as Db; return { db, records }; } for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) { test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => { const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now()); const ranked = await createRankedService(db, clock, 'test'), app = Fastify(); const entered = deferred(), release = deferred(); const regularMatch = { matchId: 'bot-match', requestId: 'request-a', mode: 'bot', expectedPreviousSeq: 0 }; const room = { id: 'friend-room' }; const write = async () => { entered.resolve(); await release.promise; if (url === '/api/game-session/matches') records.game_sessions[0].activeMatch = regularMatch; else records.rooms.push({ _id: room.id, members: ['session-a'], open: true, expiresAt: new Date(clock.now() + 60000) }); return url === '/api/game-session/matches' ? regularMatch : room; }; const sessions = { async get() { return { gameSessionId: 'session-a', playerId: 'player-a' }; }, register: write, } as unknown as GameSessionStore; const rooms = { create: write, join: write } as unknown as RoomStore; registerGameApi(app, sessions, undefined, ranked); registerRoomApi(app, sessions, rooms, { async notifyRoom() {} } as unknown as Signaling, ranked); try { await ranked.join('player-b', 'session-b'); const regular = app.inject({ method: 'POST', url, headers: { authorization: `Bearer ${'a'.repeat(43)}`, 'x-game-session': 'session-a' }, payload: url === '/api/game-session/matches' ? regularMatch : { requestId: 'request-a', code: 'ABCDEF' }, }).then(response => response); await entered.promise; const pairing = ranked.join('player-a', 'session-a').then( status => ({ status, error: undefined }), error => ({ status: undefined, error }), ); // Drain queued work while the regular-mode write is explicitly held open. // With the old check-then-write guard, ranked pairing completes in this gap. await new Promise(resolve => setImmediate(resolve)); release.resolve(); assert.equal((await regular).statusCode, 200); const outcome = await pairing; assert.equal(outcome.error?.code, 'MATCH_PENDING', `ranked entered during the write: ${outcome.status?.status}`); assert.equal((await ranked.status('player-b', 'session-b')).status, 'queued'); } finally { release.resolve(); await app.close(); await ranked.close(); } }); }