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

333 lines
14 KiB
JavaScript

const SocketHandlers = require('../src/network/socketHandlers');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
const ENV_KEYS = [
'NODE_ENV',
'LEGAL_POLICY_VERSION',
'PAID_ACKNOWLEDGEMENT_REQUIRED',
'MATCH_ENABLED',
'MATCH_CRYPTO_RACE_ENABLED',
'MATCH_PAYOUTS_ENABLED'
];
function manager(overrides = {}) {
return {
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false,
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: true,
payoutsEnabled: false,
gameMode: 'PAID_CREDITS',
isPayoutEnabledForMode: () => false,
...overrides
};
}
function acknowledgement(gameModeManager, overrides = {}) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false,
...overrides
};
}
function socket() {
return {
id: 'socket-paid-flow',
handshake: { address: '127.0.0.1', headers: {} },
emit: jest.fn()
};
}
function baseContext(gameModeManager = manager()) {
return {
gameModeManager,
sessionManager: null,
broadcastManager: { sendStatusUpdate: jest.fn() },
rateLimiter: {
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
recordAttempt: jest.fn().mockResolvedValue(undefined)
},
_consumeFairnessAttempt: jest.fn().mockReturnValue({ proofVersion: 2 }),
activeGames: new Map(),
connectionHandler: { getUserBySocket: jest.fn().mockReturnValue({ serverId: 'socket-paid-flow' }) },
debugManager: { getCurrentBlockHeight: jest.fn().mockReturnValue(100), CONSOLE_LOGGING: false },
io: { to: jest.fn(() => ({ emit: jest.fn() })) }
};
}
describe('current paid acknowledgement gates every value-consuming socket flow', () => {
let previous;
beforeEach(() => {
previous = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
process.env.NODE_ENV = 'test';
process.env.LEGAL_POLICY_VERSION = 'paid-flow-current-v1';
process.env.PAID_ACKNOWLEDGEMENT_REQUIRED = 'false';
process.env.MATCH_ENABLED = 'true';
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
test('invoice request rejects a stale policy before fairness, wallet, or database work', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn() };
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.paymentHandlers.handlePaymentRequest).not.toHaveBeenCalled();
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
code: 'PAID_ACK_VERSION', policyVersion: 'paid-flow-current-v1'
}));
});
test('invoice request accepts the current policy even with intake state reported off', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn().mockResolvedValue(undefined) };
const ack = acknowledgement(ctx.gameModeManager);
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package', legalAcknowledgement: ack
});
expect(ctx.paymentHandlers.handlePaymentRequest).toHaveBeenCalledWith(client, {
type: 'credits_package', legalAcknowledgement: ack
});
});
test('auto-start rejects stale policy before consuming a fairness offer or credit', async () => {
const client = socket();
const ctx = baseContext();
ctx.gameManager = { createGameForUser: jest.fn() };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.gameManager.createGameForUser).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('auto-start with current policy reaches existing-credit consumption while intake is off', async () => {
const client = socket();
const gameModeManager = manager({
canUserStartGame: jest.fn().mockResolvedValue({
allowed: true,
effectiveMode: 'PAID_CREDITS',
useCredits: true
}),
requiresPayoutAddressForMode: jest.fn().mockReturnValue(false),
processGameStart: jest.fn().mockResolvedValue({ success: true, creditsRemaining: 4 })
});
const ctx = baseContext(gameModeManager);
const game = {
id: 'game-1',
dbId: null,
getState: jest.fn().mockReturnValue({}),
getProofCommitment: jest.fn().mockReturnValue('commitment')
};
ctx.gameManager = { createGameForUser: jest.fn().mockResolvedValue(game) };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(gameModeManager)
});
expect(gameModeManager.canUserStartGame).toHaveBeenCalledWith(client.id);
expect(gameModeManager.processGameStart).toHaveBeenCalledWith(client.id, game.id, { forceFree: false });
});
test('queued-credit entry rejects stale policy before queue eligibility or debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn() };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleGameQueue).not.toHaveBeenCalled();
});
test('queued-credit entry accepts the current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn().mockResolvedValue(undefined) };
ctx.queueManager = { getPlayerIndex: jest.fn().mockReturnValue(-1) };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleGameQueue).toHaveBeenCalledWith(
client,
expect.any(Function),
expect.objectContaining({ fairnessProof: { proofVersion: 2 } })
);
});
test('early entry rejects stale policy before queue eligibility or credit debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn() };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleEarlyEntry).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('early_entry_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('early entry accepts current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn().mockResolvedValue({ success: true }) };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleEarlyEntry).toHaveBeenCalledWith(
client,
expect.any(Function),
{ fairnessProof: { proofVersion: 2 } }
);
});
test('paid PvP rejects stale policy before a credit or ticket is escrowed', async () => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn(),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy: 'crypto_race',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.matchQueue.enqueue).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test.each(['credits_prestige', 'crypto_race'])(
'paid PvP %s accepts the current policy while new invoice intake is off',
async economy => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn().mockResolvedValue({ success: true, position: 1 }),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy,
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.matchQueue.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ userId: 1, economy }),
{ economy }
);
}
);
test('continuation cache is canonical, capped, expires, and clears on failed selection', async () => {
const gameModeManager = manager({
gameMode: 'FREE',
getCosmeticProduct: jest.fn().mockReturnValue(null)
});
const handler = new PaymentHandlers({
io: { to: () => ({ emit: jest.fn() }) },
gameModeManager,
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: { sendStatusUpdate: jest.fn() },
sessionManager: null
});
const ack = acknowledgement(gameModeManager);
try {
handler._rememberPendingCommerceAcknowledgement('canonical', {
...ack,
ignored: 'never retained'
});
expect(handler._getPendingCommerceAcknowledgement('canonical')).toEqual(ack);
expect(Object.keys(handler._getPendingCommerceAcknowledgement('canonical'))).toEqual([
'policyVersion', 'ageEligible', 'termsRead', 'riskAccepted', 'testnetUnderstood'
]);
for (let index = 0; index < 300; index += 1) {
handler._rememberPendingCommerceAcknowledgement(`socket-${index}`, ack);
}
expect(handler.pendingCommerceAcknowledgement.size).toBe(256);
const expiresAt = handler.pendingCommerceAcknowledgement.get('socket-299').expiresAt;
const now = jest.spyOn(Date, 'now').mockReturnValue(expiresAt);
expect(handler._getPendingCommerceAcknowledgement('socket-299')).toBeNull();
now.mockRestore();
handler._rememberPendingCommerceAcknowledgement('failed', ack);
handler.pendingEntryFairness.set('failed', { proofVersion: 2 });
await handler.createAndShowPaymentRequest({ id: 'failed' }, {
paymentType: 'bogus',
legalAcknowledgement: ack
});
expect(handler._getPendingCommerceAcknowledgement('failed')).toBeNull();
expect(handler.pendingEntryFairness.has('failed')).toBe(false);
} finally {
handler.dispose();
}
});
test('disconnect clears server-side acknowledgement and fairness continuation state', () => {
const client = socket();
const clearPendingCommerceAcknowledgement = jest.fn();
const pendingEntryFairness = new Map([[client.id, { proofVersion: 2 }]]);
const ctx = {
fairnessOffers: { discardSocket: jest.fn() },
paymentHandlers: { clearPendingCommerceAcknowledgement, pendingEntryFairness },
connectionHandler: { handleDisconnect: jest.fn() }
};
SocketHandlers.prototype.handleDisconnect.call(ctx, client);
expect(clearPendingCommerceAcknowledgement).toHaveBeenCalledWith(client.id);
expect(pendingEntryFairness.has(client.id)).toBe(false);
});
});