fix: 轮询按运营商账户限流
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 55s

This commit is contained in:
luo
2026-10-26 14:31:15 +08:00
parent 049f8257fb
commit 06684e1fc0
88 changed files with 7919 additions and 486 deletions

View File

@@ -56,6 +56,8 @@ const carrier = (enabled) => ({
function harness(userType, responseCarrier = carrier(1)) {
const writes = []
const attempts = []
let list
const confirmations = []
const user = vue.reactive({ info: { user_type: userType }, permissions: [], buttons: [] })
const permission = evaluate(read('src/composables/usePermission.ts'), {
@@ -66,7 +68,17 @@ function harness(userType, responseCarrier = carrier(1)) {
getCarriers: async () => ({ code: 0, data: { items: [responseCarrier], total: 1 } }),
getCarrierDetail: async () => ({ code: 0, data: responseCarrier }),
createCarrier: async (data) => writes.push({ method: 'create', data: { ...data } }),
updateCarrier: async (id, data) => writes.push({ method: 'update', id, data: { ...data } })
updateCarrier: async (id, data) => {
attempts.push({ id, data: { ...data } })
if (
list?.originalRealnameControlEnabled.value === 0 &&
data.realname_control_enabled === 1 &&
!data.realname_control_confirm
)
throw new Error('开启实名管控将停机 12 张卡,请确认后重试')
writes.push({ method: 'update', id, data: { ...data } })
return { code: 0, data: responseCarrier }
}
}
const runtimeVue = { ...vue, onMounted: () => {} }
const imports = {
@@ -75,7 +87,7 @@ function harness(userType, responseCarrier = carrier(1)) {
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) },
'@/api/modules': { CarrierService: service },
'element-plus': {
ElMessage: { success() {}, warning() {} },
ElMessage: { success() {}, warning() {}, error() {} },
ElMessageBox: {
confirm: async (...args) => {
confirmations.push(args)
@@ -102,8 +114,10 @@ function harness(userType, responseCarrier = carrier(1)) {
}
const setup = (name) =>
evaluate(components[name].script, imports, runtimeVue).default.setup({}, { expose() {} })
list = setup('index')
return {
list: setup('index'),
list,
attempts,
detail: setup('detail'),
writes,
confirmations,
@@ -189,8 +203,8 @@ test('editing then creating resets control state; default creation sends numeric
})
for (const userType of [1, 2]) {
test(`user_type=${userType}: enabling control requires confirmation before PUT and sends true`, async () => {
const { list, writes, confirmations, messageBox } = harness(userType)
test(`user_type=${userType}: initial PUT obtains actual impact before confirmed retry`, async () => {
const { list, writes, confirmations, messageBox, attempts } = harness(userType)
list.showDialog('edit', carrier(0))
list.form.realname_control_enabled = 1
let confirm
@@ -201,8 +215,11 @@ for (const userType of [1, 2]) {
})
}
const pending = list.handleSubmit(validForm)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(confirmations.length, 1)
assert.match(confirmations[0][0], /未实名的开机卡会在下一次评估时被停机/)
assert.equal(confirmations[0][0], '开启实名管控将停机 12 张卡,请确认后重试')
assert.equal(attempts.length, 1)
assert.equal(attempts[0].data.realname_control_confirm, undefined)
assert.deepEqual(writes, [])
assert.equal(list.submitLoading.value, true)
await list.handleSubmit(validForm)
@@ -275,7 +292,7 @@ test('opening another carrier resets the original control state after a cancelle
assert.equal(Object.hasOwn(writes[0].data, 'realname_control_confirm'), false)
})
test('a failed PUT requires a fresh confirmation on retry', async (t) => {
test('an unrelated PUT error never triggers confirmation or automatic retry', async (t) => {
const { list, writes, confirmations, service } = harness(2)
t.mock.method(console, 'error', () => {})
list.showDialog('edit', carrier(0))
@@ -285,13 +302,13 @@ test('a failed PUT requires a fresh confirmation on retry', async (t) => {
throw new Error('PUT failed')
}
await list.handleSubmit(validForm)
assert.equal(confirmations.length, 1)
assert.equal(confirmations.length, 0)
assert.deepEqual(writes, [])
assert.equal(list.dialogVisible.value, true)
assert.equal(list.submitLoading.value, false)
service.updateCarrier = updateCarrier
await list.handleSubmit(validForm)
assert.equal(confirmations.length, 2)
assert.equal(confirmations.length, 1)
assert.equal(writes[0].data.realname_control_confirm, true)
})
@@ -353,6 +370,9 @@ test('carrier endpoints preserve the field in list/detail responses and create/u
calls.push({ method: 'PUT', url, data })
return Promise.resolve({ code: 0, data })
}
static put(url, data) {
return this.update(url, data)
}
}
const { CarrierService } = evaluate(read('src/api/modules/carrier.ts'), {
'../BaseService': { BaseService }

View File

@@ -0,0 +1,5 @@
iccid,note
89860426012345678901,local mock
89860426012345678902,skip
89860426012345678903,fail
89860426012345678904,denied
1 iccid note
2 89860426012345678901 local mock
3 89860426012345678902 skip
4 89860426012345678903 fail
5 89860426012345678904 denied

View File

@@ -0,0 +1,48 @@
[
"DELETE /gateway-error-classifications/{id}",
"DELETE /polling-carrier-params/{carrier_id}",
"DELETE /polling-rate-limit-groups/{id}",
"GET /card-polling-pauses",
"GET /card-stop-resume-records",
"GET /gateway-error-classifications",
"GET /iot-cards/{id}/polling-state",
"GET /iot-cards/{id}/stop-resume-records",
"GET /polling-carrier-params",
"GET /polling-carrier-params/{carrier_id}",
"GET /polling-check-scenes",
"GET /polling-configs/match-preview",
"GET /polling-configs/recalculate-due/{task_id}",
"GET /polling-failure-alerts",
"GET /polling-failure-alerts/summary",
"GET /polling-failure-alerts/{id}",
"GET /polling-options",
"GET /polling-rate-limit-groups",
"GET /polling-rate-limit-groups/{id}",
"GET /polling-stats/carrier-queues",
"GET /polling-stats/carriers",
"GET /polling-stats/carriers/{carrier_id}/trend",
"GET /polling-stats/pre-reset",
"GET /polling-stats/tiers",
"GET /polling-status-imports",
"GET /polling-status-imports/{id}",
"GET /polling-status/impact",
"GET /stop-resume-rule/dry-run",
"POST /devices/batch-update-polling-status",
"POST /gateway-error-classifications",
"POST /gateway-error-classifications/test",
"POST /iot-cards/batch-update-polling-status",
"POST /polling-configs/match-preview",
"POST /polling-configs/recalculate-due",
"POST /polling-failure-alerts/close-polling",
"POST /polling-rate-limit-groups",
"POST /polling-status-imports",
"POST /stop-resume-rule/dry-run/export",
"PUT /gateway-error-classifications/{id}",
"PUT /gateway-error-classifications/{id}/status",
"PUT /polling-carrier-params/{carrier_id}",
"PUT /polling-check-scenes/{scene}",
"PUT /polling-configs/priorities",
"PUT /polling-failure-alerts/{id}/status",
"PUT /polling-rate-limit-groups/{id}",
"PUT /polling-rate-limit-groups/{id}/status"
]

View File

@@ -325,6 +325,7 @@ function mountComponent(
'@/components/business/gateway/GatewayOptionsNotice.vue': { default: {} },
'@/components/business/gateway/GatewayRuleFields.vue': { default: {} },
'@/components/business/gateway/GatewayDynamicData.vue': { default: {} },
'@/components/business/gateway/GatewayOperationTable.vue': { default: {} },
'./GatewayOptionsNotice.vue': { default: {} }
}
imports['@/composables/useGateway'] = evaluate(source('src/composables/useGateway.ts'), imports)

View File

@@ -0,0 +1,277 @@
import { mockApiPlugin as gatewayFixtures } from './gatewayBrowserFixtures.mjs'
const time = '2026-10-26T10:00:00+08:00'
const configs = [1, 2, 3].map((id) => ({
id,
config_name: `模拟配置 ${id}`,
priority: id,
description: '本地模拟,不代表真实配置',
status: id === 3 ? 0 : 1,
carrier_id: 8,
carrier_name: '模拟运营商账户',
card_category: 'normal',
card_condition: 'activated',
asset_status: 2,
package_state: 'active',
realname_check_interval: null,
carddata_check_interval: 300,
package_check_interval: 600,
card_status_check_interval: 0,
pre_reset_window: 3600,
pre_reset_carddata_interval: 60,
created_at: time,
updated_at: time
}))
const tasks = new Map()
const page = (items, url) => {
const current = Number(url.searchParams.get('page') || 1)
const size = Number(url.searchParams.get('page_size') || 20)
return {
items: items.slice((current - 1) * size, current * size),
page: current,
size,
page_size: size,
total: items.length,
total_pages: Math.ceil(items.length / size)
}
}
// 本地测试专用:所有 /api 都被当前或 gateway fixture 消费,无真实后端代理。
export const mockApiPlugin = {
name: 'polling-config-local-mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1')
const path = url.pathname
const auth = ['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path)
if (
!auth &&
!path.startsWith('/api/admin/polling-configs') &&
!['/api/admin/polling-stats/pre-reset', '/api/admin/polling-stats/tiers'].includes(path)
)
return next()
let body = ''
for await (const chunk of req) body += chunk
const input = body ? JSON.parse(body) : {}
const viewer = req.headers.authorization?.includes('local-polling-viewer')
const denied = req.headers.authorization?.includes('local-polling-denied')
const admin = !viewer && !denied
let data = {}
let code = 0
let msg = 'local mock only'
if (auth) {
const menus = [
{ url: '/polling-management/config', name: '轮询配置' },
{
url: '/polling-management/config-stats',
name: '轮询配置统计',
perm_code: 'polling_config_stats:page'
}
]
data = {
access_token: viewer
? 'local-polling-viewer'
: denied
? 'local-polling-denied'
: 'local-polling-admin',
refresh_token: '',
expires_in: 3600,
user: {
id: admin ? 1 : 2,
user_type: admin ? 1 : 2,
username: admin ? '本地模拟管理员' : '本地普通账号',
roles: [admin ? 'R_SUPER' : 'R_ADMIN']
},
permissions: denied ? [] : ['polling_config_stats:page'],
buttons: denied
? []
: [
'polling_config:match_preview',
'polling_config:recalculate_progress',
'polling_config_stats:pre_reset',
'polling_config_stats:tiers'
],
menus: [{ url: '/polling-management', name: '轮询管理', children: menus }]
}
} else if (path === '/api/admin/polling-configs/match-preview') {
const iccid = url.searchParams.get('iccid')
if (!iccid || (req.method === 'POST' && input.iccid !== iccid)) {
code = 1001
msg = 'Query 和 Body 的 ICCID 必须一致'
} else
data = {
card: {
iccid,
card_id: 42,
carrier_id: 8,
enable_polling: true,
asset_status: 2,
card_category: 'normal',
card_condition: 'activated',
package_state: 'active',
package_state_name: '有生效套餐'
},
matched:
req.method === 'POST' && input.overrides?.length
? input.overrides
.filter((item) => !item.disabled)
.map((item) => ({
id: item.id,
config_name: item.config_name,
priority: item.priority,
draft: true
}))
: [{ id: 1, config_name: configs[0].config_name, priority: 1, draft: false }],
intervals: [
{
config_id: 1,
config_name: configs[0].config_name,
inherited: false,
interval: 300,
mode: 'interval',
task_type: 'polling:carddata',
task_type_name: '流量'
},
{
config_id: 1,
config_name: configs[0].config_name,
inherited: true,
interval: 0,
mode: 'disabled',
task_type: 'polling:card_status',
task_type_name: '卡状态'
},
{
config_id: 0,
config_name: '',
inherited: false,
interval: null,
mode: 'unmatched',
task_type: 'polling:realname',
task_type_name: '实名'
}
]
}
} else if (path === '/api/admin/polling-configs/priorities') {
if (!admin) {
code = 403
msg = '仅管理员可操作'
} else {
input.ordered_ids.forEach((id, index) => {
const row = configs.find((item) => item.id === id)
if (row) row.priority = index + 1
})
data = null
}
} else if (path === '/api/admin/polling-configs/recalculate-due' && req.method === 'POST') {
if (!admin) {
code = 403
msg = '仅管理员可操作'
} else {
const id = `local-task-${tasks.size + 1}`
data = {
task_id: id,
status: 'pending',
carrier_id: input.carrier_id == null ? '' : String(input.carrier_id),
total: 100,
processed: 0,
advanced: 0,
error: '',
created_at: time,
started_at: '',
finished_at: ''
}
tasks.set(id, { data, polls: 0 })
}
} else if (path.startsWith('/api/admin/polling-configs/recalculate-due/')) {
const id = decodeURIComponent(
path.slice('/api/admin/polling-configs/recalculate-due/'.length)
)
if (!tasks.has(id))
tasks.set(id, {
data: {
task_id: id,
carrier_id: '8',
total: 100,
processed: 0,
advanced: 0,
error: '',
created_at: time,
started_at: '',
finished_at: ''
},
polls: 0
})
const entry = tasks.get(id)
entry.polls++
data = {
...entry.data,
status: entry.polls > 2 ? 'completed' : 'running',
processed: Math.min(100, entry.polls * 40),
advanced: entry.polls * 10,
started_at: time,
finished_at: entry.polls > 2 ? time : ''
}
} else if (path === '/api/admin/polling-stats/pre-reset') {
data = {
carriers: [
{
carrier_id: 8,
carrier_name: '模拟运营商账户',
previous: {
active_package_cards: 100,
checked_in_window: 80,
not_checked_in_window: 20,
reset_at: time,
last_offset_buckets: { le_5: 0, le_15: 20, le_30: 15, le_60: 25, gt_60: 10 }
},
upcoming: null
}
]
}
} else if (path === '/api/admin/polling-stats/tiers') {
data = {
by_config: [
{ config_id: 1, config_name: configs[0].config_name, cards: 95 },
{ config_id: 0, config_name: '', cards: 5 }
],
by_package_state: { active: 80, depleted: 10, pending: 5, none: 3, unknown: 2 },
computed_at: time,
total_cards: 100,
unmatched_in_stock: 2,
unmatched_sold_no_package: 3
}
} else if (path === '/api/admin/polling-configs' && req.method === 'GET') {
const query = url.searchParams.get('config_name') || ''
data = page(
configs.filter((item) => item.config_name.includes(query)),
url
)
} else if (path === '/api/admin/polling-configs' && req.method === 'POST') {
data = {
...input,
id: configs.length + 1,
status: 1,
created_at: time,
updated_at: time,
warnings: ['本地模拟:保存提示']
}
configs.push(data)
} else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'PUT') {
const row = configs.find((item) => item.id === Number(path.split('/').at(-1)))
Object.assign(row, input)
for (const key of input.clear_fields || []) row[key] = null
delete row.clear_fields
data = { ...row, warnings: ['本地模拟:保存提示'] }
} else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'GET')
data = configs.find((item) => item.id === Number(path.split('/').at(-1)))
else {
code = 400
msg = '此本地验收接口未定义,已拦截写请求'
}
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg, data }))
})
gatewayFixtures.configureServer(server)
}
}

View File

@@ -0,0 +1,737 @@
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)
}
})

View File

@@ -0,0 +1,556 @@
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'),
/<GatewayOperationTable[^>]*: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/
)
})

View File

@@ -0,0 +1,414 @@
// 本地验收专用。所有 API 由此模块或下游 mock 拦截,禁止代理真实服务。
import { mockApiPlugin as polling } from './pollingConfigBrowserFixtures.mjs'
const time = '2026-10-26T10:00:00+08:00'
const menus = [
['运营商账户调度参数', 'carrier-params', 'polling_carrier_params:page'],
['请求检查场景', 'check-scenes', 'polling_check_scene:page'],
['失败告警', 'failure-alerts', 'polling_failure_alert:page'],
['账户队列监控', 'carrier-queues', 'polling_carrier_queue:page'],
['停复机规则演练', 'stop-resume-dry-run', 'stop_resume_dry_run:page'],
['不轮询清单', 'polling-pauses', 'polling_pause:page'],
['轮询开关导入', 'polling-status-imports', 'polling_status_import:page'],
['轮询配置', 'config'],
['网关调用统计', 'gateway-stats', 'gateway_call_stats:page']
]
const permissions = [
'polling_config_create',
'polling_carrier_params:detail',
'polling_carrier_params:detail_page',
'polling_failure_alert:detail',
'polling_failure_alert:detail_page',
'polling_failure_alert:summary',
'polling_failure_alert:handle',
'polling_failure_alert:close_polling',
'polling_failure_alert:export',
'polling_pause:restore',
'polling_pause:export',
'polling_status_import:create',
'polling_status_import:detail',
'polling_status_import:detail_page',
'stop_resume_dry_run:export',
'iot_info:polling_state',
'device_info:polling_state',
'iot_card:batch_polling',
'device:batch_polling',
'gateway_call_stats:trend',
'iot_info:stop_resume_records',
'polling_config:match_preview'
]
const params = {
carrier_id: 8,
carrier_name: '模拟运营商账户',
is_default: true,
card_max_in_flight: 10,
inflight_limit: 20,
configured_card_max_in_flight: null,
configured_inflight_limit: null,
consume_capacity: 30,
priority_max_ratio: null,
sr_inflight_limit: null,
stop_resume_max_in_flight: null,
stop_resume_min_interval: null,
stop_resume_confirm_delay: null
}
const alert = {
id: 1,
alert_level: 'card',
card_id: 42,
carrier_id: 8,
carrier_name: params.carrier_name,
check_type: 'stop_resume',
check_type_name: '停复机',
created_at: time,
iccid: '89860426012345678901',
status: 1,
status_name: '未处理',
error_category: 'terminal',
fail_count: 3,
first_failed_at: time,
last_failed_at: time,
last_error: '模拟停机被拒',
handled_by: 0,
handle_remark: '',
handled_at: '',
recovered_at: '',
network_status: 1,
stop_reason: 'manual',
enable_polling: true,
has_package: false
}
const pause = {
id: 1,
asset_id: 42,
asset_type: 'iot_card',
identifier: alert.iccid,
carrier_id: 8,
carrier_name: params.carrier_name,
enable_polling: false,
gateway_extend: '',
status: 1,
status_name: '已关闭',
close_source: 'batch',
close_source_name: '勾选批量',
close_reason: '模拟关闭',
closed_by: 0,
closed_by_name: '',
closed_at: time,
restore_source: '',
restore_reason: '',
restored_by: 0,
restored_at: '',
import_task_id: 0
}
const task = {
id: 1,
task_no: 'MOCK-IMPORT-1',
asset_type: 'iot_card',
enable_polling: false,
file_name: 'mock.csv',
reason: '模拟导入',
status: 3,
status_name: '已完成',
creator_name: '模拟平台',
total_count: 4,
success_count: 1,
skip_count: 1,
fail_count: 2,
error_message: '',
created_at: time,
started_at: time,
completed_at: time,
items: [1, 2, 3, 4].map((status, index) => ({
line: index + 2,
identifier: `8986042601234567890${index}`,
asset_id: index + 42,
status,
status_name: ['', '成功', '跳过', '失败', '无权限或不存在'][status],
reason: status > 1 ? '模拟逐项原因' : ''
}))
}
const operation = {
operation: 'stop',
operation_name: '停机',
attempts: 10,
success: 8,
success_rate: 0.8,
avg_qps: 0.2,
peak_minute_qps: 0.3,
avg_duration_ms: 230,
p95_duration_ms: -1,
rate_limited: 0,
would_limit: 2,
failures: { 'upstream:terminal': 2 },
exempt: { manual_click: 1 }
}
const log = []
const page = (items, url) => ({
items,
total: items.length,
page: Number(url.searchParams.get('page') || 1),
size: Number(url.searchParams.get('page_size') || 20)
})
export const mockApiPlugin = {
name: 'polling-scheduler-local-mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1'),
path = url.pathname
if (path === '/__scheduler-upload') {
for await (const _ of req) {
}
res.end('')
return
}
if (path === '/__scheduler-log') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(log))
return
}
const handled =
/^\/api\/admin\/(polling-carrier-params|polling-check-scenes|polling-failure-alerts|card-polling-pauses|polling-status-imports|polling-status\/impact|stop-resume-rule\/dry-run|polling-stats\/carrier|storage\/upload-url)/.test(
path
) ||
/\/polling-state$|\/batch-update-polling-status$/.test(path) ||
['/api/auth/me', '/api/auth/login'].includes(path)
if (!handled) return next()
let raw = ''
for await (const chunk of req) raw += chunk
const input = raw ? JSON.parse(raw) : {},
token = String(req.headers.authorization || ''),
type = token.includes('agent')
? 3
: token.includes('enterprise')
? 4
: token.includes('admin')
? 1
: 2
log.push({
path,
method: req.method,
query: Object.fromEntries(url.searchParams),
body: input
})
let data = {},
code = 0
if (path.startsWith('/api/auth/'))
data = {
access_token: `local-scheduler-${type === 1 ? 'admin' : type === 3 ? 'agent' : type === 4 ? 'enterprise' : 'platform'}`,
refresh_token: '',
expires_in: 3600,
user: {
id: type,
user_type: type,
username: '本地模拟账号',
roles: [type === 1 ? 'R_SUPER' : 'R_ADMIN']
},
permissions: menus.map((row) => row[2]).filter(Boolean),
buttons: permissions,
menus: [
{
url: '/polling-management',
name: '轮询管理',
children: [
...menus,
[
'运营商账户调度参数详情',
'carrier-params/detail/:carrier_id',
'polling_carrier_params:detail_page'
],
['失败告警详情', 'failure-alerts/detail/:id', 'polling_failure_alert:detail_page'],
[
'导入任务详情',
'polling-status-imports/detail/:id',
'polling_status_import:detail_page'
]
].map(([name, path, perm_code]) => ({
name,
url: `/polling-management/${path}`,
perm_code
}))
}
]
}
else if (path === '/api/admin/polling-carrier-params')
data = {
default_card_max_in_flight: 10,
default_inflight_limit: 20,
subscriber_processes: 3,
items: [params]
}
else if (path.startsWith('/api/admin/polling-carrier-params/')) data = { ...params, ...input }
else if (path.startsWith('/api/admin/polling-check-scenes'))
data = [
{
scene: 'order_paid',
scene_name: '订单支付',
check_types: ['carddata', 'card_status'],
confirm_delays: [30, 60],
cooldown: null,
updated_at: time
}
]
else if (path === '/api/admin/polling-stats/carrier-queues')
data = {
scheduler_mode: 'carrier',
init_state: 'completed',
items: [
{
carrier_id: 8,
carrier_name: params.carrier_name,
queue_length: 10,
priority_length: 1,
due_count: 2,
running: 1,
inflight_limit: 20,
qps_last_minute: 0.2,
rate_limit_waits: 1,
rate_limited: 0,
expired_claims_1h: 0,
open_alerts: 2,
open_alerts_by_category: { terminal: 2 },
stop_resume_queue_length: 5,
stop_resume_due: 1,
stop_resume_running: 1,
stop_resume_pending: 2,
stop_resume_success_1h: 10,
stop_resume_failed_1h: 2,
stop_resume_retry_1h: 3,
stop_resume_unknown_1h: 2
}
]
}
else if (path === '/api/admin/polling-stats/carriers')
data = {
minutes: 15,
mode: 'observe',
carriers: [{ carrier_id: 8, carrier_name: params.carrier_name, operations: [operation] }]
}
else if (path.endsWith('/trend'))
data = { carrier_id: 8, points: [{ time: '2026-10-26 10:00', operations: [operation] }] }
else if (path === '/api/admin/polling-failure-alerts/summary')
data = [
{
carrier_id: 8,
carrier_name: params.carrier_name,
error_category: 'terminal',
open_count: 2
}
]
else if (path === '/api/admin/polling-failure-alerts')
data = page(
[
alert,
{ ...alert, id: 2, alert_level: 'carrier', card_id: 0, iccid: '', check_type: 'all' }
],
url
)
else if (path.endsWith('/close-polling') || path.endsWith('/batch-update-polling-status'))
data = {
success_count: 1,
skip_count: 1,
fail_count: 2,
impact: ['本地模拟影响'],
items: task.items
}
else if (path.startsWith('/api/admin/polling-failure-alerts/')) data = alert
else if (path === '/api/admin/card-polling-pauses') data = page([pause], url)
else if (path === '/api/admin/polling-status/impact')
data = {
enable: ['本地模拟:按配置查询并自动停复机。'],
disable: ['本地模拟:关闭查询及部分自动停复机;部分业务仍生效。']
}
else if (path === '/api/admin/polling-status-imports')
data =
req.method === 'POST' ? { ...task, status: 1, status_name: '待处理' } : page([task], url)
else if (path.startsWith('/api/admin/polling-status-imports/')) data = task
else if (path === '/api/admin/storage/upload-url')
data = {
upload_url: 'http://127.0.0.1:5190/__scheduler-upload',
file_key: 'polling-status-imports/mock.csv'
}
else if (path.endsWith('/polling-state'))
data = {
card_id: 42,
carrier_id: 8,
iccid: alert.iccid,
enable_polling: true,
scheduler_mode: 'carrier',
queue: 'priority',
queue_score_at: time,
running: false,
priority_mark: false,
claim_expire_at: '',
stop_resume_queue_enabled: true,
last_stop_resume_call_at: time,
stop_resume_command: {
action: 'stop',
reason: 'manual',
scenes: ['order_paid'],
source: 'order',
version: 1,
fail_count: 0,
last_error: '',
pending: true,
unknown: 1,
written_at: time,
next_at: time
},
types: [
{
check_type: 'carddata',
check_type_name: '流量',
next_due_at: time,
last_success_at: time,
fail_count: 0,
first_failed_at: '',
last_error: '',
pending_confirm: [30, 60]
}
]
}
else if (path === '/api/admin/stop-resume-rule/dry-run')
data = {
...page(
[
{
card_id: 42,
carrier_id: 8,
carrier_name: params.carrier_name,
iccid: alert.iccid,
device_id: 0,
network_status: 1,
card_category: 'normal',
realname_ok: false,
stop_reason: 'none',
stop_reason_name: '无',
result: 'will_stop',
result_name: '将停机',
reason: 'not_realname',
reason_name: '未实名'
}
],
url
),
totals: { total: 100, will_stop: 20, will_resume: 10, no_action: 70 },
summary: [
{
carrier_id: 8,
carrier_name: params.carrier_name,
stop_reason_name: '无',
result_name: '将停机',
reason_name: '未实名',
count: 20
}
]
}
else if (path.endsWith('/export'))
data = { task_id: 1, task_no: 'MOCK-EXPORT-1', status: 1, status_name: '待处理' }
else code = 400
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg: 'local mock only', data }))
})
polling.configureServer(server)
}
}

View File

@@ -41,9 +41,13 @@ plugins.push(
)
if (serve) {
const { mockApiPlugin } = await import(
process.argv.includes('--gateway')
? './gatewayBrowserFixtures.mjs'
: './octoberBrowserFixtures.mjs'
process.argv.includes('--scheduler')
? './pollingSchedulerBrowserFixtures.mjs'
: process.argv.includes('--polling')
? './pollingConfigBrowserFixtures.mjs'
: process.argv.includes('--gateway')
? './gatewayBrowserFixtures.mjs'
: './octoberBrowserFixtures.mjs'
)
const server = await createServer({
...loaded.config,