Files
wownerogue/test/orphanGameRecovery.test.js
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

196 lines
6.5 KiB
JavaScript

const SessionManager = require('../src/network/sessionManager');
function makeDb(initialGames, initialCredits = 0) {
const games = new Map(initialGames.map(game => [game.id, { ...game }]));
const users = new Map([[7, initialCredits]]);
const refundReasons = new Set();
const query = jest.fn(async (sql, params = []) => {
if (/SELECT g\.id[\s\S]*FROM games g[\s\S]*g\.status = 'active'/i.test(sql)) {
return {
rows: Array.from(games.values())
.filter(game => game.status === 'active')
.map(game => ({ id: game.id }))
};
}
if (/FROM games g[\s\S]*g\.id = \$1[\s\S]*FOR UPDATE/i.test(sql)) {
const game = games.get(params[0]);
return { rows: game ? [{ ...game }] : [] };
}
if (/SELECT 1 FROM credit_transactions/i.test(sql)) {
return { rows: refundReasons.has(params[1]) ? [{}] : [] };
}
if (/UPDATE users SET credits = credits \+/i.test(sql)) {
const next = (users.get(params[1]) || 0) + Number(params[0]);
users.set(params[1], next);
return { rows: [{ credits: next }], rowCount: 1 };
}
if (/INSERT INTO credit_transactions/i.test(sql)) {
refundReasons.add(params[2]);
return { rows: [], rowCount: 1 };
}
if (/UPDATE games SET status = \$1/i.test(sql)) {
const game = games.get(params[1]);
if (game) game.status = params[0];
return { rows: [], rowCount: game ? 1 : 0 };
}
return { rows: [], rowCount: 0 };
});
return {
query,
withTransaction: jest.fn(async callback => callback({ query })),
credits: userId => users.get(userId),
game: id => games.get(id),
refundReasons
};
}
function manager(db, currentConfiguredCost = 99) {
return new SessionManager({
db,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: currentConfiguredCost }
});
}
describe('orphan game recovery debit evidence', () => {
test('does not mint a refund for a pre-created active row when entry charging never committed', async () => {
const db = makeDb([{
id: 1,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: null,
entry_credits_spent: 5
}], 10);
const result = await manager(db).recoverOrphanedGames();
expect(result).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(10);
expect(db.game(1).status).toBe('expired');
expect(db.refundReasons.size).toBe(0);
});
test('restores the immutable debit snapshot, not the current configured entry cost', async () => {
const db = makeDb([{
id: 2,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 3
}], 4);
const recovery = manager(db, 99);
const first = await recovery.recoverOrphanedGames();
const second = await recovery.recoverOrphanedGames();
expect(first).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 1,
creditsRefunded: 3,
unresolved: []
});
expect(second).toEqual({
ok: true,
scanned: 0,
finalized: 0,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(7);
expect(db.game(2).status).toBe('refunded');
expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2']));
});
test('does not silently convert a linked direct payment into credits', async () => {
const db = makeDb([{
id: 3,
user_id: 7,
game_mode: 'PAID_SINGLE',
payment_id: 77,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: null
}], 1);
const result = await manager(db).recoverOrphanedGames();
expect(result.refunded).toBe(0);
expect(db.credits(7)).toBe(1);
expect(db.game(3).status).toBe('expired');
});
test('fails closed with explicit scan semantics when the orphan query fails', async () => {
const db = {
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
withTransaction: jest.fn()
};
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
ok: false,
scope: 'orphaned_solo_games',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('finishes other rows but rejects startup while any orphan remains unresolved', async () => {
const db = makeDb([{
id: 4,
user_id: 7,
game_mode: 'PAID_CREDITS',
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 2
}, {
id: 5,
user_id: 7,
game_mode: 'FREE',
status: 'active',
entry_consumed_at: null,
entry_credits_spent: null
}], 0);
const baseTransaction = db.withTransaction;
let attempts = 0;
db.withTransaction = jest.fn(async callback => {
attempts += 1;
if (attempts === 1) throw new Error('refund transaction failed');
return baseTransaction(callback);
});
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'orphaned_solo_games',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'game', id: '4' }]
}
});
expect(db.game(4).status).toBe('active');
expect(db.game(5).status).toBe('expired');
});
});