This commit is contained in:
726
scripts/tests/gateway.test.mjs
Normal file
726
scripts/tests/gateway.test.mjs
Normal file
@@ -0,0 +1,726 @@
|
||||
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'
|
||||
|
||||
function evaluate(source, imports = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
const source = (path) => readFileSync(path, 'utf8')
|
||||
const format = evaluate(source('src/utils/business/format.ts'))
|
||||
const utils = evaluate(source('src/utils/business/gateway.ts'), { './format': format })
|
||||
const constants = evaluate(source('src/config/constants/gateway.ts'))
|
||||
const permissions = constants.GATEWAY_PERMISSIONS
|
||||
const rule = {
|
||||
category: 'business',
|
||||
keyword: 'failed',
|
||||
match_type: 'contains',
|
||||
priority: 10,
|
||||
status: 1,
|
||||
operations: ['stop']
|
||||
}
|
||||
const group = {
|
||||
group_code: 'default',
|
||||
group_name: '默认组',
|
||||
max_qps: 20,
|
||||
reserved_qps: 5,
|
||||
operations: ['stop', 'start'],
|
||||
high_priority_operations: ['stop'],
|
||||
status: 1
|
||||
}
|
||||
const option = (value) => ({ value, label: `动态-${value}` })
|
||||
const options = {
|
||||
error_categories: ['business', 'terminal'].map(option),
|
||||
exempt_sources: ['manual'].map(option),
|
||||
gateway_operations: ['stop', 'start', 'query_flow', 'query_status', 'query_realname'].map(option),
|
||||
match_types: ['contains', 'prefix', 'regex'].map(option),
|
||||
rate_limit_modes: ['default'].map(option),
|
||||
stop_resume_actions: ['stop', 'start'].map(option),
|
||||
stop_resume_results: ['success', 'failed', 'unknown', 'already_target', 'skipped'].map(option)
|
||||
}
|
||||
const success = (data) => ({ code: 0, data, msg: 'ok' })
|
||||
const page = (items) => success({ items, total: items.length, page: 1, size: 20 })
|
||||
const deferred = () => {
|
||||
let resolve
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
async function flush() {
|
||||
await new Promise((done) => setImmediate(done))
|
||||
await vue.nextTick()
|
||||
}
|
||||
|
||||
test('all 17 gateway service contracts preserve methods, paths, params and response', async () => {
|
||||
const calls = []
|
||||
const response = success({ marker: true })
|
||||
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 { GatewayService: service } = evaluate(source('src/api/modules/gateway.ts'), {
|
||||
'../BaseService': base
|
||||
})
|
||||
const recordsQuery = {
|
||||
page: 2,
|
||||
page_size: 100,
|
||||
carrier_id: 0,
|
||||
start_time: '2026-10-01T00:00:00Z',
|
||||
end_time: '2026-10-24T00:00:00Z'
|
||||
}
|
||||
const contracts = [
|
||||
['getRecords', [recordsQuery], 'get', '/card-stop-resume-records', 'params', recordsQuery],
|
||||
[
|
||||
'getCardRecords',
|
||||
[42, { page: 1 }],
|
||||
'get',
|
||||
'/iot-cards/42/stop-resume-records',
|
||||
'params',
|
||||
{ page: 1 }
|
||||
],
|
||||
['getRules', [{ status: 0 }], 'get', '/gateway-error-classifications', 'params', { status: 0 }],
|
||||
['createRule', [rule], 'post', '/gateway-error-classifications', 'data', rule],
|
||||
['updateRule', [7, rule], 'put', '/gateway-error-classifications/7', 'data', rule],
|
||||
[
|
||||
'setRuleStatus',
|
||||
[7, 0],
|
||||
'put',
|
||||
'/gateway-error-classifications/7/status',
|
||||
'data',
|
||||
{ status: 0 }
|
||||
],
|
||||
['deleteRule', [7], 'del', '/gateway-error-classifications/7', 'params', undefined],
|
||||
[
|
||||
'testRule',
|
||||
[{ operation: 'stop', message: 'failed', draft: rule }],
|
||||
'post',
|
||||
'/gateway-error-classifications/test',
|
||||
'data',
|
||||
{ operation: 'stop', message: 'failed', draft: rule }
|
||||
],
|
||||
['getOptions', [], 'get', '/polling-options', 'params', undefined],
|
||||
[
|
||||
'getGroups',
|
||||
[{ only_global: false, carrier_id: 0, status: 0 }],
|
||||
'get',
|
||||
'/polling-rate-limit-groups',
|
||||
'params',
|
||||
{ only_global: false, carrier_id: 0, status: 0 }
|
||||
],
|
||||
['createGroup', [group], 'post', '/polling-rate-limit-groups', 'data', group],
|
||||
['getGroup', [4], 'get', '/polling-rate-limit-groups/4', 'params', undefined],
|
||||
['updateGroup', [4, group], 'put', '/polling-rate-limit-groups/4', 'data', group],
|
||||
['setGroupStatus', [4, 1], 'put', '/polling-rate-limit-groups/4/status', 'data', { status: 1 }],
|
||||
['deleteGroup', [4], 'del', '/polling-rate-limit-groups/4', 'params', undefined],
|
||||
['getCarrierStats', [60], 'get', '/polling-stats/carriers', 'params', { minutes: 60 }],
|
||||
[
|
||||
'getCarrierTrend',
|
||||
[0, { minutes: 10080, bucket_minutes: 60 }],
|
||||
'get',
|
||||
'/polling-stats/carriers/0/trend',
|
||||
'params',
|
||||
{ minutes: 10080, bucket_minutes: 60 }
|
||||
]
|
||||
]
|
||||
for (const [method, args, verb, url, field, payload] of contracts) {
|
||||
assert.equal(await service[method](...args), response)
|
||||
assert.deepEqual(calls.at(-1), { method: verb, url: `/api/admin${url}`, [field]: payload })
|
||||
}
|
||||
assert.equal(calls.length, 17)
|
||||
})
|
||||
|
||||
test('record dates are RFC3339, ordered and bounded to 31 days, optional only for cards', () => {
|
||||
assert.deepEqual(utils.gatewayTimeRange(['2026-10-01T08:00:00+08:00', '2026-11-01T00:00:00Z']), {
|
||||
start_time: '2026-10-01T00:00:00.000Z',
|
||||
end_time: '2026-11-01T00:00:00.000Z'
|
||||
})
|
||||
assert.deepEqual(utils.gatewayTimeRange(null, false), {})
|
||||
for (const range of [
|
||||
null,
|
||||
[],
|
||||
[''],
|
||||
['invalid', 'invalid'],
|
||||
['2026-10-02', '2026-10-01'],
|
||||
['2026-10-01', '2026-11-01T00:00:00.001Z']
|
||||
])
|
||||
assert.throws(() => utils.gatewayTimeRange(range))
|
||||
const [start, end] = utils.recentGatewayRange()
|
||||
assert.equal(end - start, 7 * 86400000)
|
||||
})
|
||||
|
||||
test('limit groups validate QPS, operation subset, carrier zero and text limits', () => {
|
||||
assert.doesNotThrow(() => utils.validateGatewayGroup({ ...group, carrier_id: 0 }))
|
||||
assert.doesNotThrow(() =>
|
||||
utils.validateGatewayGroup({ ...group, reserved_qps: 0, high_priority_operations: [] })
|
||||
)
|
||||
for (const patch of [
|
||||
{ max_qps: 0 },
|
||||
{ max_qps: 10001 },
|
||||
{ max_qps: 1.5 },
|
||||
{ reserved_qps: -1 },
|
||||
{ reserved_qps: 20 },
|
||||
{ reserved_qps: 0.5 },
|
||||
{ operations: [] },
|
||||
{ high_priority_operations: [] },
|
||||
{ high_priority_operations: ['query_flow'] },
|
||||
{ group_code: ' ' },
|
||||
{ group_name: 'x'.repeat(101) },
|
||||
{ carrier_id: -1 }
|
||||
])
|
||||
assert.throws(() => utils.validateGatewayGroup({ ...group, ...patch }))
|
||||
})
|
||||
|
||||
test('rules validate required fields, priority and lengths, including draft rules', () => {
|
||||
assert.doesNotThrow(() => utils.validateGatewayRule(rule))
|
||||
for (const patch of [
|
||||
{ keyword: ' ' },
|
||||
{ keyword: 'x'.repeat(201) },
|
||||
{ category: '' },
|
||||
{ match_type: '' },
|
||||
{ priority: 0 },
|
||||
{ priority: 10001 },
|
||||
{ priority: 1.2 },
|
||||
{ remark: 'x'.repeat(501) }
|
||||
])
|
||||
assert.throws(() => utils.validateGatewayRule({ ...rule, ...patch }))
|
||||
})
|
||||
|
||||
test('unknown enums and statistic fields remain visible, without fabricated zeroes', () => {
|
||||
assert.equal(utils.gatewayLabel(options.gateway_operations, 'stop'), '动态-stop')
|
||||
assert.equal(
|
||||
utils.gatewayLabel(options.gateway_operations, 'future_operation'),
|
||||
'future_operation'
|
||||
)
|
||||
assert.equal(utils.gatewayValue(0), '0')
|
||||
assert.equal(utils.gatewayValue(false), 'false')
|
||||
assert.equal(utils.gatewayValue(null), '-')
|
||||
assert.deepEqual(utils.gatewayRows([{ future_metric: 12 }, 0, null]), [
|
||||
{ future_metric: 12 },
|
||||
{ value: 0 },
|
||||
{ value: null }
|
||||
])
|
||||
assert.deepEqual(
|
||||
utils.compactGatewayQuery({
|
||||
carrier_id: 0,
|
||||
status: 0,
|
||||
only_global: false,
|
||||
blank: '',
|
||||
missing: undefined
|
||||
}),
|
||||
{ carrier_id: 0, status: 0, only_global: false }
|
||||
)
|
||||
assert.throws(() => utils.gatewayData({ code: 403, msg: '没有权限' }), /没有权限/)
|
||||
})
|
||||
|
||||
function mountComponent(
|
||||
t,
|
||||
path,
|
||||
{ superAdmin = false, perms = [], overrides = {}, props = {} } = {}
|
||||
) {
|
||||
const calls = []
|
||||
const user = vue.reactive({ isSuperAdmin: superAdmin, perms })
|
||||
const service = Object.fromEntries(
|
||||
['getRecords', 'getCardRecords', 'getRules', 'getGroups'].map((name) => [
|
||||
name,
|
||||
async (...args) => {
|
||||
calls.push({ name, args })
|
||||
return page([])
|
||||
}
|
||||
])
|
||||
)
|
||||
Object.assign(service, { getOptions: async () => success(options) })
|
||||
for (const name of [
|
||||
'createRule',
|
||||
'updateRule',
|
||||
'setRuleStatus',
|
||||
'deleteRule',
|
||||
'testRule',
|
||||
'createGroup',
|
||||
'updateGroup',
|
||||
'setGroupStatus',
|
||||
'deleteGroup',
|
||||
'getGroup',
|
||||
'getCarrierStats',
|
||||
'getCarrierTrend'
|
||||
])
|
||||
service[name] = async (...args) => {
|
||||
calls.push({ name, args })
|
||||
return success({
|
||||
...group,
|
||||
id: 4,
|
||||
matched: true,
|
||||
rule_id: 0,
|
||||
category: 'business',
|
||||
category_name: '业务错误',
|
||||
keyword: 'failed',
|
||||
maybe_accepted: false,
|
||||
carriers: [],
|
||||
points: [],
|
||||
carrier_id: args[0]
|
||||
})
|
||||
}
|
||||
for (const [name, handler] of Object.entries(overrides))
|
||||
service[name] = async (...args) => {
|
||||
calls.push({ name, args })
|
||||
return handler(...args)
|
||||
}
|
||||
const imports = {
|
||||
vue,
|
||||
'vue-router': {
|
||||
useRouter: () => ({
|
||||
push: async (url) => {
|
||||
calls.push({ name: 'navigate', args: [url] })
|
||||
},
|
||||
back() {}
|
||||
}),
|
||||
useRoute: () => ({ params: { id: '4' } })
|
||||
},
|
||||
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) },
|
||||
'@element-plus/icons-vue': { ArrowLeft: {} },
|
||||
'@/router/routesAlias': {
|
||||
RoutesAlias: { GatewayRateLimitGroupDetail: '/polling-management/rate-limit-groups/detail' }
|
||||
},
|
||||
'@/utils/business/format': format,
|
||||
'@/api/modules/carrier': {
|
||||
CarrierService: {
|
||||
getCarriers: async (...args) => {
|
||||
calls.push({ name: 'getCarriers', args })
|
||||
if (overrides.getCarriers) return overrides.getCarriers(...args)
|
||||
return page([{ id: 8, carrier_name: '模拟运营商' }])
|
||||
}
|
||||
}
|
||||
},
|
||||
'element-plus': {
|
||||
ElMessage: { success() {}, error() {} },
|
||||
ElMessageBox: { confirm: async () => {} }
|
||||
},
|
||||
'@/api/modules/gateway': { GatewayService: service },
|
||||
'@/store/modules/user': { useUserStore: () => user },
|
||||
'@/composables/useAuth': {
|
||||
useAuth: () => ({ hasAuth: (perm) => user.isSuperAdmin || user.perms.includes(perm) })
|
||||
},
|
||||
'@/config/constants/gateway': constants,
|
||||
'@/utils/business/gateway': utils,
|
||||
'@/components/business/gateway/GatewayOptionsNotice.vue': { default: {} },
|
||||
'@/components/business/gateway/GatewayRuleFields.vue': { default: {} },
|
||||
'@/components/business/gateway/GatewayDynamicData.vue': { default: {} },
|
||||
'./GatewayOptionsNotice.vue': { default: {} }
|
||||
}
|
||||
imports['@/composables/useGateway'] = evaluate(source('src/composables/useGateway.ts'), imports)
|
||||
const { descriptor } = parse(source(path))
|
||||
const component = evaluate(
|
||||
compileScript(descriptor, { id: 'gateway-test' }).content,
|
||||
imports
|
||||
).default
|
||||
let state
|
||||
const reactiveProps = vue.reactive(props)
|
||||
const renderer = vue.createRenderer({
|
||||
createComment: () => ({}),
|
||||
insert() {},
|
||||
remove() {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null
|
||||
})
|
||||
const app = renderer.createApp({
|
||||
setup() {
|
||||
state = component.setup(reactiveProps, { expose() {}, emit() {} })
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
app.mount({})
|
||||
t.after(() => app.unmount())
|
||||
return { state, calls, user, props: reactiveProps, service }
|
||||
}
|
||||
const rulePage = 'src/views/polling-management/error-classifications/index.vue'
|
||||
const groupPage = 'src/views/polling-management/rate-limit-groups/index.vue'
|
||||
const statsPage = 'src/views/polling-management/gateway-stats/index.vue'
|
||||
const recordComponent = 'src/components/business/gateway/StopResumeRecords.vue'
|
||||
|
||||
test('ordinary users cannot maintain either rules or groups, even if handlers are called', async (t) => {
|
||||
for (const [path, area, save, mutate, input] of [
|
||||
[rulePage, 'rules', 'saveRule', 'changeRule', rule],
|
||||
[groupPage, 'groups', 'saveGroup', 'changeGroup', group]
|
||||
]) {
|
||||
const { state, calls } = mountComponent(t, path, { perms: [permissions[area].page] })
|
||||
await flush()
|
||||
state.form.value = input
|
||||
await state[save]()
|
||||
await state[mutate]({ ...input, id: 1 }, true)
|
||||
assert.equal(
|
||||
calls.filter((call) => /create|update|delete|set.*Status/.test(call.name)).length,
|
||||
0
|
||||
)
|
||||
assert.match(state.editorError.value, /无权/)
|
||||
}
|
||||
})
|
||||
|
||||
test('superadmin saves use write DTOs without record ids or timestamps', async (t) => {
|
||||
for (const [path, save, input, method] of [
|
||||
[rulePage, 'saveRule', rule, 'createRule'],
|
||||
[groupPage, 'saveGroup', group, 'createGroup']
|
||||
]) {
|
||||
const { state, calls } = mountComponent(t, path, { superAdmin: true })
|
||||
await flush()
|
||||
state.form.value = { ...input }
|
||||
await state[save]()
|
||||
const call = calls.find((call) => call.name === method)
|
||||
assert.ok(call)
|
||||
assert.equal(call.args[0].id, undefined)
|
||||
if (method === 'createGroup') assert.equal(call.args[0].carrier_id, null)
|
||||
assert.equal(state.editorError.value, '')
|
||||
}
|
||||
})
|
||||
|
||||
test('ordinary rule tests require page + button, validate drafts, and never write rules', async (t) => {
|
||||
const { state, calls, user } = mountComponent(t, rulePage, {
|
||||
perms: [permissions.rules.page, permissions.rules.test]
|
||||
})
|
||||
await flush()
|
||||
state.openTest()
|
||||
state.testForm.operation = 'stop'
|
||||
state.testForm.message = 'failed'
|
||||
state.includeDraft.value = true
|
||||
await state.runTest()
|
||||
assert.equal(calls.filter((call) => call.name === 'testRule').length, 0)
|
||||
state.draft.value = { ...rule }
|
||||
await state.runTest()
|
||||
assert.deepEqual(calls.find((call) => call.name === 'testRule').args[0], {
|
||||
operation: 'stop',
|
||||
message: 'failed',
|
||||
draft: rule
|
||||
})
|
||||
assert.equal(state.testResult.value.matched, true)
|
||||
user.perms = [permissions.rules.test]
|
||||
await state.runTest()
|
||||
assert.match(state.testError.value, /无权/)
|
||||
assert.equal(calls.filter((call) => call.name === 'testRule').length, 1)
|
||||
})
|
||||
|
||||
test('test close/reopen discards a pending result', async (t) => {
|
||||
const pending = deferred()
|
||||
const { state } = mountComponent(t, rulePage, {
|
||||
superAdmin: true,
|
||||
overrides: { testRule: () => pending.promise }
|
||||
})
|
||||
await flush()
|
||||
state.openTest()
|
||||
Object.assign(state.testForm, { operation: 'stop', message: 'failed' })
|
||||
const running = state.runTest()
|
||||
state.testVisible.value = false
|
||||
await vue.nextTick()
|
||||
state.openTest()
|
||||
pending.resolve(success({ matched: true }))
|
||||
await running
|
||||
assert.equal(state.testResult.value, null)
|
||||
})
|
||||
|
||||
test('enum failure is visible, blocks editing and can be retried with dynamic labels', async (t) => {
|
||||
let failed = true
|
||||
const { state } = mountComponent(t, rulePage, {
|
||||
superAdmin: true,
|
||||
overrides: {
|
||||
getOptions: () => (failed ? Promise.reject(new Error('枚举不可用')) : success(options))
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
assert.equal(state.optionsReady.value, false)
|
||||
assert.match(state.optionsError.value, /枚举不可用/)
|
||||
state.openEditor()
|
||||
assert.equal(state.editorVisible.value, false)
|
||||
failed = false
|
||||
await state.loadOptions()
|
||||
assert.equal(state.optionsReady.value, true)
|
||||
assert.equal(state.label('gateway_operations', 'stop'), '动态-stop')
|
||||
})
|
||||
|
||||
test('record queries validate time, preserve zero filters and isolate cards by real ID', async (t) => {
|
||||
const { state, calls, props } = mountComponent(t, recordComponent, {
|
||||
perms: [permissions.cardHistory],
|
||||
props: { cardId: 42, permission: permissions.cardHistory }
|
||||
})
|
||||
await flush()
|
||||
assert.equal(calls[0].name, 'getCardRecords')
|
||||
assert.equal(calls[0].args[0], 42)
|
||||
state.range.value = null
|
||||
await state.search()
|
||||
assert.deepEqual(calls.at(-1).args, [42, { page: 1, page_size: 20 }])
|
||||
props.cardId = 43
|
||||
await flush()
|
||||
assert.equal(calls.at(-1).args[0], 43)
|
||||
props.cardId = undefined
|
||||
props.permission = permissions.records.page
|
||||
await flush()
|
||||
assert.match(state.error.value, /无权/)
|
||||
})
|
||||
|
||||
test('invalid global date ranges never request records; zero carrier survives', async (t) => {
|
||||
const { state, calls } = mountComponent(t, recordComponent, {
|
||||
perms: [permissions.records.page],
|
||||
props: { permission: permissions.records.page }
|
||||
})
|
||||
await flush()
|
||||
calls.length = 0
|
||||
state.range.value = null
|
||||
await state.search()
|
||||
assert.equal(calls.length, 0)
|
||||
state.range.value = [new Date('2026-10-01'), new Date('2026-10-24')]
|
||||
state.filters.carrier_id = 0
|
||||
await state.search()
|
||||
assert.equal(calls[0].args[0].carrier_id, 0)
|
||||
})
|
||||
|
||||
test('list latest request wins and errors clear previous records', async (t) => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
let count = 0
|
||||
const { state } = mountComponent(t, groupPage, {
|
||||
superAdmin: true,
|
||||
overrides: { getGroups: () => (++count === 1 ? first.promise : second.promise) }
|
||||
})
|
||||
const latest = state.load()
|
||||
second.resolve(page([{ ...group, id: 2 }]))
|
||||
await latest
|
||||
first.resolve(page([{ ...group, id: 1 }]))
|
||||
await flush()
|
||||
assert.equal(state.rows.value[0].id, 2)
|
||||
})
|
||||
|
||||
test('group details require permissions before navigation to shop-style detail page', async (t) => {
|
||||
const { state, calls, user } = mountComponent(t, groupPage, { perms: [permissions.groups.page] })
|
||||
await flush()
|
||||
await state.openDetail(4)
|
||||
assert.equal(
|
||||
calls.some((call) => call.name === 'navigate'),
|
||||
false
|
||||
)
|
||||
user.perms.push(permissions.groups.detail)
|
||||
await state.openDetail(4)
|
||||
assert.equal(
|
||||
calls.find((call) => call.name === 'navigate').args[0],
|
||||
'/polling-management/rate-limit-groups/detail/4'
|
||||
)
|
||||
})
|
||||
|
||||
test('statistics support account 0, reject bad bounds and retain raw unknown metrics', async (t) => {
|
||||
const { state, calls } = mountComponent(t, statsPage, {
|
||||
perms: [permissions.stats.page, permissions.stats.trend],
|
||||
overrides: {
|
||||
getCarrierTrend: (id) =>
|
||||
success({ carrier_id: id, points: [{ new_metric: 9, future_time: '2026-10-24' }] })
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
state.carrierId.value = 0
|
||||
await state.loadTrend()
|
||||
assert.equal(state.trend.value.carrier_id, 0)
|
||||
assert.equal(state.trend.value.points[0].new_metric, 9)
|
||||
state.trendMinutes.value = 10081
|
||||
await state.loadTrend()
|
||||
assert.equal(calls.filter((call) => call.name === 'getCarrierTrend').length, 1)
|
||||
state.summaryMinutes.value = 61
|
||||
await state.loadSummary()
|
||||
assert.equal(calls.filter((call) => call.name === 'getCarrierStats').length, 1)
|
||||
assert.equal(state.trend.value, null)
|
||||
})
|
||||
|
||||
test('route aliases match all four pages and ordinary permissions use colon codes', () => {
|
||||
const routes = source('src/router/routes/asyncRoutes.ts')
|
||||
const aliases = source('src/router/routesAlias.ts')
|
||||
for (const [name, segment] of [
|
||||
['GatewayStopResumeRecords', 'stop-resume-records'],
|
||||
['GatewayErrorClassifications', 'error-classifications'],
|
||||
['GatewayRateLimitGroups', 'rate-limit-groups'],
|
||||
['GatewayCallStats', 'gateway-stats']
|
||||
]) {
|
||||
assert.ok(routes.includes(`name: '${name}'`))
|
||||
assert.ok(aliases.includes(`'/polling-management/${segment}'`))
|
||||
assert.ok(source(`src/views/polling-management/${segment}/index.vue`))
|
||||
}
|
||||
const flattened = Object.values(permissions).flatMap((value) =>
|
||||
typeof value === 'string' ? [value] : Object.values(value)
|
||||
)
|
||||
assert.equal(flattened.length, 10)
|
||||
for (const code of flattened) assert.match(code, /^[a-z_]+:[a-z_]+$/)
|
||||
})
|
||||
|
||||
test('detail page checks permission and formats dates using the shop formatter', async (t) => {
|
||||
const path = 'src/views/polling-management/rate-limit-groups/detail.vue'
|
||||
const denied = mountComponent(t, path)
|
||||
await flush()
|
||||
assert.equal(
|
||||
denied.calls.some((call) => call.name === 'getGroup'),
|
||||
false
|
||||
)
|
||||
const allowed = mountComponent(t, path, { perms: [permissions.groups.detailPage] })
|
||||
await flush()
|
||||
assert.equal(allowed.state.detail.value.id, 4)
|
||||
assert.equal(allowed.state.formatDateTime('2026-10-24T10:00:00+08:00'), '2026-10-24 10:00:00')
|
||||
})
|
||||
|
||||
test('gateway search expand toggles follow the >=4 rule', () => {
|
||||
for (const path of [recordComponent, rulePage, groupPage, statsPage]) {
|
||||
const matches = [...source(path).matchAll(/:show-expand="([^"]+)"/g)]
|
||||
assert.ok(matches.length)
|
||||
for (const match of matches) assert.match(match[1], /\.length >= 4$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('record time picker matches expiring assets estimated-expiry range configuration', async (t) => {
|
||||
const { descriptor } = parse(source('src/views/asset-management/expiring-assets/index.vue'))
|
||||
const script = ts.createSourceFile(
|
||||
'expiring-assets.ts',
|
||||
descriptor.scriptSetup.content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS
|
||||
)
|
||||
let reference
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isObjectLiteralExpression(node) &&
|
||||
node.properties.some(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
property.name.getText(script) === 'label' &&
|
||||
ts.isStringLiteral(property.initializer) &&
|
||||
property.initializer.text === '预计到期起止'
|
||||
)
|
||||
) {
|
||||
reference = evaluate(`export const item = ${node.getText(script)}`).item
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(script)
|
||||
assert.ok(reference)
|
||||
const { state } = mountComponent(t, recordComponent, {
|
||||
perms: [permissions.records.page],
|
||||
props: { permission: permissions.records.page }
|
||||
})
|
||||
await flush()
|
||||
const item = state.searchItems.value.find((item) => item.prop === 'range')
|
||||
assert.equal(item.type, reference.type)
|
||||
assert.equal(item.elColSpan, reference.elColSpan)
|
||||
assert.deepEqual(item.config, reference.config)
|
||||
assert.equal(item.label, '记录时间')
|
||||
})
|
||||
|
||||
test('business timestamps match shop list formatting and IDs retain zero', () => {
|
||||
const timestamp = '2026-10-24T10:00:00+08:00'
|
||||
assert.equal(utils.gatewayDisplayValue(timestamp, 'created_at'), format.formatDateTime(timestamp))
|
||||
assert.equal(
|
||||
utils.gatewayDisplayValue({ created_at: timestamp }),
|
||||
'{"created_at":"2026-10-24 10:00:00"}'
|
||||
)
|
||||
assert.equal(
|
||||
utils.gatewayDisplayValue(1792807200, 'timestamp'),
|
||||
format.formatDateTime(1792807200)
|
||||
)
|
||||
assert.equal(utils.gatewayDisplayValue(0, 'duration_ms'), '0')
|
||||
assert.equal(utils.gatewayOptionalId('0'), 0)
|
||||
assert.equal(utils.gatewayOptionalId(''), undefined)
|
||||
assert.throws(() => utils.gatewayOptionalId('-1'))
|
||||
})
|
||||
|
||||
test('carrier name search uses exact backend parameters and remembers edit labels', async (t) => {
|
||||
const { state, calls } = mountComponent(t, groupPage, { superAdmin: true })
|
||||
await flush()
|
||||
await state.searchCarriers('运营商名称')
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
name: 'getCarriers',
|
||||
args: [{ page: 1, page_size: 20, carrier_name: '运营商名称' }]
|
||||
})
|
||||
state.openEditor({ ...group, id: 7, carrier_id: 99, carrier_name: '原账户' })
|
||||
assert.equal(state.carrierOptions.value.find((option) => option.value === 99).label, '原账户')
|
||||
assert.equal(state.form.value.carrier_id, 99)
|
||||
assert.equal(state.searchItems.value[0].label, '运营商账户')
|
||||
assert.equal(state.searchItems.value[0].type, 'select')
|
||||
})
|
||||
|
||||
test('carrier search ignores a late old response and retains selected account labels', async (t) => {
|
||||
const older = deferred()
|
||||
const { state } = mountComponent(t, groupPage, {
|
||||
superAdmin: true,
|
||||
overrides: {
|
||||
getCarriers: ({ carrier_name }) =>
|
||||
carrier_name === 'old' ? older.promise : page([{ id: 8, carrier_name: '新账户' }])
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
const first = state.searchCarriers('old')
|
||||
await state.searchCarriers('new')
|
||||
state.selectCarrier(8)
|
||||
older.resolve(page([{ id: 9, carrier_name: '旧账户' }]))
|
||||
await first
|
||||
assert.equal(
|
||||
state.carrierOptions.value.some((option) => option.value === 9),
|
||||
false
|
||||
)
|
||||
assert.equal(state.carrierOptions.value.find((option) => option.value === 8).label, '新账户')
|
||||
})
|
||||
|
||||
test('duplicate maintenance submits cannot send a second write while saving', async (t) => {
|
||||
const pending = deferred()
|
||||
const { state, calls } = mountComponent(t, rulePage, {
|
||||
superAdmin: true,
|
||||
overrides: { createRule: () => pending.promise }
|
||||
})
|
||||
await flush()
|
||||
state.form.value = { ...rule }
|
||||
const first = state.saveRule()
|
||||
await state.saveRule()
|
||||
assert.equal(calls.filter((call) => call.name === 'createRule').length, 1)
|
||||
pending.resolve(success(rule))
|
||||
await first
|
||||
})
|
||||
|
||||
test('single-card history entry rejects missing IDs, other assets and denied permissions', () => {
|
||||
const text = source('src/views/asset-management/asset-information/index.vue')
|
||||
const functionText = text.match(/ {2}function showStopResumeRecords[\s\S]*?\n {2}}/)[0]
|
||||
const { create } = evaluate(
|
||||
`export const create = (cardInfo, hasAuth, stopResumePermission, stopResumeTarget, stopResumeVisible) => { ${functionText}; return showStopResumeRecords }`
|
||||
)
|
||||
const asset = vue.ref({
|
||||
asset_type: 'device',
|
||||
asset_id: 100,
|
||||
cards: [{ card_id: 42, iccid: 'CARD' }]
|
||||
})
|
||||
const target = vue.ref(null)
|
||||
const visible = vue.ref(false)
|
||||
let allowed = false
|
||||
const open = create(asset, () => allowed, vue.ref(permissions.deviceCardHistory), target, visible)
|
||||
open({ cardId: 42, iccid: 'CARD' })
|
||||
assert.equal(visible.value, false)
|
||||
allowed = true
|
||||
open({ cardId: 100, iccid: 'CARD' })
|
||||
assert.equal(visible.value, false)
|
||||
open({ cardId: 42, iccid: 'CARD' })
|
||||
assert.equal(target.value.cardId, 42)
|
||||
assert.equal(visible.value, true)
|
||||
visible.value = false
|
||||
asset.value = { asset_type: 'card', asset_id: 7, iccid: 'CARD-7' }
|
||||
open({ cardId: 42, iccid: 'CARD' })
|
||||
assert.equal(visible.value, false)
|
||||
open({ cardId: 7, iccid: 'CARD-7' })
|
||||
assert.equal(target.value.cardId, 7)
|
||||
})
|
||||
245
scripts/tests/gatewayBrowserFixtures.mjs
Normal file
245
scripts/tests/gatewayBrowserFixtures.mjs
Normal file
@@ -0,0 +1,245 @@
|
||||
// 本地浏览器验收专用;所有 /api 请求在这里终止,不会代理到真实后端。
|
||||
const time = '2026-10-24T10:00:00+08:00'
|
||||
const option = (value, label) => ({ value, label })
|
||||
const options = {
|
||||
error_categories: [
|
||||
option('rate_limited_upstream', '上游频率限制'),
|
||||
option('business', '业务错误')
|
||||
],
|
||||
exempt_sources: [option('manual', '人工')],
|
||||
gateway_operations: [
|
||||
option('query_flow', '查流量'),
|
||||
option('query_status', '查状态'),
|
||||
option('query_realname', '查实名'),
|
||||
option('stop', '停机'),
|
||||
option('start', '复机')
|
||||
],
|
||||
match_types: [option('contains', '包含'), option('prefix', '前缀'), option('regex', '正则')],
|
||||
rate_limit_modes: [option('group', '限流组')],
|
||||
stop_resume_actions: [option('stop', '停机'), option('start', '复机')],
|
||||
stop_resume_results: [
|
||||
option('success', '成功'),
|
||||
option('failed', '失败'),
|
||||
option('unknown', '未知'),
|
||||
option('already_target', '已是目标状态'),
|
||||
option('skipped', '已跳过')
|
||||
]
|
||||
}
|
||||
const rules = [
|
||||
{
|
||||
id: 1,
|
||||
category: 'rate_limited_upstream',
|
||||
category_name: '上游频率限制',
|
||||
keyword: '请求过于频繁',
|
||||
match_type: 'contains',
|
||||
operations: ['stop'],
|
||||
priority: 10,
|
||||
maybe_accepted: false,
|
||||
status: 1,
|
||||
remark: '本地模拟规则',
|
||||
created_at: time,
|
||||
updated_at: time
|
||||
}
|
||||
]
|
||||
const groups = [
|
||||
{
|
||||
id: 1,
|
||||
carrier_id: null,
|
||||
carrier_name: '',
|
||||
group_code: 'default',
|
||||
group_name: '全局默认组',
|
||||
operations: ['query_flow', 'stop', 'start'],
|
||||
max_qps: 20,
|
||||
reserved_qps: 5,
|
||||
high_priority_operations: ['stop', 'start'],
|
||||
status: 1,
|
||||
status_name: '启用',
|
||||
created_at: time,
|
||||
updated_at: time
|
||||
}
|
||||
]
|
||||
const records = [
|
||||
{
|
||||
id: 1,
|
||||
card_id: 42,
|
||||
carrier_id: 8,
|
||||
iccid: 'MOCK-ICCID-42',
|
||||
action: 'stop',
|
||||
action_name: '停机',
|
||||
result: 'success',
|
||||
error_category: '',
|
||||
error_category_name: '',
|
||||
provider_message: '操作成功',
|
||||
provider_code: '0',
|
||||
attempt: 1,
|
||||
command_id: 'MOCK-CMD',
|
||||
source: 'manual',
|
||||
scene: 'manual_stop',
|
||||
reason: '本地验收',
|
||||
duration_ms: 42,
|
||||
created_at: time
|
||||
}
|
||||
]
|
||||
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: 'gateway-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
|
||||
if (!path.startsWith('/api/')) return next()
|
||||
let body = ''
|
||||
for await (const chunk of req) body += chunk
|
||||
const input = body ? JSON.parse(body) : {}
|
||||
let data = page([], url)
|
||||
let code = 0
|
||||
let msg = 'local mock only'
|
||||
if (path === '/api/auth/me' || path === '/api/auth/login') {
|
||||
const viewer = req.headers.authorization?.includes('local-gateway-viewer')
|
||||
const pagePermissions = [
|
||||
'card_stop_resume_record:page',
|
||||
'gateway_error_classification:page',
|
||||
'gateway_rate_limit_group:page',
|
||||
'gateway_rate_limit_group:detail_page',
|
||||
'gateway_call_stats:page'
|
||||
]
|
||||
const menuEntries = [
|
||||
['停复机记录', 'stop-resume-records', pagePermissions[0]],
|
||||
['上游错误分类', 'error-classifications', pagePermissions[1]],
|
||||
['网关限流组', 'rate-limit-groups', pagePermissions[2]],
|
||||
['网关限流组详情', 'rate-limit-groups/detail/:id', pagePermissions[3]],
|
||||
['网关调用统计', 'gateway-stats', pagePermissions[4]]
|
||||
]
|
||||
data = {
|
||||
access_token: viewer ? 'local-gateway-viewer' : 'local-gateway-mock-only',
|
||||
refresh_token: '',
|
||||
expires_in: 3600,
|
||||
user: {
|
||||
id: viewer ? 2 : 1,
|
||||
user_type: viewer ? 2 : 1,
|
||||
username: viewer ? '本地只读账号' : '本地模拟管理员',
|
||||
roles: [viewer ? 'R_ADMIN' : 'R_SUPER']
|
||||
},
|
||||
permissions: viewer ? pagePermissions : [],
|
||||
buttons: viewer
|
||||
? [
|
||||
'gateway_error_classification:test',
|
||||
'gateway_rate_limit_group:detail',
|
||||
'gateway_call_stats:trend'
|
||||
]
|
||||
: [],
|
||||
menus: viewer
|
||||
? [
|
||||
{
|
||||
url: '/polling-management',
|
||||
name: '轮询管理',
|
||||
children: menuEntries.map(([name, segment, perm_code]) => ({
|
||||
name,
|
||||
url: `/polling-management/${segment}`,
|
||||
perm_code
|
||||
}))
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
} else if (path === '/api/admin/polling-options') data = options
|
||||
else if (path === '/api/admin/carriers')
|
||||
data = page(
|
||||
[
|
||||
{
|
||||
id: 8,
|
||||
carrier_name: '模拟运营商账户',
|
||||
carrier_type: 'CMCC',
|
||||
status: 1,
|
||||
created_at: time,
|
||||
updated_at: time
|
||||
}
|
||||
].filter((carrier) =>
|
||||
carrier.carrier_name.includes(url.searchParams.get('carrier_name') || '')
|
||||
),
|
||||
url
|
||||
)
|
||||
else if (
|
||||
path === '/api/admin/card-stop-resume-records' ||
|
||||
/\/iot-cards\/\d+\/stop-resume-records$/.test(path)
|
||||
)
|
||||
data = page(records, url)
|
||||
else if (path === '/api/admin/gateway-error-classifications/test') {
|
||||
data = {
|
||||
matched: input.message?.includes(input.draft?.keyword || '请求过于频繁') || false,
|
||||
rule_id: input.draft ? 0 : 1,
|
||||
category: input.draft?.category || rules[0].category,
|
||||
category_name: '上游频率限制',
|
||||
keyword: input.draft?.keyword || rules[0].keyword,
|
||||
maybe_accepted: false
|
||||
}
|
||||
} else if (
|
||||
/^\/api\/admin\/(gateway-error-classifications|polling-rate-limit-groups)(\/\d+)?(\/status)?$/.test(
|
||||
path
|
||||
)
|
||||
) {
|
||||
const list = path.includes('gateway-error-classifications') ? rules : groups
|
||||
const id = Number(path.split('/')[4])
|
||||
const row = list.find((item) => item.id === id)
|
||||
if (req.method === 'GET') data = id ? row : page(list, url)
|
||||
else if (req.method === 'POST') {
|
||||
data = {
|
||||
...input,
|
||||
id: Math.max(...list.map((item) => item.id), 0) + 1,
|
||||
created_at: time,
|
||||
updated_at: time
|
||||
}
|
||||
list.push(data)
|
||||
} else if (req.method === 'PUT' && row) {
|
||||
Object.assign(row, input)
|
||||
if ('group_name' in row) row.status_name = row.status === 1 ? '启用' : '停用'
|
||||
data = row
|
||||
} else if (req.method === 'DELETE' && row) {
|
||||
list.splice(list.indexOf(row), 1)
|
||||
data = {}
|
||||
}
|
||||
} else if (path === '/api/admin/polling-stats/carriers') {
|
||||
data = {
|
||||
minutes: Number(url.searchParams.get('minutes') || 15),
|
||||
mode: '本地模拟模式',
|
||||
carriers: [
|
||||
{
|
||||
carrier_id: 8,
|
||||
carrier_name: '模拟运营商',
|
||||
example_calls: 100,
|
||||
example_upstream_failures: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
} else if (/\/polling-stats\/carriers\/\d+\/trend$/.test(path)) {
|
||||
data = {
|
||||
carrier_id: Number(path.split('/')[5]),
|
||||
points: [{ example_time: time, example_calls: 10 }]
|
||||
}
|
||||
} else if (path.startsWith('/api/admin/assets/resolve/')) {
|
||||
data = {
|
||||
asset_type: 'card',
|
||||
asset_id: 42,
|
||||
identifier: 'MOCK-ICCID-42',
|
||||
iccid: 'MOCK-ICCID-42',
|
||||
status: 1,
|
||||
network_status: 1,
|
||||
real_name_status: 1,
|
||||
carrier_name: '模拟运营商',
|
||||
cards: []
|
||||
}
|
||||
} else if (req.method !== 'GET') {
|
||||
code = 400
|
||||
msg = '此本地验收接口未定义,已拦截写请求'
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.end(JSON.stringify({ code, msg, data }))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,11 @@ plugins.push(
|
||||
})
|
||||
)
|
||||
if (serve) {
|
||||
const { mockApiPlugin } = await import('./octoberBrowserFixtures.mjs')
|
||||
const { mockApiPlugin } = await import(
|
||||
process.argv.includes('--gateway')
|
||||
? './gatewayBrowserFixtures.mjs'
|
||||
: './octoberBrowserFixtures.mjs'
|
||||
)
|
||||
const server = await createServer({
|
||||
...loaded.config,
|
||||
configFile: false,
|
||||
|
||||
Reference in New Issue
Block a user