This commit is contained in:
@@ -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 }
|
||||
|
||||
5
scripts/tests/fixtures/polling-status-import.csv
vendored
Normal file
5
scripts/tests/fixtures/polling-status-import.csv
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
iccid,note
|
||||
89860426012345678901,local mock
|
||||
89860426012345678902,skip
|
||||
89860426012345678903,fail
|
||||
89860426012345678904,denied
|
||||
|
48
scripts/tests/fixtures/pollingSchedulerEndpoints.json
vendored
Normal file
48
scripts/tests/fixtures/pollingSchedulerEndpoints.json
vendored
Normal 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"
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
277
scripts/tests/pollingConfigBrowserFixtures.mjs
Normal file
277
scripts/tests/pollingConfigBrowserFixtures.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
737
scripts/tests/pollingConfigExtensions.test.mjs
Normal file
737
scripts/tests/pollingConfigExtensions.test.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
556
scripts/tests/pollingScheduler.test.mjs
Normal file
556
scripts/tests/pollingScheduler.test.mjs
Normal 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/
|
||||
)
|
||||
})
|
||||
414
scripts/tests/pollingSchedulerBrowserFixtures.mjs
Normal file
414
scripts/tests/pollingSchedulerBrowserFixtures.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user