fix: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 54s

This commit is contained in:
luo
2026-10-28 15:18:44 +08:00
parent 06684e1fc0
commit f2614d8efb
99 changed files with 11511 additions and 132 deletions

View File

@@ -87,11 +87,17 @@ function mountBasicInfo(t, userType, permissions = []) {
},
'../composables/useAssetFormatters': formatters,
'@/composables/useAuth': { useAuth: () => ({ hasAuth }) },
'@/components/business/package-silence/SilenceRecordsDialog.vue': {
default: vue.defineComponent(() => () => null)
},
'@/store/modules/user': { useUserStore: () => user },
'@/config/constants': {
JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } }
},
'@/utils/business/format': { formatDateTime: (value) => String(value || '') },
'@/utils/business/packageSilence': evaluate(
readFileSync('src/utils/business/packageSilence.ts', 'utf8')
),
'@/utils/business/expiryEstimate': {
getExpiryEstimateText: () => '-',
getExpiryEstimateTooltip: () => '',
@@ -179,6 +185,29 @@ function mountBasicInfo(t, userType, permissions = []) {
const textOf = (node) =>
node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('')
test('enterprise account hides polling and realname writes while authorized start/stop remain', async (t) => {
const { root, asset, user } = mountBasicInfo(t, 4)
user.permissions = [
'device_info:auto_polling',
'iot_info:auto_polling',
'iot_info:update_realname_status',
'bound_card:update_realname_status',
'iot_info:start',
'iot_info:stop'
]
await vue.nextTick()
assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/)
assert.equal(nodesWithClass(root, 'binding-card').length, 2)
asset.asset_type = 'card'
asset.network_status = 0
await vue.nextTick()
assert.match(textOf(root), /启用此卡/)
assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/)
asset.network_status = 1
await vue.nextTick()
assert.match(textOf(root), /停用此卡/)
})
const nodesWithClass = (node, className) => [
...(String(node.props.class || '')
.split(' ')

View File

@@ -557,6 +557,8 @@ function pageHandlers(state) {
visit(source)
assert.equal(declarations.length, wanted.size)
const bindings = {
useUserStore: () => ({ info: { user_type: 1 } }),
canEditRestrictedAsset: (type) => [1, 2, 3].includes(Number(type)),
cardInfo: state.cardInfo,
fetchAssetDetail: state.fetchAssetDetail,
loadPackageList: state.loadPackageList,

View File

@@ -0,0 +1,341 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
import * as vue from 'vue'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}) {
const code = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', code)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected import ${name}`)
return imports[name]
},
module,
module.exports
)
return module.exports
}
const format = evaluate(read('src/utils/business/format.ts'))
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const response = (data) => ({ code: 0, data })
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
const tick = async () => {
await vue.nextTick()
await Promise.resolve()
await vue.nextTick()
}
function serviceHarness(overrides = {}) {
const calls = []
const service = new Proxy(
{},
{
get:
(_, method) =>
async (...args) => {
calls.push({ method, args })
return overrides[method] ? overrides[method](...args) : response({ items: [], total: 0 })
}
}
)
const modules = {
'./packageManage': { PackageManageService: service },
'./packageSeries': { PackageSeriesService: service },
'./shop': { ShopService: service },
'./card': { CardService: service },
'./device': { DeviceService: service },
'./account': { AccountService: service },
'./packageSilence': { PackageSilenceService: service },
'@/utils/business/gateway': gateway
}
return { ...evaluate(read('src/api/modules/businessOptions.ts'), modules), calls }
}
test('entity search uses documented keyword/name fields and returns labels, never database IDs as labels', async () => {
const h = serviceHarness({
getPackages: () => response({ items: [{ id: 8, package_name: '实名套餐' }], total: 41 }),
getPackageSeries: () => response({ items: [{ id: 9, series_name: '流量系列' }], total: 1 }),
getShops: () => response({ items: [{ id: 10, shop_name: '一号代理' }], total: 1 }),
getAccounts: () =>
response({ items: [{ id: 11, username: '张三', shop_name: '一号代理' }], total: 1 }),
getStandaloneIotCards: () =>
response({ items: [{ id: 12, iccid: '89860001', virtual_no: 'V-01' }], total: 1 }),
getDevices: () =>
response({ items: [{ id: 13, virtual_no: 'D-01', device_name: '设备甲' }], total: 1 })
})
for (const [kind, method, field] of [
['package', 'getPackages', 'package_name'],
['series', 'getPackageSeries', 'series_name'],
['shop', 'getShops', 'shop_name'],
['account', 'getAccounts', 'username'],
['card', 'getStandaloneIotCards', 'keyword']
]) {
const result = await h.searchBusinessOptions(kind, ' 关键字 ', 2)
assert.ok(result.options.length)
assert.equal(typeof result.options[0].value, 'number')
assert.notEqual(result.options[0].label, String(result.options[0].value))
const call = h.calls.at(-1)
assert.equal(call.method, method)
assert.deepEqual(call.args[0], { page: 2, page_size: 20, [field]: '关键字' })
assert.equal(result.hasMore, kind === 'package')
}
const assets = await h.searchBusinessOptions('asset', '01')
assert.deepEqual(
assets.options.map((x) => x.value),
['89860001', 'D-01']
)
assert.match(assets.options[0].label, /V-01/)
assert.match(assets.options[1].label, /设备甲/)
})
test('package record selection uses real usage IDs with asset/package context and readable order labels', async () => {
const h = serviceHarness({
usages: () =>
response({
items: [
{
package_usage_id: 99,
package_name: '实名套餐',
asset_identifier: '8986',
order_no: 'ORD-01'
}
],
total: 1
})
})
const result = await h.searchBusinessOptions('usage', '', 1, {
asset_identifier: '8986',
package_id: 8
})
assert.deepEqual(h.calls[0].args[0], {
page: 1,
page_size: 20,
view: 'all',
asset_identifier: '8986',
package_id: 8
})
assert.deepEqual(result.options, [{ value: 99, label: '实名套餐 · 8986 · ORD-01' }])
await h.searchBusinessOptions('usage', '8987', 1, { asset_identifier: '8986' })
assert.equal(h.calls.at(-1).args[0].asset_identifier, '8987')
})
test('asset lookup keeps permitted card results when the device lookup fails and reports the partial failure', async () => {
const h = serviceHarness({
getStandaloneIotCards: () => response({ items: [{ id: 12, iccid: '89860001' }], total: 1 }),
getDevices: () => ({ code: 403, msg: '无设备查看权限' })
})
const result = await h.searchBusinessOptions('asset', '')
assert.equal(result.options[0].value, '89860001')
assert.match(result.warning, /设备.*无设备查看权限/)
})
test('policy selection resolves existing package/series names through detail endpoints and propagates business errors', async () => {
const h = serviceHarness({
getPackageDetail: () => response({ package_name: '已有套餐' }),
getPackageSeriesDetail: () => response({ series_name: '已有系列' }),
getShops: () => ({ code: 403, msg: '无数据权限' })
})
assert.deepEqual(await h.resolveBusinessOption('package', 8), { value: 8, label: '已有套餐' })
assert.deepEqual(await h.resolveBusinessOption('series', 9), { value: 9, label: '已有系列' })
await assert.rejects(h.searchBusinessOptions('shop', ''), /无数据权限/)
})
function componentHarness({ entity = 'shop', modelValue, search, resolve, allowed = true } = {}) {
const hooks = [],
calls = [],
events = []
const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true })
const props = vue.reactive({ entity, modelValue, allowed, clearable: true })
const runtime = {
...vue,
onBeforeUnmount: (fn) => hooks.push(fn),
onDeactivated: (fn) => hooks.push(fn),
onActivated() {}
}
const { descriptor } = parse(read('src/components/business/BusinessEntitySelect.vue'))
const component = evaluate(compileScript(descriptor, { id: 'entity-select' }).content, {
vue: runtime,
'@/store/modules/user': { useUserStore: () => user },
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling,
'@/api/modules/businessOptions': {
BUSINESS_OPTION_LABELS: {
shop: '店铺',
package: '套餐',
series: '系列',
asset: '资产',
account: '操作人'
},
searchBusinessOptions: async (...args) => {
calls.push(args)
return search ? search(...args) : { options: [], hasMore: false }
},
resolveBusinessOption: resolve || (async (_, value) => ({ value, label: '已有名称' }))
}
}).default
const scope = vue.effectScope()
const state = scope.run(() =>
component.setup(props, {
expose() {},
emit: (event, value) => {
events.push([event, value])
props.modelValue = value
}
})
)
return {
state,
props,
user,
calls,
events,
dispose: () => {
hooks.forEach((fn) => fn())
scope.stop()
}
}
}
test('rapid keywords ignore late responses, selection retains its label through later searches and clear emits no ID', async () => {
const old = deferred()
const h = componentHarness({
search: (_, keyword) =>
keyword === 'old'
? old.promise
: {
options: [
{ value: keyword === 'new' ? 2 : 3, label: keyword === 'new' ? '新店铺' : '其他店铺' }
],
hasMore: false
}
})
const pending = h.state.search('old')
await h.state.search('new')
old.resolve({ options: [{ value: 1, label: '旧店铺' }], hasMore: false })
await pending
assert.deepEqual(h.state.options.value, [{ value: 2, label: '新店铺' }])
h.state.selection.value = 2
await h.state.search('other')
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['新店铺', '其他店铺']
)
assert.deepEqual(h.events[0], ['update:modelValue', 2])
h.state.selection.value = undefined
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['其他店铺']
)
h.dispose()
})
test('load more appends options and request failures are retryable rather than fabricated empty results', async () => {
let fail = true
const h = componentHarness({
search: (_, keyword, page) => {
if (keyword === 'fail' && fail) throw new Error('网络失败')
return { options: [{ value: page, label: `店铺${page}` }], hasMore: page < 2 }
}
})
await h.state.search('')
assert.equal(h.state.hasMore.value, true)
await h.state.load(2)
assert.deepEqual(
h.state.options.value.map((x) => x.value),
[1, 2]
)
assert.equal(h.state.hasMore.value, false)
await h.state.search('fail')
assert.equal(h.state.error.value, '网络失败')
fail = false
await h.state.load(1)
assert.equal(h.state.error.value, '')
assert.equal(h.calls.at(-1)[1], 'fail')
h.dispose()
})
test('permission/account/context changes and unmount discard in-flight results without new unauthorized requests', async () => {
for (const action of ['permission', 'account', 'context', 'unmount']) {
const pending = deferred(),
h = componentHarness({ search: () => pending.promise })
const loading = h.state.search('a')
if (action === 'permission') h.props.allowed = false
if (action === 'account') h.user.info.id = 2
if (action === 'context') h.props.context = { package_id: 8 }
if (action === 'unmount') h.dispose()
await tick()
pending.resolve({ options: [{ value: 8, label: '已失效数据' }], hasMore: true })
await loading
assert.deepEqual(h.state.options.value, [], action)
assert.equal(h.state.loading.value, false)
if (action === 'permission' || action === 'unmount') {
await h.state.search('b')
assert.equal(h.calls.length, 1)
}
if (action !== 'unmount') h.dispose()
}
})
test('existing multi-selection hydrates readable labels and system operator preserves numeric zero', async () => {
const h = componentHarness({
entity: 'package',
modelValue: [7, 8],
resolve: async (_, value) => ({ value, label: `套餐名${value}` })
})
await tick()
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['套餐名7', '套餐名8']
)
h.props.modelValue = [8]
await tick()
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['套餐名8']
)
h.dispose()
const account = componentHarness({ entity: 'account' })
account.props.includeSystem = true
account.state.selection.value = 0
assert.deepEqual(account.events.at(-1), ['update:modelValue', 0])
assert.deepEqual(account.state.options.value, [{ value: 0, label: '系统' }])
account.dispose()
})
test('all affected templates compile and raw-ID search controls are replaced throughout the shared pages', () => {
const files = [
'src/components/business/BusinessEntitySelect.vue',
'src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue',
'src/components/core/forms/art-search-bar/index.vue',
'src/components/business/package-silence/SilenceManagement.vue',
'src/components/business/package-silence/SilencePolicyDialog.vue',
'src/views/order-management/order-list/index.vue',
'src/views/finance/refund/index.vue',
'src/views/polling-management/priority-queue/index.vue',
'src/views/polling-management/polling-pauses/index.vue',
'src/views/asset-management/phone-asset-association/index.vue',
'src/views/asset-management/record-management/asset-assign/index.vue'
]
for (const file of files) {
const { descriptor, errors } = parse(read(file))
assert.deepEqual(errors, [], file)
assert.deepEqual(
compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors,
[],
file
)
}
const management = read(files[3])
assert.match(read(files[2]), /v-show="index < visibleItemCount"/)
for (const field of ['asset_identifier', 'package_id', 'shop_id', 'package_usage_id'])
assert.match(management, new RegExp(`entity\\('${field}'`))
assert.doesNotMatch(management, /请输入套餐 ID|number\('(?:package_id|shop_id|package_usage_id)'/)
assert.doesNotMatch(read(files[4]), /label: `ID/)
assert.doesNotMatch(read(files[7]), /请输入卡ID/)
})

View File

@@ -0,0 +1,491 @@
// 全 API 本地拦截;沉默期响应依据 docs/api/package-silence.openapi.json。
import { mockApiPlugin as fallback } from './pollingSchedulerBrowserFixtures.mjs'
const time = '2026-10-28T10:00:00+08:00'
const log = []
let localPaymentStatus = 'pending_payment'
const silence = {
silence_status: 'expired',
silence_status_name: '沉默期已过期',
silence_deadline_at: '2026-10-27T23:59:59+08:00',
silence_period_days: 30,
silence_extended_days: 0,
silence_paid_extended_days: 0,
silence_extension_count: 0,
silence_free_extension_used: 0,
silence_adjust_pending: false,
extendable: true
}
const silenceOrder = () => ({
id: 7,
order_no: 'LOCAL-SILENCE-7',
status: localPaymentStatus,
status_name: localPaymentStatus === 'completed' ? '已完成' : '待支付',
package_usage_id: 4,
package_id: 8,
package_name: '本地实名套餐',
asset_identifier: '89860426012345678901',
asset_type: 'iot_card',
asset_id: 2,
source: 'admin',
source_name: '后台',
amount: 100,
paid_amount: null,
payment_method: 'wechat',
input_days: 31,
extension_days: 60,
qr_content: localPaymentStatus === 'pending_payment' ? 'https://local.invalid/payment/7' : '',
expire_at: '2026-10-28T23:59:59+08:00',
before_deadline_at: '2026-10-27T23:59:59+08:00',
expected_deadline_at: '2026-12-26T23:59:59+08:00',
created_at: time,
approval_required: false
})
const silencePolicy = {
id: 1,
name: '本地全部套餐政策',
scope_type: 'all',
scope_type_name: '全部套餐',
scope_ids: [],
unit_price: 100,
free_extension_limit: 1,
paid_max_months: 12,
single_max_months: null,
effective_from: time,
effective_to: null,
approval_exempt_types: [],
status: 1,
created_at: time,
updated_at: time
}
const silenceBatch = {
id: 3,
batch_no: 'LOCAL-BATCH-3',
path: 'special',
extension_type: 'free',
status: 'pending_approval',
approval_required: true,
approval_status: 'pending',
requested_count: 1,
succeeded_count: 0,
failed_count: 0,
reason: '本地原因',
basis: '本地依据',
amount: 0,
requested_at: time,
evidence_refs: [],
items: [
{
id: 9,
row_no: 1,
package_usage_id: 4,
asset_identifier: '89860426012345678901',
package_name: '本地实名套餐',
extension_days: 30,
status: 'exception',
failure_code: 'local_exception',
failure_summary: '仅本地模拟执行异常',
amount: 0,
before_deadline_at: time
}
]
}
const pkg = {
id: 8,
package_name: '本地实名套餐',
package_code: 'LOCAL',
package_type: 'formal',
series_id: 1,
series_name: '本地系列',
expiry_base: 'from_activation',
silence_period_days: 30,
silence_extension_enabled: true,
silence_config_required: true,
duration_months: 1,
calendar_type: 'natural_month',
status: 1,
shelf_status: 1,
cost_price: 100,
suggested_retail_price: 200,
real_data_mb: 1024,
is_gift: false,
created_at: time,
updated_at: time
}
const entries = [
['平台订单', '/order-management/platform-orders', 'orders:platform_page'],
['代理商订单', '/order-management/agent-orders', 'orders:agent_page'],
['在线支付订单', '/order-management/online-orders', 'orders:online_page'],
['订单列表', '/order-management/order-list', ''],
['订单详情', '/order-management/order-list/detail/:id', '']
]
const page = (items, url) => ({
items,
total: items.length,
page: Number(url.searchParams.get('page') || 1),
page_size: Number(url.searchParams.get('page_size') || 20),
size: Number(url.searchParams.get('page_size') || 20),
total_pages: 1
})
const ok = (res, data, code = 0, msg = 'local mock only') => {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg, data }))
}
export const mockApiPlugin = {
name: 'order-silence-known-contract-mock',
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 === '/__order-silence-log') return ok(res, log)
const handled =
['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path) ||
/^\/api\/admin\/(orders|packages|package-series|package-silence|employee-collection-payment-methods|assets|system-configs|iot-cards|devices|shops|accounts)(\/|$)/.test(
path
)
if (!handled) return next()
let raw = ''
for await (const part of req) raw += part
const input = raw ? JSON.parse(raw) : {},
token = String(req.headers.authorization || ''),
type = token.includes('enterprise')
? 4
: token.includes('agent')
? 3
: token.includes('super')
? 1
: 2
log.push({
path,
method: req.method,
body: input,
query: Object.fromEntries(url.searchParams)
})
const matches = (items, field, keyword) =>
items.filter((item) => !keyword || String(item[field]).includes(keyword))
if (path === '/api/admin/iot-cards/standalone')
return ok(
res,
page(
matches(
[
{
id: 2,
iccid: '89860426012345678901',
virtual_no: 'LOCAL-CARD',
shop_name: '本地店铺'
}
],
'iccid',
url.searchParams.get('keyword')
),
url
)
)
if (path === '/api/admin/devices')
return ok(
res,
page(
matches(
[
{
id: 3,
virtual_no: 'LOCAL-DEVICE',
device_name: '本地设备',
imei: '860000000000001',
shop_name: '本地店铺'
}
],
'virtual_no',
url.searchParams.get('keyword')
),
url
)
)
if (path === '/api/admin/shops')
return ok(
res,
page(
matches(
[{ id: 6, shop_name: '本地店铺' }],
'shop_name',
url.searchParams.get('shop_name')
),
url
)
)
if (path === '/api/admin/accounts')
return ok(
res,
page(
matches(
[{ id: 2, username: '本地操作员', shop_name: '本地店铺' }],
'username',
url.searchParams.get('username')
),
url
)
)
if (path.startsWith('/api/auth/'))
return ok(res, {
access_token: `local-silence-${type === 1 ? 'super' : 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: [
'orders:platform_page',
'orders:agent_page',
'orders:online_page',
'package_silence:orders_page',
'package_silence:order_detail_page',
'package_silence:batches_page',
'package_silence:batch_detail_page',
'package_silence:usages_page',
'package_silence:policies_page'
],
buttons: [
'orders:add',
'orders:export',
'orders:view_detail',
'package:add',
'package:edit',
'package:detail',
'package_silence:override',
'package_silence:extend',
'package_silence:extend_batch',
'package_silence:offline',
'package_silence:exception',
'package_silence:policy',
'package_silence:order_detail',
'package_silence:batch_detail',
'package_silence:order_export',
'package_silence:usage_export'
],
menus: [
{
name: '订单管理',
url: '/order-management',
children: entries.map(([name, url, perm_code]) => ({ name, url, perm_code }))
},
{
name: '套餐管理',
url: '/package-management',
children: [
{ name: '套餐列表', url: '/package-management/package-list' },
{ name: '套餐详情', url: '/package-management/package-list/detail/:id' },
...['orders', 'batches', 'usages', 'policies'].map((kind, index) => ({
name: ['延期订单', '延期批次与异常', '临期/过期套餐', '延期政策'][index],
url: `/package-management/silence-${kind}`,
perm_code: `package_silence:${kind}_page`
})),
{
name: '延期订单详情',
url: '/package-management/silence-orders/detail/:id',
perm_code: 'package_silence:order_detail_page',
is_hidden: true
},
{
name: '延期批次详情',
url: '/package-management/silence-batches/detail/:id',
perm_code: 'package_silence:batch_detail_page',
is_hidden: true
}
]
},
{
name: '设置',
url: '/settings',
children: [{ name: '沉默期总开关', url: '/settings/package-silence-switch' }]
}
]
})
if (path.startsWith('/api/admin/orders')) {
if (type === 4) return ok(res, null, 403, '企业账号无权访问订单')
if (path.endsWith('/online') && type === 3) return ok(res, page([], url))
const order = {
id: 1,
order_no: `LOCAL-${path.split('/').at(-1)}`,
order_type: 'single_card',
buyer_type: 'personal',
buyer_id: 1,
payment_status: 2,
payment_status_text: '已支付',
payment_method: 'offline',
total_amount: 200,
actual_paid_amount: 200,
paid_at: time,
items: [
{
id: 1,
package_id: 8,
package_name: pkg.package_name,
quantity: 1,
unit_price: 200,
amount: 200
}
],
asset_identifier: '89860426012345678901',
asset_type: 'card',
created_at: time,
updated_at: time,
commission_status: 2,
commission_config_version: 1,
payments: []
}
return ok(
res,
/\/orders\/\d+$/.test(path) || req.method === 'POST' ? order : page([order], url)
)
}
if (path === '/api/admin/package-silence/switch') {
if (type !== 1) return ok(res, null, 403, '仅超级管理员可操作')
const precheck = [
{ code: 'package', name: '套餐沉默期配置', passed: false, count: 2 },
{ code: 'policy', name: '延期政策', passed: true, count: 1 },
{ code: 'template', name: '企微审批模板', passed: true, count: 1 },
{ code: 'member', name: '默认审批人', passed: true, count: 1 }
]
return req.method === 'PUT'
? ok(res, { failed_items: [precheck[0]] }, 1275, '检查未通过,存在未配置沉默期套餐')
: ok(res, { enabled: false, precheck })
}
if (path === '/api/admin/employee-collection-payment-methods')
return ok(res, page([{ id: 2, name: '本地银行收款', enabled: true }], url))
if (path.startsWith('/api/admin/assets/') && path.endsWith('/first-purchase-records'))
return ok(res, [
{
id: 1,
generation: 1,
asset_identifier: 'LOCAL-ASSET',
first_purchase_at: time,
source: 'order',
source_name: '首次购买套餐',
order_no: 'LOCAL-ORDER',
end_reason: 'exchange_return',
ended_at: time,
created_at: time
}
])
if (path.startsWith('/api/admin/package-silence/')) {
if (path.endsWith('/eligibility'))
return ok(res, {
package_usage_id: 4,
can_extend: true,
special_extension_available: true,
free_extension_available: true,
min_days: 1,
max_days: 360,
min_months: 1,
max_months: 12,
unit_price: 100,
options: [{ amount: 100, billed_days: 30, months: 1, new_deadline: '2026-11-26' }],
original_deadline: '2026-10-27',
reason: '',
policy_id: 1,
policy_name: '本地全部套餐政策'
})
if (path.endsWith('/quote'))
return ok(res, {
amount: 200,
billed_days: 60,
input_days: Number(url.searchParams.get('days')),
months: 2,
new_deadline: '2026-12-26',
valid: true
})
if (path.endsWith('/orders/qrcode')) {
localPaymentStatus = 'pending_payment'
return ok(res, silenceOrder())
}
if (path.endsWith('/orders/offline'))
return ok(res, {
...silenceOrder(),
status: 'pending_approval',
status_name: '待审批',
approval_required: true,
qr_content: '',
payment_method: 'offline'
})
if (path.endsWith('/orders')) return ok(res, page([silenceOrder()], url))
if (/\/orders\/\d+$/.test(path)) {
const order = silenceOrder()
localPaymentStatus = 'completed'
return ok(res, order)
}
if (path.endsWith('/special')) return ok(res, silenceBatch)
if (path.endsWith('/batches')) return ok(res, page([silenceBatch], url))
if (/\/batches\/\d+$/.test(path)) return ok(res, silenceBatch)
if (path.endsWith('/handle'))
return ok(res, {
...silenceBatch.items[0],
status: input.action === 'close' ? 'closed' : 'completed',
failure_summary: ''
})
if (path.endsWith('/usages'))
return ok(
res,
page(
[
{
package_usage_id: 4,
package_id: 8,
package_name: pkg.package_name,
asset_id: 2,
asset_identifier: '89860426012345678901',
asset_type: 'iot_card',
shop_id: 1,
shop_name: '本地店铺',
created_at: time,
order_no: 'LOCAL-ORDER',
silence
}
],
url
)
)
if (path.endsWith('/records'))
return ok(res, [
{
id: 1,
event_type: 'set',
event_name: '设置沉默期',
before_status: null,
after_status: 'waiting',
after_deadline_at: time,
change_days: 30,
operator_id: 1,
operator_type: 'admin',
reason: '本地记录',
created_at: time
}
])
if (path.endsWith('/policies/match'))
return ok(res, { matched: true, package_id: 8, policy: silencePolicy })
if (path.endsWith('/policies'))
return ok(res, req.method === 'GET' ? [silencePolicy] : { ...silencePolicy, ...input })
if (/\/policies\/\d+(\/status)?$/.test(path)) return ok(res, { ...silencePolicy, ...input })
return ok(res, null, 400, '本地未定义的沉默期接口已阻止')
}
if (path.startsWith('/api/admin/packages'))
return ok(
res,
path === '/api/admin/packages' && req.method === 'GET'
? page([pkg], url)
: { ...pkg, ...input }
)
if (path.startsWith('/api/admin/package-series'))
return ok(
res,
page([{ id: 1, series_name: '本地系列', series_code: 'LOCAL', status: 1 }], url)
)
if (path.startsWith('/api/admin/system-configs'))
return ok(res, { list: [], total: 0, page: 1, page_size: 20 })
return ok(res, null, 400, '本地未定义,已阻止请求')
})
fallback.configureServer(server)
}
}

View File

@@ -0,0 +1,517 @@
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 { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}, globals = {}) {
const code = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const m = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected dependency: ${name}`)
return imports[name]
},
m,
m.exports,
...Object.values(globals)
)
return m.exports
}
const helpers = evaluate(read('src/utils/business/packageSilence.ts'))
const constants = evaluate(read('src/config/constants/packageSilence.ts'))
const format = evaluate(read('src/utils/business/format.ts'))
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const exportConfig = evaluate(read('src/config/constants/exportTask.ts'))
test('silence business errors never count as successful orders, package saves or refunds', () => {
for (const code of [1260, 1261, 1262, 1268, 1275])
assert.throws(() => helpers.assertSilenceResponse({ code, data: null }), {
message: helpers.SILENCE_ERROR_MESSAGES[code]
})
assert.throws(
() => helpers.assertSilenceResponse({ code: 1266, msg: 'min_days=30,max_days=90' }),
{ message: 'min_days=30,max_days=90' }
)
const response = { code: 0, data: { warnings: ['saved'] } }
assert.equal(helpers.assertSilenceResponse(response), response)
for (const path of [
'src/views/order-management/order-list/index.vue',
'src/views/package-management/package-list/index.vue',
'src/views/asset-management/asset-information/components/dialogs/PackageRechargeDialog.vue',
'src/components/business/CreateRefundDialog.vue'
])
assert.ok(read(path).includes('assertSilenceResponse'))
})
function setup(path, { type = 2, props = {}, permissions = [], overrides = {} } = {}) {
const calls = [],
unmount = []
const user = vue.reactive({
info: { id: 1, user_type: type },
get isSuperAdmin() {
return Number(this.info.user_type) === 1
},
get getUserInfo() {
return this.info
}
})
const services = new Proxy(
{},
{
get:
(_, name) =>
async (...args) => {
calls.push({ name, args })
return overrides[name]
? overrides[name](...args)
: { code: 0, data: { items: [], total: 0 } }
}
}
)
const runtime = {
...vue,
onMounted() {},
onActivated() {},
onDeactivated() {},
onBeforeUnmount: (fn) => unmount.push(fn)
}
const store = { useUserStore: () => user }
const auth = {
useAuth: () => ({ hasAuth: (code) => user.isSuperAdmin || permissions.includes(code) })
}
const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling
})
const checked = evaluate(read('src/composables/useCheckedColumns.ts'), { vue: runtime })
const imports = {
vue: runtime,
pinia: { storeToRefs: vue.toRefs },
'vue-router': { useRouter: () => ({ push() {}, back() {} }) },
'element-plus': {
ElMessage: { success() {}, warning() {}, error() {} },
ElMessageBox: { confirm: async () => {} },
ElTag: {}
},
'@/api/modules': {
OrderService: services,
ExportTaskService: services,
CardService: services,
DeviceService: services,
PackageManageService: services,
ShopService: services,
AssetService: services
},
'@/api/modules/packageSilence': { PackageSilenceService: services },
'@/store/modules/user': store,
'@/composables/useAuth': auth,
'@/composables/useCheckedColumns': checked,
'@/composables/useSchedulerRequest': scheduler,
'@/utils/business/format': format,
'@/utils/business/packageSilence': helpers,
'@/utils/business/pollingScheduler': polling,
'@/utils/business/gateway': gateway,
'@/utils/business/orderPayment': {},
'@/utils/business': { hasVoucherKeys: (v) => !!v?.length, toVoucherKeyList: (v) => v },
'@/router/routesAlias': { RoutesAlias: {} },
'@/config/constants/audit': { AUDIT_PERMISSIONS: {} },
'@/utils/business/auditNavigation': {},
'@/components/business/audit/investigationController': {},
'@/config/constants/bulkPurchase': { BULK_PURCHASE_PERMISSIONS: {} },
'@/config/constants/packageSilence': constants,
'@/config/constants': exportConfig,
'@/config/constants/exportTask': exportConfig
}
const { descriptor } = parse(read(path)),
scope = vue.effectScope()
const component = evaluate(
compileScript(descriptor, { id: 'silence-integration' }).content,
imports,
runtime
).default
const state = scope.run(() => component.setup(props, { expose() {}, emit() {} }))
return {
state,
calls,
user,
dispose() {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('order category role matrix and export scene boundaries match the document', () => {
for (const type of [1, 2, 3, 4, 0, undefined])
for (const category of ['all', 'platform', 'agent', 'online']) {
const allowed = (category === 'online' ? [1, 2] : [1, 2, 3]).includes(type)
assert.equal(helpers.canAccessOrderCategory(type, category), allowed)
assert.equal(
exportConfig.canAccessExportTaskScene(
constants.ORDER_CATEGORY_CONFIG[category].scene,
type
),
allowed
)
}
assert.equal(helpers.canEditRestrictedAsset(4), false)
assert.equal(helpers.canEditRestrictedAsset(3), true)
})
test('three category endpoints preserve query and shared legacy detail contract', async () => {
const calls = []
class BaseService {}
for (const name of ['get', 'getOne'])
BaseService[name] = (url, params) => {
calls.push({ url, params })
return Promise.resolve({ code: 0 })
}
const { OrderService: S } = evaluate(read('src/api/modules/order.ts'), {
'../BaseService': { BaseService }
})
const query = { page: 1, page_size: 20, is_expired: false, seller_shop_id: 8 }
await S.getPlatformOrders(query)
await S.getAgentOrders(query)
await S.getOnlineOrders(query)
await S.getOrders(query)
await S.getOrderById(1)
assert.deepEqual(
calls.map((x) => x.url),
[
'/api/admin/orders/platform',
'/api/admin/orders/agent',
'/api/admin/orders/online',
'/api/admin/orders',
'/api/admin/orders/1'
]
)
for (const call of calls.slice(0, 4)) {
assert.deepEqual(call.params, query)
assert.equal('category' in call.params, false)
}
})
for (const [category, method] of [
['platform', 'getPlatformOrders'],
['agent', 'getAgentOrders'],
['online', 'getOnlineOrders'],
['all', 'getOrders']
])
test(`order view ${category} uses exact service and scene, blocks disallowed identities`, async () => {
for (const type of [1, 2, 3, 4]) {
const h = setup('src/views/order-management/order-list/index.vue', {
type,
props: { category }
})
h.state.searchForm.is_expired = 0
await h.state.getTableData()
const allowed = helpers.canAccessOrderCategory(type, category)
assert.equal(h.calls.length, allowed ? 1 : 0)
if (allowed) {
assert.equal(h.calls[0].name, method)
assert.equal(h.calls[0].args[0].is_expired, false)
assert.equal('category' in h.calls[0].args[0], false)
}
assert.equal(
h.state.categoryConfig.value.scene,
constants.ORDER_CATEGORY_CONFIG[category].scene
)
h.dispose()
}
})
test('package defaults enforce 1..90, nullable purchase defaults, addon omission and immutable activation base', () => {
for (const days of [0, 91, 1.5, NaN])
assert.throws(() =>
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: days
})
)
for (const days of [1, 30, 90])
assert.equal(
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: days
}).silence_period_days,
days
)
assert.throws(() =>
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: null
})
)
assert.equal(
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_purchase',
silence_period_days: null,
silence_extension_enabled: false
}).silence_extension_enabled,
false
)
assert.deepEqual(
helpers.packageSilenceFields({ package_type: 'addon', silence_period_days: 999 }),
{}
)
assert.throws(() =>
helpers.packageSilenceFields(
{ package_type: 'formal', expiry_base: 'from_purchase' },
'from_activation'
)
)
})
test('allocation overrides enforce source defaults before single, batch and series writes', async () => {
const packageData = {
package_type: 'formal',
expiry_base: 'from_purchase',
silence_period_days: null
}
let reads = 0
const validation = evaluate(read('src/api/modules/packageSilenceValidation.ts'), {
'./packageManage': {
PackageManageService: {
getPackageDetail: async () => {
reads++
return { code: 0, data: { ...packageData } }
}
}
},
'@/utils/business/packageSilence': helpers
})
const writes = []
class BaseService {}
for (const method of ['post', 'put', 'patch', 'create'])
BaseService[method] = (url, data) => {
writes.push({ url, data })
return Promise.resolve({ code: 0, data })
}
const imports = { '../BaseService': { BaseService }, './packageSilenceValidation': validation }
const S = evaluate(
read('src/api/modules/shopPackageAllocation.ts'),
imports
).ShopPackageAllocationService
const G = evaluate(read('src/api/modules/shopSeriesGrant.ts'), imports).ShopSeriesGrantService
const calls = [
() => S.createShopPackageAllocation({ package_id: 8, expiry_base_override: 'from_activation' }),
() =>
S.createShopPackageBatchAllocations({
package_ids: [8, 9],
expiry_base_override: 'from_activation'
}),
() =>
S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_activation' }, 8),
() =>
G.createShopSeriesGrant({
packages: [{ package_id: 8 }],
expiry_base_override: 'from_activation'
}),
() =>
G.manageGrantPackages(1, {
packages: [{ package_id: 8 }],
expiry_base_override: 'from_activation'
})
]
for (const call of calls) await assert.rejects(call, /先配置默认沉默期/)
assert.equal(writes.length, 0)
packageData.silence_period_days = 30
for (const call of calls) await call()
assert.equal(writes.length, 5)
packageData.expiry_base = 'from_activation'
await assert.rejects(
() => S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_purchase' }, 8),
/不允许覆盖/
)
const previousReads = reads
await S.createShopPackageAllocation({ package_id: 8, expiry_base_override: null })
await G.manageGrantPackages(1, {
packages: [{ package_id: 8, remove: true }],
expiry_base_override: 'from_purchase'
})
assert.equal(reads, previousReads)
})
test('purchase overrides do not impose a 90-day maximum and never leak unauthorized fields', () => {
assert.deepEqual(helpers.silencePurchaseFields(false, 91, 'why'), {})
assert.deepEqual(helpers.silencePurchaseFields(true, null, 'why'), {})
assert.deepEqual(helpers.silencePurchaseFields(true, 365, ' why '), {
silence_period_days: 365,
silence_reason: 'why'
})
for (const days of [0, -1, 1.5, NaN])
assert.throws(() => helpers.silencePurchaseFields(true, days, 'why'))
assert.throws(() => helpers.silencePurchaseFields(true, 30, ' '))
})
test('purchase override remains null until explicitly enabled and disabling clears the reason', async () => {
const path = 'src/components/business/package-silence/SilencePurchaseFields.vue'
const { descriptor } = parse(read(path))
const definition = evaluate(compileScript(descriptor, { id: 'optional-override' }).content, {
vue
}).default
let state
const days = vue.ref(null),
reason = vue.ref('')
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, {
days: days.value,
reason: reason.value,
'onUpdate:days': (v) => (days.value = v),
'onUpdate:reason': (v) => (reason.value = v)
})
})
app.mount({})
assert.equal(state.overrideEnabled.value, false)
assert.equal(days.value, null)
state.overrideEnabled.value = true
await vue.nextTick()
assert.equal(days.value, 1)
days.value = 365
reason.value = 'why'
await vue.nextTick()
assert.equal(state.overrideEnabled.value, true)
state.overrideEnabled.value = false
await vue.nextTick()
assert.equal(days.value, null)
assert.equal(reason.value, '')
assert.match(descriptor.template.content, /<ElInputNumber\s+v-if="overrideEnabled"/)
app.unmount()
})
test('first purchase range is shared RFC3339 and rejects invalid or reversed input', () => {
const range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00']
assert.deepEqual(helpers.firstPurchaseRange(range), {
first_purchase_start: '2026-09-30T16:00:00Z',
first_purchase_end: '2026-10-28T15:59:59Z'
})
assert.deepEqual(helpers.firstPurchaseRange([]), {})
for (const value of [['bad', 'date'], range.toReversed(), [range[0]]])
assert.throws(() => helpers.firstPurchaseRange(value))
})
test('new order exports keep query.filters as an object, preserving zero/false and legacy serialization', () => {
for (const scene of ['order_platform', 'order_agent', 'order_online', 'order']) {
const filters = {
payment_status: 2,
is_expired: false,
seller_shop_id: 0,
start_time: '2026-10-01T00:00:00+08:00'
}
const h = setup('src/components/business/ExportTaskCreateDialog.vue', {
props: { scene, query: { filters }, modelValue: true, defaultFormat: 'xlsx' }
})
const query = h.state.getNormalizedQuery()
assert.deepEqual(query.filters, scene === 'order' ? JSON.stringify(filters) : filters)
h.dispose()
}
})
test('silence switch uses dedicated endpoints and boolean state, never generic system config', async () => {
const calls = []
class BaseService {}
for (const method of ['get', 'put'])
BaseService[method] = (url, data) => {
calls.push({ method, url, data })
return Promise.resolve({ code: 0 })
}
const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), {
'../BaseService': { BaseService }
})
await S.getSwitch()
await S.setSwitch(false)
assert.deepEqual(calls, [
{ method: 'get', url: '/api/admin/package-silence/switch', data: undefined },
{ method: 'put', url: '/api/admin/package-silence/switch', data: { enabled: false } }
])
})
test('silence switch refuses ordinary accounts and exposes failed prechecks from business errors', async () => {
for (const type of [2, 3, 4]) {
const h = setup('src/views/settings/package-silence-switch/index.vue', { type })
await h.state.load()
await h.state.toggle()
assert.equal(h.calls.length, 0)
h.dispose()
}
const failed = [{ code: 'unconfigured', name: 'missing config', passed: false, count: 3 }]
const h = setup('src/views/settings/package-silence-switch/index.vue', {
type: 1,
overrides: {
getSwitch: () => ({ code: 0, data: { enabled: false, precheck: failed } }),
setSwitch: () => ({ code: 1275, msg: 'check failed', data: { failed_items: failed } })
}
})
await h.state.load()
await h.state.toggle()
assert.equal(h.state.data.value.enabled, false)
assert.deepEqual(h.state.failed.value, failed)
assert.equal(h.state.error.value, 'check failed')
await h.state.load()
assert.deepEqual(h.state.failed.value, [])
assert.equal(h.state.error.value, '')
h.dispose()
})
test('all new and changed business templates compile, including conditional root order view', () => {
const paths = [
'src/views/order-management/order-list/index.vue',
'src/views/package-management/package-list/index.vue',
'src/views/settings/package-silence-switch/index.vue',
'src/views/asset-management/asset-information/components/PackageListCard.vue',
'src/views/asset-management/asset-information/components/CurrentPackageCard.vue',
'src/components/business/package-silence/SilenceSummary.vue',
'src/components/business/package-silence/SilencePurchaseFields.vue',
'src/components/business/package-silence/PackageSilenceFields.vue'
]
for (const path of paths) {
const { descriptor, errors } = parse(read(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('198 base regressions remain compatible while metadata and zero refunds follow the new contract', () => {
assert.equal(Object.keys(helpers.SILENCE_ERROR_MESSAGES).length, 16)
for (const type of ['override', 'extend', 'extend_batch', 'offline', 'exception', 'policy'])
assert.ok(
Object.values(constants.PACKAGE_SILENCE_PERMISSIONS).includes(`package_silence:${type}`)
)
assert.match(read('src/types/api/wecom.ts'), /package_silence_approval/)
assert.match(read('src/views/settings/wecom/scenes/index.vue'), /沉默期审批/)
assert.match(read('src/views/finance/refund/index.vue'), /requested_refund_amount"\s+:min="0"/)
assert.match(
read('src/views/finance/refund/index.vue'),
/row.requested_refund_amount == null \? undefined : fenToYuan/
)
for (const path of [
'src/views/asset-management/iot-card-management/index.vue',
'src/views/asset-management/device-list/index.vue'
])
assert.ok(read(path).includes('firstPurchaseRange'))
})

View File

@@ -193,38 +193,229 @@ function mount(
...vue,
onMounted: () => {},
onBeforeUnmount: (fn) => beforeUnmount.push(fn),
onDeactivated: (fn) => deactivated.push(fn)
onDeactivated: (fn) => deactivated.push(fn),
onActivated: () => {}
}
const { descriptor } = parse(source(path))
const script = compileScript(descriptor, { id: 'scheduler-test' }).content
const store = { useUserStore: () => user }
const schedulerRequest = evaluate(source('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': helpers
})
const imports = {
vue: runtime,
'element-plus': { ElMessageBox: { confirm } },
'vue-router': {
useRoute: () => ({ params: { id: '1', carrier_id: '8' }, fullPath: '/local/detail/1' }),
useRouter: () => ({ back() {} })
},
'element-plus': { ElMessageBox: { confirm }, ElMessage: { success() {} } },
'@/api/modules/pollingScheduler': { PollingSchedulerService: api },
'@/api/modules/storage': { StorageService: storage },
'@/store/modules/user': { useUserStore: () => user },
'@/store/modules/user': store,
'@/composables/useAuth': {
useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) })
},
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': helpers,
'@/config/constants/pollingScheduler': constants,
'@/config/constants/gateway': evaluate(source('src/config/constants/gateway.ts')),
'@/composables/useSchedulerRequest': schedulerRequest,
'@/composables/useGateway': {
useGatewayCarriers: () => ({ options: vue.ref([]), search() {}, loading: vue.ref(false) })
},
'@/components/business/polling/SchedulerTable.vue': { default: {} },
'@/components/business/gateway/StopResumeRecords.vue': { default: {} },
'./PollingStateDialog.vue': { default: {} },
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }
}
const state = evaluate(script, imports, runtime).default.setup(props, {
expose() {},
emit: (...args) => events.push(args)
})
const scope = vue.effectScope()
const state = scope.run(() =>
evaluate(script, imports, runtime).default.setup(props, {
expose() {},
emit: (...args) => events.push(args)
})
)
return {
state,
calls,
user,
events,
dispose: () => beforeUnmount.forEach((fn) => fn()),
dispose: () => {
beforeUnmount.forEach((fn) => fn())
scope.stop()
},
deactivate: () => deactivated.forEach((fn) => fn())
}
}
const dryRunPath = 'src/views/polling-management/stop-resume-dry-run/index.vue'
const schedulerDetailPath = 'src/components/business/polling/SchedulerDetail.vue'
const dryRunResult = {
items: [{ card_id: 42, iccid: '89860426012345678901' }],
total: 1,
page: 3,
size: 20,
summary: [{ carrier_id: 8, count: 1 }],
totals: { total: 1, will_stop: 1, will_resume: 0, no_action: 0 }
}
test('dry-run reset clears filters, old summary/details and errors without starting an unfiltered scan', async () => {
const h = mount(dryRunPath, {
permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page],
services: { dryRun: () => ok(dryRunResult) }
})
Object.assign(h.state.filters, {
carrier_id: 8,
stop_reason: 'manual',
result: 'will_stop',
assume_control_on: [8],
assume_control_off: [9]
})
h.state.page.value = 3
await h.state.load()
assert.equal(h.state.data.value.totals.total, 1)
assert.equal(h.state.data.value.summary.length, 1)
assert.equal(h.state.data.value.items.length, 1)
h.state.error.value = 'old export error'
h.state.reset()
assert.deepEqual(
{ ...h.state.filters },
{
carrier_id: undefined,
stop_reason: '',
result: '',
assume_control_on: [],
assume_control_off: []
}
)
assert.equal(h.state.page.value, 1)
assert.equal(h.state.data.value, null)
assert.equal(h.state.error.value, '')
assert.equal(h.state.loading.value, false)
assert.equal(h.calls.length, 1)
h.dispose()
})
test('dry-run reset invalidates pending success/error and allows a fresh explicit query', async () => {
for (const fail of [false, true]) {
let resolve, reject
const pendingResponse = new Promise((done, failed) => {
resolve = done
reject = failed
})
let requests = 0
const h = mount(dryRunPath, {
permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page],
services: { dryRun: () => (++requests === 1 ? pendingResponse : ok(dryRunResult)) }
})
h.state.filters.stop_reason = 'manual'
const pending = h.state.load()
assert.equal(h.state.loading.value, true)
h.state.reset()
if (fail) reject(new Error('stale request failed'))
else resolve(ok(dryRunResult))
await pending
assert.equal(h.state.data.value, null)
assert.equal(h.state.error.value, '')
assert.equal(h.state.loading.value, false)
assert.equal(requests, 1)
h.state.filters.carrier_id = 9
await h.state.load()
assert.equal(h.state.data.value.totals.total, 1)
assert.equal(h.calls.at(-1).args[0].carrier_id, 9)
assert.equal(h.calls.at(-1).args[0].stop_reason, undefined)
h.dispose()
}
})
for (const [kind, method] of [
['params', 'getCarrierParam'],
['alerts', 'getAlert'],
['imports', 'getImport']
])
test(`${kind} direct detail requires both page and detail permissions and retains the role boundary`, async () => {
const P = constants.SCHEDULER_PERMISSIONS[kind]
for (const permissions of [
[],
[P.page],
[P.detailPage],
[P.detail],
[P.detailPage, P.detail]
]) {
const allowed = permissions.includes(P.detailPage) && permissions.includes(P.detail)
const h = mount(schedulerDetailPath, {
props: { kind },
permissions,
services: { [method]: () => ok({ id: 1, carrier_id: 8, card_id: 0, status: 3 }) }
})
await h.state.load()
assert.equal(h.calls.length, allowed ? 1 : 0, permissions.join(','))
assert.equal(!!h.state.data.value, allowed)
h.dispose()
}
for (const userType of [1, 3, 4]) {
const allowed = kind !== 'imports' || userType === 1
const h = mount(schedulerDetailPath, {
props: { kind },
userType,
permissions: [P.detailPage, P.detail],
services: { [method]: () => ok({ id: 1, status: 3 }) }
})
await h.state.load()
assert.equal(h.calls.length, allowed ? 1 : 0)
assert.equal(!!h.state.data.value, allowed)
h.dispose()
}
})
test('revoking either detail permission clears displayed data and blocks subsequent refresh', async () => {
for (const kind of ['params', 'alerts', 'imports']) {
const P = constants.SCHEDULER_PERMISSIONS[kind]
for (const revoked of [P.detailPage, P.detail]) {
const permissions = vue.reactive([P.detailPage, P.detail])
const h = mount(schedulerDetailPath, { props: { kind }, permissions })
await h.state.load()
assert.ok(h.state.data.value)
permissions.splice(permissions.indexOf(revoked), 1)
assert.equal(h.state.data.value, null)
await h.state.load()
assert.equal(h.calls.length, 1)
h.dispose()
}
}
})
test('pending detail responses cannot appear after the detail permission is revoked', async () => {
for (const [kind, method] of [
['params', 'getCarrierParam'],
['alerts', 'getAlert'],
['imports', 'getImport']
]) {
const P = constants.SCHEDULER_PERMISSIONS[kind]
const permissions = vue.reactive([P.detailPage, P.detail])
let resolve
const response = new Promise((done) => {
resolve = done
})
const h = mount(schedulerDetailPath, {
props: { kind },
permissions,
services: { [method]: () => response }
})
const pending = h.state.load()
permissions.splice(permissions.indexOf(P.detail), 1)
resolve(ok({ id: 1, status: 3 }))
await pending
assert.equal(h.state.data.value, null)
assert.equal(h.state.loading.value, false)
assert.equal(h.calls.length, 1)
h.dispose()
}
})
test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => {
const calls = []
class BaseService {}

View File

@@ -0,0 +1,701 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}, globals = {}) {
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected dependency ${name}`)
return imports[name]
},
module,
module.exports,
...Object.values(globals)
)
return module.exports
}
const format = evaluate(read('src/utils/business/format.ts'))
const legacy = evaluate(read('src/utils/business/packageSilence.ts'))
const helpers = evaluate(read('src/utils/business/silenceExtension.ts'), {
'./format': format,
'./packageSilence': legacy
})
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const constants = evaluate(read('src/config/constants/packageSilence.ts'))
const response = (data) => ({ code: 0, data, msg: 'ok' })
const flush = async () => {
await vue.nextTick()
await Promise.resolve()
await vue.nextTick()
}
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { resolve, reject, promise }
}
function setup(
name,
{
props = {},
permissions = [],
overrides = {},
confirm = async () => {},
prompt = async () => ({ value: 'reason' })
} = {}
) {
const calls = [],
hooks = [],
timers = new Set(),
events = [],
mounted = []
const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true })
const grants = vue.reactive(new Set(permissions))
const route = vue.reactive({
params: { id: '3' },
fullPath: '/package-management/silence-orders/detail/3'
})
const hasAuth = (code) => grants.has(code)
const services = new Proxy(
{},
{
get:
(_, method) =>
async (...args) => {
calls.push({ method, args })
return overrides[method]
? overrides[method](...args)
: response(
method === 'eligibility'
? {
can_extend: true,
special_extension_available: true,
min_days: 1,
max_days: 720
}
: method === 'quote'
? {
valid: true,
amount: 100,
billed_days: 30,
input_days: args[1],
new_deadline: '2026-11-30'
}
: method === 'orders'
? { items: [], total: 0 }
: []
)
}
}
)
const runtime = {
...vue,
onMounted: (fn) => mounted.push(fn),
onActivated() {},
onDeactivated: (fn) => hooks.push(fn),
onBeforeUnmount: (fn) => hooks.push(fn)
}
const store = { useUserStore: () => user },
auth = { useAuth: () => ({ hasAuth }) }
const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling
})
const imports = {
vue: runtime,
'vue-router': { useRoute: () => route, useRouter: () => ({ push() {}, back() {} }) },
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1000) }) },
'qrcode.vue': { default: {} },
'element-plus': { ElMessageBox: { confirm, prompt } },
'@/api/modules/packageSilence': { PackageSilenceService: services },
'@/api/modules/employeeCollection': { EmployeeCollectionService: services },
'@/api/modules/packageManage': { PackageManageService: services },
'@/api/modules/packageSeries': { PackageSeriesService: services },
'@/composables/useAuth': auth,
'@/store/modules/user': store,
'@/composables/useSchedulerRequest': scheduler,
'@/utils/business/pollingScheduler': polling,
'@/utils/business/silenceExtension': helpers,
'@/utils/business/gateway': gateway,
'@/utils/business/format': format,
'@/config/constants/packageSilence': constants
}
const { descriptor } = parse(read(`src/components/business/package-silence/${name}.vue`))
const component = evaluate(
compileScript(descriptor, { id: 'silence-extension' }).content,
imports,
{
...runtime,
setTimeout: (fn) => {
timers.add(fn)
return fn
},
clearTimeout: (fn) => timers.delete(fn)
}
).default
const scope = vue.effectScope(),
reactiveProps = vue.reactive(props)
const state = scope.run(() =>
component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) })
)
return {
state,
calls,
user,
grants,
route,
props: reactiveProps,
timers,
events,
mount: async () => {
for (const fn of mounted) await fn()
},
dispose() {
hooks.forEach((fn) => fn())
scope.stop()
}
}
}
const extendPermission = constants.PACKAGE_SILENCE_PERMISSIONS.extend
async function ready(ctx) {
await ctx.state.loadEligibility()
ctx.state.days.value = 31
await flush()
await ctx.state.loadQuote()
assert.equal(ctx.state.canSubmit.value, true)
}
test('all 21 authoritative Apifox operations have exact service methods, including corrected first-purchase path and body IDs', async () => {
const calls = []
class BaseService {}
for (const method of ['get', 'post', 'put'])
BaseService[method] = async (url, body) => {
calls.push({ method, url, body })
return response({})
}
const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), {
'../BaseService': { BaseService }
})
await S.firstPurchase('card', 9)
await S.batches({ has_exception: false })
await S.batch(3)
await S.eligibility(4)
await S.handle(5, { id: 5, action: 'close', reason: 'r' })
await S.orders({ payment_method: 'offline' })
await S.order(6)
await S.createOffline({ days: 1 })
await S.createQrcode({ days: 31 })
await S.quote(4, 31)
await S.special({ items: [] })
await S.policies({ status: 0 })
await S.createPolicy({ name: 'n' })
await S.policy(7)
await S.updatePolicy(7, { id: 99, name: 'edit' })
await S.policyStatus(7, 0)
await S.match(4)
await S.records(4)
await S.getSwitch()
await S.setSwitch(false)
await S.usages({ view: 'expired' })
const spec = JSON.parse(read('docs/api/package-silence.openapi.json'))
for (const [path, methods] of Object.entries(spec.paths))
for (const method of Object.keys(methods)) {
const regex = new RegExp('^' + path.replace(/\{[^}]+\}/g, '[^/]+') + '$')
assert.ok(
calls.some((call) => call.method === method && regex.test(call.url)),
`${method} ${path}`
)
}
assert.equal(calls.length, 21)
assert.equal(calls[0].url, '/api/admin/assets/card/9/first-purchase-records')
assert.deepEqual(calls[1].body, { has_exception: false })
assert.deepEqual(calls[14].body, { id: 7, name: 'edit' })
assert.deepEqual(calls[15].body, { id: 7, status: 0 })
})
test('money conversion is exact integer fen, keeps zero, rejects precision loss and never guesses absent values', () => {
for (const [value, expected] of [
['0', 0],
['0.01', 1],
['0.29', 29],
['1.10', 110],
['100', 10000]
])
assert.equal(helpers.silenceFen(value), expected)
for (const value of ['', null, '-1', '1.001', '1e2', 'NaN', '9007199254740992'])
assert.throws(() => helpers.silenceFen(value))
assert.equal(helpers.silenceAmount(0), '¥0.00')
assert.equal(helpers.silenceAmount(undefined), '-')
assert.equal(helpers.silenceDisplay({ end_reason: 'exchange_return' }, 'end_reason'), '换货回库')
})
test('CSV import uses only two documented item fields and rejects bad headers, duplicate and unsafe IDs', () => {
assert.deepEqual(helpers.parseSilenceCsv('\uFEFFpackage_usage_id,days\r\n11,31\r\n12,181'), [
{ package_usage_id: 11, days: 31 },
{ package_usage_id: 12, days: 181 }
])
for (const input of [
'id,days\n1,30',
'package_usage_id,days',
'package_usage_id,days\n1e4,30',
'package_usage_id,days\n1,0',
'package_usage_id,days\n1,30\n1,60'
])
assert.throws(() => helpers.parseSilenceCsv(input))
})
test('business failures retain per-row details, while pending approval is not an error or fabricated completion', () => {
const rows = [{ row_no: 2, reason: 'expired' }]
try {
helpers.silenceData({ code: 1267, data: { rows }, msg: '免费次数已用完' })
} catch (e) {
assert.deepEqual(helpers.silenceRowsError(e), rows)
assert.equal(e.message, '免费次数已用完')
}
assert.deepEqual(helpers.silenceData(response({ status: 'pending_approval' })), {
status: 'pending_approval'
})
})
test('current quote is required and changing days invalidates it before any write', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission]
})
try {
await ready(ctx)
ctx.state.days.value = 32
await flush()
assert.equal(ctx.state.canSubmit.value, false)
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
} finally {
ctx.dispose()
}
})
test('online order sends days not client-calculated amount; QR status polling stops when closed', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: {
createQrcode: () => response({ id: 7, status: 'pending_payment', qr_content: 'local' }),
order: () => response({ id: 7, status: 'closed' })
}
})
try {
await ready(ctx)
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === 'createQrcode')
assert.equal(call.args[0].days, 31)
assert.equal('amount' in call.args[0], false)
assert.equal(typeof call.args[0].request_id, 'string')
assert.equal(ctx.timers.size, 1)
await ctx.state.refreshOrder()
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('retry after a transport failure reuses the same idempotency key instead of silently creating a new payment', async () => {
let count = 0
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: {
createQrcode: () => {
if (!count++) throw new Error('network')
return response({ id: 7, status: 'pending_approval' })
}
}
})
try {
await ready(ctx)
await ctx.state.submit()
await ctx.state.submit()
const calls = ctx.calls.filter((x) => x.method === 'createQrcode')
assert.equal(calls.length, 2)
assert.equal(calls[0].args[0].request_id, calls[1].args[0].request_id)
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('permission loss while confirmation is open prevents the payment write', async () => {
const confirm = deferred(),
ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
confirm: () => confirm.promise
})
try {
await ready(ctx)
const saving = ctx.state.submit()
await flush()
ctx.grants.clear()
await flush()
confirm.resolve()
await saving
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
} finally {
ctx.dispose()
}
})
test('canceling confirmation sends no write; offline amount mismatch blocks even with a valid quote', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.offline],
confirm: async () => {
throw 'cancel'
}
})
try {
await ready(ctx)
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
ctx.state.mode.value = 'offline'
await flush()
await ctx.state.loadQuote()
Object.assign(ctx.state, {})
ctx.state.methodId.value = 2
ctx.state.tradeNo.value = 'T'
ctx.state.paidYuan.value = '0.99'
ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00'
ctx.state.vouchers.value = ['a']
await ctx.state.submit()
assert.match(ctx.state.error.value, /实收金额/)
assert.equal(
ctx.calls.some((x) => x.method === 'createOffline'),
false
)
} finally {
ctx.dispose()
}
})
test('late order creation after unmount cannot display a QR code or restart polling', async () => {
const pending = deferred(),
ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: { createQrcode: () => pending.promise }
})
await ready(ctx)
const saving = ctx.state.submit()
await flush()
ctx.dispose()
pending.resolve(response({ id: 7, status: 'pending_payment', qr_content: 'late' }))
await saving
assert.equal(ctx.state.order.value, undefined)
assert.equal(ctx.timers.size, 0)
})
test('batch special extension requires both permissions and submits documented parsed items with zero amount', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { batchMode: true },
permissions: [extendPermission],
overrides: { special: () => response({ id: 2, status: 'pending_approval', items: [] }) }
})
try {
ctx.state.batchItems.value = [
{ package_usage_id: 1, days: 121 },
{ package_usage_id: 2, days: 181 }
]
ctx.state.reason.value = '原因'
ctx.state.basis.value = '依据'
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'special'),
false
)
ctx.grants.add(constants.PACKAGE_SILENCE_PERMISSIONS.batch)
await flush()
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === 'special')
assert.deepEqual(call.args[0].items, [
{ package_usage_id: 1, days: 121 },
{ package_usage_id: 2, days: 181 }
])
assert.equal(call.args[0].amount, 0)
assert.equal('request_id' in call.args[0], false)
assert.equal(ctx.state.batch.value.status, 'pending_approval')
} finally {
ctx.dispose()
}
})
test('read-only record dialogs use array responses and the exact corrected first-purchase URL contract', async () => {
const ctx = setup('SilenceRecordsDialog', {
props: { assetType: 'device', assetId: 2 },
overrides: { firstPurchase: () => response([{ generation: 1, source: 'order' }]) }
})
try {
await ctx.state.load()
assert.deepEqual(ctx.calls[0], { method: 'firstPurchase', args: ['device', 2] })
assert.equal(ctx.state.data.value[0].generation, 1)
} finally {
ctx.dispose()
}
})
test('direct order and batch details require page, hidden detail page and button permission together', async () => {
for (const kind of ['orders', 'batches']) {
const codes = constants.SILENCE_PAGES[kind]
for (const removed of ['page', 'detailPage', 'detail']) {
const ctx = setup('SilenceDetail', {
props: { kind },
permissions: Object.entries(codes)
.filter(([key]) => key !== removed)
.map(([, v]) => v)
})
try {
await ctx.state.load()
assert.equal(ctx.calls.length, 0)
assert.equal(ctx.state.allowed.value, false)
} finally {
ctx.dispose()
}
}
}
})
test('order and usage exports freeze whitelisted list filters without pagination; policy zero status survives', async () => {
for (const kind of ['orders', 'usages', 'policies']) {
const codes = constants.SILENCE_PAGES[kind],
ctx = setup('SilenceManagement', {
props: { kind },
permissions: [codes.page, codes.export, constants.PACKAGE_SILENCE_PERMISSIONS.policy]
})
try {
ctx.state.filters.injected = 'never'
ctx.state.filters.status = 0
if (kind === 'orders')
ctx.state.filters.time_range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00']
await ctx.state.load()
assert.equal('injected' in ctx.calls[0].args[0], false)
if (kind === 'policies') assert.equal(ctx.calls[0].args[0].status, 0)
else {
ctx.state.openExport()
assert.equal('page' in ctx.state.exportQuery.value.filters, false)
if (kind === 'orders')
assert.equal(ctx.state.exportQuery.value.filters.paid_start, '2026-09-30T16:00:00Z')
}
} finally {
ctx.dispose()
}
}
})
test('searchable silence filters keep real IDs for list/export, clear stale record dependencies and reset all selections', async () => {
const ctx = setup('SilenceManagement', {
props: { kind: 'orders' },
permissions: [constants.SILENCE_PAGES.orders.page, constants.SILENCE_PAGES.orders.export]
})
try {
ctx.state.filters.asset_identifier = '89860001'
ctx.state.filters.package_id = 8
ctx.state.filters.package_usage_id = 99
await ctx.state.load()
assert.equal(ctx.calls.at(-1).args[0].package_id, 8)
assert.equal(ctx.calls.at(-1).args[0].package_usage_id, 99)
ctx.state.openExport()
assert.equal(ctx.state.exportQuery.value.filters.package_usage_id, 99)
ctx.state.filters.package_id = 9
assert.equal(ctx.state.filters.package_usage_id, undefined)
ctx.state.filters.package_usage_id = 100
ctx.state.filters.asset_identifier = '89860002'
assert.equal(ctx.state.filters.package_usage_id, undefined)
ctx.state.reset()
assert.deepEqual(ctx.state.filters, {})
} finally {
ctx.dispose()
}
})
test('policy matching uses the selected package and rejects a late result after the selection dialog closes', async () => {
const pending = deferred()
const ctx = setup('SilenceManagement', {
props: { kind: 'policies' },
permissions: [
constants.SILENCE_PAGES.policies.page,
constants.PACKAGE_SILENCE_PERMISSIONS.policy
],
overrides: { match: () => pending.promise }
})
try {
await ctx.state.matchPolicy()
assert.equal(ctx.calls.length, 0)
ctx.state.matchVisible.value = true
ctx.state.matchPackage.value = 8
const matching = ctx.state.matchPolicy()
assert.deepEqual(ctx.calls[0], { method: 'match', args: [8] })
ctx.state.closeMatch()
pending.resolve(response({ matched: true, policy: { name: '不应弹出的旧结果' } }))
await matching
assert.equal(ctx.state.matchResult.value, undefined)
assert.equal(ctx.state.matching.value, false)
} finally {
ctx.dispose()
}
})
test('policy validation retains nullable limits and rejects missing scope and inverted dates', () => {
const value = {
name: '政策',
scope_type: 'all',
scope_ids: [],
effective_from: '2026-10-28T00:00:00+08:00',
effective_to: null,
unit_price: 0,
single_max_months: null
}
assert.doesNotThrow(() => helpers.validateSilencePolicy(value))
assert.throws(() => helpers.validateSilencePolicy({ ...value, scope_type: 'package' }))
assert.throws(() =>
helpers.validateSilencePolicy({ ...value, effective_to: '2026-10-27T00:00:00+08:00' })
)
})
test('offline successful registration sends fen and receipt fields, and pending approval never starts QR polling', async () => {
const permission = constants.PACKAGE_SILENCE_PERMISSIONS.offline
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [permission],
overrides: {
createOffline: () => response({ id: 7, status: 'pending_approval', approval_required: true })
}
})
try {
await ready(ctx)
ctx.state.methodId.value = 2
ctx.state.tradeNo.value = 'LOCAL-TRADE'
ctx.state.paidYuan.value = '1.00'
ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00'
ctx.state.vouchers.value = ['receipts/local.png']
await ctx.state.submit()
const body = ctx.calls.find((x) => x.method === 'createOffline').args[0]
assert.equal(body.paid_amount, 100)
assert.equal(body.paid_at, '2026-10-28T02:00:00Z')
assert.equal(body.offline_payment_method_id, 2)
assert.deepEqual(body.payment_voucher_keys, ['receipts/local.png'])
assert.equal(ctx.state.order.value.status, 'pending_approval')
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('special transport uncertainty prevents duplicate non-idempotent submissions', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { batchMode: true },
permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.batch],
overrides: {
special: () => {
throw new Error('timeout')
}
}
})
try {
ctx.state.batchItems.value = [{ package_usage_id: 4, days: 31 }]
ctx.state.reason.value = 'reason'
ctx.state.basis.value = 'basis'
await ctx.state.submit()
await ctx.state.submit()
assert.equal(ctx.calls.filter((x) => x.method === 'special').length, 1)
assert.equal(ctx.state.specialUncertain.value, true)
assert.match(ctx.state.error.value, /批次列表|无权/)
} finally {
ctx.dispose()
}
})
test('policy create and edit use full typed fields, fen, IDs and explicit nullable limits', async () => {
for (const id of [undefined, 4]) {
const ctx = setup('SilencePolicyDialog', {
props: { id, allowed: true },
overrides: {
policy: () =>
response({
id: 4,
name: 'old',
scope_type: 'all',
scope_ids: [],
unit_price: 0,
status: 0,
effective_from: '2026-10-28T00:00:00+08:00',
single_max_months: null
})
}
})
try {
await ctx.mount()
ctx.state.form.name = 'new'
ctx.state.form.effective_from = '2026-10-28T00:00:00+08:00'
ctx.state.unitYuan.value = '0.29'
ctx.state.singleLimited.value = false
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === (id ? 'updatePolicy' : 'createPolicy'))
assert.ok(call)
const body = call.args[id ? 1 : 0]
assert.equal(body.unit_price, 29)
assert.equal(body.single_max_months, null)
assert.equal(body.effective_to, null)
assert.deepEqual(body.scope_ids, [])
if (id) {
assert.equal(body.id, 4)
assert.equal(body.status, 0)
}
} finally {
ctx.dispose()
}
}
})
test('exception close uses the extension detail ID, requires a reason and both page/detail permissions', async () => {
const p = constants.SILENCE_PAGES.batches,
ctx = setup('SilenceDetail', {
props: { kind: 'batches' },
permissions: [
p.page,
p.detailPage,
p.detail,
constants.PACKAGE_SILENCE_PERMISSIONS.exception
],
prompt: async () => ({ value: '关闭原因' })
})
try {
await ctx.state.handle({ id: 99 }, 'close')
const call = ctx.calls.find((x) => x.method === 'handle')
assert.deepEqual(call.args, [99, { id: 99, action: 'close', reason: '关闭原因' }])
} finally {
ctx.dispose()
}
})
test('all added Vue templates compile and route components resolve to actual files', () => {
const dir = 'src/components/business/package-silence'
for (const file of readdirSync(dir).filter((x) => x.endsWith('.vue'))) {
const filename = dir + '/' + file,
{ descriptor } = parse(read(filename))
const compiled = compileScript(descriptor, { id: file })
const template = compileTemplate({
source: descriptor.template.content,
filename,
id: file,
compilerOptions: { bindingMetadata: compiled.bindings }
})
assert.deepEqual(template.errors, [], filename)
}
for (const kind of ['orders', 'batches', 'usages', 'policies'])
assert.ok(
read(`src/views/package-management/silence-${kind}/index.vue`).includes(`kind="${kind}"`)
)
})

View File

@@ -41,13 +41,15 @@ plugins.push(
)
if (serve) {
const { mockApiPlugin } = await import(
process.argv.includes('--scheduler')
? './pollingSchedulerBrowserFixtures.mjs'
: process.argv.includes('--polling')
? './pollingConfigBrowserFixtures.mjs'
: process.argv.includes('--gateway')
? './gatewayBrowserFixtures.mjs'
: './octoberBrowserFixtures.mjs'
process.argv.includes('--order-silence')
? './orderSilenceBrowserFixtures.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,