631 lines
32 KiB
JavaScript
631 lines
32 KiB
JavaScript
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\n]*;[^\S\r\n]*(?:\/\/[^\r\n]*)?\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() {},
|
|
getSystemInfoSync: () => ({ platform: 'android' }),
|
|
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';
|
|
const failurePage = '/pages/order_package/order_submit_error/order_submit_error';
|
|
|
|
test('iOS always registers the installed host Scheme without a Universal Link', async () => {
|
|
for (const overrides of [{}, { callbackScheme: 'wrong-app' }, { callbackScheme: 'trustbridgeapp://' },
|
|
{ universalLink: 'https://app.tbmall.xin/uni-universallinks/__UNI__4910728/' },
|
|
{ UniversalLinks: 'https://example.com/return/' }]) {
|
|
let registration;
|
|
let signedInfo;
|
|
const ctx = await payContext({
|
|
initDypay: options => { registration = options; return true; },
|
|
openDypay: (options, cb) => { signedInfo = options.payInfo; cb({ resultCode: '0' }); }
|
|
});
|
|
ctx.uni.getSystemInfoSync = () => ({ platform: 'ios' });
|
|
const current = { ...payment, douyin: { ...payment.douyin, ...overrides } };
|
|
const result = await ctx.appDypayFun(current);
|
|
assert.deepEqual({ ...registration }, { appId: 'app', callbackScheme: 'trustbridgeapp' });
|
|
assert.deepEqual(signedInfo, ctx.toSdkPayInfo(current));
|
|
assert.equal(result.state, 'paid');
|
|
assert.deepEqual(ctx.uni.navigations, [successPage]);
|
|
}
|
|
});
|
|
|
|
test('Android keeps its existing callback Scheme selection', async () => {
|
|
let registration;
|
|
const ctx = await payContext({ initDypay: options => { registration = options; return true; } });
|
|
await ctx.appDypayFun({ ...payment, douyin: { ...payment.douyin, callbackScheme: 'android-host' } });
|
|
assert.equal(registration.callbackScheme, 'android-host');
|
|
assert.equal(registration.appId, 'app');
|
|
});
|
|
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, [failurePage]);
|
|
});
|
|
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:'<html>'}, {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 only resumes the original transaction when the current response selects that same 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'});
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
|
|
assert.equal(opened, 0);
|
|
assert.deepEqual(urls, []);
|
|
await ctx.appDypayFun(payment);
|
|
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, [failurePage]);
|
|
});
|
|
|
|
test('retry of the SAME order remains blocked on unknown, processing, network error or missing backend', 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'), true);
|
|
await ctx.appDypayFun(payment);
|
|
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'), true);
|
|
await ctx.appDypayFun(payment);
|
|
assert.deepEqual(ctx.uni.navigations, [state === 'paid' ? successPage : failurePage]);
|
|
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false);
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
|
|
}
|
|
});
|
|
|
|
test('a different current order does not resume or close the cached old transaction', async () => {
|
|
let resets=0, opened;
|
|
const ctx=await payContext({resetDypay:()=>resets++, request:async options => {
|
|
assert.equal(options.url, '/payment/queryResult');
|
|
assert.equal(options.data.orderNum, 'fresh-order');
|
|
return {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.deepEqual(ctx.uni.navigations,[]);
|
|
assert.equal(resets,0);
|
|
const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}};
|
|
await ctx.appDypayFun(fresh);
|
|
assert.equal(resets,1);
|
|
assert.equal(opened,'fresh-prepay');
|
|
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false);
|
|
});
|
|
|
|
test('switching accounts during same-order reconciliation 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');
|
|
await ctx.appDypayFun({...payment,orderNum:'expired-order'});
|
|
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);
|
|
await ctx.appDypayFun(payment);
|
|
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);
|
|
await ctx.appDypayFun(payment);
|
|
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.appDypayFun(payment);
|
|
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.appDypayFun(payment);
|
|
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.appDypayFun(payment);
|
|
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.appDypayFun(payment);
|
|
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);
|
|
assert.deepEqual(ctx.uni.navigations, [failurePage]);
|
|
});
|
|
|
|
test('cancel and native failure use the same failure page without losing the unresolved transaction', async () => {
|
|
for (const code of ['1','2','100','103','-1']) {
|
|
const ctx=await payContext({openDypay:(_,cb)=>cb({resultCode:code}),queryUntilSettled:async()=>({state:'pending'})});
|
|
await ctx.appDypayFun(payment);
|
|
assert.deepEqual(ctx.uni.navigations,[failurePage]);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
|
|
assert.equal(ctx.uni.messages.includes('支付成功'),false);
|
|
}
|
|
});
|
|
|
|
test('killing the App during SDK payment restores into the failure page rather than checkout or a modal', async () => {
|
|
const before=await payContext({openDypay:()=>{ /* Process dies before callback. */ }});
|
|
before.appDypayFun(payment);
|
|
assert.equal(before.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
|
|
const restarted=await payContext({openDypay:()=>assert.fail('Restart must never launch payment'),
|
|
queryUntilSettled:async()=>({state:'pending'})});
|
|
restarted.uni.storage.set('tb-douyin:test-api:7',before.uni.storage.get('tb-douyin:test-api:7'));
|
|
await restarted.resumeDouyinPay();
|
|
await restarted.resumeDouyinPay();
|
|
assert.deepEqual(restarted.uni.navigations,[failurePage]);
|
|
assert.equal(restarted.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
|
|
restarted.queryUntilSettled=async()=>({state:'paid'});
|
|
await restarted.resumeDouyinPay();
|
|
assert.deepEqual(restarted.uni.navigations,[failurePage,successPage]);
|
|
assert.equal(restarted.uni.storage.has('tb-douyin:test-api:7'),false);
|
|
});
|
|
|
|
test('restarting after a closed payment shows failure and clears only the confirmed terminal record', async () => {
|
|
const ctx=await payContext({queryUntilSettled:async()=>({state:'failed'})});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
|
|
await ctx.resumeDouyinPay();
|
|
assert.deepEqual(ctx.uni.navigations,[failurePage]);
|
|
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false);
|
|
});
|
|
|
|
test('offline restart exits checkout but retains the order for later authoritative reconciliation', async () => {
|
|
const ctx=await payContext({queryUntilSettled:async()=>{throw new Error('offline');}});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
|
|
await ctx.resumeDouyinPay();
|
|
await ctx.resumeDouyinPay();
|
|
assert.deepEqual(ctx.uni.navigations,[failurePage]);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
|
|
assert.match(ctx.uni.messages.at(-1),/待确认/);
|
|
});
|
|
|
|
test('failure navigation fallback and retry never forget a terminal order before presenting the result', async () => {
|
|
const ctx=await payContext({queryUntilSettled:async()=>({state:'failed'})});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
|
|
ctx.uni.navigateTo=options=>options.fail();
|
|
ctx.uni.redirectTo=options=>options.fail();
|
|
await ctx.resumeDouyinPay();
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
|
|
ctx.uni.redirectTo=options=>{ctx.uni.navigations.push(options.url);options.success();};
|
|
await ctx.resumeDouyinPay();
|
|
assert.deepEqual(ctx.uni.navigations,[failurePage]);
|
|
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false);
|
|
});
|
|
|
|
test('WeChat and Alipay failures retain the same shared failure page', async () => {
|
|
const ctx=await payContext({console:{log(){},error(){}}});
|
|
loadModule('utils/payUtils.js',ctx);
|
|
ctx.uni.requestPayment=options=>options.fail({});
|
|
ctx.appWxpayFun({type:'app'},1,'wx');
|
|
ctx.zfbPayFun({type:'app'},2,'ali');
|
|
assert.deepEqual(ctx.uni.navigations,[failurePage,failurePage]);
|
|
});
|
|
|
|
test('cancel 200 yuan then buy 0.2 yuan uses the current checkout response, never the cached 200 yuan order', async () => {
|
|
const oldOrder = {...payment, orderNum:'order-200', douyin:{...payment.douyin, prepayId:'prepay-200', sign:'signature-200'}};
|
|
const current = {...payment, orderNum:'order-0.2', douyin:{...payment.douyin, prepayId:'prepay-0.2', sign:'signature-0.2'}};
|
|
const opened = [], queried = [], requested = [];
|
|
let lateOldCallback;
|
|
const ctx = await payContext({
|
|
request: async options => {
|
|
requested.push(options.url);
|
|
assert.equal(options.url, '/cart/settlementOrder');
|
|
assert.equal(options.data.goodsId, 'product-0.2');
|
|
return {bizcode:100, data:current};
|
|
},
|
|
queryUntilSettled: async (_, orderNum) => { queried.push(orderNum); return {state:orderNum === 'order-200' ? 'pending' : 'paid'}; },
|
|
openDypay: (options, cb) => {
|
|
opened.push(options.payInfo);
|
|
if (options.payInfo.prepayid === 'prepay-200') { lateOldCallback = cb; cb({resultCode:'1'}); }
|
|
else cb({resultCode:'0'});
|
|
}
|
|
});
|
|
await ctx.appDypayFun(oldOrder);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-200');
|
|
assert.deepEqual(ctx.uni.navigations, [failurePage]);
|
|
// Same sequence as checkout: preflight -> current business API -> SDK.
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
|
|
assert.equal(opened.length, 1);
|
|
loadModule('api/cart.js', ctx);
|
|
const response = await ctx.settlementOrder({goodsId:'product-0.2',payway:'douyin'});
|
|
await ctx.appDypayFun(response.data);
|
|
lateOldCallback({resultCode:'0'});
|
|
assert.deepEqual(requested, ['/cart/settlementOrder']);
|
|
assert.deepEqual(opened.map(v=>v.prepayid), ['prepay-200','prepay-0.2']);
|
|
assert.equal(opened[1].sign, 'signature-0.2');
|
|
assert.deepEqual(queried, ['order-200','order-0.2']);
|
|
assert.deepEqual(ctx.uni.navigations, [failurePage,successPage]);
|
|
});
|
|
|
|
test('new purchase, recharge and existing-order payment all ignore an unrelated cached order', async () => {
|
|
for (const [entry, file, method, url] of [
|
|
['purchase','api/cart.js','settlementOrder','/cart/settlementOrder'],
|
|
['recharge','api/finance.js','recharge','/finance/recharge'],
|
|
['existing-order','api/order.js','payment','/order/payment']
|
|
]) {
|
|
const current={...payment,orderNum:entry,douyin:{...payment.douyin,prepayId:entry}};
|
|
const ctx = await payContext({request:async options=>{
|
|
assert.equal(options.url,url);
|
|
assert.equal(options.data.payway,'douyin');
|
|
return {bizcode:100,data:current};
|
|
},
|
|
queryUntilSettled:async(_, orderNum)=>{assert.equal(orderNum, entry);return {state:'pending'};}});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7', 'old-200');
|
|
let received;
|
|
ctx.openDypay = (options, cb)=>{received=options.payInfo;cb({resultCode:'1'});};
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
|
|
assert.deepEqual(ctx.uni.navigations, []);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'old-200');
|
|
loadModule(file,ctx);
|
|
const response=await ctx[method]({payway:'douyin'});
|
|
await ctx.appDypayFun(response.data);
|
|
assert.equal(received.prepayid, entry);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), entry);
|
|
}
|
|
});
|
|
|
|
test('legacy entry also pays the supplied order rather than a cached order', async () => {
|
|
const ctx=await payContext({request:()=>assert.fail('Unrelated resume'), queryUntilSettled:async()=>({state:'paid'})});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7','old-200');
|
|
loadModule('utils/payUtils.js',ctx);
|
|
let opened;
|
|
ctx.openDypay=(options,cb)=>{opened=options.payInfo.prepayid;cb({resultCode:'0'});};
|
|
await ctx.douyinPayFun({...payment.douyin,prepayId:'current-0.2'},2,'current-order');
|
|
assert.equal(opened,'current-0.2');
|
|
});
|
|
|
|
test('bad current parameters never fall back to an unrelated old order or reset its native session', async () => {
|
|
const ctx=await payContext({request:()=>assert.fail('Old-order fallback'),
|
|
resetDypay:()=>assert.fail('Invalid response must not reset native'),openDypay:()=>assert.fail('Invalid payment')});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7','old-200');
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'),true);
|
|
assert.equal((await ctx.appDypayFun({orderNum:'new',douyin:{appId:'app'}})).state,'error');
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'old-200');
|
|
});
|
|
|
|
test('restart can reconcile the last attempt but a later new checkout cannot reopen it', async () => {
|
|
const ctx=await payContext({request:()=>assert.fail('Old resume'),queryUntilSettled:async()=>({state:'pending'})});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7','old-200');
|
|
await ctx.resumeDouyinPay();
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'),true);
|
|
let opened;
|
|
ctx.openDypay=(options,cb)=>{opened=options.payInfo.prepayid;cb({resultCode:'1'});};
|
|
await ctx.appDypayFun({...payment,orderNum:'new-0.2',douyin:{...payment.douyin,prepayId:'new-0.2'}});
|
|
assert.equal(opened,'new-0.2');
|
|
});
|
|
|
|
test('an actually running SDK payment still blocks a second order until its callback completes', async () => {
|
|
let callback, calls=0;
|
|
const ctx=await payContext({openDypay:(_,cb)=>{calls++;callback=cb;},queryUntilSettled:async()=>({state:'pending'})});
|
|
const first=ctx.appDypayFun(payment);
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'),false);
|
|
await ctx.appDypayFun({...payment,orderNum:'different'});
|
|
assert.equal(calls,1);
|
|
callback({resultCode:'1'});
|
|
await first;
|
|
assert.equal(await ctx.prepareDouyinPay('douyin'),true);
|
|
});
|
|
|
|
test('native reset for a different order cannot pay after an account switch', async () => {
|
|
const ctx=await payContext({openDypay:()=>assert.fail('Wrong account'),
|
|
resetDypay:()=>ctx.uni.storage.set('user',{userId:8})});
|
|
ctx.uni.storage.set('tb-douyin:test-api:7','old-200');
|
|
await ctx.appDypayFun(payment);
|
|
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'old-200');
|
|
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:8'),false);
|
|
});
|