Files
MatchMaster/tools/test-payment-reporting.cjs
T

218 lines
16 KiB
JavaScript

// In-memory payment responses only: no network, real payments or analytics sends.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), ts = require('typescript');
const quiet = { log() {}, warn() {}, error() {} };
const compile = source => ts.transpileModule(source, { compilerOptions: { target: 7, module: 1, experimentalDecorators: true } }).outputText;
function load(file, dependencies, globals = {}) {
const context = { exports: {}, require: name => dependencies[name] || { default: {} }, console: quiet, ...globals };
vm.runInNewContext(compile(fs.readFileSync(file, 'utf8')), context);
return context.exports.default;
}
function methods(file, names) {
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), 99, true);
return source.statements.find(ts.isClassDeclaration).members
.filter(m => m.name && names.includes(m.name.getText(source))).map(m => m.getText(source)).join('\n');
}
const pyramidContext = { require, __dirname, structuredClone, queueMicrotask, setTimeout, clearTimeout, console: quiet };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'test-pyramid-client.cjs'), 'utf8')
.split("test('collect_pyramid reports")[0] + ';globalThis.makeFixture = fixture;', pyramidContext);
for (const channel of ['success', 'ios-success', 'fallback']) {
for (const scenario of ['immediate', 'retry', 'restart']) test(`Pyramid ${channel}: ${scenario} reports original order once`, async () => {
const f = pyramidContext.makeFixture();
f.state.canPay = true; f.state.canDraw = false; f.mode(channel); await f.S.sync();
if (scenario === 'immediate') f.paid();
await f.S.buy();
let service = f.S;
if (scenario !== 'immediate') {
assert.equal(f.payments.length, 0); assert.ok(service.data().pendingOrder);
f.state.levels[0].itemPrice = 9900; // A refreshed offer cannot change the old order's amount.
f.paid(); if (scenario === 'restart') service = f.restart(); await service.sync(true);
}
assert.equal(service.data().pendingOrder, null); assert.equal(f.payments.length, 1);
assert.deepEqual(Array.from(f.payments[0]), [100, f.calls.find(c => c.outTradeNo).outTradeNo, '购买金字塔:pay_test']);
await service.sync(true); await f.restart().sync(true); assert.equal(f.payments.length, 1);
});
}
test('Pyramid closed, cancelled and unconfirmed orders do not report', async () => {
for (const mode of ['cancel', 'success']) {
const f = pyramidContext.makeFixture(); f.state.canPay = true; f.mode(mode); await f.S.sync(); await f.S.buy();
assert.equal(f.payments.length, 0);
const post = f.U.POST;
f.U.POST = (route, body, cb, options) => body.action === 'query'
? cb({ code: 1, data: { paymentStatus: 'closed' } }) : post.call(f.U, route, body, cb, options);
await f.S.sync(true); assert.equal(f.payments.length, 0);
}
});
test('Pyramid legacy pending record uses server order metadata after period change', async () => {
const f = pyramidContext.makeFixture();
f.storage.set('pyramid:v1:local/:u', { confirmingOrder: { orderId: 'old', periodId: 'ended', level: 2 } });
const post = f.U.POST;
f.U.POST = (route, body, cb, options) => body.action === 'query'
? cb({ code: 1, data: { paymentStatus: 'succeeded', goodsPrice: '1800', itemCount: 1, itemid: 'old-product' } })
: post.call(f.U, route, body, cb, options);
await f.S.sync(); assert.deepEqual(Array.from(f.payments[0]), [1800, 'old', '购买金字塔:old-product']);
});
const { fixture: goldFixture } = require('./coin-madness/check-api.cjs');
for (const channel of ['android', 'ios', 'customer']) test(`GoldMiner ${channel} reports purchase and not a later refresh`, async () => {
const f = goldFixture(new Map(), { ios: channel !== 'android' });
f.client.iosPaymentLog = () => {};
if (channel === 'customer') f.game.iosCanPay = false;
f.info.status = 'purchasable';
assert.equal((await f.client.request('purchase', { periodId: f.periodId })).code, 1);
assert.equal(f.payments.length, 1); assert.equal(f.payments[0][0], 300);
assert.equal(f.payments[0][1], channel === 'customer' ? 'legacy-1' : 'order-1');
await f.client.refresh(); assert.equal(f.payments.length, 1);
});
test('GoldMiner restart reports legacy order from server price, not current activity price', async () => {
const storage = new Map(), first = goldFixture(storage);
first.client.ledger.orders.old = { outTradeNo: 'old-order', ios: false };
first.client.persist();
const f = goldFixture(storage, { intercept(route, body, ok) {
if (route.includes('getPayInfo')) {
ok({ pay_state: 2, goodsPrice: 1800, itemCount: 1, itemid: 'gold_miner_old',
rewardDelivery: 'goldMiner.claim', goldMiner: { fulfillmentRoute: 'carry_next', targetPeriodId: 'goldMiner:next' } });
return true;
}
} });
f.info.priceFen = 9900; await f.client.refresh();
assert.deepEqual(f.payments, [[1800, 'old-order', '购买金币狂热:gold_miner_old']]);
});
test('GoldMiner cancellation and pending confirmation do not report', async () => {
const cancelled = goldFixture(new Map(), { cancelPay: true }); cancelled.info.status = 'purchasable';
await cancelled.client.request('purchase', { periodId: cancelled.periodId }); assert.equal(cancelled.payments.length, 0);
const pending = goldFixture(new Map(), { intercept(route, body, ok, fail) {
if (route.includes('getPayInfo')) { fail('PAYMENT_PENDING'); return true; }
} });
await assert.rejects(pending.client.queryOrder({ outTradeNo: 'pending', ios: false }));
assert.equal(pending.payments.length, 0);
});
function passFixture(platform = 'android', direct = true) {
const payments = [], delivered = [], gm = { uid: 'u', iosCanPay: direct, allOutTradeNo: [] };
const check = { hasSchedule: () => true, reviewOrders: new Set(), syncClock() {} };
const utils = {
buyProp(a, b, c, d, e, cb) { cb({ outTradeNo: 'pass-order' }); },
getPayInfo(cb) { cb({ code: 1, data: { pay_state: 2 } }); },
GoKEFu(a, cb, created) { created('pass-order'); cb('success'); },
getIosPayInfo(a, cb) { cb({ code: 1 }); },
async completePassOrder(order) { delivered.push(order); return { data: { rewards: [] } }; },
async settlementRequest() { return { data: { serverTime: Date.now(), rows: [] } }; }
};
const sdk = { API: { yinli_Pay: (...args) => payments.push(args) } };
const View = load('assets/Script/BattlePassSettlement.ts', {
'./module/Pay/Utils': { default: utils }, './Sdk/MiniGameSdk': { MiniGameSdk: sdk },
'./module/Config/BattlePassSettlementCheck': { default: { forPlayer: () => check } },
'./module/Config/BattlePassSettlementModel': { settlementSteps: rows => rows }
}, { cc: { _decorator: { ccclass: T => T }, Component: class {}, isValid: () => true,
warn() {}, fx: { GameConfig: { GM_INFO: gm } } }, wx: { getSystemInfoSync: () => ({ platform }) } });
const view = new View(); view.root = {}; view.row = { offer: 'shown', end: 1 };
view.playRewards = (r, cb) => cb(); view.next = () => {}; view.finish = () => {}; view.isCurrentRun = () => true;
return { view, utils, payments, delivered, gm, sdk };
}
for (const [platform, direct] of [['android', true], ['ios', true], ['ios', false]]) {
test(`post-season pass ${platform}/${direct} reports the paid order`, async () => {
const f = passFixture(platform, direct); await f.view.purchase(); if (!direct) f.view.checkIos();
await new Promise(setImmediate);
assert.deepEqual(f.delivered, ['pass-order']);
assert.deepEqual(f.payments, [[1800, 'pass-order', '购买上期战令:battlepass']]);
});
}
for (const [itemid, price] of [['battlepass', 1800], ['battlepass_30', 3000], ['battlepass_12', 1200]]) {
test(`settlement recovery reports ${itemid} with its actual amount`, async () => {
const f = passFixture(); f.gm.allOutTradeNo = [{ outTradeNo: 'recovered', itemid, goodsPrice: String(price), itemCount: 1 }];
await f.view.run(() => {});
assert.deepEqual(f.payments, [[price, 'recovered', '补发充值成功' + itemid]]); assert.equal(f.gm.allOutTradeNo.length, 0);
});
test(`home recovery reports ${itemid} after fulfillment`, async () => {
const f = passFixture(); f.gm.allOutTradeNo = [{ outTradeNo: 'recovered', itemid, goodsPrice: String(price) }];
Object.assign(f.utils, { recoverJunglePayments() {}, getShopDouble(cb) { cb({ code: 1, data: { shopDouble: {} } }); } });
const context = { exports: {}, Utils: f.utils, MiniGameSdk: f.sdk, console: quiet,
HomePopupQueue: { get: () => ({ has: () => false }) }, wx: { getSystemInfoSync: () => ({ platform: 'android' }) },
cc: { sys: { platform: 'wx', WECHAT_GAME: 'wx' }, fx: { GameConfig: { GM_INFO: f.gm, isGameplayEntryPreparing: () => false } } } };
vm.runInNewContext(compile('class Home {' + methods('assets/Script/JiaZai.ts', ['getOrder']) + '};exports.Home=Home'), context);
const home = new context.exports.Home(); home.deferHomePopupWhileTransfer = () => false; home.isHomeRuntimeAlive = () => false;
home.getOrder(); await new Promise(setImmediate);
assert.deepEqual(f.payments, [[price, 'recovered', '补发充值成功' + itemid]]);
});
}
test('SDK failure cannot block post-season rewards', async () => {
const f = passFixture(); let rewards = 0; f.view.playRewards = () => rewards++;
f.sdk.API.yinli_Pay = () => { throw Error('SDK offline'); }; await f.view.fulfill('paid'); assert.equal(rewards, 1);
});
test('post-season pending payment, failed fulfillment and stale-account results do not report', async () => {
const pending = passFixture(); pending.utils.getPayInfo = cb => cb({ code: 0, data: { pay_state: -1 } });
pending.view.error = () => {}; await pending.view.purchase(); assert.equal(pending.payments.length, 0);
const failed = passFixture(); failed.utils.completePassOrder = async () => { throw Error('network'); };
failed.view.error = () => {}; await failed.view.fulfill('failed'); assert.equal(failed.payments.length, 0);
const stale = passFixture(); let resolve; stale.utils.completePassOrder = () => new Promise(done => resolve = done);
const completion = stale.view.fulfill('old-account'); stale.gm.uid = 'other'; resolve({ data: { rewards: [] } });
await completion; assert.equal(stale.payments.length, 0);
});
function jungleFixture(storage = new Map()) {
const payments = [], calls = [], game = { uid: 'u', token: 't' };
const cc = { warn() {}, fx: { GameConfig: { GM_INFO: game }, GameTool: { shushu_Track() {} }, StorageMessage: {
getStorage: key => structuredClone(storage.get(key)), setStorage: (key, value) => storage.set(key, structuredClone(value))
} } };
const sdk = { API: { yinli_Pay: (...args) => payments.push(args) } };
const context = { exports: {}, cc, MiniGameSdk: sdk, console: quiet,
wx: { requestMidasPaymentGameItem() {}, openCustomerServiceConversation() {} } };
const names = ['junglePaymentKey', 'rememberJunglePayment', 'forgetJunglePayment', 'recoverJunglePayments', 'buyProp', 'GoKEFu'];
vm.runInNewContext(compile('class Utils {static apiBaseUrl(){return "test/";} static createPaymentOrderNo(){return "customer-order";}'
+ methods('assets/Script/module/Pay/Utils.ts', names) + '};exports.Utils=Utils;'), context);
const U = context.exports.Utils;
U.POST = (route, body, cb) => { calls.push({ route, body }); cb({ code: 1, data: route.includes('orderPaySig')
? { outTradeNo: 'direct-order', signData: 's', signature: 's', paySig: 's' } : { pay_state: 2 } }); };
U.setPayInfo = (cb, order) => { calls.push({ route: 'complete', order }); cb({ code: 1 }); };
return { U, payments, calls, storage, game, cc, sdk };
}
test('Jungle journals direct and customer orders before opening payment, then recovers on restart', async () => {
const first = jungleFixture();
first.U.buyProp('jungle_treasure_1', 1, 600, 'android', 'jungle_treasure_1', () => {});
first.U.GoKEFu({ payment_name: 'jungle_treasure_2', price: 1200, payment_count: 1 }, () => {});
assert.equal(Object.keys(first.storage.get(first.U.junglePaymentKey())).length, 2); assert.equal(first.payments.length, 0);
const f = jungleFixture(first.storage); await f.U.recoverJunglePayments([]);
assert.deepEqual(f.payments, [[600, 'direct-order', '补发充值成功jungle_treasure_1'], [1200, 'customer-order', '补发充值成功jungle_treasure_2']]);
await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 2);
});
test('Jungle legacy login order is reported and acknowledged without granting shop rewards', async () => {
const f = jungleFixture(); await f.U.recoverJunglePayments([{ outTradeNo: 'old', itemid: 'jungle_treasure_3', goodsPrice: '1800', type: 'ios' }]);
assert.deepEqual(f.payments, [[1800, 'old', '补发充值成功jungle_treasure_3']]);
assert.equal(f.calls.filter(c => c.route === 'complete').length, 1);
});
test('Jungle pending orders are retained; customer confirmation can report on a later login', async () => {
const f = jungleFixture(); f.U.rememberJunglePayment('pending', 'jungle_treasure_1', 600, true);
f.U.POST = (route, body, cb) => cb({ code: 0 }); await f.U.recoverJunglePayments([]);
assert.equal(f.payments.length, 0); assert.ok(f.storage.get(f.U.junglePaymentKey()).pending);
f.U.POST = (route, body, cb) => cb({ code: route === 'wx/iosgetPayInfo' ? 1 : 0 });
await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1);
});
test('Jungle failed acknowledgment and SDK exception leave the order available for retry', async () => {
const f = jungleFixture(); f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false);
f.U.setPayInfo = cb => cb({ code: 0 }); await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 0);
f.U.setPayInfo = cb => cb({ code: 1 }); const report = f.sdk.API.yinli_Pay;
f.sdk.API.yinli_Pay = () => { throw Error('offline'); }; await f.U.recoverJunglePayments([]);
assert.ok(f.storage.get(f.U.junglePaymentKey()).paid);
f.sdk.API.yinli_Pay = report; await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1);
});
test('Jungle recovery ignores stale-account replies and callbacks already reported by the activity', async () => {
for (const switchAccount of [true, false]) {
const f = jungleFixture(); f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false); let reply;
f.U.POST = (route, body, cb) => reply = cb; const pending = f.U.recoverJunglePayments([]);
if (switchAccount) f.game.uid = 'another'; else f.U.forgetJunglePayment('paid');
reply({ code: 1, data: { pay_state: 2 } }); await pending; assert.equal(f.payments.length, 0);
}
});
test('Jungle normal callback clears the journal and does not re-report an order completed by recovery', async () => {
const f = jungleFixture();
const context = { exports: {}, Utils: f.U, MiniGameSdk: f.sdk, cc: f.cc };
vm.runInNewContext(compile('class Jungle {' + methods('assets/jungle_treasure/script/jungle_Manager.ts', ['completeDirectPayResultPolling']) + '};exports.Jungle=Jungle;'), context);
const view = new context.exports.Jungle(); view.directPayTier = { id: 1, productId: 'jungle_treasure_1' };
view.directPayOrderNo = 'paid'; view.pendingPaymentPrice = 600; view.stopDirectPayResultPolling = () => {}; view.refreshAfterPayment = () => {};
f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false);
view.completeDirectPayResultPolling(); await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1);
view.completeDirectPayResultPolling(); assert.equal(f.payments.length, 1);
});