const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); // Follow the existing transport tests: the unrelated getGacha name contains U+200C. const transport = fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8').split(' static getGacha')[0] + '\n}'; const source = ts.transpileModule(transport, { compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.CommonJS }, }).outputText; const tick = () => new Promise(resolve => setTimeout(resolve, 0)); function fixture() { const requests = []; let failSend = false; const cc = { loader: { getXMLHttpRequest: () => ({ open(method, url) { this.url = url; }, setRequestHeader() {}, send(body) { if (failSend) { failSend = false; throw Error('send failed'); } this.body = body; requests.push(this); }, respond(data, status = 200) { this.status = status; this.readyState = 4; this.responseText = typeof data === 'string' ? data : JSON.stringify(data); this.onreadystatechange(); }, }) }, fx: { GameConfig: { GM_INFO: { uid: 'player', token: 'token', coin: 123 } }, GameTool: { getWechatGameVersion: () => '正式版' }, StorageMessage: { getStorage: () => 'player' }, }, }; const exports = {}; vm.runInNewContext(source, { exports, cc, wx: {}, console, setTimeout, require: name => name.includes('MiniGameSdk') ? { MiniGameSdk: { API: { showToast() {} } } } : {}, }); return { Utils: exports.default, requests, cc, failNextSend: () => { failSend = true; } }; } test('waits for form saves, JSON saves, and queued coin writes before blocking ordinary requests', async () => { const { Utils, requests } = fixture(); const callbacks = []; Utils.POST('userProp', { action: 'save' }, () => callbacks.push('prop')); Utils.POSTJSON('userHealth', { action: 'save' }, () => callbacks.push('health')); Utils.setUserCoin(() => callbacks.push('coin')); let prepared = false; const preparing = Utils.prepareWucaiTransfer().then(() => { prepared = true; }); await tick(); assert.equal(requests.length, 3); requests[0].respond({ code: 1 }); requests[1].respond({ code: 1 }); await tick(); assert.equal(prepared, false); requests[2].respond({ code: 1 }); await preparing; assert.deepEqual(callbacks, ['prop', 'health', 'coin']); let blocked; Utils.POST('userCoin', { action: 'save' }, response => { blocked = response; }); assert.equal(blocked.errorCode, 'WUCAI_PREPARING'); Utils.POSTJSON('userLevel', { action: 'save' }, response => { blocked = response; }); assert.equal(blocked.errorCode, 'WUCAI_PREPARING'); assert.equal(requests.length, 3); Utils.POST('login', {}, () => {}); Utils.POSTJSON('wucaiTransfer', { action: 'choose_current' }, () => {}); assert.equal(requests.length, 5); }); test('drains saves started by nested read callbacks before freezing', async () => { const { Utils, requests } = fixture(); Utils.POST('getShareLv', {}, () => { Promise.resolve().then(() => {}).then(() => {}).then(() => { Utils.POST('userLevel', { action: 'save' }, () => Utils.setUserCoin(() => {})); }); }); let prepared = false; const preparing = Utils.prepareWucaiTransfer().then(() => { prepared = true; }); requests[0].respond({ code: 1 }); await tick(); assert.equal(requests.length, 2); assert.equal(prepared, false); requests[1].respond({ code: 1 }); await tick(); assert.equal(requests.length, 3); assert.equal(prepared, false); requests[2].respond({ code: 1 }); await preparing; }); test('a failed save rejects preparation without replaying it and leaves ordinary requests enabled', async () => { const { Utils, requests } = fixture(); Utils.POST('userLevel', { action: 'save' }, () => {}); const rejected = assert.rejects(Utils.prepareWucaiTransfer(), error => error.msg === 'save failed'); requests[0].respond({ code: 0, msg: 'save failed' }); await rejected; assert.equal(requests.length, 1, 'a save with side effects must never be replayed'); Utils.POST('userCoin', { action: 'save' }, () => {}); assert.equal(requests.length, 2); requests[1].respond({ code: 1 }); Utils.cancelWucaiTransferPreparation(); await assert.rejects(Utils.prepareWucaiTransfer(), error => error.msg === 'save failed'); Utils.POST('userLevel', { action: 'save' }, () => {}); requests[2].respond({ code: 1 }); await Utils.prepareWucaiTransfer(); }); test('login and transfer requests are tracked even though the completed barrier allows them', async () => { const { Utils, requests } = fixture(); Utils.POST('login', {}, () => {}); Utils.POSTJSON('wucaiTransfer', { action: 'claim_reward' }, () => {}); let prepared = false; const preparing = Utils.prepareWucaiTransfer().then(() => { prepared = true; }); requests[0].respond({ code: 1 }); await tick(); assert.equal(prepared, false); requests[1].respond({ code: 1 }); await preparing; }); test('a save that fails before home preparation remains unresolved until a later successful save', async () => { const { Utils, requests } = fixture(); Utils.POST('userProp', { uid: 'player', action: 'save', propType: 2001 }, () => {}); requests[0].onerror(); Utils.POST('getRank', {}, () => {}); assert.equal(requests.length, 2, 'ordinary requests remain enabled before preparation'); requests[1].respond({ code: 1 }); await assert.rejects(Utils.prepareWucaiTransfer(), error => error.networkError === true); Utils.POST('userProp', { uid: 'player', action: 'save', propType: 2001 }, () => {}); requests[2].respond({ code: 1 }); await Utils.prepareWucaiTransfer(); }); test('read failures and ordinary claim denials do not prevent preparation', async () => { const { Utils, requests } = fixture(); Utils.POST('getRank', {}, () => {}); Utils.POSTJSON('goldMiner', { action: 'claim' }, () => {}); const preparing = Utils.prepareWucaiTransfer(); requests[0].onerror(); requests[1].respond({ code: 0, msg: 'already claimed' }); await preparing; }); test('an uncertain JSON claim response prevents preparation', async () => { const { Utils, requests } = fixture(); Utils.POSTJSON('passCheckSettlement', { action: 'purchase_claim' }, () => {}); const rejected = assert.rejects(Utils.prepareWucaiTransfer(), error => error.errorCode === 'NETWORK_UNKNOWN'); requests[0].ontimeout(); await rejected; }); test('a no-action drawCat timeout is an unresolved write rather than a failed read', async () => { const { Utils, requests } = fixture(); Utils.POST('drawCat', { uid: 'player' }, () => {}); requests[0].ontimeout(); await assert.rejects(Utils.prepareWucaiTransfer(), error => error.networkError === true); Utils.POST('drawCat', { uid: 'player' }, () => {}); requests[1].respond({ code: 1 }); await Utils.prepareWucaiTransfer(); }); test('a malformed form claim response remains uncertain and blocks preparation', async () => { const { Utils, requests } = fixture(); Utils.POST('passCheckSettlement', { action: 'claim' }, () => {}); const rejected = assert.rejects(Utils.prepareWucaiTransfer(), error => error.networkError === true); requests[0].respond('invalid json'); await rejected; }); test('verified no-action reads and read/info actions may fail without blocking preparation', async () => { const { Utils, requests } = fixture(); for (const path of ['getRank', 'getSRank', 'getAddressRank', 'getAddressRankOther', 'getAddressRankSelf', 'getUserInfoByID', 'getShareLv', 'getHeadInfo', 'getFilm', 'activityConfig/list']) { Utils.POST(path, { uid: 'player' }, () => {}); } Utils.POST('userCoin', { action: 'read' }, () => {}); Utils.POSTJSON('goldMiner/index', { action: 'info' }, () => {}); for (const request of requests) request.onerror(); await Utils.prepareWucaiTransfer(); }); test('entry requests and ordinary business refusals do not leave unresolved write failures', async () => { const { Utils, requests } = fixture(); Utils.POST('login', {}, () => {}); Utils.POST('wucaiTransfer', { action: 'choose_current' }, () => {}); Utils.POST('drawCat', { uid: 'player' }, () => {}); requests[0].onerror(); requests[1].ontimeout(); requests[2].respond({ code: 0, msg: '已抽取所有猫咪' }); await Utils.prepareWucaiTransfer(); }); test('a save started by an existing callback is still checked for failure', async () => { const { Utils, requests } = fixture(); Utils.POST('getShareLv', {}, () => Utils.POST('userProp', { action: 'save' }, () => {})); const rejected = assert.rejects(Utils.prepareWucaiTransfer(), error => error.networkError === true); requests[0].respond({ code: 1 }); requests[1].onerror(); await rejected; }); test('malformed save responses are failures and complete only once', async () => { const { Utils, requests } = fixture(); let called = 0; Utils.POST('userProp', { action: 'save' }, () => { called++; }); const rejected = assert.rejects(Utils.prepareWucaiTransfer()); requests[0].respond('invalid json'); requests[0].onerror(); await rejected; assert.equal(called, 1); }); test('a JSON null form response rejects preparation instead of leaving its request pending', async () => { const { Utils, requests } = fixture(); Utils.POST('userProp', { action: 'save' }, () => {}); const rejected = assert.rejects(Utils.prepareWucaiTransfer()); requests[0].respond('null'); await rejected; }); test('a synchronous send failure in a queued save cannot leave preparation hanging', async () => { const { Utils, requests, failNextSend } = fixture(); failNextSend(); Utils.setUserCoin(() => {}); await assert.rejects(Utils.prepareWucaiTransfer(), error => error.networkError === true); assert.equal(requests.length, 0); }); test('transport creation failure releases the tracked request for either transport', async () => { for (const method of ['POST', 'POSTJSON']) { const { Utils, requests, cc } = fixture(); Utils.POST('getShareLv', {}, () => { cc.loader.getXMLHttpRequest = () => { throw Error('transport unavailable'); }; Utils[method]('userProp', { action: 'save' }, () => {}); }); const rejected = assert.rejects(Utils.prepareWucaiTransfer(), error => error.msg === '请求未能发出'); requests[0].respond({ code: 1 }); await rejected; } }); test('cancelling an in-flight preparation cannot lock later requests', async () => { const { Utils, requests } = fixture(); Utils.POST('userCoin', { action: 'save' }, () => {}); const preparing = Utils.prepareWucaiTransfer(); assert.equal(Utils.prepareWucaiTransfer(), preparing); const rejected = assert.rejects(preparing); Utils.cancelWucaiTransferPreparation(); requests[0].respond({ code: 1 }); await rejected; Utils.POSTJSON('userProp', { action: 'save' }, () => {}); assert.equal(requests.length, 2); requests[1].respond({ code: 1 }); await Utils.prepareWucaiTransfer(); Utils.cancelWucaiTransferPreparation(); Utils.POST('userCoin', { action: 'save' }, () => {}); assert.equal(requests.length, 3); });