const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const root = path.resolve(__dirname, '..'); function preprocess(source, app) { const stack = [true]; return source.split('\n').filter(line => { const directive = line.match(/\/\/\s*#(ifdef|ifndef) (APP-PLUS|H5)/); if (directive) { const enabled = directive[2] === 'APP-PLUS' ? app === true : app === 'h5'; stack.push(stack.at(-1) && (directive[1] === 'ifdef' ? enabled : !enabled)); return false; } if (/\/\/\s*#endif/.test(line)) { stack.pop(); return false; } return stack.at(-1); }).join('\n'); } function loadModule(file, context, app = true) { const source = preprocess(fs.readFileSync(path.join(root, file), 'utf8'), app) .replace(/^import .*;\r?$/gm, '') .replace(/export (async function|function)/g, '$1') .replace(/export const /g, 'const ') .replace(/export default request;/g, ''); vm.createContext(context); vm.runInContext(source, context, { filename: file }); return context; } function uniMock() { const storage = new Map([['token', 'test-token'], ['user', { userId: 7 }]]); const messages = []; const navigations = []; return { storage, messages, navigations, getStorageSync: k => storage.get(k), setStorageSync: (k,v) => storage.set(k,v), removeStorageSync: k => storage.delete(k), showToast: v => messages.push(v.title), $emit() {}, showModal: () => assert.fail('Douyin must not add payment confirmation modals'), showLoading() {}, hideLoading() {}, navigateTo: options => { navigations.push(options.url); options.success?.(); }, redirectTo: options => { navigations.push(options.url); options.success?.(); } }; } const payment = { orderNum: 'order-1', douyin: { appId: 'app', mchId: 'mch', prepayId: 'prepay', packageValue: 'Sign=DYPay', nonceStr: 'nonce', timeStamp: '1780000000', sign: 'sig' } }; async function payContext(overrides = {}, app = true) { const adapter = await import('../utils/douyin-pay-adapter.mjs'); const context = { uni: uniMock(), BASE_URL: 'test-api', TOKEN_NAME: 'token', USER_DATA: 'user', ...adapter, canOpenDypay: () => true, initDypay: () => true, resetDypay: () => {}, openDypay: (_, cb) => cb({ resultCode: '0' }), request: async options => options.url === '/payment/douyin/resume' ? ({ bizcode: 100, data: { state: 'paid', orderNum: options.data.orderNum } }) : ({ bizcode: 100, data: { state: 2 } }), ...overrides }; loadModule('utils/payment-navigation.js', context, app); return loadModule('utils/douyin-pay.js', context, app); } const successPage = '/pages/order_package/order_submit_ok/order_submit_ok'; test('SDK success is confirmed with server before showing success', async () => { let queries = 0; const ctx = await payContext({ request: async () => { queries++; return { bizcode:100, data:{state:2} }; } }); await ctx.appDypayFun(payment); assert.equal(queries, 1); assert.deepEqual(ctx.uni.navigations, [successPage]); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); }); test('SDK success plus network failure remains recoverable, never success', async () => { const ctx = await payContext({ request: async () => { throw new Error('offline'); } }); await ctx.appDypayFun(payment); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1'); assert.equal(ctx.uni.messages.includes('支付成功'), false); assert.deepEqual(ctx.uni.navigations, []); }); test('cancel still queries authoritative server state', async () => { const ctx = await payContext({ openDypay: (_, cb) => cb({ resultCode:'1' }) }); await ctx.appDypayFun(payment); assert.deepEqual(ctx.uni.navigations, [successPage]); }); test('old backend parameters never invoke native SDK', async () => { let calls = 0; const ctx = await payContext({ openDypay: () => calls++ }); await ctx.appDypayFun({ orderNum:'old', douyin:{appId:'app'} }); assert.equal(calls, 0); assert.match(ctx.uni.messages.at(-1), /缺少支付参数/); }); test('new backend orderInfo (object or JSON) reaches native SDK with exact signed fields', async () => { const { toSdkPayInfo } = await import('../utils/douyin-pay-adapter.mjs'); const expected = toSdkPayInfo(payment); for (const orderInfo of [expected, JSON.stringify(expected)]) { let received; const ctx = await payContext({ openDypay: (options, cb) => { received = options.payInfo; cb({resultCode:'0'}); } }); const result = await ctx.appDypayFun({orderNum:'order-1', douyin:{type:'app', orderInfo}}); assert.deepEqual(received, expected); assert.equal(result.state, 'paid'); } }); test('legacy douyinPayFun calls native bridge, never uni payment provider or URL fallback', async () => { let nativeCalls = 0; const ctx = await payContext({ openDypay: (_, cb) => { nativeCalls++; cb({resultCode:'0'}); } }); ctx.uni.requestPayment = () => assert.fail('Must not use built-in payment provider for Douyin'); ctx.plus = { runtime: { openURL: () => assert.fail('Must not synthesize cashier URLs') } }; loadModule('utils/payUtils.js', ctx); const result = await ctx.douyinPayFun(payment.douyin, 1, payment.orderNum); assert.equal(nativeCalls, 1); assert.equal(result.state, 'paid'); }); test('invalid JSON and Scheme-only payload do not call SDK or create pending state', async () => { for (const douyin of [{orderInfo:'{'}, {orderStr:'snssdk1128://open'}, {type:'h5', prepayId:'prepay'}]) { const ctx = await payContext({openDypay: () => assert.fail('Invalid data reached SDK')}); const result = await ctx.appDypayFun({orderNum:'invalid', douyin}); assert.equal(result.state, 'error'); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); } }); test('legacy retry of settled order queries state without opening payment again', async () => { const ctx = await payContext({openDypay: () => assert.fail('Retry opened payment again')}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); const result = await ctx.appDypayFun(payment); assert.equal(result.state, 'paid'); }); test('duplicate invocation does not open SDK twice', async () => { let done; let calls = 0; const ctx = await payContext({ openDypay: (_, cb) => { calls++; done = cb; } }); const first = ctx.appDypayFun(payment); await ctx.appDypayFun(payment); done({resultCode:'0'}); await first; assert.equal(calls, 1); }); test('account switch suppresses old account result and retains its pending record', async () => { const ctx = await payContext(); ctx.request = async () => { ctx.uni.storage.set('user', {userId:8}); return {bizcode:100,data:{state:2}}; }; await ctx.appDypayFun(payment); assert.equal(ctx.uni.messages.includes('支付成功'), false); assert.deepEqual(ctx.uni.navigations, []); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1'); }); test('cold start queries saved order; unavailable app does not create order', async () => { const ctx = await payContext({ canOpenDypay: () => false }); assert.equal(await ctx.prepareDouyinPay('douyin'), false); ctx.uni.storage.set('tb-douyin:test-api:7', 'pending'); await ctx.resumeDouyinPay(); assert.deepEqual(ctx.uni.navigations, [successPage]); }); test('non-App excludes Douyin and never accesses native SDK', async () => { const ctx = await payContext({}, false); assert.equal(ctx.douyinPayTypes('1,2,3'), '1,2,3'); assert.equal(ctx.filterDouyinPayWays([{type:'douyin'},{type:'wechat'}]).length, 1); assert.equal(await ctx.prepareDouyinPay('douyin'), false); }); test('H5 displays configured Douyin but blocks payment before order creation', async () => { const ctx = await payContext({ canOpenDypay: () => { throw new Error('Native SDK must not run'); } }, 'h5'); assert.equal(ctx.douyinPayTypes('1,2,3'), '1,2,3,6'); assert.equal(ctx.douyinPayTypes('1,6'), '1,6'); assert.equal(ctx.filterDouyinPayWays([{type:'douyin'},{type:'wechat'}]).length, 2); assert.equal(ctx.filterDouyinPayWays([]).length, 0); assert.equal(await ctx.prepareDouyinPay('douyin'), false); assert.match(ctx.uni.messages.at(-1), /仅支持/); }); test('request terminates on HTTP500, business500, bad JSON, auth failure and offline', async () => { for (const response of [{statusCode:500,data:{}}, {statusCode:200,data:{status:500}}, {statusCode:200,data:''}, {statusCode:200,data:{bizcode:201,errcode:999}}, null]) { const uni = uniMock(); uni.request = options => response ? options.success(response) : options.fail(new Error('offline')); const ctx = loadModule('utils/request.js', { uni, TOKEN_NAME:'token', USER_DATA:'user', getStorageFun: () => '', BASE_URL:'test-api', Authorization:'test-only', console:{log(){}}, setTimeout, clearTimeout }); await assert.rejects(ctx.request({url:'/payment/queryResult'})); } }); test('killed App resumes the original unpaid transaction, without creating another order', async () => { const ctx = await payContext(); // This is exactly the legacy persisted format: only an order number survives process death. ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.queryUntilSettled = async () => ({state:'pending'}); await ctx.resumeDouyinPay(); let opened = 0; const urls = []; ctx.request = async options => { urls.push(options.url); assert.equal(options.data.orderNum, payment.orderNum); return {bizcode:100, data:{state:'unpaid', orderNum:payment.orderNum, payment}}; }; ctx.openDypay = (options, cb) => { opened++; assert.equal(options.payInfo.prepayid, 'prepay'); cb({resultCode:'0'}); }; ctx.queryUntilSettled = async () => ({state:'paid'}); // Returning false intentionally stops the caller from creating a NEW order after resuming. assert.equal(await ctx.prepareDouyinPay('douyin'), false); assert.equal(opened, 1); assert.deepEqual(urls, ['/payment/douyin/resume']); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); }); test('cold start never opens the SDK or asks for payment and preserves pending state', async () => { const ctx = await payContext({openDypay:()=>assert.fail('Unexpected SDK call'), queryUntilSettled: async()=>({state:'pending'})}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.uni.showModal = () => assert.fail('onShow must not ask for payment'); await ctx.resumeDouyinPay(); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); assert.deepEqual(ctx.uni.navigations, []); }); test('unknown, processing, network error and missing backend remain locked', async () => { for (const mode of ['pending','NEW_STATE','offline','missing']) { const ctx = await payContext({openDypay:()=>assert.fail('Unknown result must not open SDK'), request: async()=> { if (mode === 'offline') throw new Error('offline'); if (mode === 'missing') return {bizcode:404}; return {bizcode:100,data:{state:mode,orderNum:payment.orderNum}}; }}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); assert.equal(await ctx.prepareDouyinPay('douyin'), false); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); test('terminal server result clears saved order without payment and allows the next action', async () => { for (const state of ['paid','closed']) { const ctx = await payContext({openDypay:()=>assert.fail('Terminal order reached SDK'), request: async options => options.url === '/payment/douyin/resume' ? ({bizcode:100,data:{state,orderNum:payment.orderNum}}) : ({bizcode:100,data:{state:2}})}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); assert.equal(await ctx.prepareDouyinPay('douyin'), state === 'closed'); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); assert.equal(await ctx.prepareDouyinPay('douyin'), true); } }); test('closed old transaction unlocks a fresh server order, never reopens old parameters', async () => { let resets=0, opened; const ctx=await payContext({resetDypay:()=>resets++, request:async options => options.url === '/payment/douyin/resume' ? {bizcode:100,data:{state:'closed',orderNum:'expired-order'}} : {bizcode:100,data:{state:2}}, openDypay:(options,cb)=>{opened=options.payInfo.prepayid; cb({resultCode:'0'});}}); ctx.uni.storage.set('tb-douyin:test-api:7','expired-order'); assert.equal(await ctx.prepareDouyinPay('douyin'),true); assert.equal(resets,1); const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}}; await ctx.appDypayFun(fresh); assert.equal(opened,'fresh-prepay'); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false); }); test('switching accounts during old-order closure cannot create a new payment', async () => { const ctx=await payContext(); ctx.request=async()=>{ ctx.uni.storage.set('user',{userId:8}); return {bizcode:100,data:{state:'closed',orderNum:'expired-order'}}; }; ctx.uni.storage.set('tb-douyin:test-api:7','expired-order'); assert.equal(await ctx.prepareDouyinPay('douyin'),false); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'expired-order'); assert.deepEqual(ctx.uni.navigations, []); }); test('unconfirmed server state never resets the native lock and explains why', async () => { const ctx=await payContext({resetDypay:()=>assert.fail('Unconfirmed native reset'), request:async()=>({bizcode:100,data:{state:'pending',reason:'QUERY_FAILED',orderNum:payment.orderNum}})}); ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum); assert.equal(await ctx.prepareDouyinPay('douyin'),false); assert.match(ctx.uni.messages.at(-1),/查单失败/); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); }); test('unpaid server confirmation resets a missing native callback before reinitializing SDK', async () => { const sequence=[]; const ctx=await payContext({resetDypay:()=>sequence.push('reset'),initDypay:()=>{sequence.push('init');return true;}, request:async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}), queryUntilSettled:async()=>({state:'pending'})}); ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum); assert.equal(await ctx.prepareDouyinPay('douyin'),false); assert.deepEqual(sequence,['reset','init']); }); test('SDK rejection stays recoverable but no longer hides the native failure', async () => { const ctx=await payContext({openDypay:(_,cb)=>cb({resultCode:'103'}),queryUntilSettled:async()=>({state:'pending'})}); await ctx.appDypayFun(payment); assert.match(ctx.uni.messages.at(-1),/原生支付仍在处理中/); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); assert.equal(ctx.uni.messages.includes('支付成功'),false); }); test('account switch during resume response or native reset cannot invoke SDK', async () => { for (const phase of ['response','reset']) { const ctx = await payContext({openDypay:()=>assert.fail('Old account reached SDK')}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.request = async()=> { if (phase === 'response') ctx.uni.storage.set('user',{userId:8}); return {bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}; }; if (phase === 'reset') ctx.resetDypay = () => ctx.uni.storage.set('user',{userId:8}); await ctx.prepareDouyinPay('douyin'); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); test('concurrent clicks and onShow during continuation open only one native payment', async () => { let done; let calls = 0; const ctx = await payContext({request: async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}), queryUntilSettled: async()=>({state:'paid'}), openDypay:(_,cb)=>{ calls++; done = cb; }}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); const first = ctx.prepareDouyinPay('douyin'); await new Promise(resolve=>setImmediate(resolve)); assert.equal(await ctx.prepareDouyinPay('douyin'), false); await ctx.appDypayFun(payment); await ctx.resumeDouyinPay(); done({resultCode:'0'}); await first; assert.equal(calls, 1); assert.deepEqual(ctx.uni.navigations, [successPage]); }); test('returning from Douyin with a missing callback completes via server query and navigates once', async () => { let lateCallback; const ctx = await payContext({openDypay:(_,cb)=>{ lateCallback = cb; }}); const paying = ctx.appDypayFun(payment); await ctx.resumeDouyinPay(); assert.equal((await paying).state, 'paid'); lateCallback({resultCode:'0'}); await ctx.resumeDouyinPay(); assert.deepEqual(ctx.uni.navigations, [successPage]); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); }); test('pending or failed foreground query never unlocks or navigates before the native callback', async () => { for (const mode of ['pending', 'offline']) { let done, resets = 0; const ctx = await payContext({openDypay:(_,cb)=>{ done=cb; }, resetDypay:()=>resets++, queryUntilSettled:async()=>{ if (mode === 'offline') throw new Error('offline'); return {state:'pending'}; }}); const paying = ctx.appDypayFun(payment); await ctx.resumeDouyinPay(); assert.equal(resets, 0); assert.deepEqual(ctx.uni.navigations, []); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); ctx.queryUntilSettled = async()=>({state:'paid'}); done({resultCode:'0'}); await paying; assert.deepEqual(ctx.uni.navigations, [successPage]); } }); test('native callback racing with foreground query still navigates only once', async () => { let done, finishQuery; const ctx=await payContext({openDypay:(_,cb)=>{done=cb;}}); const paying=ctx.appDypayFun(payment); ctx.queryUntilSettled=()=>new Promise(resolve=>{finishQuery=resolve;}); const foreground=ctx.resumeDouyinPay(); ctx.queryUntilSettled=async()=>({state:'paid'}); done({resultCode:'0'}); await paying; finishQuery({state:'paid'}); await foreground; assert.deepEqual(ctx.uni.navigations,[successPage]); }); test('full page stack falls back to redirect; navigation failure remains retryable without another payment', async () => { const ctx=await payContext(); ctx.uni.navigateTo=options=>options.fail(); await ctx.appDypayFun(payment); assert.deepEqual(ctx.uni.navigations,[successPage]); const failed=await payContext(); failed.uni.navigateTo=options=>options.fail(); failed.uni.redirectTo=options=>options.fail(); await failed.appDypayFun(payment); assert.equal(failed.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); failed.uni.redirectTo=options=>{ failed.uni.navigations.push(options.url); options.success(); }; failed.openDypay=()=>assert.fail('Already paid order must not open SDK again'); await failed.prepareDouyinPay('douyin'); assert.deepEqual(failed.uni.navigations,[successPage]); assert.equal(failed.uni.storage.has('tb-douyin:test-api:7'),false); }); test('already visible success page is not pushed again', async () => { const ctx=await payContext({getCurrentPages:()=>[{route:successPage.slice(1)}]}); await ctx.appDypayFun(payment); assert.deepEqual(ctx.uni.navigations,[]); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false); }); test('WeChat and Alipay retain the same shared success page', async () => { const ctx=await payContext({feedback:async()=>({bizcode:100}),console:{log(){},error(){}}}); loadModule('utils/payUtils.js',ctx); ctx.uni.requestPayment=options=>options.success({}); ctx.appWxpayFun({type:'app'},1,'wechat-order'); ctx.zfbPayFun({type:'app',orderStr:'test'},2,'alipay-order'); assert.deepEqual(ctx.uni.navigations,[successPage,successPage]); }); test('resume refuses mismatched order, bad signed parameters and native initialization failure', async () => { for (const mode of ['outer-order','inner-order','signature','init']) { const ctx = await payContext({openDypay:()=>assert.fail('Invalid resume reached SDK')}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); const resumePayment = JSON.parse(JSON.stringify(payment)); if (mode === 'inner-order') resumePayment.orderNum = 'different'; if (mode === 'signature') delete resumePayment.douyin.sign; if (mode === 'init') ctx.initDypay = ()=>false; ctx.request = async()=>({bizcode:100,data:{state:'unpaid', orderNum:mode === 'outer-order' ? 'different' : payment.orderNum,payment:resumePayment}}); await ctx.prepareDouyinPay('douyin'); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); test('native initialization failure before first payment does not leave a permanent lock', async () => { const ctx = await payContext({initDypay:()=>false,openDypay:()=>assert.fail('Invalid SDK init')}); assert.equal((await ctx.appDypayFun(payment)).state, 'error'); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); });