This commit is contained in:
@@ -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(' ')
|
||||
|
||||
@@ -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,
|
||||
|
||||
341
scripts/tests/businessEntitySelect.test.mjs
Normal file
341
scripts/tests/businessEntitySelect.test.mjs
Normal 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/)
|
||||
})
|
||||
491
scripts/tests/orderSilenceBrowserFixtures.mjs
Normal file
491
scripts/tests/orderSilenceBrowserFixtures.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
517
scripts/tests/packageSilenceIntegration.test.mjs
Normal file
517
scripts/tests/packageSilenceIntegration.test.mjs
Normal 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'))
|
||||
})
|
||||
@@ -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 {}
|
||||
|
||||
701
scripts/tests/silenceExtension.test.mjs
Normal file
701
scripts/tests/silenceExtension.test.mjs
Normal 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}"`)
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user