702 lines
24 KiB
JavaScript
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}"`)
|
|
)
|
|
})
|