Files
one-pipe-system/scripts/tests/silenceExtension.test.mjs
luo f2614d8efb
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 54s
fix: some
2026-10-28 15:18:44 +08:00

702 lines
24 KiB
JavaScript

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