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 { compileScript, parse } from '@vue/compiler-sfc' function evaluate(source, imports = {}) { const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)( (name) => { assert.ok(imports[name], `Unexpected dependency: ${name}`) return imports[name] }, module, module.exports ) return module.exports } const source = (path) => readFileSync(path, 'utf8') const format = evaluate(source('src/utils/business/format.ts')) const utils = evaluate(source('src/utils/business/gateway.ts'), { './format': format }) const constants = evaluate(source('src/config/constants/gateway.ts')) const permissions = constants.GATEWAY_PERMISSIONS const rule = { category: 'business', keyword: 'failed', match_type: 'contains', priority: 10, status: 1, operations: ['stop'] } const group = { group_code: 'default', group_name: '默认组', max_qps: 20, reserved_qps: 5, operations: ['stop', 'start'], high_priority_operations: ['stop'], status: 1 } const option = (value) => ({ value, label: `动态-${value}` }) const options = { error_categories: ['business', 'terminal'].map(option), exempt_sources: ['manual'].map(option), gateway_operations: ['stop', 'start', 'query_flow', 'query_status', 'query_realname'].map(option), match_types: ['contains', 'prefix', 'regex'].map(option), rate_limit_modes: ['default'].map(option), stop_resume_actions: ['stop', 'start'].map(option), stop_resume_results: ['success', 'failed', 'unknown', 'already_target', 'skipped'].map(option) } const success = (data) => ({ code: 0, data, msg: 'ok' }) const page = (items) => success({ items, total: items.length, page: 1, size: 20 }) const deferred = () => { let resolve const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } async function flush() { await new Promise((done) => setImmediate(done)) await vue.nextTick() } test('all 17 gateway service contracts preserve methods, paths, params and response', async () => { const calls = [] const response = success({ marker: true }) const http = Object.fromEntries( ['get', 'post', 'put', 'del'].map((method) => [ method, (args) => { calls.push({ method, ...args }) return Promise.resolve(response) } ]) ) const base = evaluate(source('src/api/BaseService.ts'), { '@/utils/http': { default: http } }) const { GatewayService: service } = evaluate(source('src/api/modules/gateway.ts'), { '../BaseService': base }) const recordsQuery = { page: 2, page_size: 100, carrier_id: 0, start_time: '2026-10-01T00:00:00Z', end_time: '2026-10-24T00:00:00Z' } const contracts = [ ['getRecords', [recordsQuery], 'get', '/card-stop-resume-records', 'params', recordsQuery], [ 'getCardRecords', [42, { page: 1 }], 'get', '/iot-cards/42/stop-resume-records', 'params', { page: 1 } ], ['getRules', [{ status: 0 }], 'get', '/gateway-error-classifications', 'params', { status: 0 }], ['createRule', [rule], 'post', '/gateway-error-classifications', 'data', rule], ['updateRule', [7, rule], 'put', '/gateway-error-classifications/7', 'data', rule], [ 'setRuleStatus', [7, 0], 'put', '/gateway-error-classifications/7/status', 'data', { status: 0 } ], ['deleteRule', [7], 'del', '/gateway-error-classifications/7', 'params', undefined], [ 'testRule', [{ operation: 'stop', message: 'failed', draft: rule }], 'post', '/gateway-error-classifications/test', 'data', { operation: 'stop', message: 'failed', draft: rule } ], ['getOptions', [], 'get', '/polling-options', 'params', undefined], [ 'getGroups', [{ only_global: false, carrier_id: 0, status: 0 }], 'get', '/polling-rate-limit-groups', 'params', { only_global: false, carrier_id: 0, status: 0 } ], ['createGroup', [group], 'post', '/polling-rate-limit-groups', 'data', group], ['getGroup', [4], 'get', '/polling-rate-limit-groups/4', 'params', undefined], ['updateGroup', [4, group], 'put', '/polling-rate-limit-groups/4', 'data', group], ['setGroupStatus', [4, 1], 'put', '/polling-rate-limit-groups/4/status', 'data', { status: 1 }], ['deleteGroup', [4], 'del', '/polling-rate-limit-groups/4', 'params', undefined], ['getCarrierStats', [60], 'get', '/polling-stats/carriers', 'params', { minutes: 60 }], [ 'getCarrierTrend', [0, { minutes: 10080, bucket_minutes: 60 }], 'get', '/polling-stats/carriers/0/trend', 'params', { minutes: 10080, bucket_minutes: 60 } ] ] for (const [method, args, verb, url, field, payload] of contracts) { assert.equal(await service[method](...args), response) assert.deepEqual(calls.at(-1), { method: verb, url: `/api/admin${url}`, [field]: payload }) } assert.equal(calls.length, 17) }) test('record dates are RFC3339, ordered and bounded to 31 days, optional only for cards', () => { assert.deepEqual(utils.gatewayTimeRange(['2026-10-01T08:00:00+08:00', '2026-11-01T00:00:00Z']), { start_time: '2026-10-01T00:00:00.000Z', end_time: '2026-11-01T00:00:00.000Z' }) assert.deepEqual(utils.gatewayTimeRange(null, false), {}) for (const range of [ null, [], [''], ['invalid', 'invalid'], ['2026-10-02', '2026-10-01'], ['2026-10-01', '2026-11-01T00:00:00.001Z'] ]) assert.throws(() => utils.gatewayTimeRange(range)) const [start, end] = utils.recentGatewayRange() assert.equal(end - start, 7 * 86400000) }) test('limit groups validate QPS, operation subset, carrier zero and text limits', () => { assert.doesNotThrow(() => utils.validateGatewayGroup({ ...group, carrier_id: 0 })) assert.doesNotThrow(() => utils.validateGatewayGroup({ ...group, reserved_qps: 0, high_priority_operations: [] }) ) for (const patch of [ { max_qps: 0 }, { max_qps: 10001 }, { max_qps: 1.5 }, { reserved_qps: -1 }, { reserved_qps: 20 }, { reserved_qps: 0.5 }, { operations: [] }, { high_priority_operations: [] }, { high_priority_operations: ['query_flow'] }, { group_code: ' ' }, { group_name: 'x'.repeat(101) }, { carrier_id: -1 } ]) assert.throws(() => utils.validateGatewayGroup({ ...group, ...patch })) }) test('rules validate required fields, priority and lengths, including draft rules', () => { assert.doesNotThrow(() => utils.validateGatewayRule(rule)) for (const patch of [ { keyword: ' ' }, { keyword: 'x'.repeat(201) }, { category: '' }, { match_type: '' }, { priority: 0 }, { priority: 10001 }, { priority: 1.2 }, { remark: 'x'.repeat(501) } ]) assert.throws(() => utils.validateGatewayRule({ ...rule, ...patch })) }) test('unknown enums and statistic fields remain visible, without fabricated zeroes', () => { assert.equal(utils.gatewayLabel(options.gateway_operations, 'stop'), '动态-stop') assert.equal( utils.gatewayLabel(options.gateway_operations, 'future_operation'), 'future_operation' ) assert.equal(utils.gatewayValue(0), '0') assert.equal(utils.gatewayValue(false), 'false') assert.equal(utils.gatewayValue(null), '-') assert.deepEqual(utils.gatewayRows([{ future_metric: 12 }, 0, null]), [ { future_metric: 12 }, { value: 0 }, { value: null } ]) assert.deepEqual( utils.compactGatewayQuery({ carrier_id: 0, status: 0, only_global: false, blank: '', missing: undefined }), { carrier_id: 0, status: 0, only_global: false } ) assert.throws(() => utils.gatewayData({ code: 403, msg: '没有权限' }), /没有权限/) }) function mountComponent( t, path, { superAdmin = false, perms = [], overrides = {}, props = {} } = {} ) { const calls = [] const user = vue.reactive({ isSuperAdmin: superAdmin, perms }) const service = Object.fromEntries( ['getRecords', 'getCardRecords', 'getRules', 'getGroups'].map((name) => [ name, async (...args) => { calls.push({ name, args }) return page([]) } ]) ) Object.assign(service, { getOptions: async () => success(options) }) for (const name of [ 'createRule', 'updateRule', 'setRuleStatus', 'deleteRule', 'testRule', 'createGroup', 'updateGroup', 'setGroupStatus', 'deleteGroup', 'getGroup', 'getCarrierStats', 'getCarrierTrend' ]) service[name] = async (...args) => { calls.push({ name, args }) return success({ ...group, id: 4, matched: true, rule_id: 0, category: 'business', category_name: '业务错误', keyword: 'failed', maybe_accepted: false, carriers: [], points: [], carrier_id: args[0] }) } for (const [name, handler] of Object.entries(overrides)) service[name] = async (...args) => { calls.push({ name, args }) return handler(...args) } const imports = { vue, 'vue-router': { useRouter: () => ({ push: async (url) => { calls.push({ name: 'navigate', args: [url] }) }, back() {} }), useRoute: () => ({ params: { id: '4' } }) }, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, '@element-plus/icons-vue': { ArrowLeft: {} }, '@/router/routesAlias': { RoutesAlias: { GatewayRateLimitGroupDetail: '/polling-management/rate-limit-groups/detail' } }, '@/utils/business/format': format, '@/api/modules/carrier': { CarrierService: { getCarriers: async (...args) => { calls.push({ name: 'getCarriers', args }) if (overrides.getCarriers) return overrides.getCarriers(...args) return page([{ id: 8, carrier_name: '模拟运营商' }]) } } }, 'element-plus': { ElMessage: { success() {}, error() {} }, ElMessageBox: { confirm: async () => {} } }, '@/api/modules/gateway': { GatewayService: service }, '@/store/modules/user': { useUserStore: () => user }, '@/composables/useAuth': { useAuth: () => ({ hasAuth: (perm) => user.isSuperAdmin || user.perms.includes(perm) }) }, '@/config/constants/gateway': constants, '@/utils/business/gateway': utils, '@/components/business/gateway/GatewayOptionsNotice.vue': { default: {} }, '@/components/business/gateway/GatewayRuleFields.vue': { default: {} }, '@/components/business/gateway/GatewayDynamicData.vue': { default: {} }, './GatewayOptionsNotice.vue': { default: {} } } imports['@/composables/useGateway'] = evaluate(source('src/composables/useGateway.ts'), imports) const { descriptor } = parse(source(path)) const component = evaluate( compileScript(descriptor, { id: 'gateway-test' }).content, imports ).default let state const reactiveProps = vue.reactive(props) const renderer = vue.createRenderer({ createComment: () => ({}), insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null }) const app = renderer.createApp({ setup() { state = component.setup(reactiveProps, { expose() {}, emit() {} }) return () => null } }) app.mount({}) t.after(() => app.unmount()) return { state, calls, user, props: reactiveProps, service } } const rulePage = 'src/views/polling-management/error-classifications/index.vue' const groupPage = 'src/views/polling-management/rate-limit-groups/index.vue' const statsPage = 'src/views/polling-management/gateway-stats/index.vue' const recordComponent = 'src/components/business/gateway/StopResumeRecords.vue' test('ordinary users cannot maintain either rules or groups, even if handlers are called', async (t) => { for (const [path, area, save, mutate, input] of [ [rulePage, 'rules', 'saveRule', 'changeRule', rule], [groupPage, 'groups', 'saveGroup', 'changeGroup', group] ]) { const { state, calls } = mountComponent(t, path, { perms: [permissions[area].page] }) await flush() state.form.value = input await state[save]() await state[mutate]({ ...input, id: 1 }, true) assert.equal( calls.filter((call) => /create|update|delete|set.*Status/.test(call.name)).length, 0 ) assert.match(state.editorError.value, /无权/) } }) test('superadmin saves use write DTOs without record ids or timestamps', async (t) => { for (const [path, save, input, method] of [ [rulePage, 'saveRule', rule, 'createRule'], [groupPage, 'saveGroup', group, 'createGroup'] ]) { const { state, calls } = mountComponent(t, path, { superAdmin: true }) await flush() state.form.value = { ...input } await state[save]() const call = calls.find((call) => call.name === method) assert.ok(call) assert.equal(call.args[0].id, undefined) if (method === 'createGroup') assert.equal(call.args[0].carrier_id, null) assert.equal(state.editorError.value, '') } }) test('ordinary rule tests require page + button, validate drafts, and never write rules', async (t) => { const { state, calls, user } = mountComponent(t, rulePage, { perms: [permissions.rules.page, permissions.rules.test] }) await flush() state.openTest() state.testForm.operation = 'stop' state.testForm.message = 'failed' state.includeDraft.value = true await state.runTest() assert.equal(calls.filter((call) => call.name === 'testRule').length, 0) state.draft.value = { ...rule } await state.runTest() assert.deepEqual(calls.find((call) => call.name === 'testRule').args[0], { operation: 'stop', message: 'failed', draft: rule }) assert.equal(state.testResult.value.matched, true) user.perms = [permissions.rules.test] await state.runTest() assert.match(state.testError.value, /无权/) assert.equal(calls.filter((call) => call.name === 'testRule').length, 1) }) test('test close/reopen discards a pending result', async (t) => { const pending = deferred() const { state } = mountComponent(t, rulePage, { superAdmin: true, overrides: { testRule: () => pending.promise } }) await flush() state.openTest() Object.assign(state.testForm, { operation: 'stop', message: 'failed' }) const running = state.runTest() state.testVisible.value = false await vue.nextTick() state.openTest() pending.resolve(success({ matched: true })) await running assert.equal(state.testResult.value, null) }) test('enum failure is visible, blocks editing and can be retried with dynamic labels', async (t) => { let failed = true const { state } = mountComponent(t, rulePage, { superAdmin: true, overrides: { getOptions: () => (failed ? Promise.reject(new Error('枚举不可用')) : success(options)) } }) await flush() assert.equal(state.optionsReady.value, false) assert.match(state.optionsError.value, /枚举不可用/) state.openEditor() assert.equal(state.editorVisible.value, false) failed = false await state.loadOptions() assert.equal(state.optionsReady.value, true) assert.equal(state.label('gateway_operations', 'stop'), '动态-stop') }) test('record queries validate time, preserve zero filters and isolate cards by real ID', async (t) => { const { state, calls, props } = mountComponent(t, recordComponent, { perms: [permissions.cardHistory], props: { cardId: 42, permission: permissions.cardHistory } }) await flush() assert.equal(calls[0].name, 'getCardRecords') assert.equal(calls[0].args[0], 42) state.range.value = null await state.search() assert.deepEqual(calls.at(-1).args, [42, { page: 1, page_size: 20 }]) props.cardId = 43 await flush() assert.equal(calls.at(-1).args[0], 43) props.cardId = undefined props.permission = permissions.records.page await flush() assert.match(state.error.value, /无权/) }) test('invalid global date ranges never request records; zero carrier survives', async (t) => { const { state, calls } = mountComponent(t, recordComponent, { perms: [permissions.records.page], props: { permission: permissions.records.page } }) await flush() calls.length = 0 state.range.value = null await state.search() assert.equal(calls.length, 0) state.range.value = [new Date('2026-10-01'), new Date('2026-10-24')] state.filters.carrier_id = 0 await state.search() assert.equal(calls[0].args[0].carrier_id, 0) }) test('list latest request wins and errors clear previous records', async (t) => { const first = deferred() const second = deferred() let count = 0 const { state } = mountComponent(t, groupPage, { superAdmin: true, overrides: { getGroups: () => (++count === 1 ? first.promise : second.promise) } }) const latest = state.load() second.resolve(page([{ ...group, id: 2 }])) await latest first.resolve(page([{ ...group, id: 1 }])) await flush() assert.equal(state.rows.value[0].id, 2) }) test('group details require permissions before navigation to shop-style detail page', async (t) => { const { state, calls, user } = mountComponent(t, groupPage, { perms: [permissions.groups.page] }) await flush() await state.openDetail(4) assert.equal( calls.some((call) => call.name === 'navigate'), false ) user.perms.push(permissions.groups.detail) await state.openDetail(4) assert.equal( calls.find((call) => call.name === 'navigate').args[0], '/polling-management/rate-limit-groups/detail/4' ) }) test('statistics support account 0, reject bad bounds and retain raw unknown metrics', async (t) => { const { state, calls } = mountComponent(t, statsPage, { perms: [permissions.stats.page, permissions.stats.trend], overrides: { getCarrierTrend: (id) => success({ carrier_id: id, points: [{ new_metric: 9, future_time: '2026-10-24' }] }) } }) await flush() state.carrierId.value = 0 await state.loadTrend() assert.equal(state.trend.value.carrier_id, 0) assert.equal(state.trend.value.points[0].new_metric, 9) state.trendMinutes.value = 10081 await state.loadTrend() assert.equal(calls.filter((call) => call.name === 'getCarrierTrend').length, 1) state.summaryMinutes.value = 61 await state.loadSummary() assert.equal(calls.filter((call) => call.name === 'getCarrierStats').length, 1) assert.equal(state.trend.value, null) }) test('route aliases match all four pages and ordinary permissions use colon codes', () => { const routes = source('src/router/routes/asyncRoutes.ts') const aliases = source('src/router/routesAlias.ts') for (const [name, segment] of [ ['GatewayStopResumeRecords', 'stop-resume-records'], ['GatewayErrorClassifications', 'error-classifications'], ['GatewayRateLimitGroups', 'rate-limit-groups'], ['GatewayCallStats', 'gateway-stats'] ]) { assert.ok(routes.includes(`name: '${name}'`)) assert.ok(aliases.includes(`'/polling-management/${segment}'`)) assert.ok(source(`src/views/polling-management/${segment}/index.vue`)) } const flattened = Object.values(permissions).flatMap((value) => typeof value === 'string' ? [value] : Object.values(value) ) assert.equal(flattened.length, 10) for (const code of flattened) assert.match(code, /^[a-z_]+:[a-z_]+$/) }) test('detail page checks permission and formats dates using the shop formatter', async (t) => { const path = 'src/views/polling-management/rate-limit-groups/detail.vue' const denied = mountComponent(t, path) await flush() assert.equal( denied.calls.some((call) => call.name === 'getGroup'), false ) const allowed = mountComponent(t, path, { perms: [permissions.groups.detailPage] }) await flush() assert.equal(allowed.state.detail.value.id, 4) assert.equal(allowed.state.formatDateTime('2026-10-24T10:00:00+08:00'), '2026-10-24 10:00:00') }) test('gateway search expand toggles follow the >=4 rule', () => { for (const path of [recordComponent, rulePage, groupPage, statsPage]) { const matches = [...source(path).matchAll(/:show-expand="([^"]+)"/g)] assert.ok(matches.length) for (const match of matches) assert.match(match[1], /\.length >= 4$/) } }) test('record time picker matches expiring assets estimated-expiry range configuration', async (t) => { const { descriptor } = parse(source('src/views/asset-management/expiring-assets/index.vue')) const script = ts.createSourceFile( 'expiring-assets.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) let reference function visit(node) { if ( ts.isObjectLiteralExpression(node) && node.properties.some( (property) => ts.isPropertyAssignment(property) && property.name.getText(script) === 'label' && ts.isStringLiteral(property.initializer) && property.initializer.text === '预计到期起止' ) ) { reference = evaluate(`export const item = ${node.getText(script)}`).item } ts.forEachChild(node, visit) } visit(script) assert.ok(reference) const { state } = mountComponent(t, recordComponent, { perms: [permissions.records.page], props: { permission: permissions.records.page } }) await flush() const item = state.searchItems.value.find((item) => item.prop === 'range') assert.equal(item.type, reference.type) assert.equal(item.elColSpan, reference.elColSpan) assert.deepEqual(item.config, reference.config) assert.equal(item.label, '记录时间') }) test('business timestamps match shop list formatting and IDs retain zero', () => { const timestamp = '2026-10-24T10:00:00+08:00' assert.equal(utils.gatewayDisplayValue(timestamp, 'created_at'), format.formatDateTime(timestamp)) assert.equal( utils.gatewayDisplayValue({ created_at: timestamp }), '{"created_at":"2026-10-24 10:00:00"}' ) assert.equal( utils.gatewayDisplayValue(1792807200, 'timestamp'), format.formatDateTime(1792807200) ) assert.equal(utils.gatewayDisplayValue(0, 'duration_ms'), '0') assert.equal(utils.gatewayOptionalId('0'), 0) assert.equal(utils.gatewayOptionalId(''), undefined) assert.throws(() => utils.gatewayOptionalId('-1')) }) test('carrier name search uses exact backend parameters and remembers edit labels', async (t) => { const { state, calls } = mountComponent(t, groupPage, { superAdmin: true }) await flush() await state.searchCarriers('运营商名称') assert.deepEqual(calls.at(-1), { name: 'getCarriers', args: [{ page: 1, page_size: 20, carrier_name: '运营商名称' }] }) state.openEditor({ ...group, id: 7, carrier_id: 99, carrier_name: '原账户' }) assert.equal(state.carrierOptions.value.find((option) => option.value === 99).label, '原账户') assert.equal(state.form.value.carrier_id, 99) assert.equal(state.searchItems.value[0].label, '运营商账户') assert.equal(state.searchItems.value[0].type, 'select') }) test('carrier search ignores a late old response and retains selected account labels', async (t) => { const older = deferred() const { state } = mountComponent(t, groupPage, { superAdmin: true, overrides: { getCarriers: ({ carrier_name }) => carrier_name === 'old' ? older.promise : page([{ id: 8, carrier_name: '新账户' }]) } }) await flush() const first = state.searchCarriers('old') await state.searchCarriers('new') state.selectCarrier(8) older.resolve(page([{ id: 9, carrier_name: '旧账户' }])) await first assert.equal( state.carrierOptions.value.some((option) => option.value === 9), false ) assert.equal(state.carrierOptions.value.find((option) => option.value === 8).label, '新账户') }) test('duplicate maintenance submits cannot send a second write while saving', async (t) => { const pending = deferred() const { state, calls } = mountComponent(t, rulePage, { superAdmin: true, overrides: { createRule: () => pending.promise } }) await flush() state.form.value = { ...rule } const first = state.saveRule() await state.saveRule() assert.equal(calls.filter((call) => call.name === 'createRule').length, 1) pending.resolve(success(rule)) await first }) test('single-card history entry rejects missing IDs, other assets and denied permissions', () => { const text = source('src/views/asset-management/asset-information/index.vue') const functionText = text.match(/ {2}function showStopResumeRecords[\s\S]*?\n {2}}/)[0] const { create } = evaluate( `export const create = (cardInfo, hasAuth, stopResumePermission, stopResumeTarget, stopResumeVisible) => { ${functionText}; return showStopResumeRecords }` ) const asset = vue.ref({ asset_type: 'device', asset_id: 100, cards: [{ card_id: 42, iccid: 'CARD' }] }) const target = vue.ref(null) const visible = vue.ref(false) let allowed = false const open = create(asset, () => allowed, vue.ref(permissions.deviceCardHistory), target, visible) open({ cardId: 42, iccid: 'CARD' }) assert.equal(visible.value, false) allowed = true open({ cardId: 100, iccid: 'CARD' }) assert.equal(visible.value, false) open({ cardId: 42, iccid: 'CARD' }) assert.equal(target.value.cardId, 42) assert.equal(visible.value, true) visible.value = false asset.value = { asset_type: 'card', asset_id: 7, iccid: 'CARD-7' } open({ cardId: 42, iccid: 'CARD' }) assert.equal(visible.value, false) open({ cardId: 7, iccid: 'CARD-7' }) assert.equal(target.value.cardId, 7) })