mobile-customer-attachments.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
  5. import * as Vue from 'vue'
  6. import { compileTemplate, parse } from '@vue/compiler-sfc'
  7. import ts from 'typescript'
  8. import { buildAttachmentUploadFormData } from '../src/utils/crm-upload'
  9. const file = new URL('../src/views/m/crm/customer/CustomerAttachments.vue', import.meta.url)
  10. const { descriptor } = parse(readFileSync(file, 'utf8'))
  11. const ast = ts.createSourceFile(
  12. 'CustomerAttachments.ts',
  13. descriptor.scriptSetup!.content,
  14. ts.ScriptTarget.Latest,
  15. true
  16. )
  17. // Run every setup statement, replacing only external imports and SFC macros.
  18. const source = ast.statements
  19. .filter((node) => !ts.isImportDeclaration(node))
  20. .map((node) => node.getText(ast))
  21. .join('\n')
  22. function harness(initialProps: Record<string, unknown> = {}) {
  23. let resolve!: (value: unknown) => void
  24. let reject!: (error: Error) => void
  25. const response = new Promise((done, fail) => {
  26. resolve = done
  27. reject = fail
  28. })
  29. const requests: FormData[] = []
  30. const events: unknown[][] = []
  31. const notices: string[] = []
  32. const previews: unknown[] = []
  33. const downloads: unknown[] = []
  34. let exposed: { hasUploading: () => boolean }
  35. const scope = effectScope()
  36. const bindings = {
  37. computed,
  38. nextTick,
  39. reactive,
  40. ref,
  41. watch,
  42. defineProps: () => initialProps,
  43. withDefaults: (props: object, defaults: object) => reactive({ ...defaults, ...props }),
  44. defineEmits:
  45. () =>
  46. (...args: unknown[]) =>
  47. events.push(args),
  48. defineExpose: (value: typeof exposed) => {
  49. exposed = value
  50. },
  51. showToast: (message: string) => notices.push(message),
  52. showFailToast: (message: string) => notices.push(message),
  53. buildAttachmentUploadFormData,
  54. fetchUploadAttachment: (data: FormData) => {
  55. requests.push(data)
  56. return response
  57. },
  58. // Only external URL resolution and navigation are stubbed; Vue state and FormData stay real.
  59. parseCrmAttachmentField: (value: string) => value.split(',').filter(Boolean),
  60. resolveCrmAttachmentFiles: (urls: string[]) =>
  61. urls.map((url) => ({ url, name: url.split('/').pop() })),
  62. resolveCrmAttachmentUrl: (url: string) => `https://files.example/${url}`,
  63. isCrmImageFile: ({ name }: { name: string }) => name.endsWith('.png'),
  64. openCrmAttachmentPreviewInNewTab: (item: unknown) => previews.push(item),
  65. downloadCrmAttachment: (item: unknown) => downloads.push(item)
  66. }
  67. const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
  68. const instance = scope.run(() =>
  69. new Function(
  70. ...Object.keys(bindings),
  71. `${code}\nreturn { props, onPick, gridItems, fileList, uploading, onPreview, onDownload, openPicker, onRemove, uploadOne, fileInputRef }`
  72. )(...Object.values(bindings))
  73. )
  74. return {
  75. ...instance,
  76. resolve,
  77. reject,
  78. requests,
  79. events,
  80. notices,
  81. previews,
  82. downloads,
  83. hasUploading: () => exposed.hasUploading(),
  84. stop: () => scope.stop()
  85. }
  86. }
  87. async function startUpload(h: ReturnType<typeof harness>) {
  88. const raw = new File(['fixture'], '客户附件.png', { type: 'image/png' })
  89. const input = { files: [raw], value: 'selected' }
  90. const pending = h.onPick({ target: input })
  91. await nextTick()
  92. // Prime the computed cache while transport is pending, just as the template does.
  93. assert.equal(h.gridItems.value[0].uploading, true)
  94. assert.equal(h.gridItems.value[0].url, '')
  95. assert.equal(h.uploading.value, 1)
  96. assert.equal(h.hasUploading(), true)
  97. assert.equal(input.value, '')
  98. assert.equal(h.requests.length, 1)
  99. assert.equal(h.requests[0].get('bizType'), 'crm_customer_certificate')
  100. assert.equal(h.requests[0].get('folder'), 'crm/customer')
  101. assert.equal((h.requests[0].get('file') as File).name, raw.name)
  102. return { pending }
  103. }
  104. test('pending customer upload refreshes the cached grid and enables preview on success', async () => {
  105. const h = harness()
  106. try {
  107. const { pending } = await startUpload(h)
  108. h.resolve({ data: { fileUrl: 'customer/saved.png' } })
  109. await pending
  110. await nextTick()
  111. assert.equal(h.gridItems.value[0].uploading, false)
  112. assert.equal(h.gridItems.value[0].url, 'https://files.example/customer/saved.png')
  113. assert.equal(h.fileList.value[0].status, 'done')
  114. assert.equal(h.uploading.value, 0)
  115. assert.equal(h.hasUploading(), false)
  116. assert.equal(h.requests.length, 1)
  117. assert.deepEqual(h.events, [['update:modelValue', 'customer/saved.png']])
  118. h.onPreview(h.gridItems.value[0])
  119. assert.deepEqual(h.previews, [{ ossId: 'customer/saved.png', name: '客户附件.png' }])
  120. assert.deepEqual(h.notices, [])
  121. } finally {
  122. h.stop()
  123. }
  124. })
  125. test('rejected customer upload clears the cached grid uploading state without emitting a URL', async () => {
  126. const h = harness()
  127. try {
  128. const { pending } = await startUpload(h)
  129. h.reject(new Error('fixture upload failure'))
  130. await pending
  131. await nextTick()
  132. assert.equal(h.gridItems.value[0].uploading, false)
  133. assert.equal(h.gridItems.value[0].url, '')
  134. assert.equal(h.fileList.value[0].status, 'failed')
  135. assert.equal(h.uploading.value, 0)
  136. assert.equal(h.hasUploading(), false)
  137. assert.equal(h.requests.length, 1)
  138. assert.deepEqual(h.events, [])
  139. assert.deepEqual(h.notices, ['fixture upload failure'])
  140. h.onPreview(h.gridItems.value[0])
  141. assert.deepEqual(h.previews, [])
  142. } finally {
  143. h.stop()
  144. }
  145. })
  146. function renderNodes(h: ReturnType<typeof harness>) {
  147. const template = compileTemplate({
  148. source: descriptor.template!.content,
  149. filename: 'CustomerAttachments.vue',
  150. id: 'attachments'
  151. })
  152. assert.deepEqual(template.errors, [])
  153. const exports: any = {}
  154. new Function(
  155. 'require',
  156. 'exports',
  157. ts.transpile(template.code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
  158. )(() => ({ ...Vue, resolveComponent: (name: string) => name }), exports)
  159. const nodes: any[] = []
  160. function visit(node: any) {
  161. if (!node || typeof node !== 'object') return
  162. if (Array.isArray(node)) return node.forEach(visit)
  163. nodes.push(node)
  164. if (Array.isArray(node.children)) visit(node.children)
  165. }
  166. visit(exports.render(Vue.proxyRefs({ ...h.props, ...h }), []))
  167. return nodes
  168. }
  169. test('readonly attachments render no native file input while edit mode keeps the picker', () => {
  170. const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
  171. try {
  172. assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 0)
  173. h.props.readonly = false
  174. assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 1)
  175. } finally {
  176. h.stop()
  177. }
  178. })
  179. test('readonly attachment picker and removal events cannot mutate the field', () => {
  180. const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
  181. let clicks = 0
  182. h.fileInputRef.value = {
  183. click: () => {
  184. clicks++
  185. }
  186. }
  187. try {
  188. h.openPicker()
  189. assert.equal(clicks, 0)
  190. const existing = h.gridItems.value[0]
  191. h.onRemove(existing)
  192. assert.equal(h.fileList.value.length, 1)
  193. assert.deepEqual(h.events, [])
  194. h.onPreview(existing)
  195. h.onDownload(existing)
  196. assert.equal(h.previews.length, 1)
  197. assert.equal(h.downloads.length, 1)
  198. } finally {
  199. h.stop()
  200. }
  201. })
  202. test('readonly change events and direct upload calls never send a file', async () => {
  203. const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
  204. h.resolve({ data: { fileUrl: 'customer/unwanted.png' } })
  205. const raw = new File(['fixture'], 'unwanted.png', { type: 'image/png' })
  206. const input = { files: [raw], value: 'selected' }
  207. try {
  208. await h.onPick({ target: input })
  209. await h.uploadOne(raw)
  210. assert.equal(h.requests.length, 0)
  211. assert.equal(input.value, '')
  212. assert.equal(h.fileList.value.length, 1)
  213. assert.deepEqual(h.events, [])
  214. } finally {
  215. h.stop()
  216. }
  217. })
  218. test('switching to readonly after opening the native picker rejects its late change event', async () => {
  219. const h = harness({ readonly: false })
  220. let clicks = 0
  221. h.fileInputRef.value = {
  222. click: () => {
  223. clicks++
  224. }
  225. }
  226. h.resolve({ data: { fileUrl: 'customer/unwanted.png' } })
  227. try {
  228. h.openPicker()
  229. assert.equal(clicks, 1)
  230. h.props.readonly = true
  231. await h.onPick({ target: { files: [new File(['x'], 'late.png')], value: 'selected' } })
  232. assert.equal(h.requests.length, 0)
  233. assert.deepEqual(h.events, [])
  234. } finally {
  235. h.stop()
  236. }
  237. })