hyapp-h5/scripts/test_recharge_v5pay.js

367 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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