153 lines
8.2 KiB
JavaScript
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)
|
|
})
|