import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, existsSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' const source = (path) => readFileSync(path, 'utf8') function evaluate(text, imports = {}, globals = {}) { const compiled = ts.transpileModule(text, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)( (name) => { assert.ok(imports[name], `Unexpected dependency: ${name}`) return imports[name] }, module, module.exports, ...Object.values(globals) ) return module.exports } const gateway = evaluate(source('src/utils/business/gateway.ts'), { './format': evaluate(source('src/utils/business/format.ts')) }) const helpers = evaluate(source('src/utils/business/pollingScheduler.ts'), { './gateway': gateway }) const constants = evaluate(source('src/config/constants/pollingScheduler.ts')) const configHelpers = evaluate(source('src/utils/business/pollingConfig.ts'), { './gateway': gateway, '@/config/constants/pollingConfig': evaluate(source('src/config/constants/pollingConfig.ts')) }) const ok = (data) => ({ code: 0, data }) const flush = async () => { await new Promise((done) => setImmediate(done)) await vue.nextTick() } const batchPath = 'src/components/business/polling/PollingBatchDialog.vue' test('gateway metrics flatten into row arrays without old raw-data attribute overriding the table', () => { const path = 'src/components/business/gateway/GatewayOperationTable.vue' const { descriptor } = parse(source(path)) const definition = evaluate(compileScript(descriptor, { id: 'metric-table' }).content, { vue, '@/components/business/polling/SchedulerTable.vue': { default: {} }, '@/utils/business/gateway': gateway }).default const props = { values: [ { carrier_id: 8, carrier_name: 'carrier', operations: [ { operation: 'stop', operation_name: 'stop', success_rate: 0.8, p95_duration_ms: -1 } ] } ] } const state = definition.setup(props, { expose() {}, emit() {} }) assert.equal(state.rows.value.length, 1) assert.equal(state.format(state.rows.value[0], 'success_rate'), '80.00%') assert.equal(state.format(state.rows.value[0], 'p95_duration_ms'), '超过最大分桶') assert.equal(definition.inheritAttrs, false) assert.doesNotMatch( source('src/views/polling-management/gateway-stats/index.vue'), /]*:data=/s ) }) test('every one of the 46 documented added HTTP operations has a service method', async () => { const calls = [] class BaseService {} for (const [method, verb] of [ ['get', 'GET'], ['post', 'POST'], ['put', 'PUT'], ['delete', 'DELETE'] ]) BaseService[method] = (url) => { calls.push(`${verb} ${url}`) return ok({}) } for (const [file, name] of [ ['gateway', 'GatewayService'], ['pollingConfig', 'PollingConfigService'], ['pollingScheduler', 'PollingSchedulerService'] ]) { const service = evaluate(source(`src/api/modules/${file}.ts`), { '../BaseService': { BaseService } })[name] for (const key of Object.getOwnPropertyNames(service)) if (typeof service[key] === 'function' && !['length', 'name', 'prototype'].includes(key)) await service[key](1, {}, '') } const normalize = (value) => value .replace('/api/admin', '') .replace(/\{[^}]+\}/g, '{}') .replace(/\/1(?=\/|$)/g, '/{}') // 原始后端文档在本仓库被忽略,固定路径快照保证全新检出也可运行;本地有原文时额外校对。 const snapshot = JSON.parse(source('scripts/tests/fixtures/pollingSchedulerEndpoints.json')) const document = 'docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md' if (existsSync(document)) assert.deepEqual( [...source(document).matchAll(/^#### `(GET|POST|PUT|DELETE) ([^`]+)`/gm)].map( (match) => `${match[1]} ${match[2]}` ), snapshot ) const expected = snapshot.map(normalize) assert.equal(expected.length, 46) const actual = new Set(calls.map(normalize)) for (const endpoint of expected) assert.ok(actual.has(endpoint), endpoint) }) test('scheduler request rejects stale responses after reload, permission change, scope switch and unmount', async () => { const user = vue.reactive({ info: { id: 1, user_type: 2 }, isLogin: true }), permission = vue.ref(true), stops = [], deactivations = [], activations = [] const runtime = { ...vue, onBeforeUnmount: (fn) => stops.push(fn), onDeactivated: (fn) => deactivations.push(fn), onActivated: (fn) => activations.push(fn) } const { useSchedulerRequest } = evaluate(source('src/composables/useSchedulerRequest.ts'), { vue: runtime, '@/store/modules/user': { useUserStore: () => user }, '@/utils/business/gateway': gateway, '@/utils/business/pollingScheduler': helpers }) const pending = [] const scope = vue.effectScope() const state = scope.run(() => useSchedulerRequest( () => new Promise((resolve) => pending.push(resolve)), () => permission.value ) ) const a = state.load(), b = state.load() pending[1]('new') await b pending[0]('old') await a assert.equal(state.data.value, 'new') const c = state.load() user.info.id = 2 pending[2]('old account') await c assert.equal(state.data.value, null) permission.value = false await state.load() assert.equal(pending.length, 3) permission.value = true const d = state.load() stops.forEach((fn) => fn()) pending[3]('unmounted') await d assert.equal(state.data.value, null) scope.stop() }) function mount( path, { userType = 2, permissions = ['test'], props = {}, services = {}, storage = {}, confirm = async () => 'confirm' } = {} ) { const user = vue.reactive({ info: { id: 1, user_type: userType }, isLogin: true }) const events = [], calls = [], beforeUnmount = [], deactivated = [] const api = new Proxy(services, { get(target, key) { return async (...args) => { calls.push({ name: key, args }) return key in target ? target[key](...args) : ok({ enable: ['Enable impact from server'], disable: ['Disable impact from server'] }) } } }) const runtime = { ...vue, onMounted: () => {}, onBeforeUnmount: (fn) => beforeUnmount.push(fn), onDeactivated: (fn) => deactivated.push(fn) } const { descriptor } = parse(source(path)) const script = compileScript(descriptor, { id: 'scheduler-test' }).content const imports = { vue: runtime, 'element-plus': { ElMessageBox: { confirm } }, '@/api/modules/pollingScheduler': { PollingSchedulerService: api }, '@/api/modules/storage': { StorageService: storage }, '@/store/modules/user': { useUserStore: () => user }, '@/composables/useAuth': { useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) }) }, '@/utils/business/gateway': gateway, '@/utils/business/pollingScheduler': helpers, '@/config/constants/pollingScheduler': constants, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) } } const state = evaluate(script, imports, runtime).default.setup(props, { expose() {}, emit: (...args) => events.push(args) }) return { state, calls, user, events, dispose: () => beforeUnmount.forEach((fn) => fn()), deactivate: () => deactivated.forEach((fn) => fn()) } } test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => { const calls = [] class BaseService {} for (const method of ['get', 'put', 'post', 'delete']) BaseService[method] = (url, data) => { calls.push({ method, url, data }) return ok(data) } const { PollingSchedulerService: service } = evaluate( source('src/api/modules/pollingScheduler.ts'), { '../BaseService': { BaseService } } ) const toggle = { asset_ids: [1, 2], enable_polling: false, reason: 'test' }, input = { inflight_limit: null }, query = { page: 1, page_size: 20, status: 1 } const specs = [ ['getCarrierParams', [], 'get', '/polling-carrier-params'], ['getCarrierParam', [8], 'get', '/polling-carrier-params/8'], ['saveCarrierParam', [8, input], 'put', '/polling-carrier-params/8', input], ['resetCarrierParam', [8], 'delete', '/polling-carrier-params/8'], ['getScenes', [], 'get', '/polling-check-scenes'], ['saveScene', ['scene/a', input], 'put', '/polling-check-scenes/scene%2Fa', input], ['getAlerts', [query], 'get', '/polling-failure-alerts', query], ['getAlertSummary', [], 'get', '/polling-failure-alerts/summary'], ['getAlert', [1], 'get', '/polling-failure-alerts/1'], [ 'handleAlert', [1, 'note'], 'put', '/polling-failure-alerts/1/status', { status: 4, remark: 'note' } ], [ 'closeAlertPolling', [[1, 2], 'note'], 'post', '/polling-failure-alerts/close-polling', { alert_ids: [1, 2], reason: 'note' } ], ['getQueues', [], 'get', '/polling-stats/carrier-queues'], ['getCardState', [42], 'get', '/iot-cards/42/polling-state'], ['getImpact', [], 'get', '/polling-status/impact'], ['toggleCards', [toggle], 'post', '/iot-cards/batch-update-polling-status', toggle], ['toggleDevices', [toggle], 'post', '/devices/batch-update-polling-status', toggle], ['getPauses', [query], 'get', '/card-polling-pauses', query], ['getImports', [query], 'get', '/polling-status-imports', query], ['getImport', [3], 'get', '/polling-status-imports/3'], ['createImport', [toggle], 'post', '/polling-status-imports', toggle], ['dryRun', [query], 'get', '/stop-resume-rule/dry-run', query], [ 'exportDryRun', [{ carrier_id: 8, format: 'csv' }], 'post', '/stop-resume-rule/dry-run/export', { carrier_id: 8, format: 'csv' } ] ] for (const [name, args, method, path, data] of specs) { await service[name](...args) assert.deepEqual(calls.at(-1), { method, url: `/api/admin${path}`, data }) } assert.equal(specs.length, 22) }) test('role boundaries and unique 1..500 ids are enforced', () => { for (const type of [1, 2, 3, 4, 0, undefined]) { assert.equal(helpers.isPollingPlatform(type), [1, 2].includes(type)) assert.equal(helpers.canPollingBatch(type), [1, 2, 3].includes(type)) } for (const ids of [[], [1, 1], [0], [-1], [1.5], Array.from({ length: 501 }, (_, i) => i + 1)]) assert.throws(() => helpers.validatePollingIds(ids)) assert.doesNotThrow(() => helpers.validatePollingIds(Array.from({ length: 500 }, (_, i) => i + 1)) ) assert.throws(() => helpers.pollingReason(' ')) assert.equal(helpers.pollingReason(' reason '), 'reason') }) test('special alert view only sends documented query filters; zero status is preserved', () => { const query = { page: 1, page_size: 20, carrier_id: 8, status: 0, view: 'stop_refused', check_type: 'stop_resume', error_category: 'terminal', alert_level: 'card', iccid: 'x' } assert.deepEqual(helpers.failureQuery(query), { page: 1, page_size: 20, carrier_id: 8, status: 0, iccid: 'x', view: 'stop_refused' }) assert.equal(helpers.failureQuery({ ...query, view: undefined }).check_type, 'stop_resume') assert.deepEqual( helpers.schedulerTime(['2026-10-26T00:00:00+08:00', '2026-10-27T00:00:00+08:00']), { start_time: '2026-10-25T16:00:00Z', end_time: '2026-10-26T16:00:00Z' } ) assert.throws(() => helpers.schedulerTime(['bad', 'date'])) }) test('parameter, capacity, nullable cooldown and strictly increasing delays validate', () => { assert.doesNotThrow(() => helpers.validateCarrierParams({ inflight_limit: null }, 0)) assert.throws(() => helpers.validateCarrierParams({ inflight_limit: 11 }, 10)) assert.throws(() => helpers.validateCarrierParams({ priority_max_ratio: 0 }, 10)) for (const delays of [[30, 30], [60, 30], [0]]) assert.throws(() => helpers.validateScene({ check_types: ['carddata'], confirm_delays: delays, cooldown: null }) ) assert.doesNotThrow(() => helpers.validateScene({ check_types: ['realname'], confirm_delays: [30, 60], cooldown: 0 }) ) assert.throws(() => helpers.validateScene({ check_types: ['package'], confirm_delays: [], cooldown: null }) ) assert.throws(() => helpers.validateDryAssumptions([1, 2], [2])) assert.doesNotThrow(() => helpers.validateDryAssumptions([1], [2])) }) test('CSV header, quoted newlines, escaped quotes, empty lines, 50000 limit and string ids', () => { const id = '89860426012345678901' assert.equal( helpers.validatePollingCsv( 'file.CSV', `\uFEFFiccid,note\r\n${id},"line\nline"\r\n\r\n${id},"a""b"` ), 2 ) assert.equal(id, '89860426012345678901') for (const [name, text] of [ ['f.txt', 'h\nx'], ['f.csv', 'header'], ['f.csv', 'h\n"x'], ['f.csv', `h\n${'x\n'.repeat(50001)}`] ]) assert.throws(() => helpers.validatePollingCsv(name, text)) assert.equal(helpers.validatePollingCsv('f.csv', `h\n${'x\n'.repeat(50000)}`), 50000) }) test('config save persists new fields, removes protection, uses clear_fields without losing zero', () => { const draft = { ...configHelpers.newPollingDraft(), config_name: 'x', asset_status: 2, package_state: 'active', carddata_check_interval: 0, protect_check_interval: 30 } const body = configHelpers.pollingSaveBody(draft, true) assert.equal(body.asset_status, 2) assert.equal(body.package_state, 'active') assert.equal(body.carddata_check_interval, 0) assert.ok(body.clear_fields.includes('realname_check_interval')) assert.equal('realname_check_interval' in body, false) assert.equal('protect_check_interval' in body, false) assert.equal('id' in body, false) assert.equal('disabled' in body, false) assert.deepEqual( configHelpers.pollingDraftBody([{ id: 3, disabled: true, priority: -1, config_name: '' }]), [{ id: 3, disabled: true }] ) assert.throws(() => gateway.validateGatewayRule({ category: 'business', auto_disable_polling: true, keyword: 'a', match_type: 'contains' }) ) }) for (const [target, type, allowed] of [ ['iot_card', 3, true], ['device', 4, false], ['alert', 3, false], ['alert', 2, true] ]) test(`batch ${target} type${type} validates identity and shows partial results`, async () => { const result = { success_count: 1, skip_count: 1, fail_count: 2, impact: ['impact'], items: [1, 2, 3, 4].map((status) => ({ asset_id: status, identifier: String(status), status, status_name: String(status), reason: 'reason' })) } const h = mount(batchPath, { userType: type, props: { target, ids: [1, 2, 3, 4], permission: 'test' }, services: { toggleCards: () => ok(result), toggleDevices: () => ok(result), closeAlertPolling: () => ok(result) } }) await h.state.loadImpact() h.state.reason.value = ' reason ' await h.state.submit() assert.equal( h.calls.some((call) => ['toggleCards', 'toggleDevices', 'closeAlertPolling'].includes(call.name) ), allowed ) if (allowed) { assert.deepEqual(h.state.result.value, result) assert.equal(h.state.resultRows.value.length, 4) await h.state.submit() assert.equal(h.calls.length, 2) } h.dispose() }) test('batch missing impact, cancel, duplicate ids, unmount, and changed scope never write', async () => { for (const variant of ['impact', 'cancel', 'duplicate', 'unmount', 'scope']) { const props = { target: 'iot_card', ids: variant === 'duplicate' ? [1, 1] : [1], permission: 'test' } let h h = mount(batchPath, { props, services: variant === 'impact' ? { getImpact: () => { throw Error('offline') } } : {}, confirm: async () => { if (variant === 'cancel') throw 'cancel' if (variant === 'unmount') h.dispose() if (variant === 'scope') h.user.info.id = 2 } }) await h.state.loadImpact() h.state.reason.value = 'reason' await h.state.submit() assert.equal( h.calls.some((call) => call.name === 'toggleCards'), false, variant ) h.dispose() } }) test('import validates purpose/prefix and never creates after upload failure', async () => { for (const fail of [false, true]) { const uploads = [] const h = mount('src/components/business/polling/PollingImportDialog.vue', { permissions: [constants.SCHEDULER_PERMISSIONS.imports.create], storage: { getUploadUrl: async (data) => { uploads.push(data) return ok({ upload_url: 'local-upload', file_key: 'polling-status-imports/test.csv' }) }, uploadFile: async () => { if (fail) throw Error('upload failed') } }, services: { createImport: () => ok({ id: 3 }) } }) h.state.file.value = { name: 'file.csv', text: async () => 'iccid\n89860426012345678901' } h.state.reason.value = 'reason' await h.state.submit() assert.equal(uploads[0].purpose, 'polling_status_import') assert.equal( h.calls.some((call) => call.name === 'createImport'), !fail ) h.dispose() } }) test('all new Vue templates compile, including multi-statement event handlers', () => { const dirs = [ 'src/components/business/polling', 'src/views/polling-management/carrier-params', 'src/views/polling-management/check-scenes', 'src/views/polling-management/carrier-queues', 'src/views/polling-management/failure-alerts', 'src/views/polling-management/polling-pauses', 'src/views/polling-management/polling-status-imports', 'src/views/polling-management/stop-resume-dry-run' ] for (const dir of dirs) for (const file of readdirSync(dir).filter((file) => file.endsWith('.vue'))) { const path = `${dir}/${file}`, { descriptor, errors } = parse(source(path)) assert.deepEqual(errors, [], path) const result = compileTemplate({ source: descriptor.template.content, filename: path, id: path, compilerOptions: { expressionPlugins: ['typescript'] } }) assert.deepEqual(result.errors, [], path) } }) test('protection is absent from admin forms and polling pause/alert/dry-run export scenes are registered', () => { for (const path of [ 'src/views/polling-management/config/index.vue', 'src/views/polling-management/config/components/PollingDraftFields.vue', 'src/views/asset-management/asset-information/components/BasicInfoCard.vue' ]) assert.equal(/protect_check_interval|device_protect_status/.test(source(path)), false, path) for (const scene of ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run']) assert.ok(source('src/config/constants/exportTask.ts').includes(`scene: '${scene}'`)) for (const kind of [ 'carrier-params', 'check-scenes', 'failure-alerts', 'carrier-queues', 'stop-resume-dry-run', 'polling-pauses', 'polling-status-imports' ]) assert.ok(source('src/router/routes/asyncRoutes.ts').includes(`path: '${kind}'`)) assert.match( source('src/views/polling-management/stop-resume-dry-run/index.vue'), /assume_control_off/ ) })