Files
wownerogue/test/matchCrashRecovery.test.js
T

498 lines
20 KiB
JavaScript

const MatchQueue = require('../src/network/matchQueue');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchScheduler = require('../src/network/matchScheduler');
const MatchManager = require('../src/network/matchManager');
describe('match queue crash/restart invariants', () => {
const priorMatchEnabled = process.env.MATCH_ENABLED;
beforeEach(() => {
process.env.MATCH_ENABLED = 'true';
});
afterEach(() => {
if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED;
else process.env.MATCH_ENABLED = priorMatchEnabled;
});
test('persisting a match links every exact queue row before inserting linked entrants', async () => {
const captured = [];
const entries = [
{ userId: 7, socketId: 's7', queueEntryId: 101 },
{ userId: 8, socketId: 's8', queueEntryId: 102 }
];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) {
return {
rows: entries.map(entry => ({
id: entry.queueEntryId,
user_id: entry.userId
})),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const room = new MatchRoom({
economy: 'crypto_race',
entrants: {
s7: { userId: 7, socketId: 's7' },
s8: { userId: 8, socketId: 's8' }
},
seed: 'a'.repeat(64)
});
const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } });
await scheduler._persistMatch(room, entries, 'crypto_race');
const link = captured.find(call => call.sql.includes('SET match_id'));
expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']);
expect(link.sql).toContain("status = 'matched'");
expect(link.sql).toContain('match_id IS NULL');
const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants'));
expect(entrantInserts.map(call => call.params)).toEqual([
[room.id, 7, 's7', 101],
[room.id, 8, 's8', 102]
]);
scheduler.shutdown();
});
test('a finished match with linked consumed tickets is never treated as stale escrow', async () => {
let staleSql = '';
const db = {
query: jest.fn(async (sql) => {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) {
staleSql = sql;
return { rows: [], rowCount: 0 };
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(staleSql).toContain("status = 'matched' AND match_id IS NULL");
expect(staleSql).not.toContain("status = 'consumed'");
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => {
const state = {
queueStatus: 'matched',
matchId: null,
lotRemaining: 0,
userTickets: 0,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM match_queue_entries')
&& state.queueStatus === 'matched'
&& state.matchId === null) {
return {
rows: [{
id: 41,
user_id: 9,
economy: 'crypto_race',
escrow_amount: '1',
race_entry_lot_id: 71
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('UPDATE match_queue_entries')) {
expect(sql).toContain("status = 'matched' AND match_id IS NULL");
if (state.queueStatus !== 'matched' || state.matchId !== null) {
return { rows: [], rowCount: 0 };
}
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
expect(sql).toContain('refunded_at IS NULL');
expect(sql).toContain('remaining_entries + $1 <= original_entries');
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverStaleQueueEntries();
await queue._recoverStaleQueueEntries();
expect(state).toEqual(expect.objectContaining({
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
ledgerRows: 1
}));
});
test('an abandoned starting match restores a consumed funded ticket only once', async () => {
const state = {
matchStatus: 'starting',
queueStatus: 'consumed',
lotRemaining: 0,
userTickets: 0,
entrantRefunded: false,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM matches') && state.matchStatus === 'starting') {
return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 };
if (sql.includes('UPDATE matches SET status')) {
if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 };
state.matchStatus = 'cancelled';
return { rows: [{ id: 'match-9' }], rowCount: 1 };
}
if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) {
return {
rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }],
rowCount: 1
};
}
if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) {
if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 };
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
if (sql.includes('UPDATE match_entrants')) {
state.entrantRefunded = true;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverAbandonedMatches();
await queue._recoverAbandonedMatches();
expect(state).toEqual({
matchStatus: 'cancelled',
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
entrantRefunded: true,
ledgerRows: 1
});
});
test('queue initialization stays closed when the abandoned-match scan fails', async () => {
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) throw new Error('scan unavailable');
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('queue initialization rejects after processing peers when one match remains unresolved', async () => {
let transactionCount = 0;
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) {
return {
rows: [
{ id: 'unresolved-match', economy: 'crypto_race' },
{ id: 'resolved-match', economy: 'crypto_race' }
],
rowCount: 2
};
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn(async fn => {
transactionCount += 1;
if (transactionCount === 1) throw new Error('refund transaction failed');
return fn({
query: jest.fn(async sql => {
if (sql.includes('SELECT id FROM payouts')) {
return { rows: [{ id: 91 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
});
})
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'match', id: 'unresolved-match' }]
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).toHaveBeenCalledTimes(2);
});
test('paid queue admission is closed before durable recovery completes', async () => {
const queue = new MatchQueue({ db: {} });
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
});
test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => {
const queue = new MatchQueue({
db: {},
isFinancialRecoveryReady: () => false
});
queue.initialized = true;
queue._queues.credits_prestige.push(
{ queueEntryId: 1, userId: 1, socketId: 's1' },
{ queueEntryId: 2, userId: 2, socketId: 's2' }
);
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull();
await expect(queue.freezePaidMatch({
economy: 'credits_prestige',
maxPlayers: 2,
minPlayers: 2,
freezeBlockHeight: 10,
targetBlockHeight: 11,
rulesetId: 'race'
})).resolves.toBeNull();
});
test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => {
process.env.MATCH_ENABLED = 'false';
let userTickets = 0;
let lotTickets = 0;
let ledgerRows = 0;
const db = {
async query(sql) {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) {
return { rows: [], rowCount: 0 };
}
if (sql.includes("WHERE status = 'queued'")) {
return {
rows: [{
id: 55,
user_id: 12,
economy: 'crypto_race',
socket_id: 'old-socket',
session_token: 'session',
created_at: new Date().toISOString()
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('DELETE FROM match_queue_entries')) {
return {
rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }],
rowCount: 1
};
}
if (sql.includes('UPDATE users')) {
userTickets += 1;
return { rows: [{ race_entries: userTickets }], rowCount: 1 };
}
if (sql.includes('UPDATE race_entry_lots')) {
lotTickets += 1;
return { rows: [{ id: 77 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(queue.isEnabled()).toBe(false);
expect(queue.length('crypto_race')).toBe(0);
expect({ userTickets, lotTickets, ledgerRows }).toEqual({
userTickets: 1,
lotTickets: 1,
ledgerRows: 1
});
});
test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => {
process.env.MATCH_ENABLED = 'false';
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
scanned: 1,
created: 1,
failed: 0
});
await manager.initialize();
expect(manager.enabled).toBe(false);
expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1);
manager.shutdown();
});
test('finished accepted-liability failures keep match recovery and startup closed', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
});
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: false,
scanned: 1,
resolved: 0,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
}
});
expect(manager.financialRecoveryReady).toBe(false);
expect(manager._liabilityReconcileTimer).toBeNull();
manager.shutdown();
});
test('finished-liability scan errors have explicit fail-closed startup semantics', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn()
.mockRejectedValue(new Error('liability scan unavailable'));
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(manager.financialRecoveryReady).toBe(false);
manager.shutdown();
});
test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: true, scanned: 0, created: 0, failed: 0, unresolved: []
});
await manager.initialize();
manager.matchPayoutService.reconcileFinishedLiabilities
.mockResolvedValueOnce({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-retry' }]
})
.mockResolvedValueOnce({
ok: true,
scanned: 1,
created: 1,
failed: 0,
unresolved: []
});
const failed = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(failed.ok).toBe(false);
expect(manager.financialRecoveryReady).toBe(false);
const retried = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 });
expect(manager.financialRecoveryReady).toBe(true);
manager.shutdown();
});
});