teamBackgroundPublicUpload.spec.js 29 KB

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