mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
226 lines
8.6 KiB
JavaScript
226 lines
8.6 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const createAdminRoutes = require('../src/routes/admin');
|
|
const AlertService = require('../src/services/alertService');
|
|
|
|
function makeRouter(db) {
|
|
return createAdminRoutes({
|
|
db,
|
|
gameModeManager: {},
|
|
walletRPCService: {},
|
|
socketHandlers: {},
|
|
io: {},
|
|
alertService: null
|
|
});
|
|
}
|
|
|
|
function handlerFor(router, method, routePath) {
|
|
const layer = router.stack.find(item => item.route?.path === routePath
|
|
&& item.route.methods[method]);
|
|
if (!layer) throw new Error(`Missing ${method.toUpperCase()} ${routePath}`);
|
|
return layer.route.stack[layer.route.stack.length - 1].handle;
|
|
}
|
|
|
|
function invoke(handler, req) {
|
|
return new Promise((resolve, reject) => {
|
|
const res = {
|
|
statusCode: 200,
|
|
status(code) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload) {
|
|
resolve({ status: this.statusCode, payload });
|
|
return this;
|
|
}
|
|
};
|
|
handler(req, res, reject);
|
|
});
|
|
}
|
|
|
|
describe('admin financial operations visibility', () => {
|
|
test.each(['processing', 'needs_review'])(
|
|
'payout list accepts the %s filter and reports every operational total',
|
|
async status => {
|
|
const db = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{
|
|
id: 7,
|
|
game_id: 8,
|
|
user_id: 9,
|
|
amount: '1200',
|
|
payout_address: 'address',
|
|
status,
|
|
tx_hash: null,
|
|
fee: null,
|
|
retry_count: 1,
|
|
last_error: 'ambiguous result',
|
|
created_at: new Date('2026-07-21T00:00:00Z'),
|
|
processed_at: null
|
|
}] })
|
|
.mockResolvedValueOnce({ rows: [{
|
|
pending_count: '1',
|
|
processing_count: '2',
|
|
failed_count: '3',
|
|
review_count: '4',
|
|
completed_count: '5',
|
|
permanently_failed_count: '6',
|
|
batched_count: '7',
|
|
total_volume: '8000'
|
|
}] })
|
|
};
|
|
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/payouts');
|
|
|
|
const response = await invoke(handler, { query: { status } });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(db.query.mock.calls[0][1]).toEqual([50, 0, status]);
|
|
expect(response.payload.payouts[0]).toMatchObject({ status, lastError: 'ambiguous result' });
|
|
expect(response.payload.totals).toMatchObject({
|
|
pending: 1,
|
|
processing: 2,
|
|
failed: 3,
|
|
needsReview: 4,
|
|
completed: 5,
|
|
permanentlyFailed: 6,
|
|
batched: 7
|
|
});
|
|
}
|
|
);
|
|
|
|
test('refund attention filter lists requested, processing, and needs_review rows', async () => {
|
|
const db = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{
|
|
id: 11,
|
|
payment_id: 12,
|
|
user_id: 13,
|
|
payment_status: 'refunded',
|
|
status: 'needs_review',
|
|
amount: '900',
|
|
payout_address: 'refund-address',
|
|
credits_deducted: '2',
|
|
purchase_progress_deducted: '2',
|
|
race_entries_deducted: 0,
|
|
tx_hash: null,
|
|
error_message: 'wallet outcome unknown',
|
|
reason: 'operator request',
|
|
created_at: new Date('2026-07-21T00:00:00Z'),
|
|
updated_at: new Date('2026-07-21T00:01:00Z')
|
|
}] })
|
|
.mockResolvedValueOnce({ rows: [{
|
|
recorded_count: '1',
|
|
requested_count: '2',
|
|
processing_count: '3',
|
|
completed_count: '4',
|
|
review_count: '5',
|
|
completed_volume: '600'
|
|
}] })
|
|
};
|
|
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/refunds');
|
|
|
|
const response = await invoke(handler, { query: { status: 'attention' } });
|
|
|
|
expect(db.query.mock.calls[0][1]).toEqual([
|
|
50,
|
|
0,
|
|
['requested', 'processing', 'needs_review']
|
|
]);
|
|
expect(response.payload.refunds[0]).toMatchObject({
|
|
id: 11,
|
|
status: 'needs_review',
|
|
error: 'wallet outcome unknown'
|
|
});
|
|
expect(response.payload.totals).toMatchObject({
|
|
recorded: 1,
|
|
requested: 2,
|
|
processing: 3,
|
|
completed: 4,
|
|
needsReview: 5
|
|
});
|
|
});
|
|
|
|
test('dashboard sends the retry endpoint its required explicit confirmation', () => {
|
|
const html = fs.readFileSync(path.join(__dirname, '../html/admin.html'), 'utf8');
|
|
const retryFunction = html.match(/async function retryPayout[\s\S]*?async function loadRefunds/)[0];
|
|
|
|
expect(retryFunction).toContain("'Content-Type': 'application/json'");
|
|
expect(retryFunction).toContain('body: JSON.stringify({ confirm: true })');
|
|
expect(html).toContain('<option value="processing">Processing</option>');
|
|
expect(html).toContain('<option value="needs_review">Needs Review</option>');
|
|
expect(html).toContain('id="refundStatusFilter"');
|
|
});
|
|
});
|
|
|
|
describe('financial review alerts', () => {
|
|
let logSpy;
|
|
let errorSpy;
|
|
|
|
beforeEach(() => {
|
|
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
test('alerts for manual-review and stale nonterminal rows without mutating them', async () => {
|
|
const db = { query: jest.fn().mockResolvedValue({ rows: [{
|
|
payout_review_count: '2',
|
|
refund_review_count: '1',
|
|
stale_payout_processing_count: '1',
|
|
stale_refund_nonterminal_count: '2'
|
|
}] }) };
|
|
const service = new AlertService({ db, walletService: null, debugManager: {} });
|
|
service.sendAlert = jest.fn().mockResolvedValue({ sent: true });
|
|
service.resolveAlert = jest.fn().mockResolvedValue({ sent: false });
|
|
|
|
await service.checkFinancialReviewRows();
|
|
|
|
expect(db.query).toHaveBeenCalledTimes(1);
|
|
expect(db.query.mock.calls[0][1]).toEqual([service.financialReviewStaleMs]);
|
|
expect(service.sendAlert).toHaveBeenCalledWith(
|
|
'financial_needs_review',
|
|
expect.objectContaining({ level: 'crit' })
|
|
);
|
|
expect(service.sendAlert).toHaveBeenCalledWith(
|
|
'financial_nonterminal_stale',
|
|
expect.objectContaining({ level: 'crit' })
|
|
);
|
|
expect(service.resolveAlert).not.toHaveBeenCalled();
|
|
expect(db.query.mock.calls[0][0]).not.toMatch(/\bUPDATE\b/i);
|
|
});
|
|
|
|
test('resolves both aggregate alerts after all review queues clear', async () => {
|
|
const db = { query: jest.fn().mockResolvedValue({ rows: [{
|
|
payout_review_count: '0',
|
|
refund_review_count: '0',
|
|
stale_payout_processing_count: '0',
|
|
stale_refund_nonterminal_count: '0'
|
|
}] }) };
|
|
const service = new AlertService({ db, walletService: null, debugManager: {} });
|
|
service.sendAlert = jest.fn();
|
|
service.resolveAlert = jest.fn().mockResolvedValue({ sent: true });
|
|
|
|
await service.checkFinancialReviewRows();
|
|
|
|
expect(service.sendAlert).not.toHaveBeenCalled();
|
|
expect(service.resolveAlert).toHaveBeenCalledWith('financial_needs_review');
|
|
expect(service.resolveAlert).toHaveBeenCalledWith('financial_nonterminal_stale');
|
|
});
|
|
|
|
test('periodic checks include the financial review query', async () => {
|
|
const service = new AlertService({ db: {}, walletService: null, debugManager: {} });
|
|
service.checkWalletBalance = jest.fn().mockResolvedValue();
|
|
service.checkWalletConnection = jest.fn().mockResolvedValue();
|
|
service.checkPendingPayouts = jest.fn().mockResolvedValue();
|
|
service.checkFinancialReviewRows = jest.fn().mockResolvedValue();
|
|
|
|
await service.runChecks();
|
|
|
|
expect(service.checkFinancialReviewRows).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|