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