mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 09:08:32 +02:00
fix edge cases and tests
This commit is contained in:
+3
-3
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user