Files
wownerogue/test/paymentHandlers.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

269 lines
10 KiB
JavaScript

const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
function legalAcknowledgement(gameModeManager) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service.isTestNetwork === true
};
}
function makeMocks() {
const user = { id: 'user-1', clientId: 'client1', payout_address: 'wow1address' };
return {
io: { to: () => ({ emit: () => {} }) },
gameModeManager: {
gameMode: 'PAID_SINGLE',
cryptoType: 'WOW',
currencyDecimals: 11,
singleGamePrice: 1000,
creditsPayoutEnabled: false,
formatAtomicHuman: jest.fn(() => '0.001'),
getOrCreateUser: jest.fn(async () => user),
createPaymentRequest: async () => ({
id: 'pid123',
address: 'addr',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date()
})
},
walletService: {
startPaymentMonitoring: jest.fn(),
stopPaymentMonitoring: jest.fn()
},
debugManager: { getCurrentBlockHeight: () => 123, CONSOLE_LOGGING: false },
queueManager: {
getUserBySocket: () => user,
getPlayerIndex: () => -1,
getQueueLength: () => 0,
addPlayer: jest.fn(),
markConfirmed: jest.fn(),
startGameImmediately: jest.fn(() => false)
},
broadcastManager: { sendStatusUpdate: jest.fn() }
};
}
describe('PaymentHandlers basic', () => {
let ph;
afterEach(() => {
if (ph && typeof ph.dispose === 'function') {
ph.dispose();
}
ph = null;
});
test('createAndShowPaymentRequest emits payment_created', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock1' }, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(emitted.payment_created).toBeDefined();
expect(emitted.payment_created.paymentId).toBe('pid123');
// No paymentProviders registry on the mock -> legacy fallback to direct wallet monitoring.
expect(mocks.walletService.startPaymentMonitoring).toHaveBeenCalled();
});
test('monitoring routes through a routed gateway provider instead of the wallet', async () => {
const mocks = makeMocks();
const gateway = { id: 'wowcheckout', startWatch: jest.fn(), stopWatch: jest.fn() };
mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) };
mocks.io.to = () => ({ emit: () => {} });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock2' }, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW');
expect(gateway.startWatch).toHaveBeenCalled();
expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native
});
test('direct payout invoice is not created before a required payout address exists', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
mocks.gameModeManager.getOrCreateUser.mockResolvedValue({ id: 'user-1', payout_address: null });
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
mocks.gameModeManager.createPaymentRequest = jest.fn();
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: 'sock3' }, {
type: 'single_game',
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted.payment_error).toEqual(expect.objectContaining({ code: 'PAYOUT_ADDRESS_REQUIRED' }));
});
test.each(['single_game', 'credits_package'])(
'payout-disabled %s invoice intake is not halted by an unknown/low wallet reserve',
async (paymentType) => {
const mocks = makeMocks();
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(() => false);
mocks.gameModeManager.alertService = {
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'low' })
};
mocks.gameModeManager.createPaymentRequest = jest.fn(mocks.gameModeManager.createPaymentRequest);
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: `no-payout-${paymentType}` }, {
type: paymentType,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.alertService.checkBalanceForGameStart).not.toHaveBeenCalled();
expect(mocks.gameModeManager.createPaymentRequest).toHaveBeenCalledTimes(1);
}
);
test('payout-bearing direct invoice remains fail closed under a critical reserve', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(mode => mode === 'PAID_SINGLE');
mocks.gameModeManager.alertService = {
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'critical' })
};
mocks.gameModeManager.createPaymentRequest = jest.fn();
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: 'payout-stagenet' }, {
type: 'single_game',
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted.balance_critical).toEqual(expect.objectContaining({ message: 'critical' }));
});
test('product grant transaction failure emits review-required and never confirmation success', async () => {
const mocks = makeMocks();
const emitted = {};
const socket = { id: 'product-failure', emit: (event, data) => { emitted[event] = data; } };
mocks.gameModeManager.processProductPaymentConfirmation = jest.fn()
.mockRejectedValue(new Error('receipt insert failed'));
mocks.queueManager.removePlayer = jest.fn();
ph = new PaymentHandlers(mocks);
ph.socketPaymentMap.set(socket.id, {
address: 'addr', paymentId: 'pid123', amount: 1000,
paymentType: 'credits_package', package: { credits: 10 }
});
await ph._handlePaymentStatus(socket, { id: 'pid123', address: 'addr', amount: 1000 }, null, {
confirmed: true,
complete: true,
amount: 1000,
confirmations: 2,
receipts: []
});
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
code: 'PAYMENT_RECEIPT_REJECTED'
}));
expect(emitted.payment_confirmed).toBeUndefined();
expect(mocks.queueManager.removePlayer).toHaveBeenCalledWith(socket.id);
});
test('single-game payment queue preserves the consumed two-phase fairness proof', async () => {
const mocks = makeMocks();
let statusCallback;
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const fairnessProof = {
proofVersion: 2,
offerId: 'offer-paid',
serverSeed: 'a'.repeat(64),
commitment: 'b'.repeat(64),
clientSeed: 'c'.repeat(64)
};
const durableProof = {
...fairnessProof,
offerId: 'offer-persisted-on-invoice',
offerIssuedAt: 1700000000000
};
mocks.gameModeManager.createPaymentRequest = jest.fn(async () => ({
id: 'pid123',
address: 'addr',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date(),
fairnessProof: durableProof
}));
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock4', emit: jest.fn() }, {
fairnessProof,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({
paymentId: 'pid123',
fairnessProof: durableProof
}));
});
test('production refuses to queue an unbound paid invoice', async () => {
const mocks = makeMocks();
let statusCallback;
const emitted = {};
mocks.gameModeManager._requiresPaidFairnessV2 = jest.fn(() => true);
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const socket = {
id: 'sock-unbound',
emit: (event, data) => { emitted[event] = data; }
};
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest(socket, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).not.toHaveBeenCalled();
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
code: 'PAYMENT_FAIRNESS_UNBOUND'
}));
});
test('fairness proof survives a payout-address prompt before monitoring starts', async () => {
const mocks = makeMocks();
let hasAddress = false;
let statusCallback;
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
mocks.gameModeManager.getOrCreateUser.mockImplementation(async () => ({
id: 'user-1',
payout_address: hasAddress ? 'wow1address' : null
}));
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const fairnessProof = { proofVersion: 2, offerId: 'address-offer', clientSeed: 'd'.repeat(64) };
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }, {
fairnessProof,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(statusCallback).toBeUndefined();
hasAddress = true;
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() });
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ fairnessProof }));
});
});