Files
one-pipe-system/scripts/tests/pollingConfigExtensions.test.mjs
luo 06684e1fc0
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 55s
fix: 轮询按运营商账户限流
2026-10-26 14:31:15 +08:00

738 lines
23 KiB
JavaScript

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)
}
})