MatchMaster/tools/test-wucai-request-barrier.cjs

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);
});