Files
wownerogue/test/singleGameRecovery.test.js
T
jwinterm 5c546a1473 fix: recover unconsumed single-game payments; make game preservation deterministic
Two disconnect-related money/UX gaps:

1. A confirmed single_game payment whose game never started (user paid, then
   disconnected) was money-in-nothing-given with no recovery path. recover-
   PendingPayments now detects confirmed single_game payments with no linked
   games row and grants the equivalent credits — idempotently (per-payment
   reason key, row-locked re-check of both the game link and prior recovery).

2. In-progress games could be dropped on disconnect when a fresh getBySocket
   lookup failed. The stable dbUserId is now stamped on the game at creation,
   so suspend/restore keys on it directly without a failure-prone disconnect-
   time lookup. Any game (paid or free) is preserved/reconnectable whenever a
   session exists; the remaining drop-branch is only the true no-session edge
   (e.g. DB unavailable at connect), which is logged for audit rather than
   masked.

Adds singleGameRecovery.test.js. Full suite: 13 suites / 119 tests.
2026-05-31 09:36:09 -04:00

86 lines
3.4 KiB
JavaScript

/**
* Single-game disconnect recovery tests (Phase 0.5).
*
* If a user pays for a single game and disconnects before a game starts (no games row
* references the payment), the money was taken with nothing given. recoverPendingPayments
* must grant the equivalent credits — exactly once (idempotent), and never for a payment
* that was actually consumed by a game.
*/
const SessionManager = require('../src/network/sessionManager');
function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set() }) {
let credits = 0;
const handler = async (text, params = []) => {
// credits_package recovery queries (unprocessed + orphaned) — none in these tests
if (/FROM payments[\s\S]*credits_package/i.test(text)) return { rows: [] };
// outer "unconsumed single_game" discovery query
if (/payment_type = 'single_game'/i.test(text)) {
return {
rows: singleGamePayments
.filter(p => !gamesForPayment.has(p.id) && !recoveredReasons.has(`single_game_recovered:${p.id}`))
.map(p => ({ id: p.id, confirmed_at: p.confirmed_at }))
};
}
if (/SELECT id FROM payments WHERE id = \$1 FOR UPDATE/i.test(text)) return { rows: [{ id: params[0] }] };
if (/SELECT 1 FROM games WHERE payment_id/i.test(text)) return { rows: gamesForPayment.has(params[0]) ? [{}] : [] };
if (/FROM credit_transactions WHERE user_id = \$1 AND reason = \$2/i.test(text)) {
return { rows: recoveredReasons.has(params[1]) ? [{}] : [] };
}
if (/UPDATE users SET credits = credits \+/i.test(text)) { credits += Number(params[0]); return { rows: [{ credits }] }; }
if (/INSERT INTO credit_transactions/i.test(text)) { recoveredReasons.add(params[2]); return { rows: [] }; }
return { rows: [] };
};
return {
_getCredits: () => credits,
query: jest.fn(handler),
withTransaction: jest.fn(async (cb) => cb({ query: jest.fn(handler) }))
};
}
function makeSession(db) {
return new SessionManager({
db,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: 1 }
});
}
describe('Single-game disconnect recovery', () => {
test('grants a credit for a confirmed-but-unconsumed single_game payment', async () => {
const db = makeDb({ singleGamePayments: [{ id: 42, confirmed_at: new Date() }] });
const sm = makeSession(db);
const result = await sm.recoverPendingPayments(7, 'sock-1');
expect(result.creditsRecovered).toBe(1);
expect(result.paymentsProcessed).toBe(1);
expect(db._getCredits()).toBe(1);
});
test('does NOT grant when a game already consumed the payment', async () => {
const db = makeDb({
singleGamePayments: [{ id: 42, confirmed_at: new Date() }],
gamesForPayment: new Set([42])
});
const sm = makeSession(db);
const result = await sm.recoverPendingPayments(7, 'sock-1');
expect(result.creditsRecovered).toBe(0);
expect(db._getCredits()).toBe(0);
});
test('is idempotent — a second recovery does not double-credit', async () => {
const db = makeDb({ singleGamePayments: [{ id: 42, confirmed_at: new Date() }] });
const sm = makeSession(db);
const first = await sm.recoverPendingPayments(7, 'sock-1');
const second = await sm.recoverPendingPayments(7, 'sock-2');
expect(first.creditsRecovered).toBe(1);
expect(second.creditsRecovered).toBe(0);
expect(db._getCredits()).toBe(1);
});
});