mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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.
730 lines
31 KiB
JavaScript
730 lines
31 KiB
JavaScript
/**
|
|
* Comprehensive Payment and Payout Tests
|
|
* Deep testing of all payment flows, credits, payouts, and edge cases
|
|
*/
|
|
|
|
const GameModeManager = require('../src/game/gameModeManager');
|
|
|
|
// Mock dependencies
|
|
const createMockDb = () => {
|
|
const mockClient = {
|
|
query: jest.fn().mockResolvedValue({ rows: [] })
|
|
};
|
|
return {
|
|
query: jest.fn(),
|
|
// Mock withTransaction to execute the callback with a shared mock client
|
|
// so tests can set up client.query expectations
|
|
withTransaction: jest.fn().mockImplementation(async (callback) => {
|
|
return callback(mockClient);
|
|
}),
|
|
_mockClient: mockClient // Expose for test setup
|
|
};
|
|
};
|
|
|
|
const createMockWalletService = () => ({
|
|
createPaymentRequest: jest.fn(),
|
|
processPayout: jest.fn(),
|
|
startPaymentMonitoring: jest.fn(),
|
|
stopPaymentMonitoring: jest.fn()
|
|
});
|
|
|
|
const createMockDebugManager = () => ({
|
|
CONSOLE_LOGGING: false,
|
|
getCurrentBlockHeight: () => 12345
|
|
});
|
|
|
|
const createMockPaymentConfig = (overrides = {}) => ({
|
|
getConfig: () => ({
|
|
paymentsEnabled: true,
|
|
currency: { symbol: 'WOW', decimals: 11 },
|
|
modes: {
|
|
direct: { enabled: true, price: 100000000000n },
|
|
credits: { enabled: true, creditsPerGame: 1, packages: [
|
|
{ id: 'small', credits: 10, price: '500000000000', bonus: 0 },
|
|
{ id: 'medium', credits: 25, price: '1000000000000', bonus: 2 }
|
|
] }
|
|
},
|
|
payouts: {
|
|
rules: {
|
|
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
|
|
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
|
|
}
|
|
},
|
|
preferences: { preferCreditsFirst: true },
|
|
earlyEntry: { enabled: true, allowInFreeMode: true, allowInCreditsMode: true },
|
|
...overrides
|
|
}),
|
|
getLegacyGameMode: () => 'PAID_SINGLE',
|
|
eventBus: { on: () => {} }
|
|
});
|
|
|
|
describe('Payment & Payout Comprehensive Tests', () => {
|
|
let gmm;
|
|
let mockDb;
|
|
let mockWalletService;
|
|
let mockDebugManager;
|
|
let mockPaymentConfig;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockDb = createMockDb();
|
|
mockWalletService = createMockWalletService();
|
|
mockDebugManager = createMockDebugManager();
|
|
mockPaymentConfig = createMockPaymentConfig();
|
|
|
|
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig);
|
|
});
|
|
|
|
afterEach(() => {
|
|
gmm?.shutdown();
|
|
});
|
|
|
|
describe('Credit Deduction - Negative Balance Prevention', () => {
|
|
test('should fail gracefully when user has insufficient credits', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
gmm.creditsPerGameCost = 1;
|
|
|
|
// The credits update should return 0 rows because WHERE credits >= 1 fails
|
|
// (when user.credits = 0 and we require >= 1)
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE users ... WHERE credits >= 1 - returns empty!
|
|
|
|
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.reason).toBe('Insufficient credits');
|
|
expect(result.creditsRequired).toBe(1);
|
|
});
|
|
|
|
test('should succeed when user has exactly enough credits', async () => {
|
|
const user = { id: 1, credits: 1 };
|
|
gmm.creditsPerGameCost = 1;
|
|
|
|
// All queries inside withTransaction go through client.query
|
|
mockDb._mockClient.query
|
|
.mockResolvedValueOnce({ rows: [{ credits: 0 }] }) // UPDATE users ... WHERE credits >= 1
|
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE games
|
|
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
|
|
|
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.creditsRemaining).toBe(0);
|
|
expect(result.creditsSpent).toBe(1);
|
|
});
|
|
|
|
test('should deduct correct amount for multi-credit games', async () => {
|
|
const user = { id: 1, credits: 5 };
|
|
gmm.creditsPerGameCost = 2;
|
|
|
|
// All queries inside withTransaction go through client.query
|
|
mockDb._mockClient.query
|
|
.mockResolvedValueOnce({ rows: [{ credits: 3 }] }) // UPDATE users
|
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE games
|
|
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
|
|
|
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.creditsRemaining).toBe(3);
|
|
expect(result.creditsSpent).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('Payout Calculation', () => {
|
|
test('direct mode escape payout calculated correctly', () => {
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
|
|
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false });
|
|
|
|
expect(result.amount).toBe(200000000000);
|
|
expect(result.multiplier).toBe(2);
|
|
expect(result.base).toBe(100000000000);
|
|
});
|
|
|
|
test('direct mode treasure payout calculated correctly', () => {
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
|
|
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true });
|
|
|
|
expect(result.amount).toBe(300000000000);
|
|
expect(result.multiplier).toBe(3);
|
|
});
|
|
|
|
test('credits mode uses base value not single game price', () => {
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.creditsPayoutBaseValue = 50000000000;
|
|
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
|
|
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
|
|
|
|
expect(result.amount).toBe(100000000000); // 50000000000 * 2
|
|
expect(result.base).toBe(50000000000);
|
|
});
|
|
|
|
test('zero multiplier returns zero payout', () => {
|
|
gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 };
|
|
gmm.creditsPayoutBaseValue = 100000000000;
|
|
|
|
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
|
|
|
|
expect(result.amount).toBe(0);
|
|
});
|
|
|
|
test('fractional multipliers work correctly', () => {
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.directPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2.5 };
|
|
|
|
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false });
|
|
|
|
expect(result.amount).toBe(150000000000);
|
|
});
|
|
});
|
|
|
|
describe('Mixed Mode Payment Selection', () => {
|
|
beforeEach(() => {
|
|
gmm.paymentsEnabled = true;
|
|
gmm.directModeEnabled = true;
|
|
gmm.creditsModeEnabled = true;
|
|
gmm.creditsPerGameCost = 1;
|
|
});
|
|
|
|
test('prefers credits when user has credits and preferCreditsFirst is true', async () => {
|
|
gmm.preferCreditsFirst = true;
|
|
const user = { id: 1, credits: 5, payout_address: 'wow1addr' };
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
|
|
const result = await gmm.canUserStartGame('socket1');
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.useCredits).toBe(true);
|
|
expect(result.effectiveMode).toBe('PAID_CREDITS');
|
|
});
|
|
|
|
test('uses direct payment when user has no credits', async () => {
|
|
gmm.preferCreditsFirst = true;
|
|
const user = { id: 1, credits: 0, payout_address: 'wow1addr' };
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
// Has confirmed payment
|
|
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 123 }] }); // confirmed payment query
|
|
|
|
const result = await gmm.canUserStartGame('socket1');
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.paymentId).toBe(123);
|
|
expect(result.effectiveMode).toBe('PAID_SINGLE');
|
|
});
|
|
|
|
test('denies when no credits and no confirmed payment', async () => {
|
|
const user = { id: 1, credits: 0, payout_address: 'wow1addr' };
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no confirmed payment
|
|
// getPaymentOptionsForUser will be called
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser again
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
|
|
const result = await gmm.canUserStartGame('socket1');
|
|
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.action).toBe('choose_payment');
|
|
});
|
|
});
|
|
|
|
describe('Credits Package Confirmation', () => {
|
|
test('adds credits from package info correctly', async () => {
|
|
// Step 1: db.query runs the SELECT payment lookup (has user_id, status='pending')
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ user_id: 1, description: '', status: 'pending' }]
|
|
});
|
|
|
|
// Step 2: withTransaction runs these client.query calls:
|
|
mockDb._mockClient.query
|
|
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) // snapshot product promise
|
|
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // mark payment confirmed
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) // lock user
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15 }] })
|
|
.mockResolvedValueOnce({ rows: [] }) // credit transaction
|
|
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) // entitlement grant
|
|
.mockResolvedValueOnce({ rows: [] }); // active pack grants
|
|
|
|
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.creditsAdded).toBe(15);
|
|
expect(result.newBalance).toBe(15);
|
|
});
|
|
|
|
test('parses credits from payment description as fallback', async () => {
|
|
// Step 1: db.query runs the SELECT payment lookup with description
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ user_id: 1, description: 'Wowngeon 25 credits package (WOW)', status: 'pending' }]
|
|
});
|
|
|
|
// Step 2: withTransaction runs these client.query calls:
|
|
mockDb._mockClient.query
|
|
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 25, total_credits_purchased: 25 }] })
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
|
|
.mockResolvedValueOnce({ rows: [] });
|
|
|
|
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.creditsAdded).toBe(25); // Parsed from description
|
|
});
|
|
|
|
test('defaults to 10 credits when no info available', async () => {
|
|
// Step 1: db.query runs the SELECT payment lookup with non-matching description
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }]
|
|
});
|
|
|
|
// Step 2: withTransaction runs these client.query calls:
|
|
mockDb._mockClient.query
|
|
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 10, total_credits_purchased: 10 }] })
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
|
|
.mockResolvedValueOnce({ rows: [] });
|
|
|
|
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.creditsAdded).toBe(10); // Default fallback
|
|
});
|
|
});
|
|
|
|
describe('Game Completion & Payout Flow', () => {
|
|
beforeEach(() => {
|
|
gmm.gameMode = 'PAID_SINGLE';
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
// Completion owns a real transaction; route every statement through the ordered
|
|
// root query mock so these tests exercise completion + obligation atomically.
|
|
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
|
|
});
|
|
|
|
test('processes winning game with payout correctly', async () => {
|
|
// Game record lookup
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
|
});
|
|
// Game update
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
|
// Check for existing payout
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
|
// User lookup
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
|
|
});
|
|
// INSERT payout (pending record) - returns new payout id
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 999 }]
|
|
});
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 50 });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.payout).toBeDefined();
|
|
expect(result.payout.status).toBe('queued');
|
|
expect(result.payout.payoutId).toBe(999);
|
|
expect(result.payout.amount).toBe(200000000000);
|
|
expect(result.payout.multiplier).toBe(2);
|
|
// processPayout is NOT called directly: payout is batched via _scheduleBatchPayout
|
|
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('treasure bonus applied correctly to payout', async () => {
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
|
|
});
|
|
// INSERT payout (pending record)
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 999 }]
|
|
});
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 30 });
|
|
|
|
expect(result.payout.status).toBe('queued');
|
|
expect(result.payout.amount).toBe(300000000000); // 3x for treasure
|
|
expect(result.payout.multiplier).toBe(3);
|
|
expect(result.payout.treasure).toBe(true);
|
|
});
|
|
|
|
test('no payout for losing game', async () => {
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', false, false, {});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.payout).toBeNull();
|
|
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('no payout when user has no payout address', async () => {
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, payout_address: null }] // No address
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1001 }], rowCount: 1 }); // review liability
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.payout).toEqual(expect.objectContaining({
|
|
payoutId: 1001,
|
|
status: 'needs_review'
|
|
}));
|
|
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('prevents duplicate payout for same game', async () => {
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
|
// Existing payout found
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 999 }]
|
|
});
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.payout).toBeNull();
|
|
expect(result.reason).toBe('Payout already processed');
|
|
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('uses recorded payment_mode not current gameMode', async () => {
|
|
// Current global mode is PAID_SINGLE
|
|
gmm.gameMode = 'PAID_SINGLE';
|
|
// But the game was started in credits mode
|
|
gmm.creditsPayoutEnabled = true;
|
|
gmm.creditsPayoutBaseValue = 50000000000;
|
|
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
|
|
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, user_id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
|
|
});
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
|
|
});
|
|
// INSERT payout (pending record)
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 999 }]
|
|
});
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
|
|
|
// Should use credits multipliers, not direct
|
|
expect(result.payout.status).toBe('queued');
|
|
expect(result.payout.amount).toBe(75000000000); // 50000000000 * 1.5
|
|
expect(result.payout.multiplier).toBe(1.5);
|
|
});
|
|
});
|
|
|
|
describe('Payment Request Creation', () => {
|
|
test('creates single_game payment request correctly', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
gmm.singleGamePrice = 100000000000;
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
|
|
|
|
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
|
|
address: 'wow1paymentaddr',
|
|
expiresAt: new Date()
|
|
});
|
|
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 999, expires_at: new Date() }]
|
|
}); // INSERT payment
|
|
|
|
const result = await gmm.createPaymentRequest('socket1', 'single_game');
|
|
|
|
expect(result.address).toBe('wow1paymentaddr');
|
|
expect(result.amount).toBe(100000000000);
|
|
expect(result.paymentType).toBe('single_game');
|
|
expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith(
|
|
100000000000,
|
|
expect.stringContaining('single game'),
|
|
1,
|
|
'socket1'
|
|
);
|
|
});
|
|
|
|
test('creates credits_package payment request correctly', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
gmm.creditsPackagePrice = 500000000000;
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
|
|
|
|
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
|
|
address: 'wow1creditspayment',
|
|
expiresAt: new Date()
|
|
});
|
|
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1000, expires_at: new Date() }]
|
|
}); // INSERT payment
|
|
|
|
const result = await gmm.createPaymentRequest('socket1', 'credits_package');
|
|
|
|
expect(result.address).toBe('wow1creditspayment');
|
|
expect(result.paymentType).toBe('credits_package');
|
|
expect(result.package).toBeDefined();
|
|
});
|
|
|
|
test('creates cosmetic_pack payment request with durable product grants', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
gmm.configSnapshot.products = {
|
|
cosmetic: [{
|
|
id: 'pack_3d',
|
|
label: '3D Character Pack',
|
|
price: 250000000000n,
|
|
grants: { packs: ['kenney-3d-characters'] }
|
|
}]
|
|
};
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
|
|
|
|
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
|
|
address: 'wow1packpayment',
|
|
expiresAt: new Date()
|
|
});
|
|
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1001, expires_at: new Date() }]
|
|
}); // INSERT payment
|
|
|
|
const result = await gmm.createPaymentRequest('socket1', 'cosmetic_pack', { productId: 'pack_3d' });
|
|
|
|
expect(result.address).toBe('wow1packpayment');
|
|
expect(result.paymentType).toBe('cosmetic_pack');
|
|
expect(result.productId).toBe('pack_3d');
|
|
expect(result.grants.packs).toEqual(['kenney-3d-characters']);
|
|
expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith(
|
|
250000000000,
|
|
expect.stringContaining('3D Character Pack'),
|
|
1,
|
|
'socket1'
|
|
);
|
|
|
|
const insertCall = mockDb.query.mock.calls.find(([sql]) => /INSERT INTO payments/i.test(sql));
|
|
expect(insertCall[1][8]).toBe('pack_3d');
|
|
expect(JSON.parse(insertCall[1][9]).packs[0].id).toBe('kenney-3d-characters');
|
|
});
|
|
|
|
test('reuses existing pending payment request', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
gmm.singleGamePrice = 100000000000;
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
// Existing pending payment
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{
|
|
id: 555,
|
|
subaddress: 'wow1existingaddr',
|
|
expected_amount: '100000000000',
|
|
payment_type: 'single_game',
|
|
status: 'pending',
|
|
expires_at: new Date(Date.now() + 1800000)
|
|
}]
|
|
});
|
|
|
|
const result = await gmm.createPaymentRequest('socket1', 'single_game', { reuseExisting: true });
|
|
|
|
expect(result.id).toBe(555);
|
|
expect(result.address).toBe('wow1existingaddr');
|
|
expect(result.reused).toBe(true);
|
|
// Should not create a new payment
|
|
expect(mockWalletService.createPaymentRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('throws on invalid payment type', async () => {
|
|
const user = { id: 1, credits: 0 };
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
|
|
await expect(gmm.createPaymentRequest('socket1', 'invalid_type'))
|
|
.rejects.toThrow(/Invalid payment type/);
|
|
});
|
|
});
|
|
|
|
describe('Game Mode Info', () => {
|
|
test('returns complete game mode info', () => {
|
|
gmm.gameMode = 'PAID_SINGLE';
|
|
gmm.cryptoType = 'WOW';
|
|
gmm.singleGamePrice = 100000000000;
|
|
gmm.paymentsEnabled = true;
|
|
gmm.directModeEnabled = true;
|
|
gmm.creditsModeEnabled = false;
|
|
|
|
const info = gmm.getGameModeInfo();
|
|
|
|
expect(info.mode).toBe('PAID_SINGLE');
|
|
expect(info.cryptoType).toBe('WOW');
|
|
expect(info.singleGamePrice).toBe(100000000000);
|
|
expect(info.paymentsEnabled).toBe(true);
|
|
expect(info.directModeEnabled).toBe(true);
|
|
expect(info.creditsModeEnabled).toBe(false);
|
|
expect(info.earlyEntry).toBeDefined();
|
|
expect(info.payoutMultipliers).toBeDefined();
|
|
});
|
|
|
|
test('includes testnet warning for XMR stagenet', () => {
|
|
gmm.cryptoType = 'XMR';
|
|
gmm.network = 'stagenet';
|
|
gmm.isTestNetwork = true;
|
|
|
|
const info = gmm.getGameModeInfo();
|
|
|
|
expect(info.isTestNetwork).toBe(true);
|
|
expect(info.testnetWarning).toContain('STAGENET');
|
|
});
|
|
|
|
test('no testnet warning for WOW (only has mainnet)', () => {
|
|
gmm.cryptoType = 'WOW';
|
|
gmm.network = 'mainnet';
|
|
gmm.isTestNetwork = false;
|
|
|
|
const info = gmm.getGameModeInfo();
|
|
|
|
expect(info.isTestNetwork).toBe(false);
|
|
expect(info.testnetWarning).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('User Stats', () => {
|
|
test('returns user statistics from database function', async () => {
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{
|
|
total_games: 100,
|
|
games_won: 45,
|
|
win_rate: 0.45,
|
|
total_paid: 5000000000000,
|
|
total_won: 6000000000000,
|
|
net_profit: 1000000000000,
|
|
credits_remaining: 15
|
|
}]
|
|
});
|
|
|
|
const stats = await gmm.getUserStats('socket1');
|
|
|
|
expect(stats.total_games).toBe(100);
|
|
expect(stats.games_won).toBe(45);
|
|
expect(stats.net_profit).toBe(1000000000000);
|
|
});
|
|
|
|
test('returns default stats when none found', async () => {
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
|
|
|
const stats = await gmm.getUserStats('socket1');
|
|
|
|
expect(stats.total_games).toBe(0);
|
|
expect(stats.games_won).toBe(0);
|
|
expect(stats.net_profit).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases and Error Handling', () => {
|
|
let gmm;
|
|
let mockDb;
|
|
let mockWalletService;
|
|
|
|
beforeEach(() => {
|
|
mockDb = createMockDb();
|
|
mockWalletService = createMockWalletService();
|
|
gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig());
|
|
});
|
|
|
|
afterEach(() => {
|
|
gmm?.shutdown();
|
|
});
|
|
|
|
test('handles payout insert failure gracefully', async () => {
|
|
gmm.gameMode = 'PAID_SINGLE';
|
|
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] });
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
|
|
});
|
|
// INSERT payout fails
|
|
mockDb.query.mockRejectedValueOnce(new Error('Database connection lost'));
|
|
|
|
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
|
|
|
// The payout insert and game completion share one transaction. A failed obligation
|
|
// therefore fails/rolls back completion instead of leaving a completed unpaid win.
|
|
expect(result.success).toBe(false);
|
|
expect(result.payout).toBeUndefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
test('handles database error during credits deduction', async () => {
|
|
const user = { id: 1, credits: 5 };
|
|
|
|
// _processGameStartWithCredits uses withTransaction, so error must come from there
|
|
mockDb.withTransaction.mockRejectedValueOnce(new Error('Database connection lost'));
|
|
|
|
await expect(gmm._processGameStartWithCredits(user, 'socket1', 'game123'))
|
|
.rejects.toThrow('Database connection lost');
|
|
});
|
|
|
|
test('handles payment creation when wallet is unavailable', async () => {
|
|
const user = { id: 1 };
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
|
|
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
|
|
|
|
mockWalletService.createPaymentRequest.mockRejectedValueOnce(
|
|
new Error('RPC connection refused')
|
|
);
|
|
|
|
await expect(gmm.createPaymentRequest('socket1', 'single_game'))
|
|
.rejects.toThrow();
|
|
});
|
|
});
|