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