175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import * as Vue from 'vue'
|
|
import ts from 'typescript'
|
|
import { findPendingQuoteApproval } from '../src/utils/mp-crm-quote-card'
|
|
function deferred() {
|
|
let resolve!: (value?: any) => void
|
|
const promise = new Promise<any>((r) => {
|
|
resolve = r
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
async function flush() {
|
|
for (let i = 0; i < 30; i++) await Promise.resolve()
|
|
}
|
|
function harness(overrides: Record<string, any> = {}) {
|
|
const source = readFileSync(
|
|
new URL('../src/views/m/crm/quote/detail.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const ast = ts.createSourceFile(
|
|
'detail.ts',
|
|
parse(source).descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
|
|
const names = statements.flatMap((s) =>
|
|
ts.isFunctionDeclaration(s) && s.name
|
|
? [s.name.text]
|
|
: ts.isVariableStatement(s)
|
|
? s.declarationList.declarations.map((d) => d.name.getText(ast))
|
|
: []
|
|
)
|
|
const route = Vue.reactive({
|
|
name: 'MpCrmQuoteDetail',
|
|
path: '/m/crm/quote/Q1',
|
|
params: { id: 'Q1' },
|
|
query: { from: 'approve' } as Record<string, string>
|
|
})
|
|
const user = Vue.reactive({ accessToken: 'token', info: { id: 'user', tenantNo: 'T1' } })
|
|
const writes: any[] = []
|
|
const unmounts: (() => void)[] = []
|
|
const bindings: Record<string, any> = { ...Vue }
|
|
for (const s of ast.statements)
|
|
if (ts.isImportDeclaration(s)) {
|
|
if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
|
|
const n = s.importClause?.namedBindings
|
|
if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
|
|
}
|
|
Object.assign(
|
|
bindings,
|
|
{
|
|
useRoute: () => route,
|
|
useRouter: () => ({
|
|
push: () => {},
|
|
replace: async ({ query }: any) => {
|
|
route.query = query
|
|
}
|
|
}),
|
|
useUserStore: () => user,
|
|
useDictStore: () => ({ getLabel: (_: any, value: any) => value, getTagType: () => 'info' }),
|
|
getQuote: async (id: string) => ({ data: { id, status: 'submitted', productList: [] } }),
|
|
listApproval: async () => ({
|
|
data: [{ id: 'approval-1', step: 1, approverId: 'user', approvalResult: 'pending' }]
|
|
}),
|
|
fetchCategoryTree: async () => [],
|
|
buildPathLabelMapFromTreeRoots: () => new Map(),
|
|
listQuoteProduct: async () => ({ rows: [] }),
|
|
showCrmConfirmDialog: async () => {},
|
|
approveQuote: async (q: any) => writes.push(q),
|
|
findPendingQuoteApproval,
|
|
onUnmounted: () => {},
|
|
onMounted: () => {},
|
|
onBeforeUnmount: (fn: () => void) => unmounts.push(fn),
|
|
document: { querySelector: () => null },
|
|
window: { removeEventListener: () => {} }
|
|
},
|
|
overrides
|
|
)
|
|
for (const key of Object.keys(bindings))
|
|
if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
|
|
const scope = Vue.effectScope()
|
|
const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
const api = scope.run(() =>
|
|
new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
|
|
...Object.values(bindings)
|
|
)
|
|
)
|
|
return {
|
|
api,
|
|
route,
|
|
user,
|
|
writes,
|
|
source,
|
|
stop: () => {
|
|
unmounts.forEach((fn) => fn())
|
|
scope.stop()
|
|
}
|
|
}
|
|
}
|
|
test('detail denies explicit false permission but supports omitted detail flags with current-step approval', async () => {
|
|
for (const flag of [false, undefined, true]) {
|
|
const h = harness({
|
|
getQuote: async (id: string) => ({ data: { id, status: 'submitted', canApprove: flag } })
|
|
})
|
|
try {
|
|
h.api.onShellReady()
|
|
await flush()
|
|
assert.equal(
|
|
h.api.fabActions.value.some((a: any) => a.key === 'approve'),
|
|
flag !== false
|
|
)
|
|
await h.api.runAction('approve')
|
|
await h.api.confirmApprove()
|
|
assert.equal(h.writes.length, flag === false ? 0 : 1)
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
}
|
|
})
|
|
test('identity change clears detail immediately and cancels an open approval confirmation', async () => {
|
|
const confirmation = deferred()
|
|
const h = harness({ showCrmConfirmDialog: () => confirmation.promise })
|
|
try {
|
|
h.api.onShellReady()
|
|
await flush()
|
|
await h.api.runAction('approve')
|
|
const pending = h.api.confirmApprove()
|
|
h.user.info.tenantNo = 'T2'
|
|
assert.equal(h.api.quote.value, null)
|
|
assert.equal(h.api.approveOpen.value, false)
|
|
confirmation.resolve()
|
|
await pending
|
|
assert.deepEqual(h.writes, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
test('approval history error is retryable and a late history response cannot cross identity changes', async () => {
|
|
let failing = true
|
|
const history = deferred()
|
|
let pendingRead = false
|
|
const h = harness({
|
|
listApproval: async () => {
|
|
if (failing) throw new Error('审批历史离线')
|
|
if (pendingRead) return history.promise
|
|
return { data: [{ id: 'node', step: 1, approverId: 'user', approvalResult: 'pending' }] }
|
|
}
|
|
})
|
|
try {
|
|
h.api.onShellReady()
|
|
await flush()
|
|
assert.ok(h.api.approvalError, 'history must have a distinct error state')
|
|
assert.equal(h.api.approvalError.value, '审批历史离线')
|
|
assert.equal(h.api.pendingApproval.value, null)
|
|
failing = false
|
|
await h.api.retryApprovalHistory()
|
|
assert.equal(h.api.approvalError.value, '')
|
|
assert.equal(h.api.pendingApproval.value.id, 'node')
|
|
pendingRead = true
|
|
const retry = h.api.retryApprovalHistory()
|
|
h.user.accessToken = ''
|
|
history.resolve({ data: [{ id: 'stale', approverId: 'user', approvalResult: 'pending' }] })
|
|
await retry
|
|
assert.deepEqual(h.api.approvalList.value, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|