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), onActivated: () => {} } const { descriptor } = parse(source(path)) const script = compileScript(descriptor, { id: 'scheduler-test' }).content const store = { useUserStore: () => user } const schedulerRequest = evaluate(source('src/composables/useSchedulerRequest.ts'), { vue: runtime, '@/store/modules/user': store, '@/utils/business/gateway': gateway, '@/utils/business/pollingScheduler': helpers }) const imports = { vue: runtime, 'vue-router': { useRoute: () => ({ params: { id: '1', carrier_id: '8' }, fullPath: '/local/detail/1' }), useRouter: () => ({ back() {} }) }, 'element-plus': { ElMessageBox: { confirm }, ElMessage: { success() {} } }, '@/api/modules/pollingScheduler': { PollingSchedulerService: api }, '@/api/modules/storage': { StorageService: storage }, '@/store/modules/user': store, '@/composables/useAuth': { useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) }) }, '@/utils/business/gateway': gateway, '@/utils/business/pollingScheduler': helpers, '@/config/constants/pollingScheduler': constants, '@/config/constants/gateway': evaluate(source('src/config/constants/gateway.ts')), '@/composables/useSchedulerRequest': schedulerRequest, '@/composables/useGateway': { useGatewayCarriers: () => ({ options: vue.ref([]), search() {}, loading: vue.ref(false) }) }, '@/components/business/polling/SchedulerTable.vue': { default: {} }, '@/components/business/gateway/StopResumeRecords.vue': { default: {} }, './PollingStateDialog.vue': { default: {} }, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) } } const scope = vue.effectScope() const state = scope.run(() => evaluate(script, imports, runtime).default.setup(props, { expose() {}, emit: (...args) => events.push(args) }) ) return { state, calls, user, events, dispose: () => { beforeUnmount.forEach((fn) => fn()) scope.stop() }, deactivate: () => deactivated.forEach((fn) => fn()) } } const dryRunPath = 'src/views/polling-management/stop-resume-dry-run/index.vue' const schedulerDetailPath = 'src/components/business/polling/SchedulerDetail.vue' const dryRunResult = { items: [{ card_id: 42, iccid: '89860426012345678901' }], total: 1, page: 3, size: 20, summary: [{ carrier_id: 8, count: 1 }], totals: { total: 1, will_stop: 1, will_resume: 0, no_action: 0 } } test('dry-run reset clears filters, old summary/details and errors without starting an unfiltered scan', async () => { const h = mount(dryRunPath, { permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page], services: { dryRun: () => ok(dryRunResult) } }) Object.assign(h.state.filters, { carrier_id: 8, stop_reason: 'manual', result: 'will_stop', assume_control_on: [8], assume_control_off: [9] }) h.state.page.value = 3 await h.state.load() assert.equal(h.state.data.value.totals.total, 1) assert.equal(h.state.data.value.summary.length, 1) assert.equal(h.state.data.value.items.length, 1) h.state.error.value = 'old export error' h.state.reset() assert.deepEqual( { ...h.state.filters }, { carrier_id: undefined, stop_reason: '', result: '', assume_control_on: [], assume_control_off: [] } ) assert.equal(h.state.page.value, 1) assert.equal(h.state.data.value, null) assert.equal(h.state.error.value, '') assert.equal(h.state.loading.value, false) assert.equal(h.calls.length, 1) h.dispose() }) test('dry-run reset invalidates pending success/error and allows a fresh explicit query', async () => { for (const fail of [false, true]) { let resolve, reject const pendingResponse = new Promise((done, failed) => { resolve = done reject = failed }) let requests = 0 const h = mount(dryRunPath, { permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page], services: { dryRun: () => (++requests === 1 ? pendingResponse : ok(dryRunResult)) } }) h.state.filters.stop_reason = 'manual' const pending = h.state.load() assert.equal(h.state.loading.value, true) h.state.reset() if (fail) reject(new Error('stale request failed')) else resolve(ok(dryRunResult)) await pending assert.equal(h.state.data.value, null) assert.equal(h.state.error.value, '') assert.equal(h.state.loading.value, false) assert.equal(requests, 1) h.state.filters.carrier_id = 9 await h.state.load() assert.equal(h.state.data.value.totals.total, 1) assert.equal(h.calls.at(-1).args[0].carrier_id, 9) assert.equal(h.calls.at(-1).args[0].stop_reason, undefined) h.dispose() } }) for (const [kind, method] of [ ['params', 'getCarrierParam'], ['alerts', 'getAlert'], ['imports', 'getImport'] ]) test(`${kind} direct detail requires both page and detail permissions and retains the role boundary`, async () => { const P = constants.SCHEDULER_PERMISSIONS[kind] for (const permissions of [ [], [P.page], [P.detailPage], [P.detail], [P.detailPage, P.detail] ]) { const allowed = permissions.includes(P.detailPage) && permissions.includes(P.detail) const h = mount(schedulerDetailPath, { props: { kind }, permissions, services: { [method]: () => ok({ id: 1, carrier_id: 8, card_id: 0, status: 3 }) } }) await h.state.load() assert.equal(h.calls.length, allowed ? 1 : 0, permissions.join(',')) assert.equal(!!h.state.data.value, allowed) h.dispose() } for (const userType of [1, 3, 4]) { const allowed = kind !== 'imports' || userType === 1 const h = mount(schedulerDetailPath, { props: { kind }, userType, permissions: [P.detailPage, P.detail], services: { [method]: () => ok({ id: 1, status: 3 }) } }) await h.state.load() assert.equal(h.calls.length, allowed ? 1 : 0) assert.equal(!!h.state.data.value, allowed) h.dispose() } }) test('revoking either detail permission clears displayed data and blocks subsequent refresh', async () => { for (const kind of ['params', 'alerts', 'imports']) { const P = constants.SCHEDULER_PERMISSIONS[kind] for (const revoked of [P.detailPage, P.detail]) { const permissions = vue.reactive([P.detailPage, P.detail]) const h = mount(schedulerDetailPath, { props: { kind }, permissions }) await h.state.load() assert.ok(h.state.data.value) permissions.splice(permissions.indexOf(revoked), 1) assert.equal(h.state.data.value, null) await h.state.load() assert.equal(h.calls.length, 1) h.dispose() } } }) test('pending detail responses cannot appear after the detail permission is revoked', async () => { for (const [kind, method] of [ ['params', 'getCarrierParam'], ['alerts', 'getAlert'], ['imports', 'getImport'] ]) { const P = constants.SCHEDULER_PERMISSIONS[kind] const permissions = vue.reactive([P.detailPage, P.detail]) let resolve const response = new Promise((done) => { resolve = done }) const h = mount(schedulerDetailPath, { props: { kind }, permissions, services: { [method]: () => response } }) const pending = h.state.load() permissions.splice(permissions.indexOf(P.detail), 1) resolve(ok({ id: 1, status: 3 })) await pending assert.equal(h.state.data.value, null) assert.equal(h.state.loading.value, false) assert.equal(h.calls.length, 1) h.dispose() } }) 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/ ) })