Clean up abandoned matches and fix analytics validation
This commit is contained in:
@@ -162,12 +162,16 @@ function viewHarness(t: import('node:test').TestContext) {
|
||||
const actors = [new Element(), new Element()];
|
||||
const container = new Element();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: { addEventListener() {}, removeEventListener() {} } });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
} });
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
t.after(() => {
|
||||
view.destroy();
|
||||
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
@@ -252,7 +252,7 @@ function domHarness(t: import('node:test').TestContext, reducedMotion = true) {
|
||||
}
|
||||
const actors = [new Element(), new Element()], appended: Element[] = [];
|
||||
const container = new Element();
|
||||
const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() };
|
||||
const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element(), addEventListener() {}, removeEventListener() {} };
|
||||
const globals = { document: doc, HTMLElement: Element, window: {
|
||||
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
|
||||
} };
|
||||
|
||||
@@ -2,9 +2,118 @@ 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<typeof collection.updateOne>) => {
|
||||
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('-', '')}`);
|
||||
|
||||
@@ -42,6 +42,11 @@ test('HTTP analytics batches deduplicate, throttle activity writes and keep clie
|
||||
assert.equal(await stats.db.collection('matches').countDocuments(), 0);
|
||||
assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400);
|
||||
assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400);
|
||||
assert.equal((await post('/api/analytics/events', { events: [
|
||||
{ eventId: 'ranked-screen', eventType: 'screen_view', screen: 'ranked-queue' },
|
||||
{ eventId: 'disconnect-summary', eventType: 'game_finished', matchId: 'disconnected', mode: 'friend', outcome: 'aborted', reason: 'disconnect' },
|
||||
{ eventId: 'double-disconnect-summary', eventType: 'game_finished', matchId: 'both-disconnected', mode: 'friend', outcome: 'aborted', reason: 'double_disconnect' },
|
||||
] })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401);
|
||||
} finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PRESENCE_AUTH_REFRESH_MS } from '../constants/presence.js';
|
||||
|
||||
test('presence counts authenticated players once across tabs and removes disconnected players', async () => {
|
||||
const http = createServer(), io = new Server(http);
|
||||
registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) {
|
||||
registerPresence(io, { async touchPresence(auth: { token: string; gameSessionId: string }) {
|
||||
if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' };
|
||||
} } as unknown as GameSessionStore);
|
||||
@@ -45,7 +45,7 @@ test('temporary revalidation errors allow reconnect, invalid sessions remain dis
|
||||
}) as typeof setInterval);
|
||||
let failure: 'temporary' | 'invalid' | undefined;
|
||||
const http = createServer(), io = new Server(http);
|
||||
registerPresence(io, { async get() {
|
||||
registerPresence(io, { async touchPresence() {
|
||||
if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION');
|
||||
if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); }
|
||||
return { playerId: 'one' };
|
||||
|
||||
@@ -17,6 +17,20 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
const integration = { skip: !process.env.TEST_MONGODB_URI };
|
||||
test('orphan cleanup unblocks the same player without blocking independent browsers or live tabs', integration, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.sessions.register(f.a, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
|
||||
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'queued');
|
||||
await f.service.cancel('player-b', f.b.gameSessionId);
|
||||
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
|
||||
f.clock.time += 90000; await f.sessions.touchPresence(f.a);
|
||||
f.clock.time += 31000; await f.sessions.cleanupDisconnectedMatches();
|
||||
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
|
||||
f.clock.time += 90000; await f.sessions.cleanupDisconnectedMatches();
|
||||
assert.equal((await f.service.join('player-a', f.a2.gameSessionId)).status, 'queued');
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) {
|
||||
const paired = await service.join('player-b', sessionB);
|
||||
assert.equal(paired.status, 'confirming');
|
||||
|
||||
@@ -6,6 +6,40 @@ import { io } from 'socket.io-client';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
const uri = process.env.TEST_MONGODB_URI;
|
||||
test('server cleanup releases a closed browser while an authenticated presence connection protects another tab', { skip: !uri }, async () => {
|
||||
const databaseName = 'dragon_ball_test_cleanup_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
|
||||
const app = await createGameServer({ mongoUri: uri!, databaseName, clientDirectory: 'dist/client', clock });
|
||||
const client = await new MongoClient(uri!).connect();
|
||||
let socket: ReturnType<typeof io> | undefined;
|
||||
try {
|
||||
const url = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const create = async (guestToken?: string) => (await app.inject({ method: 'POST', url: '/api/game-sessions',
|
||||
payload: { createRequestId: randomBytes(32).toString('base64url'), guestToken } })).json().data;
|
||||
const old = await create(), live = await create(old.guestToken), otherBrowser = await create();
|
||||
assert.equal(old.playerId, live.playerId);
|
||||
assert.notEqual(old.playerId, otherBrowser.playerId);
|
||||
const headers = (s: typeof old) => ({ authorization: 'Bearer ' + s.token, 'x-game-session': s.gameSessionId });
|
||||
for (const session of [old, live]) {
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/matches', headers: headers(session),
|
||||
payload: { requestId: 'bot', matchId: session.gameSessionId, mode: 'bot', expectedPreviousSeq: 0 } })).statusCode, 200);
|
||||
}
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(otherBrowser), payload: {} })).statusCode, 200);
|
||||
await clock.advance(90000);
|
||||
socket = io(url + '/presence', { autoConnect: false, reconnection: false, auth: { gameSessionId: live.gameSessionId, token: live.token } });
|
||||
const connected = new Promise<void>((resolve, reject) => { socket!.once('connect', resolve); socket!.once('connect_error', reject); });
|
||||
socket.connect(); await connected;
|
||||
await clock.advance(60000);
|
||||
const get = async (s: typeof old) => (await app.inject({ url: '/api/game-session', headers: headers(s) })).json().data;
|
||||
assert.equal((await get(old)).activeMatch, undefined);
|
||||
assert.equal((await get(old)).stats.bot.losses, 0);
|
||||
assert.equal((await get(live)).activeMatch.matchId, live.gameSessionId);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).json().code, 'MATCH_PENDING');
|
||||
socket.disconnect();
|
||||
await clock.advance(90000);
|
||||
assert.equal((await get(live)).activeMatch, undefined);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).statusCode, 200);
|
||||
} finally { socket?.disconnect(); await app.close(); await client.db(databaseName).dropDatabase(); await client.close(); }
|
||||
});
|
||||
|
||||
test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => {
|
||||
const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
|
||||
|
||||
Reference in New Issue
Block a user