teamBackgroundManagement.spec.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import fs from 'fs'
  2. import path from 'path'
  3. import * as babel from '@babel/core'
  4. import { parseComponent } from 'vue-template-compiler'
  5. import { ACCEPT, formatFileSize, validateBackgroundFile } from '@/utils/teamBackgroundFile'
  6. process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'
  7. const queuePath = path.resolve(__dirname, '../../src/components/team-background/TeamBackgroundUploadQueue.vue')
  8. const dialogPath = path.resolve(__dirname, '../../src/components/team-background/TeamBackgroundFileDialog.vue')
  9. const managerPath = path.resolve(__dirname, '../../src/views/modules/wechatUser/teamManager.vue')
  10. const readIfPresent = file => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
  11. function deferred() {
  12. let resolve
  13. let reject
  14. const promise = new Promise((resolvePromise, rejectPromise) => {
  15. resolve = resolvePromise
  16. reject = rejectPromise
  17. })
  18. return { promise, resolve, reject }
  19. }
  20. function loadVueOptions(file, mocks = {}) {
  21. const source = readIfPresent(file)
  22. const descriptor = parseComponent(source)
  23. const code = babel.transformSync(descriptor.script.content, {
  24. babelrc: false,
  25. configFile: false,
  26. plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
  27. }).code
  28. const module = { exports: {} }
  29. const localRequire = id => {
  30. if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id]
  31. throw new Error(`Missing test mock for ${id}`)
  32. }
  33. const evaluate = new Function('require', 'module', 'exports', code)
  34. evaluate(localRequire, module, module.exports)
  35. return module.exports.default
  36. }
  37. function expectComponent(source) {
  38. expect(source).not.toBe('')
  39. return source !== ''
  40. }
  41. describe('team background upload queue contract', () => {
  42. const source = readIfPresent(queuePath)
  43. test('accepts queue items and renders every required status without mutating the list', () => {
  44. if (!expectComponent(source)) return
  45. const options = loadVueOptions(queuePath)
  46. expect(options.props.files).toBeDefined()
  47. for (const state of ['pending', 'uploading', 'success', 'failed']) {
  48. expect(source).toContain(state)
  49. }
  50. expect(source).not.toMatch(/files\.(?:splice|pop|shift)|files\s*=|filter\([^)]*status/)
  51. })
  52. test('exposes explicit retry and remove actions', () => {
  53. if (!expectComponent(source)) return
  54. expect(source).toMatch(/\$emit\(['"]retry['"],\s*item\)/)
  55. expect(source).toMatch(/\$emit\(['"]remove['"],\s*item\)/)
  56. })
  57. })
  58. describe('team background management dialog behavior', () => {
  59. const source = readIfPresent(dialogPath)
  60. const api = {
  61. createTeamBackgroundSession: jest.fn(),
  62. disableTeamBackgroundFile: jest.fn(),
  63. downloadTeamBackgroundFile: jest.fn(),
  64. listTeamBackgroundFiles: jest.fn(),
  65. uploadTeamBackgroundFile: jest.fn()
  66. }
  67. const saveAs = jest.fn()
  68. function createVm(overrides = {}) {
  69. const options = loadVueOptions(dialogPath, {
  70. '@/api/teamBackgroundFile': api,
  71. '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
  72. 'file-saver': { saveAs },
  73. './TeamBackgroundUploadQueue.vue': {}
  74. })
  75. const vm = {
  76. ...options.data(),
  77. visible: true,
  78. teamId: 7,
  79. teamName: 'Alpha',
  80. $emit: jest.fn(),
  81. $message: { success: jest.fn(), error: jest.fn() },
  82. $confirm: jest.fn().mockResolvedValue(),
  83. $refs: { fileInput: { value: 'selected' } },
  84. ...options.methods,
  85. ...overrides
  86. }
  87. vm.$componentWatch = options.watch || {}
  88. return vm
  89. }
  90. function invokeWatcher(vm, name, value, oldValue) {
  91. const watcher = vm.$componentWatch[name]
  92. if (!watcher) return
  93. const handler = typeof watcher === 'function' ? watcher : watcher.handler
  94. return handler.call(vm, value, oldValue)
  95. }
  96. beforeEach(() => {
  97. Object.values(api).forEach(mock => mock.mockReset())
  98. saveAs.mockReset()
  99. })
  100. test('creates the exact queue item shape and rejects more than ten files before enqueueing', async () => {
  101. if (!expectComponent(source)) return
  102. const vm = createVm()
  103. const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.docx', size: 2 }]
  104. const now = jest.spyOn(Date, 'now').mockReturnValue(123456)
  105. expect(vm.createQueueItems(files)).toEqual([
  106. { id: '123456-0', file: files[0], name: 'one.pdf', progress: 0, status: 'pending', error: '' },
  107. { id: '123456-1', file: files[1], name: 'two.docx', progress: 0, status: 'pending', error: '' }
  108. ])
  109. now.mockRestore()
  110. vm.uploadOne = jest.fn()
  111. await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
  112. expect(vm.queue).toEqual([])
  113. expect(vm.uploadOne).not.toHaveBeenCalled()
  114. expect(vm.$message.error).toHaveBeenCalledWith(expect.stringContaining('10'))
  115. })
  116. test('validates before upload and retains a successful item when another upload fails', async () => {
  117. if (!expectComponent(source)) return
  118. const vm = createVm()
  119. const success = { id: '1-0', file: { name: 'ok.pdf', size: 1 }, name: 'ok.pdf', progress: 0, status: 'pending', error: '' }
  120. const failure = { id: '1-1', file: { name: 'bad.docx', size: 1 }, name: 'bad.docx', progress: 0, status: 'pending', error: '' }
  121. const invalid = { id: '1-2', file: { name: 'bad.zip', size: 1 }, name: 'bad.zip', progress: 0, status: 'pending', error: '' }
  122. vm.queue = [success, failure, invalid]
  123. vm.refreshFiles = jest.fn().mockResolvedValue()
  124. api.uploadTeamBackgroundFile
  125. .mockResolvedValueOnce({ code: 0, data: {} })
  126. .mockRejectedValueOnce({ msg: '网络失败' })
  127. await vm.uploadOne(success)
  128. await vm.uploadOne(failure)
  129. await vm.uploadOne(invalid)
  130. expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
  131. expect(failure).toMatchObject({ status: 'failed', error: '网络失败' })
  132. expect(invalid).toMatchObject({ status: 'failed', error: expect.stringContaining('文件类型') })
  133. expect(vm.queue).toEqual([success, failure, invalid])
  134. expect(api.uploadTeamBackgroundFile).toHaveBeenCalledTimes(2)
  135. })
  136. test('uses a multiple accept-filtered input and keeps the ten-file batch cap in source', () => {
  137. if (!expectComponent(source)) return
  138. expect(source).toMatch(/<input[^>]+type="file"[^>]+multiple[^>]+:accept="ACCEPT"/s)
  139. expect(source).toMatch(/selectedFiles\.length\s*>\s*10/)
  140. })
  141. test('refreshes active files and emits their count', async () => {
  142. if (!expectComponent(source)) return
  143. const rows = [{ id: 1 }, { id: 2 }]
  144. api.listTeamBackgroundFiles.mockResolvedValue({ code: 0, data: rows })
  145. const vm = createVm()
  146. await vm.refreshFiles()
  147. expect(vm.fileList).toBe(rows)
  148. expect(vm.$emit).toHaveBeenCalledWith('changed', 2)
  149. })
  150. test('downloads only the authorized blob and disables through the team API', async () => {
  151. if (!expectComponent(source)) return
  152. const blob = { type: 'application/pdf' }
  153. api.downloadTeamBackgroundFile.mockResolvedValue(blob)
  154. api.disableTeamBackgroundFile.mockResolvedValue({ code: 0 })
  155. const vm = createVm()
  156. vm.refreshFiles = jest.fn().mockResolvedValue()
  157. const row = { id: 12, fileName: 'brief.pdf', url: 'https://untrusted.example/file' }
  158. await vm.downloadFile(row)
  159. await vm.disableFile(row)
  160. expect(api.downloadTeamBackgroundFile).toHaveBeenCalledWith(7, 12)
  161. expect(saveAs).toHaveBeenCalledWith(blob, 'brief.pdf')
  162. expect(api.disableTeamBackgroundFile).toHaveBeenCalledWith(7, 12)
  163. expect(vm.refreshFiles).toHaveBeenCalledTimes(1)
  164. })
  165. test('generates and displays a copyable, refreshable PC upload session', async () => {
  166. if (!expectComponent(source)) return
  167. const session = { code: '123456', uploadUrl: 'https://upload.example/token', expiresAt: '2026-07-21 18:00:00' }
  168. api.createTeamBackgroundSession.mockResolvedValue({ code: 0, data: session })
  169. const vm = createVm()
  170. await vm.createUploadSession()
  171. expect(vm.session).toBe(session)
  172. for (const field of ['session.code', 'session.expiresAt', 'session.uploadUrl', 'copyUploadUrl', 'refreshFiles']) {
  173. expect(source).toContain(field)
  174. }
  175. })
  176. test('visible false and closed invalidate pending work and reset both loading flags immediately', () => {
  177. if (!expectComponent(source)) return
  178. api.listTeamBackgroundFiles.mockReturnValue(new Promise(() => {}))
  179. api.createTeamBackgroundSession.mockReturnValue(new Promise(() => {}))
  180. const vm = createVm()
  181. vm.refreshFiles()
  182. vm.createUploadSession()
  183. expect(vm.loading).toBe(true)
  184. expect(vm.sessionLoading).toBe(true)
  185. vm.visible = false
  186. invokeWatcher(vm, 'visible', false, true)
  187. expect(vm.loading).toBe(false)
  188. expect(vm.sessionLoading).toBe(false)
  189. vm.loading = true
  190. vm.sessionLoading = true
  191. vm.handleClosed()
  192. expect(vm.loading).toBe(false)
  193. expect(vm.sessionLoading).toBe(false)
  194. })
  195. test('ignores late team-A list and session responses after close and reopen for team B', async () => {
  196. if (!expectComponent(source)) return
  197. const teamAList = deferred()
  198. const teamBList = deferred()
  199. const teamASession = deferred()
  200. const teamBSession = deferred()
  201. api.listTeamBackgroundFiles
  202. .mockReturnValueOnce(teamAList.promise)
  203. .mockReturnValueOnce(teamBList.promise)
  204. api.createTeamBackgroundSession
  205. .mockReturnValueOnce(teamASession.promise)
  206. .mockReturnValueOnce(teamBSession.promise)
  207. const vm = createVm({ teamId: 7 })
  208. const listA = vm.handleOpen()
  209. const sessionA = vm.createUploadSession()
  210. vm.visible = false
  211. invokeWatcher(vm, 'visible', false, true)
  212. vm.teamId = 8
  213. invokeWatcher(vm, 'teamId', 8, 7)
  214. vm.visible = true
  215. const listB = vm.handleOpen()
  216. const sessionB = vm.createUploadSession()
  217. const filesB = [{ id: 81 }, { id: 82 }]
  218. const currentSession = { code: '888888', uploadUrl: 'https://upload.example/b', expiresAt: 'later' }
  219. teamBList.resolve({ code: 0, data: filesB })
  220. teamBSession.resolve({ code: 0, data: currentSession })
  221. await Promise.all([listB, sessionB])
  222. teamAList.resolve({ code: 0, data: [{ id: 71 }] })
  223. teamASession.resolve({ code: 0, data: { code: '777777', uploadUrl: 'https://upload.example/a', expiresAt: 'old' } })
  224. await Promise.all([listA, sessionA])
  225. expect(api.listTeamBackgroundFiles.mock.calls).toEqual([[7], [8]])
  226. expect(api.createTeamBackgroundSession.mock.calls).toEqual([[7], [8]])
  227. expect(vm.fileList).toBe(filesB)
  228. expect(vm.session).toBe(currentSession)
  229. expect(vm.$emit.mock.calls.filter(call => call[0] === 'changed')).toEqual([['changed', 2]])
  230. expect(vm.loading).toBe(false)
  231. expect(vm.sessionLoading).toBe(false)
  232. })
  233. test('only the newest same-team list and session requests may update state', async () => {
  234. if (!expectComponent(source)) return
  235. const firstList = deferred()
  236. const secondList = deferred()
  237. const firstSession = deferred()
  238. const secondSession = deferred()
  239. api.listTeamBackgroundFiles
  240. .mockReturnValueOnce(firstList.promise)
  241. .mockReturnValueOnce(secondList.promise)
  242. api.createTeamBackgroundSession
  243. .mockReturnValueOnce(firstSession.promise)
  244. .mockReturnValueOnce(secondSession.promise)
  245. const vm = createVm()
  246. const listOne = vm.refreshFiles()
  247. const listTwo = vm.refreshFiles()
  248. const sessionOne = vm.createUploadSession()
  249. const sessionTwo = vm.createUploadSession()
  250. const newestFiles = [{ id: 2 }, { id: 3 }]
  251. const newestSession = { code: '222222', uploadUrl: 'https://upload.example/new', expiresAt: 'new' }
  252. secondList.resolve({ code: 0, data: newestFiles })
  253. secondSession.resolve({ code: 0, data: newestSession })
  254. await Promise.all([listTwo, sessionTwo])
  255. firstList.resolve({ code: 0, data: [{ id: 1 }] })
  256. firstSession.resolve({ code: 0, data: { code: '111111', uploadUrl: 'https://upload.example/old', expiresAt: 'old' } })
  257. await Promise.all([listOne, sessionOne])
  258. expect(vm.fileList).toBe(newestFiles)
  259. expect(vm.session).toBe(newestSession)
  260. expect(vm.$emit.mock.calls.filter(call => call[0] === 'changed')).toEqual([['changed', 2]])
  261. expect(vm.loading).toBe(false)
  262. expect(vm.sessionLoading).toBe(false)
  263. })
  264. test('an old disable confirmation cannot act on a new team context', async () => {
  265. if (!expectComponent(source)) return
  266. const confirmation = deferred()
  267. const vm = createVm({ teamId: 7, $confirm: jest.fn(() => confirmation.promise) })
  268. const disabling = vm.disableFile({ id: 12, fileName: 'old.pdf' })
  269. vm.visible = false
  270. invokeWatcher(vm, 'visible', false, true)
  271. vm.teamId = 8
  272. invokeWatcher(vm, 'teamId', 8, 7)
  273. confirmation.resolve()
  274. await disabling
  275. expect(api.disableTeamBackgroundFile).not.toHaveBeenCalled()
  276. })
  277. test('reports a JSON blob download error and never saves it as a file', async () => {
  278. if (!expectComponent(source)) return
  279. const blob = {
  280. type: 'application/json;charset=UTF-8',
  281. text: jest.fn().mockResolvedValue(JSON.stringify({ code: 500, msg: '文件已停用' }))
  282. }
  283. api.downloadTeamBackgroundFile.mockResolvedValue(blob)
  284. const vm = createVm()
  285. await vm.downloadFile({ id: 12, fileName: 'brief.pdf' })
  286. expect(blob.text).toHaveBeenCalled()
  287. expect(saveAs).not.toHaveBeenCalled()
  288. expect(vm.$message.error).toHaveBeenCalledWith('文件已停用')
  289. })
  290. test('shows complete file metadata and user-facing WECHAT/PC labels', () => {
  291. if (!expectComponent(source)) return
  292. for (const field of ['fileName', 'fileExt', 'fileSize', 'source', 'createDate']) {
  293. expect(source).toContain(field)
  294. }
  295. expect(source).toContain("WECHAT: '微信端'")
  296. expect(source).toContain("PC: '电脑端'")
  297. expect(source).toContain('formatFileSize')
  298. })
  299. })
  300. describe('team table integration contract', () => {
  301. const source = readIfPresent(managerPath)
  302. test('places enterprise website immediately after company and renders the document count link', () => {
  303. expect(source).toContain('label="企业官网" prop="enterpriseWebsite"')
  304. const company = source.indexOf('label="所在公司"')
  305. const website = source.indexOf('label="企业官网"')
  306. const region = source.indexOf('label="所属地区"')
  307. const nextLabel = source.slice(company + 1).match(/label="([^"]+)"/)
  308. expect(company).toBeGreaterThan(-1)
  309. expect(website).toBeGreaterThan(company)
  310. expect(region).toBeGreaterThan(website)
  311. expect(nextLabel[1]).toBe('企业官网')
  312. expect(source).toContain("row.enterpriseWebsite || '无'")
  313. expect(source).toContain('row.backgroundFileCount || 0')
  314. expect(source).toContain('@click="openBackgroundFiles(row)"')
  315. expect(source).toMatch(/<el-button[^>]+type="text"[^>]+@click="openBackgroundFiles\(row\)"/s)
  316. expect(source).not.toMatch(/<el-link[^>]+@click="openBackgroundFiles\(row\)"/s)
  317. })
  318. test('mounts one dialog and updates only the matching row on changed(count)', () => {
  319. const mounts = source.match(/<team-background-file-dialog\b/g) || []
  320. expect(mounts).toHaveLength(1)
  321. expect(source).toContain('@changed="handleBackgroundFilesChanged"')
  322. expect(source).toMatch(/dataList\.value\.find\([^)]*activeTeam\.value\.id/)
  323. expect(source).toMatch(/row\.backgroundFileCount\s*=\s*count/)
  324. expect(source).not.toMatch(/handleBackgroundFilesChanged[\s\S]{0,180}getList\(/)
  325. })
  326. })