mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +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.
685 lines
31 KiB
JavaScript
685 lines
31 KiB
JavaScript
/**
|
|
* 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('production payout and free-play policy', () => {
|
|
test('financial recovery gate blocks invoice intake and every paid start but preserves explicit free play', async () => {
|
|
gmm.paymentsEnabled = true;
|
|
gmm.directModeEnabled = true;
|
|
gmm.creditsModeEnabled = true;
|
|
gmm.financialAdmissionAllowed = () => false;
|
|
|
|
expect(gmm.isPaymentIntakeEnabled('single_game')).toBe(false);
|
|
expect(gmm.isPaymentIntakeEnabled('credits_package')).toBe(false);
|
|
expect(gmm.isPaymentIntakeEnabled('cosmetic_pack')).toBe(false);
|
|
|
|
const user = { id: 7, credits: 5, payout_address: 'wallet' };
|
|
jest.spyOn(gmm, 'getOrCreateUser').mockResolvedValue(user);
|
|
const paid = await gmm.processGameStart('socket-paid', 'game-paid');
|
|
expect(paid).toMatchObject({
|
|
success: false,
|
|
code: 'FINANCIAL_RECOVERY_PENDING'
|
|
});
|
|
expect(mockDb.query).not.toHaveBeenCalled();
|
|
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
|
|
|
expect(await gmm._processGameStartWithCredits(user, 'socket-paid', 'game-credits'))
|
|
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
|
|
expect(await gmm._processGameStartWithPayment(user, { id: 9 }, 'game-direct'))
|
|
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
|
|
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
|
|
|
jest.spyOn(gmm, '_processGameStartFree').mockResolvedValue({
|
|
success: true,
|
|
effectiveMode: 'FREE'
|
|
});
|
|
await expect(gmm.processGameStart('socket-free', 'game-free', { forceFree: true }))
|
|
.resolves.toEqual({ success: true, effectiveMode: 'FREE' });
|
|
});
|
|
|
|
test('master payout switch overrides enabled per-mode rules and stops dispatch', async () => {
|
|
gmm.payoutsEnabled = false;
|
|
gmm.directPayoutEnabled = true;
|
|
gmm.creditsPayoutEnabled = true;
|
|
gmm.directRequiresAddress = true;
|
|
|
|
expect(gmm.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
|
|
expect(gmm.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
|
|
expect(gmm.requiresPayoutAddressForMode('PAID_SINGLE')).toBe(false);
|
|
|
|
await gmm._processPendingPayouts();
|
|
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
|
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('shutdown cancels a queued payout debounce and rejects later scheduling', async () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
const processPending = jest.spyOn(gmm, '_processPendingPayouts').mockResolvedValue();
|
|
|
|
gmm._scheduleBatchPayout();
|
|
expect(jest.getTimerCount()).toBe(1);
|
|
|
|
gmm.shutdown();
|
|
expect(gmm._batchPayoutTimer).toBeNull();
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
|
|
gmm._scheduleBatchPayout();
|
|
jest.advanceTimersByTime(5000);
|
|
await Promise.resolve();
|
|
expect(processPending).not.toHaveBeenCalled();
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('unified paid config does not accidentally inherit free mode when FREE_PLAY_ENABLED is false', () => {
|
|
const oldGameMode = process.env.GAME_MODE;
|
|
const oldFree = process.env.FREE_PLAY_ENABLED;
|
|
try {
|
|
delete process.env.GAME_MODE;
|
|
process.env.FREE_PLAY_ENABLED = 'false';
|
|
const manager = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
|
|
expect(manager.paymentsEnabled).toBe(true);
|
|
expect(manager.freePlayEnabled).toBe(false);
|
|
} finally {
|
|
if (oldGameMode === undefined) delete process.env.GAME_MODE; else process.env.GAME_MODE = oldGameMode;
|
|
if (oldFree === undefined) delete process.env.FREE_PLAY_ENABLED; else process.env.FREE_PLAY_ENABLED = oldFree;
|
|
}
|
|
});
|
|
|
|
test('game mode info advertises the selected safe ruleset catalog', () => {
|
|
const old = process.env.MATCH_RULESET_ID;
|
|
try {
|
|
process.env.MATCH_RULESET_ID = 'last-alive';
|
|
const match = gmm.getGameModeInfo().modes.match;
|
|
expect(match.activeRuleset).toEqual(expect.objectContaining({
|
|
id: 'last-alive',
|
|
label: 'Last Alive',
|
|
winCondition: 'last-alive'
|
|
}));
|
|
expect(match.rulesets.map(r => r.id)).toEqual(expect.arrayContaining(['race', 'last-alive', 'score-attack', 'coop-escape']));
|
|
expect(match.rulesets.map(r => r.id)).not.toContain('solo-classic');
|
|
} finally {
|
|
if (old === undefined) delete process.env.MATCH_RULESET_ID; else process.env.MATCH_RULESET_ID = old;
|
|
}
|
|
});
|
|
|
|
test('game mode info publishes only a recognized operated product profile id', () => {
|
|
const old = process.env.OPERATED_PRODUCT_PROFILE;
|
|
try {
|
|
process.env.OPERATED_PRODUCT_PROFILE = 'such-play-wow-prestige';
|
|
expect(gmm.getGameModeInfo().operatedProductProfileId)
|
|
.toBe('such-play-wow-prestige');
|
|
process.env.OPERATED_PRODUCT_PROFILE = 'unreviewed-profile';
|
|
expect(gmm.getGameModeInfo().operatedProductProfileId).toBeNull();
|
|
} finally {
|
|
if (old === undefined) delete process.env.OPERATED_PRODUCT_PROFILE;
|
|
else process.env.OPERATED_PRODUCT_PROFILE = old;
|
|
}
|
|
});
|
|
|
|
test('game mode info binds Socket.IO clients to the immutable release', () => {
|
|
const release = {
|
|
verified: true,
|
|
id: `git-${'a'.repeat(12)}`,
|
|
commit: 'a'.repeat(40)
|
|
};
|
|
gmm.releaseIdentity = release;
|
|
expect(gmm.getGameModeInfo().release).toEqual(release);
|
|
});
|
|
|
|
test('crypto race economy is hidden when the payout master switch is off', () => {
|
|
const keys = [
|
|
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
|
|
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
|
|
'MATCH_MAX_PLAYERS'
|
|
];
|
|
const old = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
|
try {
|
|
process.env.MATCH_ENABLED = 'true';
|
|
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
|
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
|
process.env.MATCH_PAYOUT_MAX = '1000000000000';
|
|
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
|
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
|
|
process.env.MATCH_MAX_PLAYERS = '4';
|
|
gmm.payoutsEnabled = false;
|
|
expect(gmm._getMatchEconomies().crypto_race).toBeUndefined();
|
|
gmm.payoutsEnabled = true;
|
|
expect(gmm._getMatchEconomies().crypto_race).toBe(true);
|
|
} finally {
|
|
for (const key of keys) {
|
|
if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key];
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('calculatePayout', () => {
|
|
test('publishes only the double/triple outcomes implemented by payout calculation', () => {
|
|
gmm.directPayoutMultipliers = {
|
|
escape: 2,
|
|
escapeWithTreasure: 3,
|
|
perfectRun: 5,
|
|
experimentalJackpot: 20
|
|
};
|
|
gmm.creditPayoutMultipliers = {
|
|
escape: 2,
|
|
escapeWithTreasure: 3,
|
|
perfectRun: 3
|
|
};
|
|
|
|
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_SINGLE')).toStrictEqual({
|
|
escape: 2,
|
|
escapeWithTreasure: 3
|
|
});
|
|
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_CREDITS')).toStrictEqual({
|
|
escape: 2,
|
|
escapeWithTreasure: 3
|
|
});
|
|
|
|
const publicInfo = gmm.getGameModeInfo();
|
|
expect(publicInfo.payoutMultipliers).toStrictEqual({
|
|
direct: { escape: 2, escapeWithTreasure: 3 },
|
|
credits: { escape: 2, escapeWithTreasure: 3 }
|
|
});
|
|
});
|
|
|
|
test('publishes exact effective outcomes after cap/min policy', () => {
|
|
gmm.payoutsEnabled = true;
|
|
gmm.directPayoutEnabled = true;
|
|
gmm.creditsPayoutEnabled = true;
|
|
gmm.singleGamePrice = 20000;
|
|
gmm.creditsPayoutBaseValue = 1000;
|
|
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
gmm.configSnapshot.payouts.rules.direct.minPayout = 1n;
|
|
gmm.configSnapshot.payouts.rules.direct.maxPayout = 50000n;
|
|
gmm.configSnapshot.payouts.rules.credits.minPayout = 3000n;
|
|
gmm.configSnapshot.payouts.rules.credits.maxPayout = 50000n;
|
|
|
|
const outcomes = gmm.getGameModeInfo().payoutOutcomes;
|
|
expect(outcomes.direct.escape).toEqual(expect.objectContaining({
|
|
payable: true,
|
|
amountAtomic: '40000',
|
|
rawAmountAtomic: '40000',
|
|
capApplied: false
|
|
}));
|
|
expect(outcomes.direct.escapeWithTreasure).toEqual(expect.objectContaining({
|
|
payable: true,
|
|
amountAtomic: '50000',
|
|
rawAmountAtomic: '60000',
|
|
capApplied: true
|
|
}));
|
|
expect(outcomes.credits.escape).toEqual(expect.objectContaining({
|
|
payable: false,
|
|
amountAtomic: '0',
|
|
rawAmountAtomic: '2000',
|
|
suppressedReason: 'below_minimum'
|
|
}));
|
|
|
|
const snapshot = gmm._computePayoutSnapshot('PAID_SINGLE');
|
|
expect(snapshot.treasureAmount).toBe('50000');
|
|
expect(snapshot.terms.version).toBe(2);
|
|
});
|
|
|
|
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, the SELECT payment lookup
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ user_id: 1, description: '', status: 'pending' }]
|
|
});
|
|
|
|
// Step 2: withTransaction, then client.query calls
|
|
mockClient.query.mockImplementation(async (sql) => {
|
|
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
|
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
|
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'free' }], rowCount: 1 };
|
|
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
|
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
|
|
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.mockImplementation(async (sql) => {
|
|
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
|
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
|
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }], rowCount: 1 };
|
|
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
|
|
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
|
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
|
|
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.mockImplementation(async (sql) => {
|
|
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 456 }], rowCount: 1 };
|
|
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
|
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], rowCount: 1 };
|
|
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
|
|
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 456 }], rowCount: 1 };
|
|
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
|
|
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('creates an exact funded lot from the durable confirmed-payment grant snapshot', async () => {
|
|
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
|
|
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
|
try {
|
|
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
|
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
|
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{
|
|
user_id: 1,
|
|
description: 'Two race entries',
|
|
status: 'pending',
|
|
payment_type: 'cosmetic_pack',
|
|
product_id: 'race_2',
|
|
product_grants: {
|
|
credits: 0,
|
|
raceEntries: 2,
|
|
raceEntryValueAtomic: '10000',
|
|
packs: [],
|
|
premiumLevel: null
|
|
},
|
|
received_amount: '20000',
|
|
expected_amount: '20000'
|
|
}]
|
|
});
|
|
mockClient.query.mockImplementation(async (sql) => {
|
|
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 700 }], rowCount: 1 };
|
|
if (/SELECT id, premium_level/i.test(sql)) {
|
|
return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
|
}
|
|
if (/SET credits = credits/i.test(sql)) {
|
|
return {
|
|
rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
if (/SET race_entries = race_entries \+/i.test(sql)) {
|
|
return { rows: [{ race_entries: 2 }], rowCount: 1 };
|
|
}
|
|
if (/INSERT INTO race_entry_lots/i.test(sql)) {
|
|
return { rows: [{ id: 88 }], rowCount: 1 };
|
|
}
|
|
if (/SELECT pack_id/i.test(sql)) return { rows: [], rowCount: 0 };
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
|
|
const result = await gmm.processProductPaymentConfirmation('new-socket', 700, {
|
|
id: 'changed-live-catalog',
|
|
grants: { race_entries: 99, race_entry_value_atomic: '1' }
|
|
}, '20000');
|
|
|
|
expect(result.success).toBe(true);
|
|
const lot = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_lots/i.test(sql));
|
|
expect(lot[1]).toEqual([1, 700, '10000', 2, 'race_2']);
|
|
const ticketLedger = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_transactions/i.test(sql));
|
|
expect(ticketLedger[1][3]).toBe(700);
|
|
} finally {
|
|
for (const key of keys) {
|
|
if (previous[key] === undefined) delete process.env[key];
|
|
else process.env[key] = previous[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
test('does not confirm an underfunded race-ticket invoice', async () => {
|
|
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
|
|
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
|
try {
|
|
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
|
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
|
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{
|
|
user_id: 1,
|
|
status: 'pending',
|
|
payment_type: 'cosmetic_pack',
|
|
product_id: 'race_2',
|
|
product_grants: {
|
|
credits: 0,
|
|
raceEntries: 2,
|
|
raceEntryValueAtomic: '10000'
|
|
},
|
|
received_amount: '19999',
|
|
expected_amount: '20000'
|
|
}]
|
|
});
|
|
|
|
const result = await gmm.processProductPaymentConfirmation('new-socket', 701, null, '19999');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
|
} finally {
|
|
for (const key of keys) {
|
|
if (previous[key] === undefined) delete process.env[key];
|
|
else process.env[key] = previous[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
test('falls back to default credits when package info missing and no description', async () => {
|
|
// Step 1: db.query, the SELECT payment lookup with no credits in description
|
|
mockDb.query.mockResolvedValueOnce({
|
|
rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }]
|
|
});
|
|
|
|
// Step 2: withTransaction, then client.query calls
|
|
mockClient.query.mockImplementation(async (sql) => {
|
|
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
|
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
|
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 10, total_credits_purchased: 10, premium_level: 'free' }], rowCount: 1 };
|
|
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
|
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
|
|
return { rows: [], rowCount: 1 };
|
|
});
|
|
|
|
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('when direct is preferred, a confirmed direct entry is not replaced by an existing credit', async () => {
|
|
gmm.paymentsEnabled = true;
|
|
gmm.directModeEnabled = true;
|
|
gmm.creditsModeEnabled = true;
|
|
gmm.preferCreditsFirst = false;
|
|
|
|
mockDb.query
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] })
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|