teamBackgroundPublicUpload.spec.js 33 KB

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