mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 16:18:33 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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 id FROM games WHERE status = 'active'/i.test(sql)) {
|
||||
return {
|
||||
rows: Array.from(games.values())
|
||||
.filter(game => game.status === 'active')
|
||||
.map(game => ({ id: game.id }))
|
||||
};
|
||||
}
|
||||
if (/FROM games WHERE id = \$1 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user