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' 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 format = evaluate(source('src/utils/business/format.ts')) const gateway = evaluate(source('src/utils/business/gateway.ts'), { './format': format }) const constants = evaluate(source('src/config/constants/pollingConfig.ts')) const helpers = evaluate(source('src/utils/business/pollingConfig.ts'), { '@/config/constants/pollingConfig': constants, './gateway': gateway }) const perms = constants.POLLING_CONFIG_PERMISSIONS const ok = (data) => ({ code: 0, data, msg: 'ok' }) const page = (items) => ok({ items, page: 1, page_size: 20, total: items.length }) const draft = () => ({ ...helpers.newPollingDraft(), config_name: '测试草稿' }) const task = (id = 'task-1', status = 'running') => ({ task_id: id, status, total: 100, processed: 20, advanced: 10, carrier_id: '8', error: '', created_at: '2026-10-26T10:00:00+08:00', started_at: '', finished_at: '' }) const preview = { card: { iccid: 'CARD', asset_status: 2, package_state: 'active' }, matched: [], intervals: [{ task_type: 'polling:carddata', mode: 'disabled', interval: 0 }] } const preReset = { carriers: [ { carrier_id: 8, carrier_name: '账户', previous: { active_package_cards: 0, checked_in_window: 0, not_checked_in_window: 0, reset_at: '2026-10-26T00:00:00+08:00', last_offset_buckets: { le_5: 0, le_15: 0, le_30: 0, le_60: 0, gt_60: 0 } }, upcoming: null } ] } const tiers = { total_cards: 0, by_config: [{ config_id: 0, config_name: '', cards: 0 }], by_package_state: { active: 0, unknown: 0 }, computed_at: '2026-10-26T00:00:00Z', unmatched_in_stock: 0, unmatched_sold_no_package: 0 } function deferred() { let resolve let reject const promise = new Promise((done, fail) => { resolve = done reject = fail }) return { promise, resolve, reject } } async function flush() { await new Promise((done) => setImmediate(done)) await vue.nextTick() } test('all seven new API methods use exact URLs, methods, query and body contracts', async () => { const calls = [] const response = ok(preview) 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 { PollingConfigService: service } = evaluate(source('src/api/modules/pollingConfig.ts'), { '../BaseService': base }) const body = { iccid: 'CARD/1', overrides: [draft()] } const specs = [ [ 'previewMatch', ['CARD/1'], { method: 'get', url: '/api/admin/polling-configs/match-preview', params: { iccid: 'CARD/1' } } ], [ 'previewDraft', [body], { method: 'post', url: '/api/admin/polling-configs/match-preview', params: { iccid: body.iccid }, data: body } ], [ 'updatePriorities', [[12, 3, 8]], { method: 'put', url: '/api/admin/polling-configs/priorities', data: { ordered_ids: [12, 3, 8] } } ], [ 'recalculateDue', [null], { method: 'post', url: '/api/admin/polling-configs/recalculate-due', data: { carrier_id: null } } ], [ 'getRecalculateTask', ['task/a b'], { method: 'get', url: '/api/admin/polling-configs/recalculate-due/task%2Fa%20b', params: undefined } ], [ 'getPreResetStats', [], { method: 'get', url: '/api/admin/polling-stats/pre-reset', params: undefined } ], [ 'getTierStats', [], { method: 'get', url: '/api/admin/polling-stats/tiers', params: undefined } ] ] for (const [name, args, expected] of specs) { assert.equal(await service[name](...args), response) assert.deepEqual(calls.at(-1), expected) } }) test('drafts preserve null inheritance, zero disable, numeric intervals and body whitelist', () => { const item = { ...draft(), carrier_id: 0, realname_check_interval: null, carddata_check_interval: 0, package_check_interval: 60, status: 0, key: 'ui', created_at: 'unexpected' } const [body] = helpers.pollingDraftBody([item]) assert.equal(body.carrier_id, 0) assert.equal(body.realname_check_interval, null) assert.equal(body.carddata_check_interval, 0) assert.equal(body.package_check_interval, 60) for (const key of ['status', 'key', 'created_at']) assert.equal(key in body, false) assert.equal(item.status, 0) assert.equal(helpers.pollingDraftBody([draft(), draft()]).length, 2) assert.throws(() => helpers.pollingDraftBody([ { ...draft(), id: 1 }, { ...draft(), id: 1 } ]) ) }) test('draft validation enforces documented interval minima, priority and paired windows', () => { for (const patch of [ { config_name: ' ' }, { config_name: 'a'.repeat(101) }, { description: 'x'.repeat(501) }, { priority: 0 }, { priority: 1001 }, { priority: 1.2 }, { asset_status: 5 }, { carrier_id: -1 }, { realname_check_interval: 29 }, { carddata_check_interval: 59 }, { package_check_interval: -1 }, { card_status_check_interval: 0.1 }, { pre_reset_window: 60 }, { pre_reset_window: 59, pre_reset_carddata_interval: 60 } ]) assert.throws(() => helpers.pollingDraftBody([{ ...draft(), ...patch }])) assert.doesNotThrow(() => helpers.pollingDraftBody([ { ...draft(), protect_check_interval: 30, pre_reset_window: 60, pre_reset_carddata_interval: 60 } ]) ) assert.equal(helpers.pollingIccid(' CARD '), 'CARD') assert.throws(() => helpers.pollingIccid('')) assert.throws(() => helpers.pollingIccid('x'.repeat(31))) for (const ids of [[], [1, 1], [-1], [0], [1.5]]) assert.throws(() => helpers.validatePollingOrder(ids)) assert.doesNotThrow(() => helpers.validatePollingOrder([3, 2, 1])) }) function mount( t, path, { superAdmin = false, permissions = [], overrides = {}, props = {}, globals = {} } = {} ) { const calls = [] const events = [] const user = vue.reactive({ isSuperAdmin: superAdmin, permissions }) const config = { id: 1, config_name: '默认配置', priority: 1, status: 1, carrier_id: 8, realname_check_interval: null } const defaults = { getPollingConfigs: () => page([config]), getPollingConfigById: () => ok(config), previewMatch: () => ok(preview), previewDraft: () => ok(preview), updatePriorities: () => undefined, recalculateDue: () => ok(task()), getRecalculateTask: (id) => ok(task(id)), getPreResetStats: () => ok(preReset), getTierStats: () => ok(tiers) } const service = Object.fromEntries( Object.entries(defaults).map(([name, handler]) => [ name, async (...args) => { calls.push({ name, args }) return (overrides[name] || handler)(...args) } ]) ) const hasAuth = (value) => user.isSuperAdmin || user.permissions.includes(value) const imports = { vue, 'element-plus': { ElMessageBox: { confirm: overrides.confirm || (async () => {}) }, ElMessage: { success() {} } }, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, '@/api/modules/pollingConfig': { PollingConfigService: service }, '@/store/modules/user': { useUserStore: () => user }, '@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, '@/config/constants/pollingConfig': constants, '@/utils/business/pollingConfig': helpers, '@/utils/business/gateway': gateway, '@/utils/business/format': format, '@/composables/useGateway': { useGatewayCarriers: () => ({ options: vue.ref([]), loading: vue.ref(false), error: vue.ref(''), search() {}, select() {}, label: String }), useGatewayColumns: (definitions) => ({ columns: vue.computed(() => definitions), columnChecks: vue.ref(definitions) }) }, './PollingDraftFields.vue': { default: {} } } imports['@/composables/usePollingConfigExtensions'] = evaluate( source('src/composables/usePollingConfigExtensions.ts'), imports, globals ) let component if (path === 'progress') component = { setup: () => imports['@/composables/usePollingConfigExtensions'].usePollingRecalculateProgress(() => hasAuth(perms.progress) ) } else { const { descriptor } = parse(source(path)) component = evaluate( compileScript(descriptor, { id: 'polling-config-test' }).content, imports, globals ).default } let state const renderer = vue.createRenderer({ createComment: () => ({}), insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null }) const reactiveProps = vue.reactive(props) const app = renderer.createApp({ setup() { state = component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) }) return () => null } }) app.mount({}) t.after(() => app.unmount()) return { state, calls, events, user, unmount: () => app.unmount() } } const previewPath = 'src/views/polling-management/config/components/PollingPreviewDialog.vue' const priorityPath = 'src/views/polling-management/config/components/PollingPriorityDialog.vue' const recalculatePath = 'src/views/polling-management/config/components/PollingRecalculateDialog.vue' const statsPath = 'src/views/polling-management/config-stats/index.vue' const writes = (calls) => calls.filter((call) => ['updatePriorities', 'recalculateDue', 'createPollingConfig', 'updatePollingConfig'].includes( call.name ) ) test('preview permissions and ICCID validation block unauthorized or invalid requests', async (t) => { const { state, calls, user } = mount(t, previewPath) await flush() state.iccid.value = 'CARD' await state.runPreview() assert.match(state.error.value, /无权/) user.permissions.push(perms.preview) state.iccid.value = 'x'.repeat(31) await state.runPreview() assert.match(state.error.value, /ICCID/) assert.equal( calls.some((call) => call.name === 'previewMatch'), false ) }) test('GET and multi-override POST previews never save configurations', async (t) => { const { state, calls } = mount(t, previewPath, { permissions: [perms.preview] }) state.iccid.value = 'CARD' await state.runPreview() assert.equal(state.result.value.intervals[0].interval, 0) state.useDraft.value = true state.addDraft(draft()) state.addDraft({ ...draft(), id: 2, disabled: true }) await state.runPreview() const body = calls.find((call) => call.name === 'previewDraft').args[0] assert.equal(body.iccid, 'CARD') assert.equal(body.overrides.length, 2) assert.equal(body.overrides[1].disabled, true) assert.equal(writes(calls).length, 0) }) test('a stale preview cannot overwrite a changed card or unmounted dialog', async (t) => { const pending = deferred() const { state, unmount } = mount(t, previewPath, { permissions: [perms.preview], overrides: { previewMatch: () => pending.promise } }) state.iccid.value = 'OLD' const running = state.runPreview() state.iccid.value = 'NEW' pending.resolve(ok(preview)) await running assert.equal(state.result.value, null) unmount() }) test('ordinary users cannot submit priority updates or start recalculation', async (t) => { for (const [path, action] of [ [priorityPath, 'save'], [recalculatePath, 'start'] ]) { const { state, calls } = mount(t, path, { permissions: [perms.progress] }) await flush() await state[action]() assert.equal(writes(calls).length, 0) } }) test('priority updates load all pages, preserve chosen order, confirm and accept empty success DTO', async (t) => { const { state, calls, events } = mount(t, priorityPath, { superAdmin: true, overrides: { getPollingConfigs: ({ page: current }) => ok({ items: [{ id: current, config_name: `配置${current}`, priority: current }], total: 2 }) } }) await flush() assert.equal(state.rows.value.length, 2) state.move(1, -1) await state.save() assert.deepEqual(calls.find((call) => call.name === 'updatePriorities').args[0], [2, 1]) assert.ok(events.some((event) => event[0] === 'saved')) }) test('priority cancellation or role revocation after confirmation sends no write', async (t) => { for (const cancel of [true, false]) { const confirmation = deferred() const { state, calls, user } = mount(t, priorityPath, { superAdmin: true, overrides: { confirm: () => confirmation.promise } }) await flush() const saving = state.save() if (cancel) confirmation.reject('cancel') else { user.isSuperAdmin = false confirmation.resolve('ok') } await saving assert.equal(writes(calls).length, 0) } }) test('recalculation uses numeric/null carrier IDs and only reports actual task status', async (t) => { const { state, calls } = mount(t, recalculatePath, { superAdmin: true }) await state.start() assert.equal(calls.find((call) => call.name === 'recalculateDue').args[0], null) assert.equal(state.task.value.status, 'running') state.allCarriers.value = false state.carrierId.value = 8 await state.start() assert.equal(calls.filter((call) => call.name === 'recalculateDue').at(-1).args[0], 8) }) function fakeTimers() { let key = 0 const timers = new Map() return { timers, globals: { setTimeout: (fn) => { timers.set(++key, fn) return key }, clearTimeout: (id) => timers.delete(id) }, async tick() { const next = timers.entries().next().value if (next) { timers.delete(next[0]) next[1]() await flush() } } } } test('progress polling stops at terminal state and cleanup removes pending timers', async (t) => { const clock = fakeTimers() let count = 0 const { state, unmount } = mount(t, 'progress', { permissions: [perms.progress], globals: clock.globals, overrides: { getRecalculateTask: (id) => ok(task(id, ++count === 1 ? 'running' : 'completed')) } }) state.taskId.value = 'task-1' state.autoRefresh.value = true await state.query() assert.equal(clock.timers.size, 1) await clock.tick() assert.equal(state.task.value.status, 'completed') assert.equal(clock.timers.size, 0) state.adopt(task()) assert.equal(clock.timers.size, 1) unmount() assert.equal(clock.timers.size, 0) }) test('changing task ID, closing, errors or loss of permission discard old progress', async (t) => { const clock = fakeTimers() const pending = deferred() const { state, user } = mount(t, 'progress', { permissions: [perms.progress], globals: clock.globals, overrides: { getRecalculateTask: () => pending.promise } }) state.taskId.value = 'OLD' const request = state.query() state.taskId.value = 'NEW' pending.resolve(ok(task('OLD'))) await request assert.equal(state.task.value, null) state.adopt(task('NEW')) state.autoRefresh.value = true user.permissions = [] await flush() assert.equal(clock.timers.size, 0) assert.equal(state.task.value, null) }) test('statistics retain null periods, zero counts, raw bucket values and unknown package states', async (t) => { const { state } = mount(t, statsPath, { permissions: [perms.statsPage, perms.preReset, perms.tiers] }) await flush() assert.equal(state.preRows.value.length, 2) assert.equal(state.preValue(state.preRows.value[0], 'checked_in_window'), 0) assert.equal(state.preValue(state.preRows.value[0], 'le_5'), 0) assert.equal(state.preValue(state.preRows.value[1], 'reset_at'), '无周期数据') assert.equal(state.preValue(state.preRows.value[1], 'checked_in_window'), '-') assert.equal(state.tiers.value.by_config[0].config_id, 0) assert.equal(state.packageRows.value.find((row) => row.state === 'unknown').cards, 0) }) test('statistics page and separate area permissions are both required', async (t) => { for (const permissions of [[], [perms.preReset, perms.tiers], [perms.statsPage]]) { const { state, calls } = mount(t, statsPath, { permissions }) await state.loadPreReset() await state.loadTiers() assert.equal( calls.filter((call) => ['getPreResetStats', 'getTierStats'].includes(call.name)).length, 0 ) } }) test('new fields persist through whitelisted CRUD and menu/button contracts use distinct codes', () => { const pageSource = source('src/views/polling-management/config/index.vue') const body = helpers.pollingSaveBody( { ...draft(), asset_status: 2, package_state: 'active', pre_reset_window: 60, pre_reset_carddata_interval: 60 }, false ) for (const field of [ 'asset_status', 'package_state', 'pre_reset_window', 'pre_reset_carddata_interval' ]) assert.ok(Object.hasOwn(body, field)) assert.match(pageSource, /pollingSaveBody\(formData/) assert.match(pageSource, /:show-expand="searchFormItems.length >= 4"/) assert.match( source('src/router/routesAlias.ts'), /PollingConfigStats = '\/polling-management\/config-stats'/ ) assert.equal(new Set(Object.values(perms)).size, 5) for (const code of Object.values(perms)) assert.match(code, /^[a-z_]+:[a-z_]+$/) }) test('interval field UI emits distinct null, zero and positive values', async (t) => { const { descriptor } = parse( source('src/views/polling-management/config/components/PollingIntervalField.vue') ) const definition = evaluate(compileScript(descriptor, { id: 'polling-interval-test' }).content, { vue }).default const value = vue.ref(null) let state const component = { ...definition, setup(props, context) { state = definition.setup(props, context) return () => null } } const renderer = vue.createRenderer({ createComment: () => ({}), insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null }) const app = renderer.createApp({ render: () => vue.h(component, { min: 30, modelValue: value.value, 'onUpdate:modelValue': (next) => { value.value = next } }) }) app.mount({}) t.after(() => app.unmount()) assert.equal(state.mode.value, 'inherit') state.mode.value = 'disabled' await vue.nextTick() assert.equal(value.value, 0) state.mode.value = 'interval' await vue.nextTick() assert.equal(value.value, 30) state.mode.value = 'inherit' await vue.nextTick() assert.equal(value.value, null) }) test('progress errors stop automatic polling and do not create a new task', async (t) => { const clock = fakeTimers() const { state, calls } = mount(t, 'progress', { permissions: [perms.progress], globals: clock.globals, overrides: { getRecalculateTask: () => { throw new Error('任务查询失败') } } }) state.taskId.value = 'task-1' state.autoRefresh.value = true await state.query() assert.match(state.error.value, /任务查询失败/) assert.equal(clock.timers.size, 0) assert.equal(writes(calls).length, 0) }) test('partial or duplicate configuration pages cannot be submitted as a complete priority order', async (t) => { const { state, calls } = mount(t, priorityPath, { superAdmin: true, overrides: { getPollingConfigs: () => ok({ items: [{ id: 1, config_name: '重复配置', priority: 1 }], total: 2 }) } }) await flush() assert.equal(state.loaded.value, false) assert.match(state.error.value, /不完整或存在重复/) await state.save() assert.equal(writes(calls).length, 0) }) test('pre-reset draft controls require an explicit enable and preserve null when disabled', async (t) => { const { descriptor } = parse( source('src/views/polling-management/config/components/PollingDraftFields.vue') ) const definition = evaluate(compileScript(descriptor, { id: 'polling-window-test' }).content, { vue, '@/config/constants/pollingConfig': constants, '@/composables/useGateway': { useGatewayCarriers: () => ({ options: vue.ref([]), loading: vue.ref(false), error: vue.ref(''), search() {}, remember() {}, select() {} }) }, './PollingIntervalField.vue': { default: {} } }).default const value = vue.ref(draft()) let state const component = { ...definition, setup(props, context) { state = definition.setup(props, context) return () => null } } const renderer = vue.createRenderer({ createComment: () => ({}), insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null }) const app = renderer.createApp({ render: () => vue.h(component, { modelValue: value.value, 'onUpdate:modelValue': (next) => { value.value = next } }) }) app.mount({}) t.after(() => app.unmount()) assert.equal(state.preResetEnabled.value, false) assert.equal(value.value.pre_reset_window, null) state.preResetEnabled.value = true await vue.nextTick() assert.equal(value.value.pre_reset_window, 60) assert.equal(value.value.pre_reset_carddata_interval, 60) state.preResetEnabled.value = false await vue.nextTick() assert.equal(value.value.pre_reset_window, null) assert.equal(value.value.pre_reset_carddata_interval, null) }) test('closing a preview or progress query while pending discards its response', async (t) => { for (const kind of ['preview', 'progress']) { const pending = deferred() const { state, unmount } = mount(t, kind === 'preview' ? previewPath : 'progress', { permissions: [perms.preview, perms.progress], overrides: kind === 'preview' ? { previewMatch: () => pending.promise } : { getRecalculateTask: () => pending.promise } }) if (kind === 'preview') state.iccid.value = 'CARD' else state.taskId.value = 'task-1' const query = kind === 'preview' ? state.runPreview() : state.query() unmount() pending.resolve(ok(kind === 'preview' ? preview : task())) await query assert.equal(kind === 'preview' ? state.result.value : state.task.value, null) } })