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