teamBackgroundPublicUpload.spec.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
  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 loadPageModule(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. })
  88. }
  89. function loadPageOptions(api) {
  90. return loadPageModule(api).default
  91. }
  92. function loadExpiryParser(api) {
  93. const parser = loadPageModule(api).parseUploadExpiry
  94. expect(parser).toEqual(expect.any(Function))
  95. return parser
  96. }
  97. function createPageVm(api, overrides = {}) {
  98. const options = loadPageOptions(api)
  99. const vm = {
  100. ...options.data(),
  101. $route: { query: {} },
  102. $message: { error: jest.fn(), success: jest.fn() },
  103. $refs: { fileInput: { value: 'selected', click: jest.fn() } },
  104. ...options.methods,
  105. ...overrides
  106. }
  107. vm.$componentOptions = options
  108. return vm
  109. }
  110. function computed(vm, name) {
  111. return vm.$componentOptions.computed[name].call(vm)
  112. }
  113. function invokeWatcher(vm, name, value, oldValue) {
  114. const watcher = vm.$componentOptions.watch && vm.$componentOptions.watch[name]
  115. expect(watcher).toBeDefined()
  116. if (!watcher) return
  117. const handler = typeof watcher === 'function' ? watcher : watcher.handler
  118. return handler.call(vm, value, oldValue)
  119. }
  120. describe('public team background upload route', () => {
  121. test('is a page route outside the authenticated layout and short-circuits before menu requests', () => {
  122. const { default: router, pageRoutes, moduleRoutes, http } = loadRouter()
  123. const route = pageRoutes.find(candidate => candidate.path === '/team-background-upload')
  124. const next = jest.fn()
  125. expect(route).toMatchObject({
  126. path: '/team-background-upload',
  127. name: 'team-background-upload',
  128. meta: { title: '上传团队背景资料', public: true }
  129. })
  130. expect(moduleRoutes.children.some(candidate => candidate.path === route.path)).toBe(false)
  131. router.guard({ path: route.path, matched: [{ meta: route.meta }] }, {}, next)
  132. expect(next).toHaveBeenCalledTimes(1)
  133. expect(next).toHaveBeenCalledWith()
  134. expect(http.get).not.toHaveBeenCalled()
  135. })
  136. })
  137. describe('public team background upload page state machine', () => {
  138. const source = readIfPresent(pagePath)
  139. let api
  140. beforeEach(() => {
  141. api = {
  142. completePublicUpload: jest.fn(),
  143. getPublicUploadSession: jest.fn(),
  144. uploadPublicTeamBackgroundFile: jest.fn(),
  145. verifyPublicUploadCode: jest.fn()
  146. }
  147. })
  148. afterEach(() => {
  149. jest.useRealTimers()
  150. })
  151. test('parses the backend timestamp as GMT+8 at the countdown boundary', () => {
  152. if (!expectPage(source)) return
  153. const parseUploadExpiry = loadExpiryParser(api)
  154. if (typeof parseUploadExpiry !== 'function') return
  155. expect(parseUploadExpiry('2026-07-21 10:00:02')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
  156. jest.useFakeTimers()
  157. jest.setSystemTime(new Date('2026-07-21T02:00:00.000Z'))
  158. const vm = createPageVm(api)
  159. vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21 10:00:02' })
  160. expect(vm.remainingSeconds).toBe(2)
  161. jest.advanceTimersByTime(2000)
  162. expect(vm.state).toBe('expired')
  163. })
  164. test('retains valid ISO timestamps with explicit offsets', () => {
  165. if (!expectPage(source)) return
  166. const parseUploadExpiry = loadExpiryParser(api)
  167. if (typeof parseUploadExpiry !== 'function') return
  168. expect(parseUploadExpiry('2026-07-21T02:00:02.500Z')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2, 500))
  169. expect(parseUploadExpiry('2026-07-21T10:00:02+08:00')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
  170. })
  171. test('rejects malformed, timezone-free and impossible expiry values', () => {
  172. if (!expectPage(source)) return
  173. const parseUploadExpiry = loadExpiryParser(api)
  174. if (typeof parseUploadExpiry !== 'function') return
  175. for (const invalid of [
  176. '',
  177. '2026-02-29 10:00:00',
  178. '2026-13-01 10:00:00',
  179. '2026-07-21 24:00:00',
  180. '2026-02-30T10:00:00Z',
  181. '2026-07-21T10:00:00',
  182. 'not-a-date'
  183. ]) {
  184. expect(Number.isNaN(parseUploadExpiry(invalid))).toBe(true)
  185. }
  186. })
  187. test('preserves valid leap days and one-second calendar rollover', () => {
  188. if (!expectPage(source)) return
  189. const parseUploadExpiry = loadExpiryParser(api)
  190. if (typeof parseUploadExpiry !== 'function') return
  191. expect(parseUploadExpiry('2028-02-29 08:00:00')).toBe(Date.UTC(2028, 1, 29, 0, 0, 0))
  192. expect(parseUploadExpiry('2027-01-01 00:00:00') - parseUploadExpiry('2026-12-31 23:59:59')).toBe(1000)
  193. })
  194. test('auto-validates a token and ignores its deferred result after expiry', async () => {
  195. if (!expectPage(source)) return
  196. const request = deferred()
  197. api.getPublicUploadSession.mockReturnValue(request.promise)
  198. const vm = createPageVm(api, { $route: { query: { token: 'link-token' } } })
  199. const loading = vm.$componentOptions.created.call(vm)
  200. expect(vm.state).toBe('verifying')
  201. expect(api.getPublicUploadSession).toHaveBeenCalledWith('link-token')
  202. vm.expireSession()
  203. request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2099-01-01T00:00:00Z' } })
  204. await loading
  205. expect(vm.state).toBe('expired')
  206. expect(vm.session).toBeNull()
  207. })
  208. test('renews an expired component for a changed token and ignores the old verification', async () => {
  209. if (!expectPage(source)) return
  210. const oldRequest = deferred()
  211. const newRequest = deferred()
  212. api.getPublicUploadSession
  213. .mockReturnValueOnce(oldRequest.promise)
  214. .mockReturnValueOnce(newRequest.promise)
  215. const vm = createPageVm(api, { $route: { query: { token: 'old-token' } } })
  216. const initial = vm.$componentOptions.created.call(vm)
  217. expect(api.getPublicUploadSession).toHaveBeenCalledTimes(1)
  218. vm.expireSession()
  219. vm.$route.query.token = ' new-token '
  220. const renewal = invokeWatcher(vm, '$route.query.token', ' new-token ', 'old-token')
  221. if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
  222. expect(api.getPublicUploadSession.mock.calls).toEqual([['old-token'], ['new-token']])
  223. expect(vm.state).toBe('verifying')
  224. expect(vm.session).toBeNull()
  225. oldRequest.resolve({ code: 0, data: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' } })
  226. await initial
  227. expect(vm.state).toBe('verifying')
  228. newRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
  229. await renewal
  230. expect(vm.state).toBe('ready')
  231. expect(vm.session.uploadToken).toBe('new-upload-token')
  232. vm.$componentOptions.beforeDestroy.call(vm)
  233. })
  234. test('resets a completed component to code entry for a blank token without duplicate initial verification', async () => {
  235. if (!expectPage(source)) return
  236. jest.useFakeTimers()
  237. const vm = createPageVm(api, {
  238. $route: { query: { token: 'finished-token' } },
  239. state: 'completed',
  240. session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
  241. queue: [{ id: 'old', status: 'success' }],
  242. successCount: 1,
  243. code: '123456',
  244. completing: true,
  245. remainingSeconds: 30,
  246. countdownTimer: setInterval(() => {}, 1000)
  247. })
  248. const generation = vm.asyncGeneration
  249. const requestId = vm.verificationRequestId
  250. vm.$route.query.token = ' '
  251. const resetting = invokeWatcher(vm, '$route.query.token', ' ', 'finished-token')
  252. if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
  253. await resetting
  254. expect(vm).toMatchObject({
  255. state: 'code',
  256. session: null,
  257. queue: [],
  258. successCount: 0,
  259. code: '',
  260. completing: false,
  261. remainingSeconds: 0,
  262. countdownTimer: null
  263. })
  264. expect(vm.asyncGeneration).toBeGreaterThan(generation)
  265. expect(vm.verificationRequestId).toBeGreaterThan(requestId)
  266. expect(jest.getTimerCount()).toBe(0)
  267. expect(api.getPublicUploadSession).not.toHaveBeenCalled()
  268. })
  269. test('route renewal from ready ignores old upload progress and result', async () => {
  270. if (!expectPage(source)) return
  271. const uploadRequest = deferred()
  272. const renewalRequest = deferred()
  273. let oldProgress
  274. api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, onProgress) => {
  275. oldProgress = onProgress
  276. return uploadRequest.promise
  277. })
  278. api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
  279. const item = { id: 'old', file: { name: 'old.pdf', size: 1 }, name: 'old.pdf', progress: 0, status: 'pending', error: '' }
  280. const vm = createPageVm(api, {
  281. $route: { query: { token: 'old-link-token' } },
  282. state: 'ready',
  283. session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
  284. queue: [item],
  285. code: '123456',
  286. successCount: 2
  287. })
  288. const uploading = vm.uploadOne(item)
  289. expect(item.status).toBe('uploading')
  290. vm.$route.query.token = 'new-link-token'
  291. const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
  292. if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
  293. expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], code: '', successCount: 0 })
  294. oldProgress({ loaded: 4, total: 5 })
  295. uploadRequest.resolve({ code: 0, data: {} })
  296. await uploading
  297. expect(item).toMatchObject({ status: 'uploading', progress: 0 })
  298. expect(vm.state).toBe('verifying')
  299. renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
  300. await renewal
  301. expect(vm.state).toBe('ready')
  302. expect(vm.session.uploadToken).toBe('new-upload-token')
  303. vm.$componentOptions.beforeDestroy.call(vm)
  304. })
  305. test('route renewal cannot resume remaining files from an old batch with the new token', async () => {
  306. if (!expectPage(source)) return
  307. const oldFirstUpload = deferred()
  308. const renewalRequest = deferred()
  309. api.uploadPublicTeamBackgroundFile
  310. .mockReturnValueOnce(oldFirstUpload.promise)
  311. .mockResolvedValue({ code: 0, data: {} })
  312. api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
  313. const vm = createPageVm(api, {
  314. $route: { query: { token: 'old-link-token' } },
  315. state: 'ready',
  316. session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' }
  317. })
  318. const oldBatch = vm.handleFileChange({
  319. target: { files: [{ name: 'old-one.pdf', size: 1 }, { name: 'old-two.pdf', size: 1 }] }
  320. })
  321. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  322. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledWith('old-upload-token', expect.objectContaining({ name: 'old-one.pdf' }), expect.any(Function))
  323. vm.$route.query.token = 'new-link-token'
  324. const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
  325. renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
  326. await renewal
  327. expect(vm.state).toBe('ready')
  328. expect(vm.session.uploadToken).toBe('new-upload-token')
  329. oldFirstUpload.resolve({ code: 0, data: {} })
  330. await oldBatch
  331. vm.$componentOptions.beforeDestroy.call(vm)
  332. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  333. expect(vm.queue).toEqual([])
  334. expect(vm.session.uploadToken).toBe('new-upload-token')
  335. })
  336. test('route renewal from ready ignores an old deferred completion', async () => {
  337. if (!expectPage(source)) return
  338. const completionRequest = deferred()
  339. const renewalRequest = deferred()
  340. api.completePublicUpload.mockReturnValue(completionRequest.promise)
  341. api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
  342. const vm = createPageVm(api, {
  343. $route: { query: { token: 'old-link-token' } },
  344. state: 'ready',
  345. session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
  346. queue: [{ id: 'old', status: 'success' }]
  347. })
  348. const completing = vm.completeUpload()
  349. expect(vm.completing).toBe(true)
  350. vm.$route.query.token = 'new-link-token'
  351. const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
  352. if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
  353. expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], completing: false })
  354. completionRequest.resolve({ code: 0 })
  355. await completing
  356. expect(vm.state).toBe('verifying')
  357. expect(vm.successCount).toBe(0)
  358. renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
  359. await renewal
  360. expect(vm.state).toBe('ready')
  361. expect(vm.session.uploadToken).toBe('new-upload-token')
  362. vm.$componentOptions.beforeDestroy.call(vm)
  363. })
  364. test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
  365. if (!expectPage(source)) return
  366. const request = deferred()
  367. api.verifyPublicUploadCode.mockReturnValue(request.promise)
  368. const vm = createPageVm(api)
  369. await vm.$componentOptions.created.call(vm)
  370. expect(vm.state).toBe('code')
  371. expect(api.getPublicUploadSession).not.toHaveBeenCalled()
  372. for (const invalidCode of ['12345', '1234567', '12345a', '123456']) {
  373. vm.code = invalidCode
  374. await vm.verifyCode()
  375. }
  376. expect(api.verifyPublicUploadCode).not.toHaveBeenCalled()
  377. expect(vm.$message.error).toHaveBeenCalledTimes(4)
  378. expect(vm.$message.error).toHaveBeenLastCalledWith('请输入6位数字验证码')
  379. vm.code = '123456'
  380. const verifying = vm.verifyCode()
  381. expect(vm.state).toBe('verifying')
  382. request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'verified-token', expiresAt: '2099-01-01T00:00:00Z' } })
  383. await verifying
  384. expect(api.verifyPublicUploadCode).toHaveBeenCalledWith('123456')
  385. expect(vm.state).toBe('ready')
  386. expect(vm.session.uploadToken).toBe('verified-token')
  387. vm.$componentOptions.beforeDestroy.call(vm)
  388. })
  389. test('ignores a deferred code response after the page becomes expired', async () => {
  390. if (!expectPage(source)) return
  391. const request = deferred()
  392. api.verifyPublicUploadCode.mockReturnValue(request.promise)
  393. const vm = createPageVm(api, { code: '123456', state: 'code' })
  394. const verifying = vm.verifyCode()
  395. vm.expireSession()
  396. request.resolve({ code: 0, data: { teamName: 'Late', uploadToken: 'late-token', expiresAt: '2099-01-01T00:00:00Z' } })
  397. await verifying
  398. expect(vm.state).toBe('expired')
  399. expect(vm.session).toBeNull()
  400. })
  401. test('uses the fixed expiry for a one-second countdown and clears timers', () => {
  402. if (!expectPage(source)) return
  403. jest.useFakeTimers()
  404. jest.setSystemTime(new Date('2026-07-21T10:00:00.000Z'))
  405. const vm = createPageVm(api)
  406. vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21T10:00:02.500Z' })
  407. expect(vm.remainingSeconds).toBe(2)
  408. expect(jest.getTimerCount()).toBe(1)
  409. jest.advanceTimersByTime(1000)
  410. expect(vm.remainingSeconds).toBe(1)
  411. jest.advanceTimersByTime(1000)
  412. expect(vm.remainingSeconds).toBe(0)
  413. expect(vm.state).toBe('expired')
  414. expect(jest.getTimerCount()).toBe(0)
  415. vm.activateSession({ teamName: 'Beta', uploadToken: 'other-token', expiresAt: '2026-07-21T10:01:00.000Z' })
  416. expect(jest.getTimerCount()).toBe(1)
  417. vm.$componentOptions.beforeDestroy.call(vm)
  418. expect(jest.getTimerCount()).toBe(0)
  419. })
  420. test('creates the exact queue shape and rejects more than ten files before enqueueing', async () => {
  421. if (!expectPage(source)) return
  422. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  423. const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.docx', size: 2 }]
  424. const now = jest.spyOn(Date, 'now').mockReturnValue(123456)
  425. expect(vm.createQueueItems(files)).toEqual([
  426. { id: '123456-0', file: files[0], name: 'one.pdf', progress: 0, status: 'pending', error: '' },
  427. { id: '123456-1', file: files[1], name: 'two.docx', progress: 0, status: 'pending', error: '' }
  428. ])
  429. now.mockRestore()
  430. await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
  431. expect(vm.queue).toEqual([])
  432. expect(api.uploadPublicTeamBackgroundFile).not.toHaveBeenCalled()
  433. expect(vm.$message.error).toHaveBeenCalledWith('一次最多选择 10 个文件')
  434. })
  435. test('reports progress, retains independent results and retries only the failed item', async () => {
  436. if (!expectPage(source)) return
  437. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  438. const success = { id: '1-0', file: { name: 'ok.pdf', size: 1 }, name: 'ok.pdf', progress: 0, status: 'pending', error: '' }
  439. const failure = { id: '1-1', file: { name: 'retry.docx', size: 1 }, name: 'retry.docx', progress: 0, status: 'pending', error: '' }
  440. vm.queue = [success, failure]
  441. api.uploadPublicTeamBackgroundFile
  442. .mockImplementationOnce((_token, _file, onProgress) => {
  443. onProgress({ loaded: 1, total: 2 })
  444. return Promise.resolve({ code: 0, data: {} })
  445. })
  446. .mockRejectedValueOnce({ msg: '网络失败' })
  447. await vm.uploadOne(success)
  448. await vm.uploadOne(failure)
  449. expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
  450. expect(failure).toMatchObject({ status: 'failed', progress: 0, error: '网络失败' })
  451. expect(vm.queue).toEqual([success, failure])
  452. expect(api.uploadPublicTeamBackgroundFile.mock.calls[0]).toEqual([
  453. 'upload-token',
  454. success.file,
  455. expect.any(Function)
  456. ])
  457. api.uploadPublicTeamBackgroundFile.mockImplementationOnce((_token, _file, onProgress) => {
  458. onProgress({ loaded: 3, total: 4 })
  459. expect(failure.progress).toBe(75)
  460. return Promise.resolve({ code: 0, data: {} })
  461. })
  462. await vm.retryUpload(failure)
  463. expect(success).toMatchObject({ status: 'success', progress: 100 })
  464. expect(failure).toMatchObject({ status: 'success', progress: 100, error: '' })
  465. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
  466. })
  467. test('rejects overlapping batches, retry and completion until the active upload settles', async () => {
  468. if (!expectPage(source)) return
  469. const activeRequest = deferred()
  470. api.uploadPublicTeamBackgroundFile
  471. .mockReturnValueOnce(activeRequest.promise)
  472. .mockResolvedValue({ code: 0, data: {} })
  473. api.completePublicUpload.mockResolvedValue({ code: 0 })
  474. const failed = { id: 'failed', file: { name: 'retry.pdf', size: 1 }, name: 'retry.pdf', progress: 0, status: 'failed', error: '网络失败' }
  475. const vm = createPageVm(api, {
  476. state: 'ready',
  477. session: { uploadToken: 'upload-token' },
  478. queue: [failed]
  479. })
  480. const firstEvent = { target: { value: 'first', files: [{ name: 'first.pdf', size: 1 }] } }
  481. const firstBatch = vm.handleFileChange(firstEvent)
  482. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  483. expect(vm.queue[1]).toMatchObject({ name: 'first.pdf', status: 'uploading' })
  484. expect(computed(vm, 'uploadDisabled')).toBe(true)
  485. const secondEvent = { target: { value: 'second', files: [{ name: 'second.pdf', size: 1 }] } }
  486. await vm.handleFileChange(secondEvent)
  487. await vm.retryUpload(failed)
  488. await vm.completeUpload()
  489. expect(secondEvent.target.value).toBe('')
  490. expect(vm.queue).toHaveLength(2)
  491. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  492. expect(api.completePublicUpload).not.toHaveBeenCalled()
  493. expect(failed.status).toBe('failed')
  494. expect(vm.$message.error).toHaveBeenCalledWith('文件正在上传,请稍后再试')
  495. activeRequest.resolve({ code: 0, data: {} })
  496. await firstBatch
  497. expect(computed(vm, 'uploadDisabled')).toBe(false)
  498. await vm.retryUpload(failed)
  499. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(2)
  500. expect(failed).toMatchObject({ status: 'success', progress: 100, error: '' })
  501. })
  502. test('does not upload a pending batch item removed while an earlier item is active', async () => {
  503. if (!expectPage(source)) return
  504. const firstRequest = deferred()
  505. api.uploadPublicTeamBackgroundFile
  506. .mockReturnValueOnce(firstRequest.promise)
  507. .mockResolvedValue({ code: 0, data: {} })
  508. const vm = createPageVm(api, {
  509. state: 'ready',
  510. session: { uploadToken: 'upload-token' }
  511. })
  512. const batch = vm.handleFileChange({
  513. target: { files: [{ name: 'first.pdf', size: 1 }, { name: 'removed.pdf', size: 1 }] }
  514. })
  515. const removedItem = vm.queue[1]
  516. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  517. expect(removedItem).toMatchObject({ name: 'removed.pdf', status: 'pending' })
  518. vm.removeQueueItem(removedItem)
  519. expect(vm.queue).not.toContain(removedItem)
  520. firstRequest.resolve({ code: 0, data: {} })
  521. await batch
  522. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  523. expect(vm.queue).not.toContain(removedItem)
  524. })
  525. test('a session-level upload failure expires the page and stops later files in the batch', async () => {
  526. if (!expectPage(source)) return
  527. api.uploadPublicTeamBackgroundFile.mockRejectedValue({ msg: '上传会话已过期' })
  528. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  529. const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.pdf', size: 1 }]
  530. await vm.handleFileChange({ target: { files } })
  531. expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
  532. expect(vm.queue[0]).toMatchObject({ status: 'failed', error: '上传会话已过期' })
  533. expect(vm.queue[1]).toMatchObject({ status: 'pending' })
  534. expect(vm.state).toBe('expired')
  535. })
  536. test('late upload progress and success cannot overwrite expiry', async () => {
  537. if (!expectPage(source)) return
  538. const request = deferred()
  539. let onProgress
  540. api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, callback) => {
  541. onProgress = callback
  542. return request.promise
  543. })
  544. const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
  545. const item = { id: '1-0', file: { name: 'slow.pdf', size: 1 }, name: 'slow.pdf', progress: 0, status: 'pending', error: '' }
  546. const uploading = vm.uploadOne(item)
  547. onProgress({ loaded: 1, total: 5 })
  548. expect(item.progress).toBe(20)
  549. vm.expireSession()
  550. onProgress({ loaded: 4, total: 5 })
  551. request.resolve({ code: 0, data: {} })
  552. await uploading
  553. expect(vm.state).toBe('expired')
  554. expect(item).toMatchObject({ status: 'uploading', progress: 20 })
  555. })
  556. test('completion uses only the upload token, counts successes and freezes further uploads', async () => {
  557. if (!expectPage(source)) return
  558. const request = deferred()
  559. api.completePublicUpload.mockReturnValue(request.promise)
  560. const vm = createPageVm(api, {
  561. state: 'ready',
  562. session: { uploadToken: 'upload-token' },
  563. queue: [
  564. { id: '1', status: 'success' },
  565. { id: '2', status: 'failed' },
  566. { id: '3', status: 'success' }
  567. ]
  568. })
  569. vm.uploadOne = jest.fn()
  570. expect(computed(vm, 'uploadDisabled')).toBe(false)
  571. const completing = vm.completeUpload()
  572. expect(vm.completing).toBe(true)
  573. expect(computed(vm, 'uploadDisabled')).toBe(true)
  574. request.resolve({ code: 0 })
  575. await completing
  576. expect(api.completePublicUpload).toHaveBeenCalledWith('upload-token')
  577. expect(vm.successCount).toBe(2)
  578. expect(vm.state).toBe('completed')
  579. expect(computed(vm, 'uploadDisabled')).toBe(true)
  580. await vm.handleFileChange({ target: { files: [{ name: 'late.pdf', size: 1 }] } })
  581. await vm.retryUpload(vm.queue[1])
  582. await vm.completeUpload()
  583. expect(vm.uploadOne).not.toHaveBeenCalled()
  584. expect(api.completePublicUpload).toHaveBeenCalledTimes(1)
  585. })
  586. test('completion closes empty and all-failed queues with a zero success count', async () => {
  587. if (!expectPage(source)) return
  588. api.completePublicUpload.mockResolvedValue({ code: 0 })
  589. for (const queue of [[], [{ id: 'failed', status: 'failed' }]]) {
  590. const vm = createPageVm(api, {
  591. state: 'ready',
  592. session: { uploadToken: 'upload-token' },
  593. queue
  594. })
  595. expect(computed({ ...vm, uploadDisabled: false }, 'completeDisabled')).toBe(false)
  596. await vm.completeUpload()
  597. expect(vm.state).toBe('completed')
  598. expect(vm.successCount).toBe(0)
  599. }
  600. expect(api.completePublicUpload).toHaveBeenCalledTimes(2)
  601. expect(api.completePublicUpload).toHaveBeenNthCalledWith(1, 'upload-token')
  602. expect(api.completePublicUpload).toHaveBeenNthCalledWith(2, 'upload-token')
  603. })
  604. test('late completion cannot overwrite timer expiry', async () => {
  605. if (!expectPage(source)) return
  606. const request = deferred()
  607. api.completePublicUpload.mockReturnValue(request.promise)
  608. const vm = createPageVm(api, {
  609. state: 'ready',
  610. session: { uploadToken: 'upload-token' },
  611. queue: [{ id: '1', status: 'success' }]
  612. })
  613. const completing = vm.completeUpload()
  614. vm.expireSession()
  615. request.resolve({ code: 0 })
  616. await completing
  617. expect(vm.state).toBe('expired')
  618. expect(vm.successCount).toBe(0)
  619. })
  620. test('ready and terminal templates expose only the approved public information', () => {
  621. if (!expectPage(source)) return
  622. const template = parseComponent(source).template.content
  623. const sessionFields = Array.from(template.matchAll(/session\.([A-Za-z_$][\w$]*)/g), match => match[1])
  624. expect(source).toContain('session.teamName')
  625. expect(source).toContain('session.expiresAt')
  626. expect(source).toContain('remainingSeconds')
  627. expect(source).toContain('TeamBackgroundUploadQueue')
  628. expect(source).toContain('请返回小程序或管理端重新生成上传链接')
  629. expect(source).toMatch(/state === 'expired'[\s\S]*请返回小程序或管理端重新生成上传链接/)
  630. expect(source).toMatch(/state === 'completed'[\s\S]*successCount/)
  631. expect(source).not.toMatch(/teamDescription|teamMembers|coachName|existingFiles|fileList|listTeamBackgroundFiles/)
  632. expect(new Set(sessionFields)).toEqual(new Set(['teamName', 'expiresAt']))
  633. expect(template).not.toMatch(/{{\s*session\s*}}/)
  634. })
  635. })