/** * GameModeManager Tests * Tests for mixed mode, credits, and payment logic */ // Mock the database const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) }; const mockDb = { query: jest.fn(), withTransaction: jest.fn().mockImplementation(async (callback) => { return callback(mockClient); }) }; // Mock wallet service const mockWalletService = { createPaymentRequest: jest.fn(), processPayout: jest.fn() }; // Mock debug manager const mockDebugManager = { CONSOLE_LOGGING: false }; // Mock payment config manager const mockPaymentConfigManager = { 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 }] } }, payouts: { rules: { direct: { multipliers: { escape: 2, escapeWithTreasure: 3 } }, credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 } } } }, preferences: { preferCreditsFirst: true } }), getLegacyGameMode: () => 'PAID_SINGLE', eventBus: { on: () => {} } }; const GameModeManager = require('../src/game/gameModeManager'); describe('GameModeManager', () => { let gmm; beforeEach(() => { jest.clearAllMocks(); mockDb.query.mockReset(); mockClient.query.mockReset().mockResolvedValue({ rows: [] }); gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager); }); describe('getEffectiveModeForUser', () => { test('returns FREE mode when payments disabled', () => { gmm.paymentsEnabled = false; const result = gmm.getEffectiveModeForUser({ credits: 5 }); expect(result.mode).toBe('FREE'); }); test('returns PAID_CREDITS when user has credits and preferCreditsFirst', () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.preferCreditsFirst = true; gmm.creditsPerGameCost = 1; const result = gmm.getEffectiveModeForUser({ credits: 5 }); expect(result.mode).toBe('PAID_CREDITS'); expect(result.hasCredits).toBe(true); expect(result.bothModesEnabled).toBe(true); }); test('returns PAID_SINGLE when user has no credits in mixed mode', () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.preferCreditsFirst = true; gmm.creditsPerGameCost = 1; const result = gmm.getEffectiveModeForUser({ credits: 0 }); expect(result.mode).toBe('PAID_SINGLE'); expect(result.hasCredits).toBe(false); }); test('returns correct mode when only direct is enabled', () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = false; const result = gmm.getEffectiveModeForUser({ credits: 10 }); expect(result.mode).toBe('PAID_SINGLE'); expect(result.bothModesEnabled).toBe(false); }); test('returns correct mode when only credits is enabled', () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = false; gmm.creditsModeEnabled = true; const result = gmm.getEffectiveModeForUser({ credits: 5 }); expect(result.mode).toBe('PAID_CREDITS'); }); }); describe('calculatePayout', () => { test('calculates direct mode payout correctly', () => { gmm.singleGamePrice = 100000000000; // 1 WOW gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false }); expect(result.amount).toBe(200000000000); // 2x expect(result.multiplier).toBe(2); }); test('calculates treasure bonus correctly', () => { gmm.singleGamePrice = 100000000000; gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true }); expect(result.amount).toBe(300000000000); // 3x expect(result.multiplier).toBe(3); }); test('returns zero payout for credits mode when disabled', () => { gmm.creditsPayoutBaseValue = 100000000000; gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 }; const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); expect(result.amount).toBe(0); }); test('calculates credits mode payout when enabled', () => { gmm.creditsPayoutBaseValue = 50000000000; // 0.5 WOW base gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 }; const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); expect(result.amount).toBe(75000000000); // 0.5 * 1.5 }); }); describe('getPaymentOptionsForUser', () => { beforeEach(() => { mockDb.query.mockResolvedValue({ rows: [{ id: 1, credits: 5, payout_address: 'wow1addr' }] }); }); test('returns use_credit option when user has credits', async () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.creditsPerGameCost = 1; const result = await gmm.getPaymentOptionsForUser('socket1'); const useCreditOption = result.options.find(o => o.type === 'use_credit'); expect(useCreditOption).toBeDefined(); expect(useCreditOption.mode).toBe('PAID_CREDITS'); }); test('returns pay_direct option', async () => { gmm.directModeEnabled = true; const result = await gmm.getPaymentOptionsForUser('socket1'); const directOption = result.options.find(o => o.type === 'pay_direct'); expect(directOption).toBeDefined(); expect(directOption.mode).toBe('PAID_SINGLE'); }); test('returns buy_credits option when credits mode enabled', async () => { gmm.creditsModeEnabled = true; const result = await gmm.getPaymentOptionsForUser('socket1'); const buyOption = result.options.find(o => o.type === 'buy_credits'); expect(buyOption).toBeDefined(); }); }); describe('processCreditsPackageConfirmation', () => { test('adds correct credits from package info', async () => { // Step 1: db.query — SELECT payment lookup mockDb.query.mockResolvedValueOnce({ rows: [{ user_id: 1, description: '', status: 'pending' }] }); // Step 2: withTransaction — client.query calls mockClient.query .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' .mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15 .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 }); expect(result.success).toBe(true); expect(result.creditsAdded).toBe(15); // 10 + 5 bonus }); test('applies bundled pack grants from package info', async () => { mockDb.query.mockResolvedValueOnce({ rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }] }); mockClient.query .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments .mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }] }) // UPDATE users .mockResolvedValueOnce({ rows: [] }) // INSERT credit_transactions .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { id: 'bundle', credits: 10, grants: { credits: 15, packs: ['kenney-3d-characters'], premiumLevel: 'supporter' } }); expect(result.success).toBe(true); expect(result.creditsAdded).toBe(15); expect(result.grantsApplied.packs.map(p => p.id)).toEqual(['kenney-3d-characters']); expect(result.entitlements.packs['kenney-3d-characters']).toBe(true); const entitlementInsert = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO user_pack_entitlements/i.test(sql)); expect(entitlementInsert).toBeDefined(); expect(entitlementInsert[1][1]).toBe('kenney-3d-characters'); }); test('applies standalone cosmetic pack product without granting credits', async () => { mockDb.query.mockResolvedValueOnce({ rows: [{ user_id: 1, description: '3D pack', status: 'pending', payment_type: 'cosmetic_pack', product_id: 'pack_3d', product_grants: { credits: 0, packs: [{ id: 'kenney-3d-characters' }], premiumLevel: null } }] }); mockClient.query .mockResolvedValueOnce({ rows: [{ id: 456 }] }) // UPDATE payments .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }] }) // UPDATE users .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000); expect(result.success).toBe(true); expect(result.creditsAdded).toBe(0); expect(result.entitlements.packs['kenney-3d-characters']).toBe(true); expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false); }); test('falls back to default credits when package info missing and no description', async () => { // Step 1: db.query — SELECT payment lookup with no credits in description mockDb.query.mockResolvedValueOnce({ rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }] }); // Step 2: withTransaction — client.query calls mockClient.query .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' .mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); expect(result.success).toBe(true); expect(result.creditsAdded).toBe(10); // Default fallback }); }); describe('canUserStartGame - mixed mode', () => { test('allows game with credits in mixed mode', async () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.creditsPerGameCost = 1; mockDb.query .mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] }) // getOrCreateUser lookup .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('allows game with confirmed payment when user has no credits', async () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.creditsPerGameCost = 1; mockDb.query .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup .mockResolvedValueOnce({ rows: [] }) // update last_active .mockResolvedValueOnce({ rows: [{ id: 999 }] }); // confirmed payment query const result = await gmm.canUserStartGame('socket1'); expect(result.allowed).toBe(true); expect(result.paymentId).toBe(999); expect(result.effectiveMode).toBe('PAID_SINGLE'); }); test('denies game when no credits and no payment in mixed mode', async () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; gmm.creditsModeEnabled = true; gmm.creditsPerGameCost = 1; mockDb.query .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup .mockResolvedValueOnce({ rows: [] }) // update last_active .mockResolvedValueOnce({ rows: [] }) // no confirmed payment .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser for getPaymentOptions .mockResolvedValueOnce({ rows: [] }); // update last_active const result = await gmm.canUserStartGame('socket1'); expect(result.allowed).toBe(false); expect(result.action).toBe('choose_payment'); }); }); });