265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
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);
|
|
});
|