mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 09:08:32 +02:00
fix(showcase): harden Smirk checkout and multiplayer presentation
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
* window), so a map full of long-expired entries stayed full.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const express = require('../src/node_modules/express');
|
||||
const http = require('http');
|
||||
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
const MatchManager = require('../src/network/matchManager');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
|
||||
describe('MatchQueue', () => {
|
||||
@@ -238,4 +240,179 @@ describe('SocketHandlers match queue acknowledgements', () => {
|
||||
payload: { success: true, economy: 'free' }
|
||||
});
|
||||
});
|
||||
|
||||
test('reconnect restores owned queued entries without joining or charging again', async () => {
|
||||
const emitted = [];
|
||||
const socket = { id: 'new-socket', emit: (event, payload) => emitted.push({ event, payload }) };
|
||||
const reattach = jest.fn(async (userId, economy, socketId, token) => {
|
||||
expect(userId).toBe(7);
|
||||
expect(socketId).toBe(socket.id);
|
||||
expect(token).toBe('current-session');
|
||||
return economy === 'free' || economy === 'credits_prestige'
|
||||
? { inQueue: true, position: economy === 'free' ? 2 : 1 }
|
||||
: { inQueue: false };
|
||||
});
|
||||
const enqueue = jest.fn();
|
||||
const handleReconnect = jest.fn(() => false);
|
||||
const context = {
|
||||
matchManager: { enabled: true, handleReconnect },
|
||||
matchQueue: { reattach, enqueue },
|
||||
_resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' })),
|
||||
debugManager: { CONSOLE_LOGGING: false }
|
||||
};
|
||||
|
||||
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, {
|
||||
userId: 999, economy: 'crypto_race', sessionToken: 'untrusted'
|
||||
});
|
||||
|
||||
expect(handleReconnect).toHaveBeenCalledWith(socket, expect.objectContaining({ userId: 7 }));
|
||||
expect(reattach).toHaveBeenCalledWith(7, 'free', socket.id, 'current-session');
|
||||
expect(reattach).toHaveBeenCalledWith(7, 'credits_prestige', socket.id, 'current-session');
|
||||
expect(reattach).toHaveBeenCalledWith(7, 'crypto_race', socket.id, 'current-session');
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
expect(emitted.filter(item => item.event === 'match_queue_joined')).toEqual([
|
||||
{ event: 'match_queue_joined', payload: {
|
||||
success: true, economy: 'free', position: 2, alreadyQueued: true, restored: true
|
||||
} },
|
||||
{ event: 'match_queue_joined', payload: {
|
||||
success: true, economy: 'credits_prestige', position: 1, alreadyQueued: true, restored: true
|
||||
} }
|
||||
]);
|
||||
});
|
||||
|
||||
test('reconnect does not reclaim a queue row while its active match owns the player', async () => {
|
||||
const socket = { id: 'new-socket', emit: jest.fn() };
|
||||
const reattach = jest.fn();
|
||||
const context = {
|
||||
matchManager: { enabled: true, handleReconnect: jest.fn(() => true) },
|
||||
matchQueue: { reattach },
|
||||
_resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' }))
|
||||
};
|
||||
|
||||
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket);
|
||||
|
||||
expect(reattach).not.toHaveBeenCalled();
|
||||
expect(socket.emit).not.toHaveBeenCalledWith('match_queue_joined', expect.anything());
|
||||
});
|
||||
|
||||
test('reconnect does not use a client claimed user when session resolution fails', async () => {
|
||||
const socket = { id: 'new-socket', emit: jest.fn() };
|
||||
const handleReconnect = jest.fn();
|
||||
const reattach = jest.fn();
|
||||
const context = {
|
||||
matchManager: { enabled: true, handleReconnect },
|
||||
matchQueue: { reattach },
|
||||
_resolveMatchSession: jest.fn(async () => null)
|
||||
};
|
||||
|
||||
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, { userId: 7 });
|
||||
|
||||
expect(handleReconnect).not.toHaveBeenCalled();
|
||||
expect(reattach).not.toHaveBeenCalled();
|
||||
expect(socket.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchQueue queued reconnect', () => {
|
||||
test('updates the durable queued row and memory socket while preserving its place', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const statements = [];
|
||||
const queuedRow = { id: 31, created_at: new Date('2026-10-21T10:00:00Z') };
|
||||
const db = { query: jest.fn(async (sql, params) => {
|
||||
statements.push(sql);
|
||||
expect(params).toEqual(['new-socket', 'current-session', 7, 'free']);
|
||||
return { rowCount: 1, rows: [queuedRow] };
|
||||
}) };
|
||||
const queue = new MatchQueue({ db });
|
||||
queue._queues.free.push({
|
||||
queueEntryId: queuedRow.id, userId: 7, socketId: 'old-socket',
|
||||
sessionToken: 'old-session', createdAt: queuedRow.created_at.getTime()
|
||||
});
|
||||
try {
|
||||
const restored = await queue.reattach(7, 'free', 'new-socket', 'current-session');
|
||||
expect(restored).toEqual({ inQueue: true, position: 1 });
|
||||
expect(queue._queues.free[0]).toMatchObject({
|
||||
queueEntryId: queuedRow.id, userId: 7, socketId: 'new-socket',
|
||||
sessionToken: 'current-session', createdAt: queuedRow.created_at.getTime()
|
||||
});
|
||||
expect(statements).toHaveLength(1);
|
||||
} finally {
|
||||
delete process.env.MATCH_ENABLED;
|
||||
}
|
||||
});
|
||||
|
||||
test('a matched or absent durable row cannot be restored from stale memory', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = { query: jest.fn(async () => ({ rowCount: 0, rows: [] })) };
|
||||
const queue = new MatchQueue({ db });
|
||||
queue._queues.free.push({ userId: 7, socketId: 'old-socket' });
|
||||
try {
|
||||
expect(await queue.reattach(7, 'free', 'new-socket', 'current-session'))
|
||||
.toEqual({ inQueue: false });
|
||||
expect(queue._queues.free[0].socketId).toBe('old-socket');
|
||||
} finally {
|
||||
delete process.env.MATCH_ENABLED;
|
||||
}
|
||||
});
|
||||
|
||||
test('a restored entrant receives the countdown and start on the new socket', async () => {
|
||||
jest.useFakeTimers();
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = { query: jest.fn(async () => ({
|
||||
rowCount: 1, rows: [{ id: 31, created_at: new Date('2026-10-21T10:00:00Z') }]
|
||||
})) };
|
||||
const queue = new MatchQueue({ db });
|
||||
queue._queues.free.push({
|
||||
queueEntryId: 31, userId: 7, socketId: 'old-socket',
|
||||
sessionToken: 'old-session', createdAt: Date.parse('2026-10-21T10:00:00Z')
|
||||
});
|
||||
const makeSocket = id => ({
|
||||
id, channels: new Set(), events: [],
|
||||
join(channel) { this.channels.add(channel); },
|
||||
emit(event, payload) { this.events.push({ event, payload }); }
|
||||
});
|
||||
const oldSocket = makeSocket('old-socket');
|
||||
const newSocket = makeSocket('new-socket');
|
||||
const otherSocket = makeSocket('other-socket');
|
||||
const sockets = new Map([[newSocket.id, newSocket], [otherSocket.id, otherSocket]]);
|
||||
const io = {
|
||||
sockets: { sockets },
|
||||
to(channel) {
|
||||
return { emit(event, payload) {
|
||||
for (const socket of sockets.values()) {
|
||||
if (socket.channels.has(channel)) socket.emit(event, payload);
|
||||
}
|
||||
} };
|
||||
}
|
||||
};
|
||||
let manager;
|
||||
try {
|
||||
await queue.reattach(7, 'free', newSocket.id, 'current-session');
|
||||
const entrant = queue._queues.free[0];
|
||||
const room = new MatchRoom({
|
||||
economy: 'free',
|
||||
entrants: {
|
||||
[entrant.socketId]: { userId: entrant.userId },
|
||||
[otherSocket.id]: { userId: 8 }
|
||||
},
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
manager = new MatchManager({ io, db: null });
|
||||
manager.setEngine(room.id, { start: () => room.start(), stop: () => {} });
|
||||
manager.attach(room, [entrant, { userId: 8, socketId: otherSocket.id }], {
|
||||
countdownMs: 20, minDurationMs: 20000, hardCeilingMs: 60000
|
||||
});
|
||||
|
||||
expect(newSocket.events.some(item => item.event === 'match_joined')).toBe(true);
|
||||
expect(oldSocket.events).toHaveLength(0);
|
||||
jest.advanceTimersByTime(20);
|
||||
expect(room.status).toBe('active');
|
||||
expect(newSocket.events.some(item => item.event === 'match_start')).toBe(true);
|
||||
expect(oldSocket.events).toHaveLength(0);
|
||||
} finally {
|
||||
manager?.shutdown();
|
||||
jest.useRealTimers();
|
||||
delete process.env.MATCH_ENABLED;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
DEFAULT_SEED,
|
||||
assertCaptureSafety,
|
||||
assertCaptureStateEnvironment,
|
||||
assertFreeRoom,
|
||||
capturePageHtml,
|
||||
capturePresentation,
|
||||
captureResultSummary,
|
||||
@@ -123,6 +124,51 @@ describe('deterministic PvP capture harness', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the curated escape race reaches a decisive first exit with no money state', () => {
|
||||
const config = options({ args: ['--ruleset', 'race', '--ticks', '120'] });
|
||||
const room = createCaptureRoom(config);
|
||||
room.start();
|
||||
const events = [];
|
||||
|
||||
for (let tick = 0; tick < config.ticks && room.status === 'active'; tick++) {
|
||||
const state = room.toGameState();
|
||||
for (const botId of room.occupants.keys()) {
|
||||
const move = planBotMove(botId, state);
|
||||
if (Math.abs(move.dx) + Math.abs(move.dy) === 1) {
|
||||
room.queueMove(botId, move.dx, move.dy);
|
||||
}
|
||||
}
|
||||
events.push(...room.resolveTick().events);
|
||||
}
|
||||
room.finalize();
|
||||
|
||||
const exits = events.filter(event => event.type === 'player_exit');
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('escaped');
|
||||
expect(exits).toHaveLength(1);
|
||||
expect(room.winnerId).toBe(exits[0].id);
|
||||
expect(room.playerStates.get(room.winnerId).escaped).toBe(true);
|
||||
expect(assertFreeRoom(room)).toBe(true);
|
||||
});
|
||||
|
||||
test('race and co-op use distinct exit completion rules', () => {
|
||||
for (const rulesetId of ['race', 'coop-escape']) {
|
||||
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
|
||||
room.start();
|
||||
const [firstId] = room.occupants.keys();
|
||||
const [x, y] = room.dungeon.exit;
|
||||
room.occupants.get(firstId).moveTo(x, y);
|
||||
const result = room.resolveTick();
|
||||
|
||||
expect(result.events.some(event => event.type === 'player_exit' && event.id === firstId))
|
||||
.toBe(true);
|
||||
expect(result.finished).toBe(rulesetId === 'race');
|
||||
expect(room.status).toBe(rulesetId === 'race' ? 'finished' : 'active');
|
||||
expect(room.pvpCombat).toBe(false);
|
||||
expect(assertFreeRoom(room)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('last-alive bots pursue a rival instead of following the race exit', () => {
|
||||
const move = planBotMove('bot-1', {
|
||||
tick: 1,
|
||||
|
||||
@@ -40,6 +40,7 @@ function fixture() {
|
||||
write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n');
|
||||
write(root, 'src/scripts/preflight.js', "'use strict';\n");
|
||||
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
|
||||
write(root, 'src/scripts/showcase-browser-smoke.js', "throw new Error('source-only browser harness');\n");
|
||||
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
|
||||
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
|
||||
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
|
||||
@@ -136,6 +137,7 @@ describe('release artifact builder', () => {
|
||||
expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh');
|
||||
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
|
||||
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
|
||||
expect(members).not.toContain('candidate-1/src/scripts/showcase-browser-smoke.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
function makeElement(tag = 'div') {
|
||||
const attributes = {};
|
||||
const element = {
|
||||
tagName: tag.toUpperCase(),
|
||||
style: {},
|
||||
dataset: {},
|
||||
children: [],
|
||||
listeners: {},
|
||||
attributes,
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
className: '',
|
||||
classList: {
|
||||
values: new Set(),
|
||||
add(value) { this.values.add(value); },
|
||||
remove(value) { this.values.delete(value); },
|
||||
contains(value) { return this.values.has(value); },
|
||||
toggle(value, force) {
|
||||
const enabled = force == null ? !this.values.has(value) : !!force;
|
||||
if (enabled) this.values.add(value); else this.values.delete(value);
|
||||
return enabled;
|
||||
}
|
||||
},
|
||||
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); },
|
||||
dispatch(name, event = {}) {
|
||||
event.target ||= this;
|
||||
(this.listeners[name] || []).forEach(fn => fn(event));
|
||||
},
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; },
|
||||
setAttribute(name, value) { attributes[name] = String(value); },
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '[data-economy]') return this.children.filter(child => child.dataset.economy);
|
||||
if (selector === '[data-dir]') return this.children.filter(child => child.dataset.dir);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
Object.defineProperty(element, 'innerHTML', {
|
||||
get() { return ''; },
|
||||
set() { element.children = []; }
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function loadMatchClient() {
|
||||
const ids = [
|
||||
'economies', 'status', 'queueBtn', 'leaveBtn', 'queuePanel', 'racePanel', 'raceMode',
|
||||
'raceEconomy', 'raceTimer', 'racePlace', 'raceResult', 'stage', 'renderModes',
|
||||
'modeTitle', 'modeDescription', 'modeBadge', 'rulesetCatalog', 'economyHelp',
|
||||
'objectiveHint', 'matchDpad'
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map(id => [id, makeElement(id.includes('Btn') ? 'button' : 'div')]));
|
||||
const socketHandlers = {};
|
||||
const windowHandlers = {};
|
||||
const emitted = [];
|
||||
const socket = {
|
||||
id: 'local-player', connected: true,
|
||||
on(name, fn) { socketHandlers[name] = fn; },
|
||||
emit(name, payload) { emitted.push({ name, payload }); }
|
||||
};
|
||||
const modeList = [
|
||||
{ id: 'tiles', label: 'Tiled', premium: false },
|
||||
{ id: 'ascii', label: 'ASCII', premium: false }
|
||||
];
|
||||
const RK = {
|
||||
RENDER_MODES: modeList,
|
||||
loadMode: () => 'tiles',
|
||||
saveMode: () => {},
|
||||
canUseMode: () => true,
|
||||
modeAvailability: id => ({ usable: true, runtimeAvailable: true }),
|
||||
createRendererAsync: (mode, host, options, done) => done({ name: mode, destroy() {}, render() {} })
|
||||
};
|
||||
const MatchCopy = {
|
||||
normalize: raw => ({ id: raw.id || 'race', label: 'Race', description: 'Reach the exit', badge: 'RACE', objective: 'reach the exit' }),
|
||||
fromPayload: (data, fallback) => fallback,
|
||||
plural: () => 'delvers',
|
||||
liveSummary: () => 'Racing',
|
||||
finalResult: () => ({ text: 'Finished', mode: { id: 'race' } })
|
||||
};
|
||||
const window = {
|
||||
RK,
|
||||
MatchUiCopy: MatchCopy,
|
||||
addEventListener(name, fn) { windowHandlers[name] = fn; },
|
||||
CommerceConsent: null
|
||||
};
|
||||
const document = { getElementById: id => elements[id], createElement: tag => makeElement(tag) };
|
||||
const context = {
|
||||
window, document, io: () => socket, localStorage: { getItem: () => null, setItem() {} },
|
||||
navigator: { userAgent: 'test' }, console, Date, Math, Promise, TextEncoder,
|
||||
setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {},
|
||||
Array, Object, Number, String, RegExp
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8'), context);
|
||||
return { elements, socket, socketHandlers, windowHandlers, emitted, modeList };
|
||||
}
|
||||
|
||||
describe('renderer controls and failed skin fallback', () => {
|
||||
test('match render mode announces its selected state and updates it after a switch', () => {
|
||||
const { elements, socketHandlers } = loadMatchClient();
|
||||
socketHandlers.game_mode_info({ modes: { match: { enabled: true, economies: { free: true }, rulesets: [{ id: 'race' }] } } });
|
||||
let [tiled, ascii] = elements.renderModes.children;
|
||||
expect(tiled.attributes['aria-pressed']).toBe('true');
|
||||
expect(ascii.attributes['aria-pressed']).toBe('false');
|
||||
|
||||
ascii.dispatch('click');
|
||||
[tiled, ascii] = elements.renderModes.children;
|
||||
expect(tiled.attributes['aria-pressed']).toBe('false');
|
||||
expect(ascii.attributes['aria-pressed']).toBe('true');
|
||||
});
|
||||
|
||||
test('a restored queue selects the server-returned economy and exposes the leave action', () => {
|
||||
const { elements, socketHandlers } = loadMatchClient();
|
||||
socketHandlers.game_mode_info({ modes: { match: {
|
||||
enabled: true,
|
||||
economies: { free: true, credits_prestige: true },
|
||||
rulesets: [{ id: 'race' }]
|
||||
} } });
|
||||
|
||||
socketHandlers.match_queue_joined({ success: true, economy: 'credits_prestige', position: 2, restored: true });
|
||||
|
||||
const [free, prestige] = elements.economies.children;
|
||||
expect(free.attributes['aria-pressed']).toBe('false');
|
||||
expect(prestige.attributes['aria-pressed']).toBe('true');
|
||||
expect(elements.queueBtn.classList.contains('hidden')).toBe(true);
|
||||
expect(elements.leaveBtn.classList.contains('hidden')).toBe(false);
|
||||
expect(elements.status.textContent).toContain('Queue restored for Prestige');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['INPUT', false], ['TEXTAREA', false], ['SELECT', false], ['DIV', true]
|
||||
])('match movement ignores text-entry and select targets (%s)', (tagName, contentEditable) => {
|
||||
const { socketHandlers, windowHandlers, emitted } = loadMatchClient();
|
||||
socketHandlers.match_start({ ruleset: { id: 'race' } });
|
||||
const target = { tagName, isContentEditable: contentEditable };
|
||||
let prevented = false;
|
||||
windowHandlers.keydown({ key: 'ArrowUp', target, preventDefault() { prevented = true; } });
|
||||
expect(prevented).toBe(false);
|
||||
expect(emitted.some(event => event.name === 'match_move')).toBe(false);
|
||||
});
|
||||
|
||||
test('match movement ignores contenteditable targets but still moves from the game surface', () => {
|
||||
const { socketHandlers, windowHandlers, emitted } = loadMatchClient();
|
||||
socketHandlers.match_start({ ruleset: { id: 'race' } });
|
||||
let prevented = false;
|
||||
windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV', isContentEditable: true }, preventDefault() { prevented = true; } });
|
||||
expect(prevented).toBe(false);
|
||||
|
||||
windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV' }, preventDefault() { prevented = true; } });
|
||||
expect(prevented).toBe(true);
|
||||
expect(emitted.some(event => event.name === 'match_move' && event.payload.dx === 0 && event.payload.dy === -1)).toBe(true);
|
||||
});
|
||||
|
||||
test('a failed premium skin completes pending loads and draws a free character fallback', () => {
|
||||
const images = [];
|
||||
const RK = {
|
||||
CHARS: { 'char-villager': { frame: 1 } },
|
||||
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
|
||||
appearance: appearance => ({ label: appearance.avatar }),
|
||||
isChar: id => id === 'char-villager',
|
||||
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
|
||||
charAtlas: () => ({}),
|
||||
charFrame: () => ({ squash: 1, bob: 0 }),
|
||||
drawCharTileCanvas: jest.fn(),
|
||||
charOverlayParts: () => []
|
||||
};
|
||||
const context = {
|
||||
window: null,
|
||||
Image: class { constructor() { images.push(this); } set src(value) { this.url = value; } },
|
||||
console: { warn: jest.fn() }
|
||||
};
|
||||
context.window = context;
|
||||
context.RK = RK;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context);
|
||||
|
||||
const visual = RK.avatarVisuals.resolve({ avatar: 'wownero-rogue' }, { projection: 'topdown' });
|
||||
const ready = jest.fn();
|
||||
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
|
||||
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
|
||||
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
|
||||
expect(images).toHaveLength(1);
|
||||
|
||||
images[0].onerror();
|
||||
expect(ready).toHaveBeenCalledTimes(1);
|
||||
expect(RK.skinSheetFailed('wownero-rogue')).toBe(true);
|
||||
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true);
|
||||
expect(RK.drawCharTileCanvas).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a normal character still waits for its atlas and resolves the render callback', () => {
|
||||
let atlasReady;
|
||||
let atlasLoaded = false;
|
||||
const RK = {
|
||||
CHARS: { 'char-villager': { frame: 1 } },
|
||||
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
|
||||
appearance: appearance => ({ label: appearance.avatar }),
|
||||
isChar: id => id === 'char-villager',
|
||||
charAtlas: () => atlasLoaded ? {} : null,
|
||||
loadCharAtlas: callback => { atlasReady = callback; },
|
||||
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
|
||||
charFrame: () => ({ squash: 1, bob: 0 }),
|
||||
drawCharTileCanvas: jest.fn(),
|
||||
charOverlayParts: () => []
|
||||
};
|
||||
const context = { window: null, Image: class {}, console };
|
||||
context.window = context;
|
||||
context.RK = RK;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context);
|
||||
|
||||
const visual = RK.avatarVisuals.resolve({ avatar: 'char-villager' }, { projection: 'topdown' });
|
||||
const ready = jest.fn();
|
||||
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
|
||||
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
|
||||
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
|
||||
|
||||
atlasLoaded = true;
|
||||
atlasReady();
|
||||
expect(ready).toHaveBeenCalledTimes(1);
|
||||
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true);
|
||||
expect(RK.drawCharTileCanvas).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
const ConnectionHandler = require('../src/network/connectionHandler');
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
const { paymentRequest } = require('../html/js/network/smirkPaymentAdapter');
|
||||
|
||||
const row = {
|
||||
id: 31, user_id: 7, subaddress: 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn',
|
||||
expected_amount: '123456789012', description: '10 credit package', payment_type: 'credits_package',
|
||||
expires_at: new Date(Date.now() + 3600000).toISOString()
|
||||
};
|
||||
|
||||
test.each([false, true])('authenticated session events expose the invoice owner without deriving it from the browser: resumed=%s', resumed => {
|
||||
const emit = jest.fn();
|
||||
ConnectionHandler.prototype._emitSessionEvents.call({ io: { to: () => ({ emit }) } },
|
||||
{ id: 'fixture-socket' }, { resumed, token: 'fixture-session', user: { id: 7 } });
|
||||
expect(emit).toHaveBeenCalledWith(resumed ? 'session_resumed' : 'session_token', expect.objectContaining({ userId: 7 }));
|
||||
});
|
||||
|
||||
test.each(['request', 'show', 'restore'])('the %s invoice path supplies an exact, spendable WOW contract', async path => {
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
Object.assign(manager, {
|
||||
cryptoType: 'WOW', network: 'mainnet', currencyDecimals: 11,
|
||||
isPaymentIntakeEnabled: () => true,
|
||||
formatAtomicHuman: () => '1.2346',
|
||||
gameMode: 'PAID_CREDITS', creditsPackagePrice: row.expected_amount,
|
||||
getPrimaryCreditPackage: () => ({ price: row.expected_amount, credits: 10 }),
|
||||
createPaymentRequest: jest.fn(async () => manager._mapPaymentRowToRequest(row, 'credits_package', { credits: 10 })),
|
||||
db: { query: jest.fn(async () => ({ rows: [row] })) }
|
||||
});
|
||||
const emitted = [];
|
||||
const io = { to: () => ({ emit: (event, data) => emitted.push([event, data]) }) };
|
||||
const handlers = new PaymentHandlers({
|
||||
io, gameModeManager: manager, debugManager: { CONSOLE_LOGGING: false },
|
||||
queueManager: { getUserBySocket: () => ({ serverId: 'fixture-socket' }) },
|
||||
broadcastManager: { sendStatusUpdate: jest.fn() },
|
||||
walletService: {}
|
||||
});
|
||||
handlers._requirePaidAcknowledgement = () => ({ policyVersion: 'fixture' });
|
||||
handlers._allowInvoiceFlowForReserve = async () => true;
|
||||
handlers._ensureRequiredPayoutAddress = async () => ({ ok: true });
|
||||
handlers._monitorAddress = jest.fn();
|
||||
const socket = { id: 'fixture-socket', emit: (event, data) => emitted.push([event, data]) };
|
||||
try {
|
||||
if (path === 'request') await handlers.handlePaymentRequest(socket, { type: 'credits_package' });
|
||||
else if (path === 'show') await handlers.createAndShowPaymentRequest(socket, { paymentType: 'credits_package' });
|
||||
else await SocketHandlers.prototype._restorePaymentMonitoring.call({
|
||||
gameModeManager: manager, paymentHandlers: handlers, debugManager: { CONSOLE_LOGGING: false },
|
||||
connectionHandler: { getUserBySocket: () => ({ serverId: socket.id }) },
|
||||
broadcastManager: { sendStatusUpdate: jest.fn() }
|
||||
}, socket, 7);
|
||||
const payload = emitted.find(([event]) => event === 'payment_created')?.[1];
|
||||
expect(payload).toMatchObject({ ownerUserId: 7, asset: 'wow', network: 'mainnet', humanAmount: '1.23456789012', memo: row.description });
|
||||
expect(paymentRequest(payload)).toEqual({ asset: 'wow', amount: '1.23456789012', address: row.subaddress, memo: row.description });
|
||||
} finally {
|
||||
handlers.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
function loadAuth(wallet) {
|
||||
const context = {
|
||||
window: { smirk: wallet, socket: { id: 'fixture-socket', on() {}, emit: jest.fn() }, location: { origin: 'https://game.test' } },
|
||||
localStorage: { getItem: () => 'fixture-session' },
|
||||
document: {}, URL, console: { log() {}, warn() {}, error() {} }, setTimeout() {},
|
||||
$: () => ({ ready() {}, prop() { return this; }, css() { return this; }, text() { return this; }, remove() {} }),
|
||||
SocketHandlers: { _smirkEnabled: true },
|
||||
fetch: jest.fn(async path => ({
|
||||
ok: true,
|
||||
json: async () => path.endsWith('challenge') ? { challenge: 'game-fixture-challenge' } : { linked: true }
|
||||
}))
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(require.resolve('../html/js/network/smirkAuth'), 'utf8') + '\nthis.auth = SmirkAuth;', context);
|
||||
return context;
|
||||
}
|
||||
function walletFixture() {
|
||||
return {
|
||||
connect: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }),
|
||||
signNostrEvent: jest.fn().mockResolvedValue({ pubkey: 'fixture-nostr-key' }),
|
||||
getNostrPublicKey: jest.fn().mockResolvedValue('fixture-nostr-key'),
|
||||
getAddresses: jest.fn().mockResolvedValue({ wow: 'fixture-wow-address' }),
|
||||
getPublicKeys: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }),
|
||||
signMessage: jest.fn().mockResolvedValue({ signatures: [{ asset: 'wow', publicKey: 'legacy-key', signature: 'fixture-proof' }] })
|
||||
};
|
||||
}
|
||||
|
||||
describe('Smirk client identity and WOW grant boundaries', () => {
|
||||
test('keeps Nostr identity while explicitly acquiring only WOW asset scope', async () => {
|
||||
const wallet = walletFixture();
|
||||
const { auth } = loadAuth(wallet);
|
||||
await auth.login();
|
||||
expect(wallet.connect).toHaveBeenCalledWith(['wow']);
|
||||
expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1);
|
||||
expect(wallet.signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not switch account credential method after Nostr transport failure', async () => {
|
||||
const wallet = walletFixture();
|
||||
wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('transport interrupted'), { code: 'TIMEOUT' }));
|
||||
const { auth } = loadAuth(wallet);
|
||||
await expect(auth.login()).rejects.toThrow('transport interrupted');
|
||||
expect(wallet.signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('WOW scope denial stops the flow without another signature popup', async () => {
|
||||
const wallet = walletFixture();
|
||||
wallet.connect.mockRejectedValue(Object.assign(new Error('denied'), { code: 'USER_REJECTED' }));
|
||||
const { auth } = loadAuth(wallet);
|
||||
await expect(auth.login()).rejects.toMatchObject({ code: 'USER_REJECTED' });
|
||||
expect(wallet.signNostrEvent).not.toHaveBeenCalled();
|
||||
expect(wallet.signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a declined Nostr grant does not retry signing or switch credentials', async () => {
|
||||
const wallet = walletFixture();
|
||||
wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('scope required'), { code: 'NOT_AUTHORIZED' }));
|
||||
wallet.getNostrPublicKey.mockResolvedValue(null);
|
||||
const { auth } = loadAuth(wallet);
|
||||
await expect(auth.login()).rejects.toThrow();
|
||||
expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1);
|
||||
expect(wallet.signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('legacy-only wallets still prove the same credential with an explicit WOW grant', async () => {
|
||||
const wallet = walletFixture();
|
||||
delete wallet.signNostrEvent;
|
||||
delete wallet.getNostrPublicKey;
|
||||
const { auth } = loadAuth(wallet);
|
||||
await auth.login();
|
||||
expect(wallet.connect).toHaveBeenCalledTimes(1);
|
||||
expect(wallet.connect).toHaveBeenCalledWith(['wow']);
|
||||
expect(wallet.signMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('backend account linkage does not imply a WOW grant and never prompts on status check', async () => {
|
||||
const wallet = walletFixture();
|
||||
wallet.getPublicKeys.mockResolvedValue({ wow: null });
|
||||
const { auth } = loadAuth(wallet);
|
||||
await auth.checkStatus();
|
||||
expect(auth._isLinked).toBe(true);
|
||||
expect(auth._wowReady).toBe(false);
|
||||
expect(wallet.connect).not.toHaveBeenCalled();
|
||||
await auth.requestWowAccess();
|
||||
expect(auth._wowReady).toBe(true);
|
||||
expect(wallet.signNostrEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['forbidden', 'offline'])('failed status lookup clears stale account and scope: %s', async failure => {
|
||||
const context = loadAuth(walletFixture());
|
||||
context.auth._isLinked = true;
|
||||
context.auth._wowReady = true;
|
||||
context.fetch.mockImplementation(async () => {
|
||||
if (failure === 'offline') throw new Error('offline fixture');
|
||||
return { ok: false };
|
||||
});
|
||||
await context.auth.checkStatus();
|
||||
expect(context.auth._isLinked).toBe(false);
|
||||
expect(context.auth._wowReady).toBe(false);
|
||||
});
|
||||
|
||||
test('a prior account status response cannot relink the current session', async () => {
|
||||
const context = loadAuth(walletFixture());
|
||||
let finish;
|
||||
context.fetch.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
|
||||
const pending = context.auth.checkStatus();
|
||||
context.SocketHandlers._paymentSessionEpoch = 2;
|
||||
finish({ ok: true, json: async () => ({ linked: true }) });
|
||||
await pending;
|
||||
expect(context.auth._isLinked).toBe(false);
|
||||
expect(context.auth._wowReady).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
const { createAdapter, paymentRequest } = require('../html/js/network/smirkPaymentAdapter');
|
||||
|
||||
const NOW = 1800000000000;
|
||||
function invoice(extra = {}) {
|
||||
return {
|
||||
paymentId: 31, ownerUserId: 7, asset: 'wow', network: 'mainnet',
|
||||
amount: '123456789012', humanAmount: '1.23456789012',
|
||||
address: 'Wo' + '1'.repeat(95), expiresAt: new Date(NOW + 60000).toISOString(),
|
||||
paymentType: 'credits_package', memo: 'Buy 10 credits', package: { credits: 10 }, ...extra
|
||||
};
|
||||
}
|
||||
function setup(extra = {}) {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key)
|
||||
};
|
||||
let tail = Promise.resolve();
|
||||
const withLock = (_name, work) => {
|
||||
const result = tail.then(work);
|
||||
tail = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
const options = { storage, withLock, crypto: require('crypto').webcrypto, now: () => NOW, ...extra };
|
||||
return { adapter: createAdapter(options), options, values };
|
||||
}
|
||||
|
||||
describe('Smirk WOW invoice contract', () => {
|
||||
test('uses exact server decimal and memo without inventing wallet parameters', () => {
|
||||
expect(paymentRequest(invoice(), NOW)).toEqual({
|
||||
asset: 'wow', amount: '1.23456789012', address: invoice().address, memo: 'Buy 10 credits'
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts a WOW RPC subaddress as well as a standard address', () => {
|
||||
const address = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
|
||||
expect(paymentRequest(invoice({ address }), NOW).address).toBe(address);
|
||||
});
|
||||
|
||||
test('restored invoices keep the persisted memo without package display metadata', () => {
|
||||
expect(paymentRequest(invoice({ package: undefined, restored: true }), NOW).memo).toBe('Buy 10 credits');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ humanAmount: undefined, amountFormatted: undefined },
|
||||
{ humanAmount: '0' }, { humanAmount: '-1' }, { humanAmount: '1e-3' },
|
||||
{ humanAmount: '1.000000000001' }, { humanAmount: 1 },
|
||||
{ humanAmount: '1.23' }, { amount: '1.2' },
|
||||
{ asset: 'xmr' }, { asset: undefined }, { network: 'stagenet' }, { network: undefined },
|
||||
{ paymentId: undefined }, { ownerUserId: undefined }, { address: 'not-a-wow-address' }, { memo: undefined },
|
||||
{ expiresAt: undefined }, { expiresAt: new Date(NOW).toISOString() }
|
||||
])('refuses invalid or mismatched invoice before wallet invocation: %j', async extra => {
|
||||
const { adapter } = setup();
|
||||
const wallet = { requestPayment: jest.fn() };
|
||||
await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow();
|
||||
expect(wallet.requestPayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smirk submission evidence and repeat protection', () => {
|
||||
test.each([
|
||||
[{ success: true, txid: 'a'.repeat(64) }, 'submitted'],
|
||||
[{ success: true }, 'unknown'],
|
||||
[{ success: false }, 'failed'],
|
||||
[{ success: false, txid: 'a'.repeat(64) }, 'unknown'],
|
||||
[undefined, 'unknown'], [true, 'unknown'], [{ txid: 'a'.repeat(64) }, 'unknown'],
|
||||
[{ success: true, txid: 'malformed' }, 'unknown']
|
||||
])('classifies wallet result %j as %s without confirming credits', async (result, state) => {
|
||||
const { adapter } = setup();
|
||||
const wallet = { requestPayment: jest.fn().mockResolvedValue(result) };
|
||||
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state });
|
||||
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
|
||||
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state, repeated: true });
|
||||
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([['USER_REJECTED', 'cancelled'], ['NOT_AUTHORIZED', 'unavailable'], ['TIMEOUT', 'unknown']])(
|
||||
'classifies %s and never automatically retries', async (code, state) => {
|
||||
const { adapter } = setup();
|
||||
const wallet = { requestPayment: jest.fn().mockRejectedValue(Object.assign(new Error('fixture'), { code })) };
|
||||
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state });
|
||||
await adapter.pay(invoice(), wallet);
|
||||
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
test('coordinates concurrent events, two tabs and a page reload for the same invoice', async () => {
|
||||
const { adapter, options } = setup();
|
||||
const secondTab = createAdapter(options);
|
||||
const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) };
|
||||
await Promise.all([adapter.pay(invoice(), wallet), secondTab.pay(invoice(), wallet)]);
|
||||
const reloaded = createAdapter(options);
|
||||
expect(await reloaded.pay(invoice({ reused: true }), wallet)).toMatchObject({ state: 'unknown', repeated: true });
|
||||
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('records intent before invoking a wallet and retains unknown after interruption', async () => {
|
||||
const { adapter, options } = setup();
|
||||
const wallet = { requestPayment: jest.fn(async () => {
|
||||
expect(createAdapter(options).status(invoice()).state).toBe('unknown');
|
||||
throw new Error('Transport closed after possible submission');
|
||||
}) };
|
||||
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state: 'unknown' });
|
||||
expect(createAdapter(options).status(invoice())).toMatchObject({ state: 'unknown' });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ownerUserId: 8 }, { memo: 'different purchase' },
|
||||
{ humanAmount: '2', amount: '200000000000' },
|
||||
{ expiresAt: new Date(NOW + 120000).toISOString() }
|
||||
])('refuses changed immutable invoice fields after an attempt: %j', async extra => {
|
||||
const { adapter } = setup();
|
||||
const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) };
|
||||
await adapter.pay(invoice(), wallet);
|
||||
await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow(/changed/i);
|
||||
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('rechecks session admission inside the cross-tab lock before writing intent', async () => {
|
||||
const { adapter } = setup();
|
||||
const wallet = { requestPayment: jest.fn() };
|
||||
await expect(adapter.pay(invoice(), wallet, () => { throw new Error('Account changed'); })).rejects.toThrow('Account changed');
|
||||
expect(wallet.requestPayment).not.toHaveBeenCalled();
|
||||
expect(adapter.status(invoice())).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ withLock: null },
|
||||
{ storage: null },
|
||||
{ storage: { getItem() { throw new Error('blocked'); }, setItem() {} } }
|
||||
])('refuses unsafe repeat protection before requesting funds', async options => {
|
||||
const { adapter } = setup(options);
|
||||
const wallet = { requestPayment: jest.fn() };
|
||||
await expect(adapter.pay(invoice(), wallet)).rejects.toThrow();
|
||||
expect(wallet.requestPayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
const handlers = require('../html/js/network/socketHandlers');
|
||||
|
||||
test('new, duplicate and recovered invoice events display details without opening a wallet', () => {
|
||||
const previous = handlers._showPaymentFlow;
|
||||
handlers._showPaymentFlow = jest.fn();
|
||||
global.SmirkAuth = { _isLinked: true, isAvailable: () => true };
|
||||
global.window = { smirk: { requestPayment: jest.fn() } };
|
||||
const payment = jest.spyOn(handlers, '_trySmirkPayment').mockResolvedValue();
|
||||
try {
|
||||
handlers.onPaymentCreated({ paymentId: 8 });
|
||||
handlers.onPaymentCreated({ paymentId: 8 });
|
||||
handlers.onPaymentCreated({ paymentId: 8, reused: true });
|
||||
expect(payment).not.toHaveBeenCalled();
|
||||
expect(handlers._showPaymentFlow).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
payment.mockRestore();
|
||||
handlers._showPaymentFlow = previous;
|
||||
delete global.SmirkAuth;
|
||||
delete global.window;
|
||||
}
|
||||
});
|
||||
|
||||
test('account changes invalidate invoice consent and cannot submit an old owner invoice', async () => {
|
||||
const chain = {
|
||||
append() { return this; }, text: jest.fn(function() { return this; }),
|
||||
prop: jest.fn(function() { return this; }), remove: jest.fn(),
|
||||
hide: jest.fn(), val: jest.fn(), removeData: jest.fn(function() { return this; })
|
||||
};
|
||||
global.$ = () => chain;
|
||||
global.UI = { scrollChat() {} };
|
||||
global.localStorage = { setItem() {} };
|
||||
global.SmirkAuth = { _isLinked: true, _wowReady: true };
|
||||
global.window = { smirk: { requestPayment: jest.fn() } };
|
||||
const refresh = jest.spyOn(handlers, '_refreshSmirkLinked').mockImplementation(() => {});
|
||||
const address = jest.spyOn(handlers, '_updateAddressButtonStatus').mockImplementation(() => {});
|
||||
const adapter = jest.spyOn(handlers, '_getSmirkPaymentAdapter').mockReturnValue({ pay: jest.fn() });
|
||||
const hideQR = jest.spyOn(handlers, 'hidePaymentQR').mockImplementation(() => {});
|
||||
try {
|
||||
Object.assign(handlers, { _smirkEnabled: true, _cryptoType: 'WOW', _network: 'mainnet', _sessionUserId: 7, _activePaymentId: 31 });
|
||||
handlers.onSessionResumed({ token: 'fixture-session', userId: 8 });
|
||||
expect(handlers._sessionUserId).toBe(8);
|
||||
expect(handlers._activePaymentId).toBeNull();
|
||||
expect(chain.val).toHaveBeenCalledWith('');
|
||||
expect(chain.removeData).toHaveBeenCalledWith('payment-id');
|
||||
expect(hideQR).toHaveBeenCalled();
|
||||
expect(SmirkAuth._isLinked).toBe(false);
|
||||
expect(SmirkAuth._wowReady).toBe(false);
|
||||
await handlers._trySmirkPayment({ paymentId: 31, ownerUserId: 7 });
|
||||
expect(adapter).not.toHaveBeenCalled();
|
||||
expect(window.smirk.requestPayment).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
refresh.mockRestore(); address.mockRestore(); adapter.mockRestore(); hideQR.mockRestore();
|
||||
for (const key of ['$', 'UI', 'localStorage', 'SmirkAuth', 'window']) delete global[key];
|
||||
handlers._sessionUserId = null;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user