Files
wownerogue/test/paymentProviders_ultra.test.js
jwinterm fad2a695bd Wownerogue
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.
2026-07-27 08:23:01 -04:00

168 lines
8.4 KiB
JavaScript

/**
* Modular payment-provider system (Pillar 3, reframed around BTCPay).
*/
const { PaymentProviderRegistry } = require('../src/payments/providers/paymentProvider');
const BTCPayProvider = require('../src/payments/providers/btcpayProvider');
const NativeMoneroProvider = require('../src/payments/providers/nativeMoneroProvider');
function recordingFetch(routes) {
const calls = [];
const fn = async (url, opts) => {
const method = (opts && opts.method) || 'GET';
let body = null;
try { body = opts && opts.body ? JSON.parse(opts.body) : null; } catch (_) {}
calls.push({ url, method, body });
for (const r of routes) {
if (r.method === method && r.match.test(url)) {
return { ok: r.ok !== false, status: r.status || 200, text: async () => JSON.stringify(r.body) };
}
}
return { ok: false, status: 404, text: async () => '{}' };
};
fn.calls = calls;
return fn;
}
describe('PaymentProviderRegistry routing', () => {
const btc = new BTCPayProvider({ baseUrl: 'x', storeId: 's', apiKey: 'k', chains: ['BTC', 'LTC', 'GRIN'], fetchImpl: async () => ({}) });
const native = new NativeMoneroProvider({ walletService: {}, chains: ['XMR', 'WOW'] });
test('routes each chain to its configured provider', () => {
const reg = new PaymentProviderRegistry().register(btc).register(native)
.setRouting({ BTC: 'btcpay', LTC: 'btcpay', GRIN: 'btcpay', XMR: 'native-monero', WOW: 'native-monero' });
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('GRIN').id).toBe('btcpay');
expect(reg.getProvider('WOW').id).toBe('native-monero');
});
test('a chain routed to a provider that cannot serve it falls back to one that can', () => {
const reg = new PaymentProviderRegistry().register(btc).register(native).setRouting({ XMR: 'btcpay' });
expect(reg.getProvider('XMR').id).toBe('native-monero'); // btcpay(chains) excludes XMR here
});
});
describe('BTCPayProvider (Greenfield)', () => {
test('creates an invoice priced in the chain and reads the address from payment-methods', async () => {
const fetchImpl = recordingFetch([
{ method: 'POST', match: /\/stores\/store1\/invoices$/, body: { id: 'inv1', checkoutLink: 'https://pay/inv1', expirationTime: 1700 } },
{ method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: [{ paymentMethod: 'BTC', destination: 'bc1qaddr', paymentLink: 'bitcoin:bc1qaddr' }] }
]);
const p = new BTCPayProvider({ baseUrl: 'https://btcpay.lan/', storeId: 'store1', apiKey: 'KEY', fetchImpl });
const inv = await p.createInvoice({ chain: 'BTC', amountAtomic: 100000000n, description: 'entry', orderId: 'o1' });
expect(inv.invoiceId).toBe('inv1');
expect(inv.address).toBe('bc1qaddr');
const post = fetchImpl.calls.find(c => c.method === 'POST');
expect(post.url).toBe('https://btcpay.lan/api/v1/stores/store1/invoices');
expect(post.body.currency).toBe('BTC');
expect(post.body.amount).toBe('1'); // 1e8 atomic / 1e8 = 1 BTC
});
test('maps invoice status to the contract', async () => {
const mk = (status) => new BTCPayProvider({
baseUrl: 'x', storeId: 's', apiKey: 'k',
fetchImpl: recordingFetch([{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status } }])
});
expect((await mk('Settled').getInvoiceStatus('inv1')).complete).toBe(true);
expect((await mk('New').getInvoiceStatus('inv1')).status).toBe('pending');
expect((await mk('Expired').getInvoiceStatus('inv1')).status).toBe('expired');
});
test('errors clearly when unconfigured', async () => {
const p = new BTCPayProvider({ fetchImpl: async () => ({}) });
await expect(p.getInvoiceStatus('x')).rejects.toThrow(/not configured/);
});
describe('getWalletStatus (raw shape the confirmation callback consumes)', () => {
const mk = (invStatus, methods, chains) => new BTCPayProvider({
baseUrl: 'x', storeId: 's', apiKey: 'k', chains,
fetchImpl: recordingFetch([
{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status: invStatus } },
{ method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: methods }
])
});
test('Settled -> confirmed+complete with paid/required in atomic units (XMR, 12 decimals)', async () => {
const st = await mk('Settled', [{ cryptoCode: 'XMR', destination: 'a', totalPaid: '1.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1');
expect(st.confirmed).toBe(true);
expect(st.complete).toBe(true);
expect(st.in_mempool).toBe(false);
expect(st.amount).toBe(1500000000000); // 1.5 * 1e12
expect(st.required).toBe(1500000000000);
});
test('WOW uses 11 decimals', async () => {
const st = await mk('Settled', [{ cryptoCode: 'WOW', totalPaid: '1.5', amount: '1.5' }], ['WOW']).getWalletStatus('inv1');
expect(st.amount).toBe(150000000000); // 1.5 * 1e11
});
test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => {
const st = await mk('Settled', [{
cryptoCode: 'XMR',
totalPaid: '10000.000000000001',
amount: '10000.000000000001'
}], ['XMR']).getWalletStatus('inv1');
expect(st.amount).toBe('10000000000000001');
expect(st.required).toBe('10000000000000001');
});
test('Processing -> in_mempool, not confirmed', async () => {
const st = await mk('Processing', [{ cryptoCode: 'XMR', totalPaid: '0.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1');
expect(st.in_mempool).toBe(true);
expect(st.confirmed).toBe(false);
expect(st.complete).toBe(false);
expect(st.amount).toBe(500000000000);
});
test('New -> pending, no amount', async () => {
const st = await mk('New', [], ['XMR']).getWalletStatus('inv1');
expect(st.confirmed).toBe(false);
expect(st.in_mempool).toBe(false);
expect(st.amount).toBe(0);
});
test('Expired -> terminal flag so the watcher stops', async () => {
const st = await mk('Expired', [], ['XMR']).getWalletStatus('inv1');
expect(st._terminal).toBe(true);
expect(st.complete).toBe(false);
});
});
});
describe('NativeMoneroProvider wraps walletRPCService', () => {
const wallet = {
createPaymentRequest: jest.fn(async () => ({ address: 'Wo3xaddr' })),
checkPaymentStatus: jest.fn(async () => ({ confirmed: true, complete: true, amount: 100000000000, confirmations: 3 })),
processBatchPayout: jest.fn(async () => ({ tx_hash_list: ['tx1'] }))
};
test('supports only XMR/WOW; delegates invoice + status + payout', async () => {
const np = new NativeMoneroProvider({ walletService: wallet });
expect(np.supportsChain('WOW')).toBe(true);
expect(np.supportsChain('BTC')).toBe(false);
const inv = await np.createInvoice({ chain: 'WOW', amountAtomic: '100000000000', userId: 1 });
expect(inv.address).toBe('Wo3xaddr');
const st = await np.getInvoiceStatus('Wo3xaddr');
expect(st.status).toBe('paid');
expect(st.complete).toBe(true);
const po = await np.sendPayout({ chain: 'WOW', address: 'Wo3ydest', amountAtomic: '50000000000' });
expect(po.txids).toEqual(['tx1']);
expect(wallet.processBatchPayout).toHaveBeenCalledWith([{ address: 'Wo3ydest', amount: '50000000000' }]);
});
test('startWatch passes the RAW wallet status through untouched (native path preserved)', () => {
let captured;
const w = { startPaymentMonitoring: jest.fn((addr, cb) => { captured = cb; }) };
const np = new NativeMoneroProvider({ walletService: w });
const seen = [];
np.startWatch('Wo3xaddr', (s) => seen.push(s), 2000);
expect(w.startPaymentMonitoring).toHaveBeenCalledWith('Wo3xaddr', expect.any(Function), 2000);
const raw = { in_mempool: true, confirmed: false, complete: false, amount: 42, required: 100, confirmations: 0 };
captured(raw);
expect(seen[0]).toBe(raw); // same object, not normalized: callback sees exactly what the wallet emitted
});
});