mobile-quote-approval-guards.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import * as Vue from 'vue'
  6. import ts from 'typescript'
  7. import { findPendingQuoteApproval } from '../src/utils/mp-crm-quote-card'
  8. function deferred() {
  9. let resolve!: (value?: any) => void
  10. const promise = new Promise<any>((r) => {
  11. resolve = r
  12. })
  13. return { promise, resolve }
  14. }
  15. async function flush() {
  16. for (let i = 0; i < 30; i++) await Promise.resolve()
  17. }
  18. function harness(overrides: Record<string, any> = {}) {
  19. const source = readFileSync(
  20. new URL('../src/views/m/crm/quote/detail.vue', import.meta.url),
  21. 'utf8'
  22. )
  23. const ast = ts.createSourceFile(
  24. 'detail.ts',
  25. parse(source).descriptor.scriptSetup!.content,
  26. ts.ScriptTarget.Latest,
  27. true
  28. )
  29. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  30. const names = statements.flatMap((s) =>
  31. ts.isFunctionDeclaration(s) && s.name
  32. ? [s.name.text]
  33. : ts.isVariableStatement(s)
  34. ? s.declarationList.declarations.map((d) => d.name.getText(ast))
  35. : []
  36. )
  37. const route = Vue.reactive({
  38. name: 'MpCrmQuoteDetail',
  39. path: '/m/crm/quote/Q1',
  40. params: { id: 'Q1' },
  41. query: { from: 'approve' } as Record<string, string>
  42. })
  43. const user = Vue.reactive({ accessToken: 'token', info: { id: 'user', tenantNo: 'T1' } })
  44. const writes: any[] = []
  45. const unmounts: (() => void)[] = []
  46. const bindings: Record<string, any> = { ...Vue }
  47. for (const s of ast.statements)
  48. if (ts.isImportDeclaration(s)) {
  49. if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
  50. const n = s.importClause?.namedBindings
  51. if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
  52. }
  53. Object.assign(
  54. bindings,
  55. {
  56. useRoute: () => route,
  57. useRouter: () => ({
  58. push: () => {},
  59. replace: async ({ query }: any) => {
  60. route.query = query
  61. }
  62. }),
  63. useUserStore: () => user,
  64. useDictStore: () => ({ getLabel: (_: any, value: any) => value, getTagType: () => 'info' }),
  65. getQuote: async (id: string) => ({ data: { id, status: 'submitted', productList: [] } }),
  66. listApproval: async () => ({
  67. data: [{ id: 'approval-1', step: 1, approverId: 'user', approvalResult: 'pending' }]
  68. }),
  69. fetchCategoryTree: async () => [],
  70. buildPathLabelMapFromTreeRoots: () => new Map(),
  71. listQuoteProduct: async () => ({ rows: [] }),
  72. showCrmConfirmDialog: async () => {},
  73. approveQuote: async (q: any) => writes.push(q),
  74. findPendingQuoteApproval,
  75. onUnmounted: () => {},
  76. onMounted: () => {},
  77. onBeforeUnmount: (fn: () => void) => unmounts.push(fn),
  78. document: { querySelector: () => null },
  79. window: { removeEventListener: () => {} }
  80. },
  81. overrides
  82. )
  83. for (const key of Object.keys(bindings))
  84. if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
  85. const scope = Vue.effectScope()
  86. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  87. target: ts.ScriptTarget.ESNext
  88. })
  89. const api = scope.run(() =>
  90. new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  91. ...Object.values(bindings)
  92. )
  93. )
  94. return {
  95. api,
  96. route,
  97. user,
  98. writes,
  99. source,
  100. stop: () => {
  101. unmounts.forEach((fn) => fn())
  102. scope.stop()
  103. }
  104. }
  105. }
  106. test('detail denies explicit false permission but supports omitted detail flags with current-step approval', async () => {
  107. for (const flag of [false, undefined, true]) {
  108. const h = harness({
  109. getQuote: async (id: string) => ({ data: { id, status: 'submitted', canApprove: flag } })
  110. })
  111. try {
  112. h.api.onShellReady()
  113. await flush()
  114. assert.equal(
  115. h.api.fabActions.value.some((a: any) => a.key === 'approve'),
  116. flag !== false
  117. )
  118. await h.api.runAction('approve')
  119. await h.api.confirmApprove()
  120. assert.equal(h.writes.length, flag === false ? 0 : 1)
  121. } finally {
  122. h.stop()
  123. }
  124. }
  125. })
  126. test('identity change clears detail immediately and cancels an open approval confirmation', async () => {
  127. const confirmation = deferred()
  128. const h = harness({ showCrmConfirmDialog: () => confirmation.promise })
  129. try {
  130. h.api.onShellReady()
  131. await flush()
  132. await h.api.runAction('approve')
  133. const pending = h.api.confirmApprove()
  134. h.user.info.tenantNo = 'T2'
  135. assert.equal(h.api.quote.value, null)
  136. assert.equal(h.api.approveOpen.value, false)
  137. confirmation.resolve()
  138. await pending
  139. assert.deepEqual(h.writes, [])
  140. } finally {
  141. h.stop()
  142. }
  143. })
  144. test('approval history error is retryable and a late history response cannot cross identity changes', async () => {
  145. let failing = true
  146. const history = deferred()
  147. let pendingRead = false
  148. const h = harness({
  149. listApproval: async () => {
  150. if (failing) throw new Error('审批历史离线')
  151. if (pendingRead) return history.promise
  152. return { data: [{ id: 'node', step: 1, approverId: 'user', approvalResult: 'pending' }] }
  153. }
  154. })
  155. try {
  156. h.api.onShellReady()
  157. await flush()
  158. assert.ok(h.api.approvalError, 'history must have a distinct error state')
  159. assert.equal(h.api.approvalError.value, '审批历史离线')
  160. assert.equal(h.api.pendingApproval.value, null)
  161. failing = false
  162. await h.api.retryApprovalHistory()
  163. assert.equal(h.api.approvalError.value, '')
  164. assert.equal(h.api.pendingApproval.value.id, 'node')
  165. pendingRead = true
  166. const retry = h.api.retryApprovalHistory()
  167. h.user.accessToken = ''
  168. history.resolve({ data: [{ id: 'stale', approverId: 'user', approvalResult: 'pending' }] })
  169. await retry
  170. assert.deepEqual(h.api.approvalList.value, [])
  171. } finally {
  172. h.stop()
  173. }
  174. })