Files
banban/banban-mini/tests/traffic-binding.test.cjs
T

153 lines
8.2 KiB
JavaScript

const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { test } = require('node:test')
const ts = require('typescript')
const operation = { operation_id: '12345678-1234-1234-1234-123456789abc', device_id: 'test-device', action: 'bind', status: 'pending', traffic_status: 'pending' }
const iccid = '898604E91123D1334901'
function load() {
const storage = new Map()
let user = 7
let version = 'trial'
const calls = []
const jumps = []
const timers = new Map()
let request = async (url, options) => { calls.push({ url, options }); return { ...operation, status: 'completed' } }
const Taro = { getStorageSync: k => storage.get(k), setStorageSync: (k, v) => storage.set(k, v),
navigateTo: () => new Promise(() => {}),
getAccountInfoSync: () => ({ miniProgram: { envVersion: version } }),
navigateToMiniProgram: async options => { jumps.push(options) } }
const source = fs.readFileSync(path.join(__dirname, '../src/services/traffic-binding.ts'), 'utf8')
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, esModuleInterop: true } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', 'setTimeout', 'clearTimeout', js)(id => {
if (id === '@tarojs/taro') return Taro
if (id === './session') return { getCurrentUserId: () => user }
if (id === './api') return { request: (...args) => request(...args) }
throw Error(id)
}, module, module.exports, (fn, ms) => { timers.set(fn, ms); return fn }, fn => timers.delete(fn))
return { api: module.exports, storage, calls, jumps, timers,
user: value => { user = value }, version: value => { version = value }, request: fn => { request = fn } }
}
function returned(data = {}, appId = 'wx56e1cfc52aab630f') {
return { referrerInfo: { appId, extraData: { operationId: operation.operation_id, action: 'bind',
requestId: `joint-${operation.operation_id}`, code: 'OK', success: true, iccid, ...data } } }
}
test('only matching source, operation, action and business request can confirm', () => {
const { api } = load()
api.rememberOperation(operation)
assert.equal(api.captureTrafficReturn(returned({}, 'wrong')), false)
for (const invalid of [{ operationId: 'old' }, { action: 'unbind' }, { requestId: 'wrong' }, { success: 'true' }, { iccid: 'short' }]) {
assert.equal(api.captureTrafficReturn(returned(invalid)), false)
}
assert.equal(api.captureTrafficReturn(returned()), true)
})
test('receipt survives network failure and account switch cannot consume it', async () => {
const context = load()
context.api.rememberOperation(operation)
context.api.captureTrafficReturn(returned())
context.request(async () => { throw new Error('network') })
await assert.rejects(context.api.flushTrafficReceipt(), /network/)
assert.ok(context.api.getPendingTraffic().receipt)
context.user(8)
assert.equal(context.api.getPendingTraffic(), null)
assert.equal(context.api.captureTrafficReturn(returned()), false)
context.user(7)
context.request(async () => ({ ...operation, status: 'completed' }))
await context.api.flushTrafficReceipt()
assert.equal(context.api.getPendingTraffic().receipt, undefined)
})
test('only confirmed association opens query and recharge, unbinding closes them', () => {
const { api } = load()
assert.equal(api.trafficLinked(null), false)
assert.equal(api.trafficLinked({ bind: operation }), false)
assert.equal(api.trafficLinked({ bind: { ...operation, status: 'completed', traffic_status: 'client_confirmed' }, unbind: null }), false)
assert.equal(api.trafficLinked({ bind: { ...operation, status: 'completed', traffic_status: 'server_confirmed' }, unbind: null }), true)
assert.equal(api.trafficLinked({ bind: { ...operation, status: 'completed' }, unbind: { status: 'pending' } }), false)
})
test('jump uses trial for testing and release in production and never treats navigation as binding', async () => {
const context = load()
context.request(async () => ({ operation, target_app_id: 'wx56e1cfc52aab630f', handoff_token: 'secret', request_id: `joint-${operation.operation_id}`, source_binding_id: 'banban:test' }))
assert.equal(await context.api.jumpTraffic(operation), null)
assert.equal(context.jumps[0].envVersion, 'trial')
assert.equal(context.api.getPendingTraffic().operation.status, 'pending')
assert.ok(!JSON.stringify([...context.storage.values()]).includes('secret'))
context.version('release')
await context.api.jumpTraffic(operation)
assert.equal(context.jumps[1].envVersion, 'release')
assert.equal(context.timers.size, 0)
})
test('a late response after account change cannot start a jump or overwrite state', async () => {
const context = load()
context.request(async () => { context.user(8); return { operation } })
await assert.rejects(context.api.jumpTraffic(operation), /账号已变化/)
assert.equal(context.jumps.length, 0)
assert.equal(context.api.getPendingTraffic(), null)
})
test('rebind discards a stale receipt and starts a current operation once', async () => {
const context = load()
context.api.rememberOperation(operation)
context.api.captureTrafficReturn(returned())
const urls = []
context.request(async url => {
urls.push(url)
if (url.endsWith('/result')) throw Object.assign(new Error('stale'), { code: 'STALE_OPERATION' })
return { ...operation, operation_id: 'new-instance' }
})
const resumed = await context.api.resumeTrafficOperation(operation.device_id, 'bind')
assert.equal(resumed.operation_id, 'new-instance')
assert.equal(urls.length, 2)
assert.equal(context.api.getPendingTraffic().receipt, undefined)
})
test('stale cached operation recovers once; network failure keeps its receipt for retry', async () => {
const context = load()
context.api.rememberOperation(operation)
context.request(async url => {
if (url.endsWith(`${operation.operation_id}/check`)) throw Object.assign(new Error('stale'), { code: 'STALE_OPERATION' })
return { ...operation, operation_id: 'new-instance' }
})
assert.equal((await context.api.resumeTrafficOperation(operation.device_id, 'bind')).operation_id, 'new-instance')
context.api.rememberOperation(operation)
context.api.captureTrafficReturn(returned())
context.request(async () => { throw new Error('network') })
await assert.rejects(context.api.resumeTrafficOperation(operation.device_id, 'bind'), /network/)
assert.ok(context.api.getPendingTraffic().receipt)
})
test('missing navigation callback cannot keep the management button locked forever', async () => {
const context = load()
const pending = context.api.openTrafficPage(operation.device_id, 'unbind')
const [timer, duration] = [...context.timers][0]
assert.equal(duration, 15000)
timer()
await assert.rejects(pending, /跳转等待已结束/)
assert.equal(context.timers.size, 0)
})
test('server-confirmed release proceeds locally without a redundant 4G jump', async () => {
const context = load()
const released = { ...operation, action: 'unbind', traffic_status: 'server_confirmed' }
context.request(async () => ({ operation: released }))
assert.deepEqual(await context.api.jumpTraffic(released), released)
assert.equal(context.jumps.length, 0)
})
test('a legacy client receipt alone cannot skip the 4G result check', async () => {
const context = load()
const pending = { ...operation, action: 'unbind', traffic_status: 'client_confirmed' }
context.request(async () => ({ operation: pending, target_app_id: 'wx56e1cfc52aab630f', iccid,
request_id: `joint-${operation.operation_id}`, source_binding_id: 'banban:test' }))
assert.equal(await context.api.jumpTraffic(pending), null)
assert.equal(context.jumps.length, 1)
})
test('management refresh checks the original relation before showing traffic entrances', async () => {
const context = load()
const calls = []
context.request(async url => {
calls.push(url)
return url.endsWith('/check') ? { ...operation, status: 'pending', traffic_status: 'pending' }
: { device_id: operation.device_id, bind: { ...operation, status: 'completed', traffic_status: 'server_confirmed' }, unbind: null }
})
const overview = await context.api.getTrafficOverview(operation.device_id)
assert.equal(calls.length, 2)
assert.equal(context.api.trafficLinked(overview), false)
})