367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const vm = require('node:vm');
|
||
|
||
const root = path.join(__dirname, '..');
|
||
const configPaths = {
|
||
aslan: path.join(root, 'recharge', 'config', 'aslan.json'),
|
||
yumi: path.join(root, 'recharge', 'config', 'yumi.json'),
|
||
};
|
||
const currentRuntimeTypes = {
|
||
EG: ['FAWRY', 'VODAFONE'],
|
||
IN: ['UPI'],
|
||
PH: [
|
||
'GCASH_ONLINE',
|
||
'COINS_ONLINE',
|
||
'INSTAPAY_QR_STATIC',
|
||
'INSTAPAY_QR',
|
||
'QRPH_GCASH',
|
||
'PESONET',
|
||
'UB_ONLINE',
|
||
'BPI_ONLINE',
|
||
'RCBC_ONLINE',
|
||
'BANKVA',
|
||
'BILL',
|
||
],
|
||
PK: ['JAZZCASH'],
|
||
VN: ['BANK_TRANSFER', 'QR'],
|
||
TR: ['GPAY', 'BKM_EXPRESS', 'BANK_TRANSFER', 'CREDIT_CARD'],
|
||
MX: ['SPEI', 'CODI', 'SEVEN_ELEVEN', 'OXXO', 'CREDIT_CARD'],
|
||
BR: ['PICPAY', 'PIX', 'BOLETO', 'BANK_TRANSFER', 'CREDIT_CARD', 'BINANCE'],
|
||
};
|
||
// V5Pay 商户虽已开放 8 个国家,但各应用只有同时存在有效币种汇率和上架商品时才可展示;
|
||
// 这里锁定线上真实交集,防止前端把无法生成金额快照的空入口误放给用户。
|
||
const expectedTypesByApp = {
|
||
aslan: Object.fromEntries(
|
||
['EG', 'IN', 'PH', 'PK', 'TR', 'BR'].map((code) => [
|
||
code,
|
||
currentRuntimeTypes[code],
|
||
])
|
||
),
|
||
yumi: Object.fromEntries(
|
||
['EG', 'IN', 'PH', 'PK', 'VN', 'TR', 'BR'].map((code) => [
|
||
code,
|
||
currentRuntimeTypes[code],
|
||
])
|
||
),
|
||
};
|
||
|
||
function response(payload) {
|
||
return {
|
||
headers: { get: () => '' },
|
||
ok: true,
|
||
status: 200,
|
||
statusText: 'OK',
|
||
text: () =>
|
||
Promise.resolve(
|
||
JSON.stringify({ code: 'OK', message: 'ok', data: payload })
|
||
),
|
||
};
|
||
}
|
||
|
||
function createAPIHarness() {
|
||
const requests = [];
|
||
const storage = new Map();
|
||
let nextPayload = {};
|
||
const window = {
|
||
document: { documentElement: { setAttribute: () => {} } },
|
||
fetch: (rawURL, options = {}) => {
|
||
// 每次调用都记录真实序列化后的 URL/body,测试的是浏览器最终发出的契约,而不是内部 helper 的中间对象。
|
||
requests.push({
|
||
url: new URL(rawURL),
|
||
options,
|
||
body: options.body ? JSON.parse(options.body) : null,
|
||
});
|
||
return Promise.resolve(response(nextPayload));
|
||
},
|
||
localStorage: {
|
||
getItem: (key) => storage.get(key) || null,
|
||
removeItem: (key) => storage.delete(key),
|
||
setItem: (key, value) => storage.set(key, String(value)),
|
||
},
|
||
location: {
|
||
hash: '',
|
||
hostname: 'h5.global-interaction.com',
|
||
href: 'https://h5.global-interaction.com/recharge/index.html',
|
||
origin: 'https://h5.global-interaction.com',
|
||
pathname: '/recharge/index.html',
|
||
search: '?language=tr',
|
||
},
|
||
};
|
||
const context = vm.createContext({
|
||
FormData,
|
||
JSON,
|
||
Math,
|
||
Number,
|
||
Object,
|
||
Promise,
|
||
String,
|
||
URL,
|
||
URLSearchParams,
|
||
Uint32Array,
|
||
window,
|
||
});
|
||
vm.runInContext(
|
||
fs.readFileSync(path.join(root, 'common', 'api.js'), 'utf8'),
|
||
context,
|
||
{ filename: 'common/api.js' }
|
||
);
|
||
return {
|
||
api: window.HyAppAPI,
|
||
requests,
|
||
respondWith(payload) {
|
||
nextPayload = payload;
|
||
},
|
||
};
|
||
}
|
||
|
||
function readConfigs() {
|
||
return Object.fromEntries(
|
||
Object.entries(configPaths).map(([app, file]) => [
|
||
app,
|
||
JSON.parse(fs.readFileSync(file, 'utf8')),
|
||
])
|
||
);
|
||
}
|
||
|
||
function assertRuntimeConfig(config, app) {
|
||
const provider = config.providers && config.providers.v5pay;
|
||
assert.ok(provider, `${app} should configure V5Pay`);
|
||
assert.equal(provider.provider_code, 'v5pay');
|
||
assert.equal(provider.provider_name, 'V5Pay');
|
||
assert.equal(provider.status, 'active');
|
||
assert.deepEqual(
|
||
Object.fromEntries(
|
||
provider.countries.map((country) => [
|
||
country.country_code,
|
||
country.methods.map((method) => method.pay_type),
|
||
])
|
||
),
|
||
expectedTypesByApp[app],
|
||
`${app} should expose only V5Pay countries backed by current app products`
|
||
);
|
||
provider.countries.forEach((country) => {
|
||
country.methods.forEach((method) => {
|
||
assert.equal(method.channel_code, method.pay_type);
|
||
assert.equal(method.logo_url, '');
|
||
assert.ok(method.pay_way, `${country.country_code} needs pay_way`);
|
||
assert.ok(method.method_name, `${method.pay_type} needs a name`);
|
||
// Lalu 的 method_id 和商户密钥属于后端数据,复制到 app JSON 会造成跨应用错误绑定或敏感信息泄露。
|
||
assert.equal(Object.hasOwn(method, 'method_id'), false);
|
||
assert.equal(Object.hasOwn(method, 'secret_key'), false);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function assertProviderParsing(harness, config, app) {
|
||
harness.api.setAppConfig(config);
|
||
const currencies = Object.fromEntries(
|
||
config.providers.v5pay.countries.map((country) => [
|
||
country.country_code,
|
||
country.currency_code,
|
||
])
|
||
);
|
||
|
||
for (const [countryCode, types] of Object.entries(
|
||
expectedTypesByApp[app]
|
||
)) {
|
||
if (app === 'aslan') {
|
||
harness.respondWith({
|
||
country_code: countryCode,
|
||
currency_code: currencies[countryCode],
|
||
products: [{ product_id: 'legacy-product' }],
|
||
});
|
||
} else {
|
||
harness.respondWith({
|
||
countryCode,
|
||
currencyCode: currencies[countryCode],
|
||
payCountryId: 'pay-country-1',
|
||
commodity: [
|
||
{
|
||
id: 'legacy-product',
|
||
amountUsd: 1,
|
||
amount: 1,
|
||
currency: currencies[countryCode],
|
||
},
|
||
],
|
||
// 生产旧接口当前只返回 MiFaPay 数据库渠道。V5Pay 使用下单时的安全合成快照,
|
||
// 必须证明它不会被一个完全无关的旧渠道集合误过滤掉。
|
||
channels: [
|
||
{
|
||
channel: { channelCode: 'MIFA_ONLY_CHANNEL' },
|
||
details: { factoryChannel: 'MIFA_ONLY_TYPE' },
|
||
},
|
||
],
|
||
});
|
||
}
|
||
const options = await harness.api.recharge.options('100000001', {
|
||
country_code: countryCode,
|
||
currency_code: currencies[countryCode],
|
||
pay_country_id: 'pay-country-1',
|
||
user_id: '100000001',
|
||
});
|
||
const v5Methods = Array.from(options.payment_methods).filter(
|
||
(method) => method.provider_code === 'v5pay'
|
||
);
|
||
const v5Types = v5Methods.map((method) => method.pay_type);
|
||
assert.deepEqual(
|
||
v5Types,
|
||
types,
|
||
`${app} should parse ${countryCode} V5Pay methods`
|
||
);
|
||
v5Methods.forEach((method) => {
|
||
assert.equal(method.provider_name, 'V5Pay');
|
||
assert.equal(method.channel_code, method.pay_type);
|
||
assert.match(method.method_id, /^v5pay:/);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function assertLegacyOrderPayloads(harness, configs) {
|
||
const legacyProductID = '90071992547409931234';
|
||
for (const app of ['aslan', 'yumi']) {
|
||
harness.api.setAppConfig(configs[app]);
|
||
harness.respondWith({
|
||
orderId: `${app}-order-1`,
|
||
factoryCode: app === 'yumi' ? 'V5_PAY' : undefined,
|
||
providerCode: app === 'aslan' ? 'v5pay' : undefined,
|
||
requestUrl: `https://pay.example/${app}-order-1`,
|
||
});
|
||
const order = await harness.api.recharge.createOrder({
|
||
product_id: legacyProductID,
|
||
pay_country_id: 'pay-country-1',
|
||
user_id: '100000001',
|
||
provider_code: 'v5pay',
|
||
pay_way: 'Cash',
|
||
pay_type: 'FAWRY',
|
||
channel_code: 'FAWRY',
|
||
country_code: 'EG',
|
||
currency_code: 'EGP',
|
||
command_id: `${app}-command-1`,
|
||
return_url: 'https://h5.global-interaction.com/recharge/index.html',
|
||
});
|
||
const body = harness.requests.at(-1).body;
|
||
assert.equal(body.goodsId, legacyProductID);
|
||
assert.equal(typeof body.goodsId, 'string');
|
||
assert.equal(body.providerCode, 'v5pay');
|
||
assert.equal(body.payWay, 'Cash');
|
||
assert.equal(body.payType, 'FAWRY');
|
||
assert.equal(body.channelCode, 'FAWRY');
|
||
if (app === 'yumi') {
|
||
assert.equal(body.countryCode, 'EG');
|
||
assert.equal(body.currencyCode, 'EGP');
|
||
assert.equal(body.commandId, 'yumi-command-1');
|
||
assert.equal(body.language, 'tr');
|
||
assert.equal(order.provider_code, 'v5pay');
|
||
|
||
harness.respondWith({
|
||
orderId: 'yumi-order-1',
|
||
factoryCode: 'V5PAY',
|
||
status: 'SUCCESS',
|
||
amount: '2.00',
|
||
});
|
||
const status = await harness.api.recharge.getOrder(
|
||
'yumi-order-1',
|
||
'100000001'
|
||
);
|
||
assert.equal(status.provider_code, 'v5pay');
|
||
assert.equal(status.status, 'credited');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function assertLaluNumericPayload(harness) {
|
||
harness.api.setAppConfig(null);
|
||
harness.respondWith({ order_id: 'lalu-order-1' });
|
||
await harness.api.recharge.createOrder({
|
||
product_id: 123456,
|
||
provider_code: 'v5pay',
|
||
});
|
||
const body = harness.requests.at(-1).body;
|
||
assert.equal(body.product_id, 123456);
|
||
assert.equal(typeof body.product_id, 'number');
|
||
}
|
||
|
||
function extractPageFunction(source, name) {
|
||
const start = source.indexOf(`function ${name}(`);
|
||
assert.notEqual(start, -1, `missing recharge page function ${name}`);
|
||
const next = source.indexOf('\n function ', start + 1);
|
||
return source.slice(start, next === -1 ? source.length : next);
|
||
}
|
||
|
||
function assertPageProductIDCompatibility() {
|
||
const pageSource = fs.readFileSync(
|
||
path.join(root, 'recharge', 'index.html'),
|
||
'utf8'
|
||
);
|
||
const source = [
|
||
extractPageFunction(pageSource, 'productID'),
|
||
extractPageFunction(pageSource, 'createOrderProductID'),
|
||
extractPageFunction(pageSource, 'isLegacyWebPayApp'),
|
||
].join('\n');
|
||
const context = vm.createContext({ isFinite, Number, String, state: {} });
|
||
vm.runInContext(source, context, { filename: 'recharge/index.html' });
|
||
|
||
context.state.appConfig = { adapter: 'yumi-web-pay' };
|
||
assert.equal(
|
||
context.createOrderProductID({ product_id: '90071992547409931234' }),
|
||
'90071992547409931234'
|
||
);
|
||
context.state.appConfig = { adapter: 'aslan-web-pay' };
|
||
assert.equal(
|
||
context.createOrderProductID({ product_id: '123456' }),
|
||
'123456'
|
||
);
|
||
context.state.appConfig = null;
|
||
assert.equal(
|
||
context.createOrderProductID({ product_id: '123456' }),
|
||
123456
|
||
);
|
||
}
|
||
|
||
function assertPageProviderCountries(configs) {
|
||
const pageSource = fs.readFileSync(
|
||
path.join(root, 'recharge', 'index.html'),
|
||
'utf8'
|
||
);
|
||
const source = extractPageFunction(
|
||
pageSource,
|
||
'configuredProviderCountries'
|
||
);
|
||
const context = vm.createContext({ Object, Array, state: {} });
|
||
vm.runInContext(source, context, { filename: 'recharge/index.html' });
|
||
Object.entries(configs).forEach(([app, config]) => {
|
||
context.state.appConfig = config;
|
||
const codes = Array.from(context.configuredProviderCountries()).map(
|
||
(country) => country.country_code
|
||
);
|
||
Object.keys(expectedTypesByApp[app]).forEach((countryCode) => {
|
||
assert.ok(
|
||
codes.includes(countryCode),
|
||
`${app} page country selection should include ${countryCode}`
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function main() {
|
||
const configs = readConfigs();
|
||
Object.entries(configs).forEach(([app, config]) =>
|
||
assertRuntimeConfig(config, app)
|
||
);
|
||
const harness = createAPIHarness();
|
||
await assertProviderParsing(harness, configs.aslan, 'aslan');
|
||
await assertProviderParsing(harness, configs.yumi, 'yumi');
|
||
await assertLegacyOrderPayloads(harness, configs);
|
||
await assertLaluNumericPayload(harness);
|
||
assertPageProductIDCompatibility();
|
||
assertPageProviderCountries(configs);
|
||
console.log('recharge V5Pay compatibility tests passed');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|