Files
frontend-app/tests/douyin-integration.test.cjs
T

275 lines
14 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?$/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 = [];
return { storage, messages, 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: options => options.success({ confirm: true }),
showLoading() {}, hideLoading() {}, navigateTo() {} };
}
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,
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 };
return loadModule('utils/douyin-pay.js', context, 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.equal(ctx.uni.messages.at(-1), '支付成功');
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);
});
test('cancel still queries authoritative server state', async () => {
const ctx = await payContext({ openDypay: (_, cb) => cb({ resultCode:'1' }) });
await ctx.appDypayFun(payment);
assert.equal(ctx.uni.messages.at(-1), '支付成功');
});
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.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.equal(ctx.uni.messages.at(-1), '支付成功');
});
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 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 and declining continuation never open the SDK or erase 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();
ctx.request = async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}});
ctx.uni.showModal = options => options.success({confirm:false});
assert.equal(await ctx.prepareDouyinPay('douyin'), false);
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum);
});
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'), false);
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false);
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
}
});
test('account switch during resume response or confirmation cannot invoke SDK', async () => {
for (const phase of ['response','confirmation']) {
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}};
};
ctx.uni.showModal = options => {ctx.uni.storage.set('user',{userId:8}); options.success({confirm:true});};
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 confirm;
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++; cb({resultCode:'0'}); }});
ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum);
ctx.uni.showModal = options => {confirm = options.success;};
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();
confirm({confirm:true});
await first;
assert.equal(calls, 1);
});
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);
});