fix edge cases and tests

This commit is contained in:
jwinterm
2026-02-19 06:15:45 -05:00
parent b6597a4a6e
commit bc55f0355e
12 changed files with 231 additions and 162 deletions
+3 -3
View File
@@ -446,7 +446,7 @@
<script>
// State
let apiKey = localStorage.getItem('adminApiKey') || '';
let apiKey = sessionStorage.getItem('adminApiKey') || '';
let currentTab = 'overview';
let usersOffset = 0;
let outcomesChart = null;
@@ -487,7 +487,7 @@
if (res.ok) {
apiKey = key;
localStorage.setItem('adminApiKey', key);
sessionStorage.setItem('adminApiKey', key);
verifyAndShowDashboard();
} else {
document.getElementById('loginError').style.display = 'block';
@@ -518,7 +518,7 @@
function logout() {
apiKey = '';
localStorage.removeItem('adminApiKey');
sessionStorage.removeItem('adminApiKey');
document.getElementById('loginModal').classList.remove('hidden');
document.getElementById('dashboard').classList.add('hidden');
document.getElementById('apiKeyInput').value = '';
-2
View File
@@ -293,11 +293,9 @@ class PaymentConfigManager {
if (creditsPayoutEnabled !== undefined) {
config.payouts.rules.credits.enabled = creditsPayoutEnabled;
}
console.log(`🔧 ENV CREDITS_PAYOUT_BASE = "${process.env.CREDITS_PAYOUT_BASE}" (raw)`);
config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape);
config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure);
config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue);
console.log(`🔧 Parsed credits baseValue = ${config.payouts.rules.credits.baseValue}`);
config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval);
config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries);
+1 -1
View File
@@ -90,7 +90,7 @@ class DatabaseManager {
database: process.env.DB_NAME || 'wownerogue',
user: process.env.DB_USER || 'wownerogue',
password: process.env.DB_PASSWORD,
max: 20, // Maximum number of clients in the pool
max: 40, // Maximum number of clients in the pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
+26 -25
View File
@@ -1214,13 +1214,26 @@ class GameModeManager {
this._isBatchProcessing = true;
try {
// Gather all pending payouts with no tx_hash (not yet attempted)
const pending = await this.db.query(`
SELECT id, user_id, game_id, payout_address, amount, multiplier, reason
FROM payouts
WHERE status = 'pending' AND tx_hash IS NULL
ORDER BY created_at ASC
`);
// Gather pending payouts inside a transaction with row-level locks.
// FOR UPDATE SKIP LOCKED prevents the retry service from grabbing the same rows.
const pending = await this.db.withTransaction(async (client) => {
const result = await client.query(`
SELECT id, user_id, game_id, payout_address, amount, multiplier, reason
FROM payouts
WHERE status = 'pending' AND tx_hash IS NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
`);
// Mark as 'processing' so retry service won't touch them even after this tx commits
if (result.rows.length > 0) {
const ids = result.rows.map(r => r.id);
await client.query(
`UPDATE payouts SET status = 'processing' WHERE id = ANY($1)`,
[ids]
);
}
return result;
});
if (pending.rows.length === 0) return;
@@ -1247,17 +1260,11 @@ class GameModeManager {
multiplier: p.multiplier
});
if (result.success && result.txHash) {
await this.db.query(
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`,
[result.txHash, result.fee || null, batchId, p.id]
);
}
// Store tx_hash + status + user stats atomically in one transaction
await this.db.withTransaction(async (client) => {
await client.query(
`UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`,
[p.id]
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`,
[result.txHash || null, result.fee || null, batchId, p.id]
);
await client.query(
`UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
@@ -1283,18 +1290,12 @@ class GameModeManager {
? Math.ceil(result.totalFee / pending.rows.length)
: null;
// Store tx_hash + status + user stats atomically for each payout
for (const p of pending.rows) {
// Store tx_hash immediately for each payout
await this.db.query(
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`,
[txHash, feePerPayout, batchId, p.id]
);
// Update status and user stats atomically
await this.db.withTransaction(async (client) => {
await client.query(
`UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`,
[p.id]
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`,
[txHash, feePerPayout, batchId, p.id]
);
await client.query(
`UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
+61 -5
View File
@@ -50,6 +50,8 @@ gameModeManager.io = io;
// Initialize remaining components
const activeGames = new Map(); // Maps socketId to Game objects
let alertService = null; // Initialized later when payment system starts
let payoutRetryService = null; // Initialized later when payment system starts
let batchPayoutInterval = null; // Initialized later when payment system starts
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
// Configure static file serving
const htmlPath = path.join(__dirname, '../html');
@@ -628,12 +630,28 @@ app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
const adminAuth = (req, res, next) => {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return res.status(503).json({
return res.status(503).json({
error: 'Admin API not configured',
message: 'Set ADMIN_API_KEY environment variable to enable admin endpoints.'
});
}
// Origin check — reject cross-origin requests to admin endpoints
const origin = req.headers.origin || req.headers.referer;
if (origin) {
try {
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
: [`http://localhost:${process.env.PORT || 3000}`, `http://127.0.0.1:${process.env.PORT || 3000}`];
const requestOrigin = new URL(origin).origin;
if (!allowedOrigins.includes(requestOrigin)) {
return res.status(403).json({ error: 'Forbidden', message: 'Origin not allowed.' });
}
} catch (e) {
return res.status(403).json({ error: 'Forbidden', message: 'Invalid origin.' });
}
}
const providedKey = req.headers['x-admin-key'];
if (!providedKey ||
providedKey.length !== adminKey.length ||
@@ -643,7 +661,7 @@ const adminAuth = (req, res, next) => {
message: 'Invalid or missing X-Admin-Key header.'
});
}
next();
};
@@ -1783,7 +1801,7 @@ async function startServer() {
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
const payoutIntervalMs = payoutIntervalSeconds * 1000;
setInterval(async () => {
batchPayoutInterval = setInterval(async () => {
try {
await walletRPCService.processBatchPayouts();
} catch (error) {
@@ -1804,7 +1822,7 @@ async function startServer() {
// Make alertService available to gameModeManager/paymentHandlers for balance checks
gameModeManager.alertService = alertService;
const payoutRetryService = new PayoutRetryService({
payoutRetryService = new PayoutRetryService({
db: databaseManager,
walletService: walletRPCService,
debugManager,
@@ -1834,3 +1852,41 @@ app.use((req, res, next) => {
app.use(createErrorMiddleware({ logger: console }));
startServer();
// Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool
async function gracefulShutdown(signal) {
console.log(`\n${signal} received. Shutting down gracefully...`);
// Stop accepting new connections
http.close();
// Stop scheduled timers
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer);
if (payoutRetryService) payoutRetryService.stop();
if (alertService) alertService.stopPeriodicChecks?.();
// Wait for in-flight batch processing to finish (up to 10 seconds)
let waitMs = 0;
while (gameModeManager._isBatchProcessing && waitMs < 10000) {
await new Promise(r => setTimeout(r, 500));
waitMs += 500;
}
if (waitMs > 0) {
console.log(` Waited ${waitMs}ms for in-flight payout processing`);
}
// Close database pool
try {
await databaseManager.close();
console.log(' Database pool closed');
} catch (err) {
console.error(' Error closing database pool:', err.message);
}
console.log('Shutdown complete.');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+5
View File
@@ -752,6 +752,11 @@ class SocketHandlers {
if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') {
this.paymentHandlers.stopMonitoringForSocket(socketId);
}
// Clean up wallet Maps to prevent memory leaks (addressToUser, addressToSocket)
if (this.walletService && typeof this.walletService.cleanupUserPayments === 'function') {
this.walletService.cleanupUserPayments(socketId);
}
// Clean up movement timestamps
this.playerMoveTimestamps.delete(socketId);
+2 -2
View File
@@ -113,7 +113,7 @@ class SpectatorManager {
playerId: socketId.substring(0, 6), // Anonymized player ID
startedAt: game.startedAt,
moveCount: game.moveCount || 0,
hasTreasure: game.player?.hasTreasure || false,
hasTreasure: game.isComplete ? (game.player?.hasTreasure || false) : undefined,
spectatorCount: spectatorCount,
// Don't expose exact positions - let spectators get that when they join
dungeonSize: {
@@ -368,7 +368,7 @@ class SpectatorManager {
player: game.player ? {
x: game.player.x,
y: game.player.y,
hasTreasure: game.player.hasTreasure
hasTreasure: undefined // Hidden from spectators during live games
} : null,
monster: game.monster ? {
x: game.monster.x,
+15 -11
View File
@@ -66,17 +66,21 @@ class PayoutRetryService {
// 1. Status is 'pending' (stuck - RPC may have succeeded but DB update failed)
// 2. Status is 'failed' with retry_count < maxRetries
// Exclude very recent failures (wait at least 1 minute before retry)
const result = await this.db.query(`
SELECT p.*
FROM payouts p
WHERE (
(p.status = 'pending' AND p.created_at < NOW() - INTERVAL '2 minutes')
OR (p.status = 'failed' AND p.retry_count < $1 AND (p.last_retry_at IS NULL OR p.last_retry_at < NOW() - INTERVAL '1 minute'))
)
AND p.status != 'permanently_failed'
ORDER BY p.created_at ASC
LIMIT 10
`, [this.maxRetries]);
// FOR UPDATE SKIP LOCKED prevents race with batch processor in gameModeManager
const result = await this.db.withTransaction(async (client) => {
return await client.query(`
SELECT p.*
FROM payouts p
WHERE (
(p.status = 'pending' AND p.created_at < NOW() - INTERVAL '2 minutes')
OR (p.status = 'failed' AND p.retry_count < $1 AND (p.last_retry_at IS NULL OR p.last_retry_at < NOW() - INTERVAL '1 minute'))
)
AND p.status NOT IN ('permanently_failed', 'processing')
ORDER BY p.created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
`, [this.maxRetries]);
});
if (result.rows.length === 0) {
if (this.debugManager?.CONSOLE_LOGGING) {
+7 -1
View File
@@ -572,11 +572,17 @@ class WalletRPCService {
}
cleanupUserPayments(userId) {
const addressesToRemove = [];
for (const [address, userInfo] of this.addressToUser.entries()) {
if (userInfo.userId === userId || userInfo.socketId === userId) {
this.stopPaymentMonitoring(address);
addressesToRemove.push(address);
}
}
for (const address of addressesToRemove) {
this.stopPaymentMonitoring(address);
this.addressToUser.delete(address);
this.addressToSocket.delete(address);
}
}
// Get service health status
+25 -13
View File
@@ -4,8 +4,14 @@
*/
// Mock the database
const mockClient = {
query: jest.fn().mockResolvedValue({ rows: [] })
};
const mockDb = {
query: jest.fn()
query: jest.fn(),
withTransaction: jest.fn().mockImplementation(async (callback) => {
return callback(mockClient);
})
};
// Mock wallet service
@@ -48,6 +54,7 @@ describe('GameModeManager', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDb.query.mockReset();
mockClient.query.mockReset().mockResolvedValue({ rows: [] });
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
});
@@ -176,12 +183,15 @@ describe('GameModeManager', () => {
describe('processCreditsPackageConfirmation', () => {
test('adds correct credits from package info', async () => {
const user = { id: 1, credits: 0 };
mockDb.query
.mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser - first lookup
.mockResolvedValueOnce({ rows: [] }) // getOrCreateUser - update last_active
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // UPDATE payments
// 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 });
@@ -190,13 +200,15 @@ describe('GameModeManager', () => {
});
test('falls back to default credits when package info missing and no description', async () => {
const user = { id: 1, credits: 0 };
mockDb.query
.mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser - first lookup
.mockResolvedValueOnce({ rows: [] }) // getOrCreateUser - update last_active
.mockResolvedValueOnce({ rows: [] }) // SELECT payment - no description
// 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: [] }) // UPDATE payments
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
+70 -82
View File
@@ -6,16 +6,20 @@
const GameModeManager = require('../src/game/gameModeManager');
// Mock dependencies
const createMockDb = () => ({
query: jest.fn(),
// Mock withTransaction to execute the callback with a mock client
withTransaction: jest.fn().mockImplementation(async (callback) => {
const mockClient = {
query: jest.fn().mockResolvedValue({ rows: [] })
};
return callback(mockClient);
})
});
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(),
@@ -91,13 +95,14 @@ describe('Payment & Payout Comprehensive Tests', () => {
const user = { id: 1, credits: 1 };
gmm.creditsPerGameCost = 1;
// UPDATE returns the new balance of 0
mockDb.query.mockResolvedValueOnce({ rows: [{ credits: 0 }] }); // UPDATE succeeds
mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE games
mockDb.query.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
// 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);
@@ -107,12 +112,14 @@ describe('Payment & Payout Comprehensive Tests', () => {
const user = { id: 1, credits: 5 };
gmm.creditsPerGameCost = 2;
mockDb.query.mockResolvedValueOnce({ rows: [{ credits: 3 }] }); // UPDATE succeeds
mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE games
mockDb.query.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
// 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);
@@ -228,52 +235,56 @@ describe('Payment & Payout Comprehensive Tests', () => {
describe('Credits Package Confirmation', () => {
test('adds credits from package info correctly', async () => {
const user = { id: 1, credits: 0 };
mockDb.query
.mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser
.mockResolvedValueOnce({ rows: [] }) // update last_active
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // UPDATE payments
// Step 1: db.query — SELECT payment lookup (has user_id, status='pending')
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: '', status: 'pending' }]
});
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' RETURNING id
.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);
expect(result.newBalance).toBe(15);
});
test('parses credits from payment description as fallback', async () => {
const user = { id: 1, credits: 5 };
mockDb.query
.mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser
.mockResolvedValueOnce({ rows: [] }) // update last_active
.mockResolvedValueOnce({ rows: [{ description: 'Wowngeon 25 credits package (WOW)' }] }) // SELECT payments
// Step 1: db.query — SELECT payment lookup with description
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: 'Wowngeon 25 credits package (WOW)', status: 'pending' }]
});
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 30 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // UPDATE payments
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
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 () => {
const user = { id: 1, credits: 0 };
mockDb.query
.mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser
.mockResolvedValueOnce({ rows: [] }) // update last_active
.mockResolvedValueOnce({ rows: [{ description: 'Some payment' }] }) // SELECT payments - no credits pattern
// Step 1: db.query — SELECT payment lookup with non-matching description
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }]
});
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // UPDATE payments
.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
});
@@ -287,12 +298,6 @@ describe('Payment & Payout Comprehensive Tests', () => {
});
test('processes winning game with payout correctly', async () => {
mockWalletService.processPayout.mockResolvedValueOnce({
success: true,
txHash: 'abc123',
fee: 1000000
});
// Game record lookup
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
@@ -309,30 +314,20 @@ describe('Payment & Payout Comprehensive Tests', () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
// UPDATE payouts (store tx_hash)
mockDb.query.mockResolvedValueOnce({ rows: [] });
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);
expect(mockWalletService.processPayout).toHaveBeenCalledWith(
expect.objectContaining({
amount: 200000000000,
address: 'wow1testaddress'
})
);
// processPayout is NOT called directly — payout is batched via _scheduleBatchPayout
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('treasure bonus applied correctly to payout', async () => {
mockWalletService.processPayout.mockResolvedValueOnce({
success: true,
txHash: 'abc123',
fee: 1000000
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
});
@@ -345,11 +340,10 @@ describe('Payment & Payout Comprehensive Tests', () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
// UPDATE payouts (store tx_hash)
mockDb.query.mockResolvedValueOnce({ rows: [] });
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);
@@ -412,14 +406,8 @@ describe('Payment & Payout Comprehensive Tests', () => {
gmm.creditsPayoutBaseValue = 50000000000;
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
mockWalletService.processPayout.mockResolvedValueOnce({
success: true,
txHash: 'xyz789',
fee: 1000000
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'credits' }] // Game was started with credits
rows: [{ id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
@@ -430,12 +418,11 @@ describe('Payment & Payout Comprehensive Tests', () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
// UPDATE payouts (store tx_hash)
mockDb.query.mockResolvedValueOnce({ rows: [] });
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);
});
@@ -622,20 +609,20 @@ describe('Edge Cases and Error Handling', () => {
gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig());
});
test('handles wallet service error during payout', async () => {
test('handles payout insert failure gracefully', async () => {
gmm.gameMode = 'PAID_SINGLE';
mockWalletService.processPayout.mockRejectedValueOnce(new Error('Wallet unavailable'));
mockDb.query.mockResolvedValueOnce({ rows: [{ 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' }]
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, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(result.payoutError).toBeDefined();
@@ -643,8 +630,9 @@ describe('Edge Cases and Error Handling', () => {
test('handles database error during credits deduction', async () => {
const user = { id: 1, credits: 5 };
mockDb.query.mockRejectedValueOnce(new Error('Database connection lost'));
// _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');
+16 -17
View File
@@ -13,17 +13,16 @@ describe('WalletRPCService interface', () => {
expect(typeof svc.checkPaymentStatus).toBe('function');
expect(typeof svc.startPaymentMonitoring).toBe('function');
expect(typeof svc.stopPaymentMonitoring).toBe('function');
expect(typeof svc.processBatchPayouts).toBe('function'); // newly added stub
expect(typeof svc.processBatchPayout).toBe('function');
expect(typeof svc.processPayout).toBe('function');
expect(typeof svc.normalizeAtomicAmount).toBe('function');
expect(typeof svc.cleanupUserPayments).toBe('function');
expect(typeof svc.getHealthStatus).toBe('function');
});
test('processBatchPayouts stub returns processed:0', async () => {
test('processBatchPayout validates destinations', async () => {
const svc = new WalletRPCService(debugManagerMock);
const res = await svc.processBatchPayouts();
expect(res).toEqual({ processed: 0 });
await expect(svc.processBatchPayout([])).rejects.toThrow(/destinations/i);
});
test('rpcCall wraps failures in ExternalServiceError', async () => {
@@ -150,14 +149,14 @@ describe('WalletRPCService.processPayout', () => {
})).rejects.toThrow(/amount/i);
});
test('calls transfer RPC with correct parameters', async () => {
test('calls transfer_split RPC with correct parameters', async () => {
// Mock validateAddress to return valid
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: {
tx_hash: 'abc123def456',
tx_key: 'key789',
fee: 1000000
tx_hash_list: ['abc123def456'],
tx_key_list: ['key789'],
fee_list: [1000000]
}
});
@@ -167,7 +166,7 @@ describe('WalletRPCService.processPayout', () => {
userId: 'user1',
gameId: 'game123'
});
expect(result.success).toBe(true);
expect(result.txHash).toBe('abc123def456');
expect(result.txKey).toBe('key789');
@@ -175,8 +174,8 @@ describe('WalletRPCService.processPayout', () => {
expect(result.userId).toBe('user1');
expect(result.gameId).toBe('game123');
expect(result.amount).toBe(100000000000);
expect(svc.rpcCall).toHaveBeenCalledWith('transfer', {
expect(svc.rpcCall).toHaveBeenCalledWith('transfer_split', {
destinations: [{
amount: 100000000000,
address: 'wow1testaddress123456789'
@@ -190,7 +189,7 @@ describe('WalletRPCService.processPayout', () => {
test('includes subaddr_indices when subaddressIndex specified', async () => {
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 }
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
});
await svc.processPayout({
@@ -198,8 +197,8 @@ describe('WalletRPCService.processPayout', () => {
amount: 100,
subaddressIndex: 5
});
expect(svc.rpcCall).toHaveBeenCalledWith('transfer',
expect(svc.rpcCall).toHaveBeenCalledWith('transfer_split',
expect.objectContaining({
subaddr_indices: [5]
})
@@ -209,7 +208,7 @@ describe('WalletRPCService.processPayout', () => {
test('does not include subaddr_indices for invalid index values', async () => {
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 }
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
});
await svc.processPayout({
@@ -217,8 +216,8 @@ describe('WalletRPCService.processPayout', () => {
amount: 100,
subaddressIndex: -1
});
expect(svc.rpcCall).toHaveBeenCalledWith('transfer',
expect(svc.rpcCall).toHaveBeenCalledWith('transfer_split',
expect.not.objectContaining({
subaddr_indices: expect.anything()
})