teamBackgroundPublicUpload.spec.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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 routerPath = path.resolve(__dirname, '../../src/router/index.js')
  8. const pagePath = path.resolve(__dirname, '../../src/views/pages/team-background-upload.vue')
  9. const readIfPresent = file => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
  10. function deferred() {
  11. let resolve
  12. let reject
  13. const promise = new Promise((resolvePromise, rejectPromise) => {
  14. resolve = resolvePromise
  15. reject = rejectPromise
  16. })
  17. return { promise, resolve, reject }
  18. }
  19. function transformModule(source) {
  20. return babel.transformSync(source, {
  21. babelrc: false,
  22. configFile: false,
  23. plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
  24. }).code
  25. }
  26. function evaluateModule(source, mocks) {
  27. const code = transformModule(source)
  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
  36. }
  37. function loadRouter() {
  38. const http = { get: jest.fn(() => new Promise(() => {})) }
  39. const Vue = { use: jest.fn(), prototype: { $message: { error: jest.fn() } } }
  40. class Router {
  41. constructor(options) {
  42. this.options = options
  43. this.app = { $options: { store: { state: { sidebarMenuActiveName: [] } } } }
  44. }
  45. beforeEach(guard) {
  46. this.guard = guard
  47. }
  48. onError(handler) {
  49. this.errorHandler = handler
  50. }
  51. addRoutes() {}
  52. push() {
  53. return Promise.resolve()
  54. }
  55. }
  56. const previousWindow = global.window
  57. global.window = {
  58. SITE_CONFIG: {
  59. dynamicMenuRoutesHasAdded: false,
  60. dynamicRoutes: [],
  61. menuList: [],
  62. contentTabDefault: {}
  63. }
  64. }
  65. try {
  66. const exports = evaluateModule(readIfPresent(routerPath), {
  67. vue: Vue,
  68. 'vue-router': Router,
  69. '@/utils/request': http
  70. })
  71. return { ...exports, http }
  72. } finally {
  73. global.window = previousWindow
  74. }
  75. }
  76. function expectPage(source) {
  77. expect(source).not.toBe('')
  78. return source !== ''
  79. }
  80. function loadPageOptions(api) {
  81. const source = readIfPresent(pagePath)
  82. const descriptor = parseComponent(source)
  83. return evaluateModule(descriptor.script.content, {
  84. '@/api/teamBackgroundFile': api,
  85. '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
  86. '@/components/team-background/TeamBackgroundUploadQueue.vue': {}
  87. }).default
  88. }
  89. function createPageVm(api, overrides = {}) {
  90. const options = loadPageOptions(api)
  91. const vm = {
  92. ...options.data(),
  93. $route: { query: {} },
  94. $message: { error: jest.fn(), success: jest.fn() },
  95. $refs: { fileInput: { value: 'selected', click: jest.fn() } },
  96. ...options.methods,
  97. ...overrides
  98. }
  99. vm.$componentOptions = options
  100. return vm
  101. }
  102. function computed(vm, name) {
  103. return vm.$componentOptions.computed[name].call(vm)
  104. }
  105. describe('public team background upload route', () => {
  106. test('is a page route outside the authenticated layout and short-circuits before menu requests', () => {
  107. const { default: router, pageRoutes, moduleRoutes, http } = loadRouter()
  108. const route = pageRoutes.find(candidate => candidate.path === '/team-background-upload')
  109. const next = jest.fn()
  110. expect(route).toMatchObject({
  111. path: '/team-background-upload',
  112. name: 'team-background-upload',
  113. meta: { title: '上传团队背景资料', public: true }
  114. })
  115. expect(moduleRoutes.children.some(candidate => candidate.path === route.path)).toBe(false)
  116. router.guard({ path: route.path, matched: [{ meta: route.meta }] }, {}, next)
  117. expect(next).toHaveBeenCalledTimes(1)
  118. expect(next).toHaveBeenCalledWith()
  119. expect(http.get).not.toHaveBeenCalled()
  120. })
  121. })
  122. describe('public team background upload page state machine', () => {
  123. const source = readIfPresent(pagePath)
  124. let api
  125. beforeEach(() => {
  126. api = {
  127. completePublicUpload: jest.fn(),
  128. getPublicUploadSession: jest.fn(),
  129. uploadPublicTeamBackgroundFile: jest.fn(),
  130. verifyPublicUploadCode: jest.fn()
  131. }
  132. })
  133. afterEach(() => {
  134. jest.useRealTimers()
  135. })
  136. test('auto-validates a token and ignores its deferred result after expiry', async () => {
  137. if (!expectPage(source)) return
  138. const request = deferred()
  139. api.getPublicUploadSession.mockReturnValue(request.promise)
  140. const vm = createPageVm(api, { $route: { query: { token: 'link-token' } } })
  141. const loading = vm.$componentOptions.created.call(vm)
  142. expect(vm.state).toBe('verifying')
  143. expect(api.getPublicUploadSession).toHaveBeenCalledWith('link-token')
  144. vm.expireSession()
  145. request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2099-01-01T00:00:00Z' } })
  146. await loading
  147. expect(vm.state).toBe('expired')
  148. expect(vm.session).toBeNull()
  149. })
  150. test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
  151. if (!expectPage(source)) return
  152. const request = deferred()
  153. api.verifyPublicUploadCode.mockReturnValue(request.promise)
  154. const vm = createPageVm(api)
  155. await vm.$componentOptions.created.call(vm)
  156. expect(vm.state).toBe('code')
  157. expect(api.getPublicUploadSession).not.toHaveBeenCalled()
  158. for (const invalidCode of ['12345', '1234567', '12345a', '123456']) {
  159. vm.code = invalidCode
  160. await vm.verifyCode()
  161. }
  162. expect(api.verifyPublicUploadCode).not.toHaveBeenCalled()
  163. expect(vm.$message.error).toHaveBeenCalledTimes(4)
  164. expect(vm.$message.error).toHaveBeenLastCalledWith('请输入6位数字验证码')
  165. vm.code = '123456'
  166. const verifying = vm.verifyCode()
  167. expect(vm.state).toBe('verifying')
  168. request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'verified-token', expiresAt: '2099-01-01T00:00:00Z' } })
  169. await verifying
  170. expect(api.verifyPublicUploadCode).toHaveBeenCalledWith('123456')
  171. expect(vm.state).toBe('ready')
  172. expect(vm.session.uploadToken).toBe('verified-token')
  173. vm.$componentOptions.beforeDestroy.call(vm)
  174. })
  175. test('ignores a deferred code response after the page becomes expired', async () => {
  176. if (!expectPage(source)) return
  177. const request = deferred()
  178. api.verifyPublicUploadCode.mockReturnValue(request.promise)
  179. const vm = createPageVm(api, { code: '123456', state: 'code' })
  180. const verifying = vm.verifyCode()
  181. vm.expireSession()
  182. request.resolve({ code: 0, data: { teamName: 'Late', uploadToken: 'late-token', expiresAt: '2099-01-01T00:00:00Z' } })
  183. await verifying
  184. expect(vm.state).toBe('expired')
  185. expect(vm.session).toBeNull()
  186. })
  187. test('uses the fixed expiry for a one-second countdown and clears timers', () => {
  188. if (!expectPage(source)) return
  189. jest.useFakeTimers()
  190. jest.setSystemTime(new Date('2026-07-21T10:00:00.000Z'))
  191. const vm = createPageVm(api)
  192. vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21T10:00:02.500Z' })
  193. expect(vm.remainingSeconds).toBe(2)
  194. expect(jest.getTimerCount()).toBe(1)
  195. jest.advanceTimersByTime(1000)
  196. expect(vm.remainingSeconds).toBe(1)
  197. jest.advanceTimersByTime(1000)
  198. expect(vm.remainingSeconds).toBe(0)
  199. expect(vm.state).toBe('expired')
  200. expect(jest.getTimerCount()).toBe(0)
  201. vm.activateSession({ teamName: 'Beta', uploadToken: 'other-token', expiresAt: '2026-07-21T10:01:00.000Z' })
  202. expect(jest.getTimerCount()).toBe(1)
  203. vm.$componentOptions.beforeDestroy.call(vm)
  204. expect(jest.getTimerCount()).toBe(0)
  205. })
  206. test('creates the exact queue shape and rejects more than ten files before enqueueing', async () => {
  207. if (!expectPage(source)) return
  208. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  209. const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.docx', size: 2 }]
  210. const now = jest.spyOn(Date, 'now').mockReturnValue(123456)
  211. expect(vm.createQueueItems(files)).toEqual([
  212. { id: '123456-0', file: files[0], name: 'one.pdf', progress: 0, status: 'pending', error: '' },
  213. { id: '123456-1', file: files[1], name: 'two.docx', progress: 0, status: 'pending', error: '' }
  214. ])
  215. now.mockRestore()
  216. await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
  217. expect(vm.queue).toEqual([])
  218. expect(api.uploadPublicTeamBackgroundFile).not.toHaveBeenCalled()
  219. expect(vm.$message.error).toHaveBeenCalledWith('一次最多选择 10 个文件')
  220. })
  221. test('reports progress, retains independent results and retries only the failed item', async () => {
  222. if (!expectPage(source)) return
  223. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  224. const success = { id: '1-0', file: { name: 'ok.pdf', size: 1 }, name: 'ok.pdf', progress: 0, status: 'pending', error: '' }
  225. const failure = { id: '1-1', file: { name: 'retry.docx', size: 1 }, name: 'retry.docx', progress: 0, status: 'pending', error: '' }
  226. vm.queue = [success, failure]
  227. api.uploadPublicTeamBackgroundFile
  228. .mockImplementationOnce((_token, _file, onProgress) => {
  229. onProgress({ loaded: 1, total: 2 })
  230. return Promise.resolve({ code: 0, data: {} })
  231. })
  232. .mockRejectedValueOnce({ msg: '网络失败' })
  233. await vm.uploadOne(success)
  234. await vm.uploadOne(failure)
  235. expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
  236. expect(failure).toMatchObject({ status: 'failed', progress: 0, error: '网络失败' })
  237. expect(vm.queue).toEqual([success, failure])
  238. expect(api.uploadPublicTeamBackgroundFile.mock.calls[0]).toEqual([
  239. 'upload-token',
  240. success.file,
  241. expect.any(Function)
  242. ])
  243. api.uploadPublicTeamBackgroundFile.mockImplementationOnce((_token, _file, onProgress) => {
  244. onProgress({ loaded: 3, total: 4 })
  245. expect(failure.progress).toBe(75)
  246. return Promise.resolve({ code: 0, data: {} })
  247. })
  248. await vm.retryUpload(failure)
  249. expect(success).toMatchObject({ status: 'success', progress: 100 })
  250. expect(failure).toMatchObject({ status: 'success', progress: 100, error: '' })
  251. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
  252. })
  253. test('a session-level upload failure expires the page and stops later files in the batch', async () => {
  254. if (!expectPage(source)) return
  255. api.uploadPublicTeamBackgroundFile.mockRejectedValue({ msg: '上传会话已过期' })
  256. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  257. const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.pdf', size: 1 }]
  258. await vm.handleFileChange({ target: { files } })
  259. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  260. expect(vm.queue[0]).toMatchObject({ status: 'failed', error: '上传会话已过期' })
  261. expect(vm.queue[1]).toMatchObject({ status: 'pending' })
  262. expect(vm.state).toBe('expired')
  263. })
  264. test('late upload progress and success cannot overwrite expiry', async () => {
  265. if (!expectPage(source)) return
  266. const request = deferred()
  267. let onProgress
  268. api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, callback) => {
  269. onProgress = callback
  270. return request.promise
  271. })
  272. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  273. const item = { id: '1-0', file: { name: 'slow.pdf', size: 1 }, name: 'slow.pdf', progress: 0, status: 'pending', error: '' }
  274. const uploading = vm.uploadOne(item)
  275. onProgress({ loaded: 1, total: 5 })
  276. expect(item.progress).toBe(20)
  277. vm.expireSession()
  278. onProgress({ loaded: 4, total: 5 })
  279. request.resolve({ code: 0, data: {} })
  280. await uploading
  281. expect(vm.state).toBe('expired')
  282. expect(item).toMatchObject({ status: 'uploading', progress: 20 })
  283. })
  284. test('completion uses only the upload token, counts successes and freezes further uploads', async () => {
  285. if (!expectPage(source)) return
  286. const request = deferred()
  287. api.completePublicUpload.mockReturnValue(request.promise)
  288. const vm = createPageVm(api, {
  289. state: 'ready',
  290. session: { uploadToken: 'upload-token' },
  291. queue: [
  292. { id: '1', status: 'success' },
  293. { id: '2', status: 'failed' },
  294. { id: '3', status: 'success' }
  295. ]
  296. })
  297. vm.uploadOne = jest.fn()
  298. expect(computed(vm, 'uploadDisabled')).toBe(false)
  299. const completing = vm.completeUpload()
  300. expect(vm.completing).toBe(true)
  301. expect(computed(vm, 'uploadDisabled')).toBe(true)
  302. request.resolve({ code: 0 })
  303. await completing
  304. expect(api.completePublicUpload).toHaveBeenCalledWith('upload-token')
  305. expect(vm.successCount).toBe(2)
  306. expect(vm.state).toBe('completed')
  307. expect(computed(vm, 'uploadDisabled')).toBe(true)
  308. await vm.handleFileChange({ target: { files: [{ name: 'late.pdf', size: 1 }] } })
  309. await vm.retryUpload(vm.queue[1])
  310. await vm.completeUpload()
  311. expect(vm.uploadOne).not.toHaveBeenCalled()
  312. expect(api.completePublicUpload).toHaveBeenCalledTimes(1)
  313. })
  314. test('completion closes empty and all-failed queues with a zero success count', async () => {
  315. if (!expectPage(source)) return
  316. api.completePublicUpload.mockResolvedValue({ code: 0 })
  317. for (const queue of [[], [{ id: 'failed', status: 'failed' }]]) {
  318. const vm = createPageVm(api, {
  319. state: 'ready',
  320. session: { uploadToken: 'upload-token' },
  321. queue
  322. })
  323. expect(computed({ ...vm, uploadDisabled: false }, 'completeDisabled')).toBe(false)
  324. await vm.completeUpload()
  325. expect(vm.state).toBe('completed')
  326. expect(vm.successCount).toBe(0)
  327. }
  328. expect(api.completePublicUpload).toHaveBeenCalledTimes(2)
  329. expect(api.completePublicUpload).toHaveBeenNthCalledWith(1, 'upload-token')
  330. expect(api.completePublicUpload).toHaveBeenNthCalledWith(2, 'upload-token')
  331. })
  332. test('late completion cannot overwrite timer expiry', async () => {
  333. if (!expectPage(source)) return
  334. const request = deferred()
  335. api.completePublicUpload.mockReturnValue(request.promise)
  336. const vm = createPageVm(api, {
  337. state: 'ready',
  338. session: { uploadToken: 'upload-token' },
  339. queue: [{ id: '1', status: 'success' }]
  340. })
  341. const completing = vm.completeUpload()
  342. vm.expireSession()
  343. request.resolve({ code: 0 })
  344. await completing
  345. expect(vm.state).toBe('expired')
  346. expect(vm.successCount).toBe(0)
  347. })
  348. test('ready and terminal templates expose only the approved public information', () => {
  349. if (!expectPage(source)) return
  350. const template = parseComponent(source).template.content
  351. const sessionFields = Array.from(template.matchAll(/session\.([A-Za-z_$][\w$]*)/g), match => match[1])
  352. expect(source).toContain('session.teamName')
  353. expect(source).toContain('session.expiresAt')
  354. expect(source).toContain('remainingSeconds')
  355. expect(source).toContain('TeamBackgroundUploadQueue')
  356. expect(source).toContain('请返回小程序或管理端重新生成上传链接')
  357. expect(source).toMatch(/state === 'expired'[\s\S]*请返回小程序或管理端重新生成上传链接/)
  358. expect(source).toMatch(/state === 'completed'[\s\S]*successCount/)
  359. expect(source).not.toMatch(/teamDescription|teamMembers|coachName|existingFiles|fileList|listTeamBackgroundFiles/)
  360. expect(new Set(sessionFields)).toEqual(new Set(['teamName', 'expiresAt']))
  361. expect(template).not.toMatch(/{{\s*session\s*}}/)
  362. })
  363. })