import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import { parse } from '@vue/compiler-sfc' const sourcePath = 'src/views/asset-management/asset-information/composables/useAssetInfo.ts' const compiledSource = ts.transpileModule(readFileSync(sourcePath, 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const response = (data) => ({ code: 0, data }) const deferred = () => { let resolve, reject const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise reject = rejectPromise }) return { promise, resolve, reject } } const boundCards = () => [ { card_id: 11, iccid: 'CARD-1', msisdn: '13800000001', carrier_name: '运营商账户', carrier_type: 'CTCC', slot_position: 1, is_current: true, network_status: 1, real_name_status: 1, real_name_at: '2026-10-01T01:00:00Z', realname_policy: 'before_order', gateway_extend: 'old reason' }, { card_id: 12, iccid: 'CARD-2', msisdn: '13800000002', carrier_name: '另一运营商账户', carrier_type: 'CMCC', slot_position: 2, is_current: false, network_status: 0, real_name_status: 0, real_name_at: null } ] const detail = (identifier = 'DEVICE-A', cards = boundCards()) => ({ asset_type: 'device', asset_id: identifier === 'DEVICE-A' ? 101 : 202, virtual_no: identifier, bound_card_count: cards.length, cards, real_total_mb: 1024, real_used_mb: 100 }) function harness(t, overrides = {}) { const user = vue.reactive({ info: { id: 1, user_type: 1 }, isLogin: true }) const messages = [] const api = { resolveAsset: async (identifier) => response(detail(identifier)), getRealtimeStatus: async () => response(null), getCurrentPackage: async () => response(null), getAssetWallet: async () => response(null), getAssetPackages: async () => response({ items: [], total: 0 }), refreshAsset: async () => response(null), ...overrides } const imports = { vue, 'element-plus': { ElMessage: Object.fromEntries( ['error', 'success', 'warning'].map((type) => [ type, (message) => messages.push({ type, message }) ]) ) }, '@/api/modules': { AssetService: api }, '@/utils/business/apiRateLimit': { FrontendRateLimitError: class extends Error {} }, '@/store/modules/user': { useUserStore: () => user } } const module = { exports: {} } new Function('require', 'module', 'exports', compiledSource)( (name) => { assert.ok(imports[name], `Unexpected runtime dependency: ${name}`) return imports[name] }, module, module.exports ) const scope = vue.effectScope() const state = scope.run(() => module.exports.useAssetInfo()) t.after(() => scope.stop()) return { state, api, user, messages, scope } } test('partial realtime responses retain the complete resolved binding-card roster', async (t) => { const { state } = harness(t, { getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-1', network_status: 0 }] }) }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual( state.cardInfo.value.cards.map((card) => card.iccid), ['CARD-1', 'CARD-2'] ) assert.equal(state.cardInfo.value.cards[0].network_status, 0) assert.equal(state.cardInfo.value.cards[1].msisdn, '13800000002') }) test('realtime fields cannot erase resolved card identity, carrier, slot, policy, or real-name time', async (t) => { const { state } = harness(t, { getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [ { iccid: 'CARD-1', card_id: 0, msisdn: '', carrier_name: '', carrier_type: '', slot_position: 0, realname_policy: '', real_name_at: 'untrusted realtime time', network_status: 0, real_name_status: 0, is_current: false, gateway_extend: '' } ] }) }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual(state.cardInfo.value.cards[0], { ...boundCards()[0], network_status: 0, real_name_status: 0, is_current: false, gateway_extend: '' }) }) test('empty or omitted realtime cards preserve the detail roster; detail reload can remove bindings', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') for (const cards of [[], undefined, null]) { api.getRealtimeStatus = async () => response({ asset_type: 'device', asset_id: 101, cards }) await state.loadRealtimeStatus('DEVICE-A', 'device') assert.equal(state.cardInfo.value.cards.length, 2) } api.resolveAsset = async () => response(detail('DEVICE-A', [])) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual(state.cardInfo.value.cards, []) }) test('unknown realtime cards do not introduce bindings absent from the authorized detail response', async (t) => { const { state } = harness(t, { getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'UNRESOLVED-CARD' }] }) }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual( state.cardInfo.value.cards.map((card) => card.iccid), ['CARD-1', 'CARD-2'] ) }) test('realtime card order does not change the resolved roster; explicit nullable timestamps can clear', async (t) => { const cards = boundCards() cards[0].last_data_check_at = 'old timestamp' const { state } = harness(t, { resolveAsset: async () => response(detail('DEVICE-A', cards)), getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [ { iccid: 'CARD-2' }, { iccid: 'CARD-1', last_data_check_at: null, real_name_status: null } ] }) }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual( state.cardInfo.value.cards.map((card) => card.iccid), ['CARD-1', 'CARD-2'] ) assert.equal(state.cardInfo.value.cards[0].last_data_check_at, null) assert.equal(state.cardInfo.value.cards[0].real_name_status, 1) }) test('a late resolve response cannot replace a newer search', async (t) => { const oldDetail = deferred() const { state } = harness(t, { resolveAsset: (identifier) => identifier === 'DEVICE-A' ? oldDetail.promise : Promise.resolve(response(detail(identifier))) }) const oldSearch = state.fetchAssetDetail('DEVICE-A') await state.fetchAssetDetail('DEVICE-B') oldDetail.resolve(response(detail('DEVICE-A'))) await oldSearch assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') }) test('late realtime responses cannot write old card data into a different asset', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') const oldRealtime = deferred() api.getRealtimeStatus = () => oldRealtime.promise const oldLoad = state.loadRealtimeStatus('DEVICE-A', 'device') api.getRealtimeStatus = async () => response(null) await state.fetchAssetDetail('DEVICE-B') oldRealtime.resolve( response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'OLD-CARD' }] }) ) await oldLoad assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') assert.deepEqual( state.cardInfo.value.cards.map((card) => card.iccid), ['CARD-1', 'CARD-2'] ) }) test('the newest same-asset realtime request wins and stale completion cannot end its loading state', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') const older = deferred() const newer = deferred() api.getRealtimeStatus = () => older.promise const oldLoad = state.loadRealtimeStatus('DEVICE-A', 'device') api.getRealtimeStatus = () => newer.promise const newLoad = state.loadRealtimeStatus('DEVICE-A', 'device') older.resolve( response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-1', network_status: 0 }] }) ) await oldLoad assert.equal(state.realtimeLoading.value, true) assert.equal(state.cardInfo.value.cards[0].network_status, 1) newer.resolve( response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-1', network_status: 1 }] }) ) await newLoad assert.equal(state.realtimeLoading.value, false) assert.equal(state.cardInfo.value.cards[0].network_status, 1) }) test('realtime responses identifying a different asset are ignored', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') for (const identity of [ { asset_type: 'device', asset_id: 202 }, { asset_type: 'card', asset_id: 101 } ]) { api.getRealtimeStatus = async () => response({ ...identity, cards: [{ iccid: 'CARD-1', network_status: 0 }] }) await state.loadRealtimeStatus('DEVICE-A', 'device') assert.equal(state.cardInfo.value.cards[0].network_status, 1) } }) test('starting a new search clears previous device, package, wallet, and binding data immediately', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') state.deviceRealtime.value = { current_iccid: 'CARD-1' } state.currentPackage.value = { package_name: 'old package' } state.packageList.value = [{ id: 1 }] state.walletInfo.value = { total_balance: 123 } const newDetail = deferred() api.resolveAsset = () => newDetail.promise const nextSearch = state.fetchAssetDetail('DEVICE-B') assert.equal(state.cardInfo.value, null) assert.equal(state.deviceRealtime.value, null) assert.equal(state.currentPackage.value, null) assert.equal(state.walletInfo.value, null) assert.deepEqual(state.packageList.value, []) newDetail.resolve({ code: 403, msg: 'forbidden' }) await nextSearch assert.equal(state.cardInfo.value, null) }) test('changing account invalidates pending requests even when searching the same identifier', async (t) => { const oldDetail = deferred() const { state, user, api } = harness(t, { resolveAsset: () => oldDetail.promise }) const oldSearch = state.fetchAssetDetail('DEVICE-A') user.info = { id: 2, user_type: 3 } api.resolveAsset = async () => response(detail('DEVICE-A', [boundCards()[1]])) await state.fetchAssetDetail('DEVICE-A') oldDetail.resolve(response(detail('DEVICE-A'))) await oldSearch assert.deepEqual( state.cardInfo.value.cards.map((card) => card.iccid), ['CARD-2'] ) }) test('logout clears visible data and invalidates pending realtime responses', async (t) => { const { state, user, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') const pending = deferred() api.getRealtimeStatus = () => pending.promise const load = state.loadRealtimeStatus('DEVICE-A', 'device') user.isLogin = false assert.equal(state.cardInfo.value, null) pending.resolve(response({ asset_type: 'device', asset_id: 101, cards: boundCards() })) await load assert.equal(state.cardInfo.value, null) }) test('a late failure of an old search does not clear the current successful asset', async (t) => { const oldDetail = deferred() const { state } = harness(t, { resolveAsset: (identifier) => identifier === 'DEVICE-A' ? oldDetail.promise : Promise.resolve(response(detail(identifier))) }) const oldSearch = state.fetchAssetDetail('DEVICE-A') await state.fetchAssetDetail('DEVICE-B') oldDetail.reject(new Error('old request failed')) await oldSearch assert.equal(state.cardInfo.value?.identifier, 'DEVICE-B') }) test('account types 1/2/3/4 retain the same resolved bindings despite sparse realtime fields', async (t) => { for (const userType of [1, 2, 3, 4]) { const { state, user } = harness(t, { getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-1', carrier_name: '' }] }) }) user.info = { id: userType, user_type: userType } await state.fetchAssetDetail('DEVICE-A') assert.deepEqual(state.cardInfo.value.cards, boundCards()) } }) test('a current-card change clears the previous current flag even for a partial realtime list', async (t) => { const { state } = harness(t, { getRealtimeStatus: async () => response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-2', is_current: true }] }) }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual( state.cardInfo.value.cards.map((card) => card.is_current), [false, true] ) }) test('same-identifier re-search never takes a real-name timestamp from a previous search or realtime', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') api.resolveAsset = async () => response(detail('DEVICE-A', [{ ...boundCards()[0], real_name_at: null }])) api.getRealtimeStatus = async () => response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'CARD-1', real_name_at: 'stale time' }] }) await state.fetchAssetDetail('DEVICE-A') assert.equal(state.cardInfo.value.cards[0].real_name_at, null) }) test('manual refresh resolves new binding relations and preserves the existing package page', async (t) => { let resolveCalls = 0 const { state } = harness(t, { resolveAsset: async () => response(detail('DEVICE-A', ++resolveCalls === 1 ? boundCards() : [])) }) await state.fetchAssetDetail('DEVICE-A') state.packageList.value = [{ id: 88, children: [{ id: 89 }] }] assert.equal(await state.refreshAsset('DEVICE-A', 'device'), true) assert.equal(resolveCalls, 2) assert.deepEqual(state.cardInfo.value.cards, []) assert.deepEqual(state.packageList.value, [{ id: 88, children: [{ id: 89 }] }]) }) test('a pending refresh does not re-search or overwrite a newer asset', async (t) => { const pending = deferred() const calls = [] const { state } = harness(t, { refreshAsset: () => pending.promise, resolveAsset: async (identifier) => { calls.push(identifier) return response(detail(identifier)) } }) await state.fetchAssetDetail('DEVICE-A') const refresh = state.refreshAsset('DEVICE-A', 'device') await state.fetchAssetDetail('DEVICE-B') pending.resolve(response(null)) assert.equal(await refresh, false) assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') assert.deepEqual(calls, ['DEVICE-A', 'DEVICE-B']) }) test('late current-package, wallet, and package-list responses do not contaminate a new asset', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') const pending = { package: deferred(), wallet: deferred(), list: deferred() } api.getCurrentPackage = () => pending.package.promise api.getAssetWallet = () => pending.wallet.promise api.getAssetPackages = () => pending.list.promise const oldLoads = [ state.loadCurrentPackage('DEVICE-A'), state.loadAssetWallet('DEVICE-A'), state.loadPackageList('DEVICE-A') ] api.getCurrentPackage = async () => response(null) api.getAssetWallet = async () => response(null) await state.fetchAssetDetail('DEVICE-B') pending.package.resolve(response({ package_name: 'old package' })) pending.wallet.resolve(response({ total_balance: 123 })) pending.list.resolve(response({ items: [{ id: 1 }], total: 1 })) const results = await Promise.all(oldLoads) assert.equal(state.currentPackage.value, null) assert.equal(state.walletInfo.value, null) assert.deepEqual(state.packageList.value, []) assert.equal(results[2], null) }) test('a newer same-asset package page wins even if the older page returns last', async (t) => { const { state, api } = harness(t) await state.fetchAssetDetail('DEVICE-A') const older = deferred() api.getAssetPackages = () => older.promise const firstPage = state.loadPackageList('DEVICE-A', 1) api.getAssetPackages = async () => response({ items: [{ id: 2, children: [{ id: 3 }] }], total: 9, page: 2 }) await state.loadPackageList('DEVICE-A', 2) older.resolve(response({ items: [{ id: 1 }], total: 1, page: 1 })) assert.equal(await firstPage, null) assert.deepEqual(state.packageList.value, [{ id: 2, children: [{ id: 3 }] }]) }) test('unmount invalidates outstanding resolve requests', async (t) => { const pending = deferred() const { state, scope } = harness(t, { resolveAsset: () => pending.promise }) const search = state.fetchAssetDetail('DEVICE-A') scope.stop() pending.resolve(response(detail('DEVICE-A'))) assert.equal(await search, null) assert.equal(state.cardInfo.value, null) }) test('failed realtime loading preserves resolved bindings and a sparse identified request can update status', async (t) => { t.mock.method(console, 'error', () => {}) const { state, api } = harness(t, { getRealtimeStatus: async () => { throw new Error('realtime unavailable') } }) await state.fetchAssetDetail('DEVICE-A') assert.deepEqual(state.cardInfo.value.cards, boundCards()) assert.equal(state.realtimeLoading.value, false) api.getRealtimeStatus = async () => response({ cards: [{ iccid: 'CARD-1', network_status: 0 }] }) await state.loadRealtimeStatus('DEVICE-A', 'device') assert.equal(state.cardInfo.value.cards[0].network_status, 0) assert.equal(state.cardInfo.value.cards.length, 2) }) test('updating user metadata without changing the account or data scope does not clear asset data', async (t) => { const { state, user } = harness(t) await state.fetchAssetDetail('DEVICE-A') const asset = state.cardInfo.value user.info = { ...user.info, username: 'updated display name' } await vue.nextTick() assert.equal(state.cardInfo.value, asset) }) test('a disposed asset composable cannot restart loads after an operation finishes late', async (t) => { let calls = 0 const { state, scope } = harness(t, { resolveAsset: async () => { calls += 1 return response(detail('DEVICE-A')) } }) scope.stop() assert.equal(await state.fetchAssetDetail('DEVICE-A'), null) assert.equal(calls, 0) }) test('standalone card realtime status still updates while the resolved gateway IMEI remains authoritative', async (t) => { const { state } = harness(t, { resolveAsset: async () => response({ ...detail('CARD-1', []), asset_type: 'card', iccid: 'CARD-1', gateway_card_imei: 'resolved-imei' }), getRealtimeStatus: async () => response({ asset_type: 'card', asset_id: 202, network_status: 0, real_name_status: 1, gateway_card_imei: 'realtime-imei' }) }) await state.fetchAssetDetail('CARD-1') assert.equal(state.cardInfo.value.network_status, 0) assert.equal(state.cardInfo.value.real_name_status, 1) assert.equal(state.cardInfo.value.gateway_card_imei, 'resolved-imei') }) function pageHandlers(state) { const { descriptor } = parse( readFileSync('src/views/asset-management/asset-information/index.vue', 'utf8') ) const source = ts.createSourceFile( 'asset-information.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) const wanted = new Set([ 'handleSearch', 'handleRefresh', 'showUpdateRealnameStatusDialog', 'handleUpdateRealnameStatusSuccess', 'handleUpdateBindingCardRealnameStatus', 'handleBindingCardRealnameStatusSuccess' ]) const declarations = [] function visit(node) { if (ts.isVariableDeclaration(node) && wanted.has(node.name.getText(source))) declarations.push(node.getText(source)) ts.forEachChild(node, visit) } visit(source) assert.equal(declarations.length, wanted.size) const bindings = { useUserStore: () => ({ info: { user_type: 1 } }), canEditRestrictedAsset: (type) => [1, 2, 3].includes(Number(type)), cardInfo: state.cardInfo, fetchAssetDetail: state.fetchAssetDetail, loadPackageList: state.loadPackageList, refreshAsset: state.refreshAsset, refreshSubmitting: vue.ref(false), refreshDisabled: vue.ref(false), updateRateLimitNow: () => {}, assetSearchCardRef: vue.ref(null), startFailureMessage: vue.ref(''), packagePagination: vue.ref({ page: 1, total: 0, pageSize: 5, status: undefined }), walletTransactionSearchToken: vue.ref(0), pollingEnabled: vue.ref(false), updateRealnameStatusDialogVisible: vue.ref(false), bindingCardRealnameStatusDialogVisible: vue.ref(false), bindingCardRealnameStatusIccid: vue.ref(''), bindingCardRealnameStatusValue: vue.ref(0), nextTick: vue.nextTick } const compiled = ts.transpileModule( `let searchRequestId = 0, pollingInitializing = false, realnameStatusAsset = null, bindingCardRealnameStatusAsset = null\n${declarations.map((declaration) => `const ${declaration}`).join('\n')}\nreturn { ${[...wanted].join(', ')} }`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } } ).outputText return { ...new Function(...Object.keys(bindings), compiled)(...Object.values(bindings)), ...bindings } } test('page search resolves a device only once and uses its canonical identifier for related requests', async (t) => { const calls = [] const { state } = harness(t, { resolveAsset: async (identifier) => { calls.push(['resolve', identifier]) return response(detail('DEVICE-A')) }, getRealtimeStatus: async (identifier) => { calls.push(['realtime', identifier]) return response(null) }, getAssetPackages: async (identifier) => { calls.push(['packages', identifier]) return response({ items: [], total: 0 }) } }) await pageHandlers(state).handleSearch({ identifier: 'IMEI-ALIAS' }) assert.deepEqual(calls, [ ['resolve', 'IMEI-ALIAS'], ['realtime', 'DEVICE-A'], ['packages', 'DEVICE-A'] ]) }) test('active gateway synchronization is called only by the sync button, never by searches or data reloads', async (t) => { const syncCalls = [] const { state } = harness(t, { refreshAsset: async (identifier) => { syncCalls.push(identifier) return response(null) } }) const page = pageHandlers(state) await page.handleRefresh() await page.handleSearch({ identifier: 'DEVICE-A' }) await page.handleSearch({ identifier: 'DEVICE-A' }) await state.loadRealtimeStatus('DEVICE-A', 'device') page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) await page.handleBindingCardRealnameStatusSuccess() page.showUpdateRealnameStatusDialog() await page.handleUpdateRealnameStatusSuccess() assert.deepEqual(syncCalls, []) await page.handleRefresh() assert.deepEqual(syncCalls, ['DEVICE-A']) await state.fetchAssetDetail('DEVICE-A') page.refreshDisabled.value = true await page.handleRefresh() assert.deepEqual(syncCalls, ['DEVICE-A']) }) test('a stale page search cannot start additional loads or reset the newer search pagination', async (t) => { const pending = deferred() const { state } = harness(t, { resolveAsset: (identifier) => identifier === 'DEVICE-A' ? pending.promise : Promise.resolve(response(detail(identifier))), getAssetPackages: async () => response({ items: [{ id: 2 }], total: 22 }) }) const page = pageHandlers(state) const older = page.handleSearch({ identifier: 'DEVICE-A' }) await page.handleSearch({ identifier: 'DEVICE-B' }) pending.resolve(response(detail('DEVICE-A'))) await older assert.equal(page.packagePagination.value.total, 22) assert.equal(page.walletTransactionSearchToken.value, 1) assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') }) test('successful bound-card real-name update reloads detail fields but an old dialog cannot reload a new asset', async (t) => { let resolveCalls = 0 const { state, api } = harness(t, { resolveAsset: async (identifier) => { resolveCalls += 1 return response(detail(identifier)) } }) await state.fetchAssetDetail('DEVICE-A') const page = pageHandlers(state) page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) api.resolveAsset = async () => { resolveCalls += 1 return response( detail('DEVICE-A', [{ ...boundCards()[0], real_name_status: 0, real_name_at: null }]) ) } await page.handleBindingCardRealnameStatusSuccess() assert.equal(state.cardInfo.value.cards[0].real_name_status, 0) assert.equal(state.cardInfo.value.cards[0].real_name_at, null) assert.equal(resolveCalls, 2) api.resolveAsset = async (identifier) => { resolveCalls += 1 return response(detail(identifier)) } page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) await state.fetchAssetDetail('DEVICE-B') await page.handleBindingCardRealnameStatusSuccess() assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') assert.equal(resolveCalls, 3) })