518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
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'))
|
|
})
|