|
|
@@ -0,0 +1,431 @@
|
|
|
+import fs from 'fs'
|
|
|
+import path from 'path'
|
|
|
+import * as babel from '@babel/core'
|
|
|
+import { parseComponent } from 'vue-template-compiler'
|
|
|
+import { ACCEPT, formatFileSize, validateBackgroundFile } from '@/utils/teamBackgroundFile'
|
|
|
+
|
|
|
+process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'
|
|
|
+
|
|
|
+const routerPath = path.resolve(__dirname, '../../src/router/index.js')
|
|
|
+const pagePath = path.resolve(__dirname, '../../src/views/pages/team-background-upload.vue')
|
|
|
+
|
|
|
+const readIfPresent = file => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
|
+
|
|
|
+function deferred() {
|
|
|
+ let resolve
|
|
|
+ let reject
|
|
|
+ const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
|
+ resolve = resolvePromise
|
|
|
+ reject = rejectPromise
|
|
|
+ })
|
|
|
+ return { promise, resolve, reject }
|
|
|
+}
|
|
|
+
|
|
|
+function transformModule(source) {
|
|
|
+ return babel.transformSync(source, {
|
|
|
+ babelrc: false,
|
|
|
+ configFile: false,
|
|
|
+ plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
|
|
|
+ }).code
|
|
|
+}
|
|
|
+
|
|
|
+function evaluateModule(source, mocks) {
|
|
|
+ const code = transformModule(source)
|
|
|
+ const module = { exports: {} }
|
|
|
+ const localRequire = id => {
|
|
|
+ if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id]
|
|
|
+ throw new Error(`Missing test mock for ${id}`)
|
|
|
+ }
|
|
|
+ const evaluate = new Function('require', 'module', 'exports', code)
|
|
|
+ evaluate(localRequire, module, module.exports)
|
|
|
+ return module.exports
|
|
|
+}
|
|
|
+
|
|
|
+function loadRouter() {
|
|
|
+ const http = { get: jest.fn(() => new Promise(() => {})) }
|
|
|
+ const Vue = { use: jest.fn(), prototype: { $message: { error: jest.fn() } } }
|
|
|
+
|
|
|
+ class Router {
|
|
|
+ constructor(options) {
|
|
|
+ this.options = options
|
|
|
+ this.app = { $options: { store: { state: { sidebarMenuActiveName: [] } } } }
|
|
|
+ }
|
|
|
+
|
|
|
+ beforeEach(guard) {
|
|
|
+ this.guard = guard
|
|
|
+ }
|
|
|
+
|
|
|
+ onError(handler) {
|
|
|
+ this.errorHandler = handler
|
|
|
+ }
|
|
|
+
|
|
|
+ addRoutes() {}
|
|
|
+
|
|
|
+ push() {
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const previousWindow = global.window
|
|
|
+ global.window = {
|
|
|
+ SITE_CONFIG: {
|
|
|
+ dynamicMenuRoutesHasAdded: false,
|
|
|
+ dynamicRoutes: [],
|
|
|
+ menuList: [],
|
|
|
+ contentTabDefault: {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const exports = evaluateModule(readIfPresent(routerPath), {
|
|
|
+ vue: Vue,
|
|
|
+ 'vue-router': Router,
|
|
|
+ '@/utils/request': http
|
|
|
+ })
|
|
|
+ return { ...exports, http }
|
|
|
+ } finally {
|
|
|
+ global.window = previousWindow
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function expectPage(source) {
|
|
|
+ expect(source).not.toBe('')
|
|
|
+ return source !== ''
|
|
|
+}
|
|
|
+
|
|
|
+function loadPageOptions(api) {
|
|
|
+ const source = readIfPresent(pagePath)
|
|
|
+ const descriptor = parseComponent(source)
|
|
|
+ return evaluateModule(descriptor.script.content, {
|
|
|
+ '@/api/teamBackgroundFile': api,
|
|
|
+ '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
|
|
|
+ '@/components/team-background/TeamBackgroundUploadQueue.vue': {}
|
|
|
+ }).default
|
|
|
+}
|
|
|
+
|
|
|
+function createPageVm(api, overrides = {}) {
|
|
|
+ const options = loadPageOptions(api)
|
|
|
+ const vm = {
|
|
|
+ ...options.data(),
|
|
|
+ $route: { query: {} },
|
|
|
+ $message: { error: jest.fn(), success: jest.fn() },
|
|
|
+ $refs: { fileInput: { value: 'selected', click: jest.fn() } },
|
|
|
+ ...options.methods,
|
|
|
+ ...overrides
|
|
|
+ }
|
|
|
+ vm.$componentOptions = options
|
|
|
+ return vm
|
|
|
+}
|
|
|
+
|
|
|
+function computed(vm, name) {
|
|
|
+ return vm.$componentOptions.computed[name].call(vm)
|
|
|
+}
|
|
|
+
|
|
|
+describe('public team background upload route', () => {
|
|
|
+ test('is a page route outside the authenticated layout and short-circuits before menu requests', () => {
|
|
|
+ const { default: router, pageRoutes, moduleRoutes, http } = loadRouter()
|
|
|
+ const route = pageRoutes.find(candidate => candidate.path === '/team-background-upload')
|
|
|
+ const next = jest.fn()
|
|
|
+
|
|
|
+ expect(route).toMatchObject({
|
|
|
+ path: '/team-background-upload',
|
|
|
+ name: 'team-background-upload',
|
|
|
+ meta: { title: '上传团队背景资料', public: true }
|
|
|
+ })
|
|
|
+ expect(moduleRoutes.children.some(candidate => candidate.path === route.path)).toBe(false)
|
|
|
+
|
|
|
+ router.guard({ path: route.path, matched: [{ meta: route.meta }] }, {}, next)
|
|
|
+
|
|
|
+ expect(next).toHaveBeenCalledTimes(1)
|
|
|
+ expect(next).toHaveBeenCalledWith()
|
|
|
+ expect(http.get).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('public team background upload page state machine', () => {
|
|
|
+ const source = readIfPresent(pagePath)
|
|
|
+ let api
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ api = {
|
|
|
+ completePublicUpload: jest.fn(),
|
|
|
+ getPublicUploadSession: jest.fn(),
|
|
|
+ uploadPublicTeamBackgroundFile: jest.fn(),
|
|
|
+ verifyPublicUploadCode: jest.fn()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ jest.useRealTimers()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('auto-validates a token and ignores its deferred result after expiry', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ api.getPublicUploadSession.mockReturnValue(request.promise)
|
|
|
+ const vm = createPageVm(api, { $route: { query: { token: 'link-token' } } })
|
|
|
+
|
|
|
+ const loading = vm.$componentOptions.created.call(vm)
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+ expect(api.getPublicUploadSession).toHaveBeenCalledWith('link-token')
|
|
|
+
|
|
|
+ vm.expireSession()
|
|
|
+ request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2099-01-01T00:00:00Z' } })
|
|
|
+ await loading
|
|
|
+
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ expect(vm.session).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ api.verifyPublicUploadCode.mockReturnValue(request.promise)
|
|
|
+ const vm = createPageVm(api)
|
|
|
+
|
|
|
+ await vm.$componentOptions.created.call(vm)
|
|
|
+ expect(vm.state).toBe('code')
|
|
|
+ expect(api.getPublicUploadSession).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ for (const invalidCode of ['12345', '1234567', '12345a', '123456']) {
|
|
|
+ vm.code = invalidCode
|
|
|
+ await vm.verifyCode()
|
|
|
+ }
|
|
|
+ expect(api.verifyPublicUploadCode).not.toHaveBeenCalled()
|
|
|
+ expect(vm.$message.error).toHaveBeenCalledTimes(4)
|
|
|
+ expect(vm.$message.error).toHaveBeenLastCalledWith('请输入6位数字验证码')
|
|
|
+
|
|
|
+ vm.code = '123456'
|
|
|
+ const verifying = vm.verifyCode()
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+ request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'verified-token', expiresAt: '2099-01-01T00:00:00Z' } })
|
|
|
+ await verifying
|
|
|
+
|
|
|
+ expect(api.verifyPublicUploadCode).toHaveBeenCalledWith('123456')
|
|
|
+ expect(vm.state).toBe('ready')
|
|
|
+ expect(vm.session.uploadToken).toBe('verified-token')
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('ignores a deferred code response after the page becomes expired', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ api.verifyPublicUploadCode.mockReturnValue(request.promise)
|
|
|
+ const vm = createPageVm(api, { code: '123456', state: 'code' })
|
|
|
+
|
|
|
+ const verifying = vm.verifyCode()
|
|
|
+ vm.expireSession()
|
|
|
+ request.resolve({ code: 0, data: { teamName: 'Late', uploadToken: 'late-token', expiresAt: '2099-01-01T00:00:00Z' } })
|
|
|
+ await verifying
|
|
|
+
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ expect(vm.session).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('uses the fixed expiry for a one-second countdown and clears timers', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ jest.useFakeTimers()
|
|
|
+ jest.setSystemTime(new Date('2026-07-21T10:00:00.000Z'))
|
|
|
+ const vm = createPageVm(api)
|
|
|
+
|
|
|
+ vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21T10:00:02.500Z' })
|
|
|
+ expect(vm.remainingSeconds).toBe(2)
|
|
|
+ expect(jest.getTimerCount()).toBe(1)
|
|
|
+
|
|
|
+ jest.advanceTimersByTime(1000)
|
|
|
+ expect(vm.remainingSeconds).toBe(1)
|
|
|
+ jest.advanceTimersByTime(1000)
|
|
|
+ expect(vm.remainingSeconds).toBe(0)
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ expect(jest.getTimerCount()).toBe(0)
|
|
|
+
|
|
|
+ vm.activateSession({ teamName: 'Beta', uploadToken: 'other-token', expiresAt: '2026-07-21T10:01:00.000Z' })
|
|
|
+ expect(jest.getTimerCount()).toBe(1)
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ expect(jest.getTimerCount()).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('creates the exact queue shape and rejects more than ten files before enqueueing', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
|
|
|
+ const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.docx', size: 2 }]
|
|
|
+ const now = jest.spyOn(Date, 'now').mockReturnValue(123456)
|
|
|
+
|
|
|
+ expect(vm.createQueueItems(files)).toEqual([
|
|
|
+ { id: '123456-0', file: files[0], name: 'one.pdf', progress: 0, status: 'pending', error: '' },
|
|
|
+ { id: '123456-1', file: files[1], name: 'two.docx', progress: 0, status: 'pending', error: '' }
|
|
|
+ ])
|
|
|
+ now.mockRestore()
|
|
|
+
|
|
|
+ await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
|
|
|
+ expect(vm.queue).toEqual([])
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).not.toHaveBeenCalled()
|
|
|
+ expect(vm.$message.error).toHaveBeenCalledWith('一次最多选择 10 个文件')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('reports progress, retains independent results and retries only the failed item', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
|
|
|
+ const success = { id: '1-0', file: { name: 'ok.pdf', size: 1 }, name: 'ok.pdf', progress: 0, status: 'pending', error: '' }
|
|
|
+ const failure = { id: '1-1', file: { name: 'retry.docx', size: 1 }, name: 'retry.docx', progress: 0, status: 'pending', error: '' }
|
|
|
+ vm.queue = [success, failure]
|
|
|
+ api.uploadPublicTeamBackgroundFile
|
|
|
+ .mockImplementationOnce((_token, _file, onProgress) => {
|
|
|
+ onProgress({ loaded: 1, total: 2 })
|
|
|
+ return Promise.resolve({ code: 0, data: {} })
|
|
|
+ })
|
|
|
+ .mockRejectedValueOnce({ msg: '网络失败' })
|
|
|
+
|
|
|
+ await vm.uploadOne(success)
|
|
|
+ await vm.uploadOne(failure)
|
|
|
+
|
|
|
+ expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
|
|
|
+ expect(failure).toMatchObject({ status: 'failed', progress: 0, error: '网络失败' })
|
|
|
+ expect(vm.queue).toEqual([success, failure])
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile.mock.calls[0]).toEqual([
|
|
|
+ 'upload-token',
|
|
|
+ success.file,
|
|
|
+ expect.any(Function)
|
|
|
+ ])
|
|
|
+
|
|
|
+ api.uploadPublicTeamBackgroundFile.mockImplementationOnce((_token, _file, onProgress) => {
|
|
|
+ onProgress({ loaded: 3, total: 4 })
|
|
|
+ expect(failure.progress).toBe(75)
|
|
|
+ return Promise.resolve({ code: 0, data: {} })
|
|
|
+ })
|
|
|
+ await vm.retryUpload(failure)
|
|
|
+
|
|
|
+ expect(success).toMatchObject({ status: 'success', progress: 100 })
|
|
|
+ expect(failure).toMatchObject({ status: 'success', progress: 100, error: '' })
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
|
|
|
+ })
|
|
|
+
|
|
|
+ test('a session-level upload failure expires the page and stops later files in the batch', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ api.uploadPublicTeamBackgroundFile.mockRejectedValue({ msg: '上传会话已过期' })
|
|
|
+ const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
|
|
|
+ const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.pdf', size: 1 }]
|
|
|
+
|
|
|
+ await vm.handleFileChange({ target: { files } })
|
|
|
+
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
|
|
|
+ expect(vm.queue[0]).toMatchObject({ status: 'failed', error: '上传会话已过期' })
|
|
|
+ expect(vm.queue[1]).toMatchObject({ status: 'pending' })
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('late upload progress and success cannot overwrite expiry', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ let onProgress
|
|
|
+ api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, callback) => {
|
|
|
+ onProgress = callback
|
|
|
+ return request.promise
|
|
|
+ })
|
|
|
+ const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
|
|
|
+ const item = { id: '1-0', file: { name: 'slow.pdf', size: 1 }, name: 'slow.pdf', progress: 0, status: 'pending', error: '' }
|
|
|
+
|
|
|
+ const uploading = vm.uploadOne(item)
|
|
|
+ onProgress({ loaded: 1, total: 5 })
|
|
|
+ expect(item.progress).toBe(20)
|
|
|
+ vm.expireSession()
|
|
|
+ onProgress({ loaded: 4, total: 5 })
|
|
|
+ request.resolve({ code: 0, data: {} })
|
|
|
+ await uploading
|
|
|
+
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ expect(item).toMatchObject({ status: 'uploading', progress: 20 })
|
|
|
+ })
|
|
|
+
|
|
|
+ test('completion uses only the upload token, counts successes and freezes further uploads', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ api.completePublicUpload.mockReturnValue(request.promise)
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ state: 'ready',
|
|
|
+ session: { uploadToken: 'upload-token' },
|
|
|
+ queue: [
|
|
|
+ { id: '1', status: 'success' },
|
|
|
+ { id: '2', status: 'failed' },
|
|
|
+ { id: '3', status: 'success' }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+ vm.uploadOne = jest.fn()
|
|
|
+
|
|
|
+ expect(computed(vm, 'uploadDisabled')).toBe(false)
|
|
|
+ const completing = vm.completeUpload()
|
|
|
+ expect(vm.completing).toBe(true)
|
|
|
+ expect(computed(vm, 'uploadDisabled')).toBe(true)
|
|
|
+ request.resolve({ code: 0 })
|
|
|
+ await completing
|
|
|
+
|
|
|
+ expect(api.completePublicUpload).toHaveBeenCalledWith('upload-token')
|
|
|
+ expect(vm.successCount).toBe(2)
|
|
|
+ expect(vm.state).toBe('completed')
|
|
|
+ expect(computed(vm, 'uploadDisabled')).toBe(true)
|
|
|
+
|
|
|
+ await vm.handleFileChange({ target: { files: [{ name: 'late.pdf', size: 1 }] } })
|
|
|
+ await vm.retryUpload(vm.queue[1])
|
|
|
+ await vm.completeUpload()
|
|
|
+ expect(vm.uploadOne).not.toHaveBeenCalled()
|
|
|
+ expect(api.completePublicUpload).toHaveBeenCalledTimes(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('completion closes empty and all-failed queues with a zero success count', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ api.completePublicUpload.mockResolvedValue({ code: 0 })
|
|
|
+
|
|
|
+ for (const queue of [[], [{ id: 'failed', status: 'failed' }]]) {
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ state: 'ready',
|
|
|
+ session: { uploadToken: 'upload-token' },
|
|
|
+ queue
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(computed({ ...vm, uploadDisabled: false }, 'completeDisabled')).toBe(false)
|
|
|
+ await vm.completeUpload()
|
|
|
+
|
|
|
+ expect(vm.state).toBe('completed')
|
|
|
+ expect(vm.successCount).toBe(0)
|
|
|
+ }
|
|
|
+ expect(api.completePublicUpload).toHaveBeenCalledTimes(2)
|
|
|
+ expect(api.completePublicUpload).toHaveBeenNthCalledWith(1, 'upload-token')
|
|
|
+ expect(api.completePublicUpload).toHaveBeenNthCalledWith(2, 'upload-token')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('late completion cannot overwrite timer expiry', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const request = deferred()
|
|
|
+ api.completePublicUpload.mockReturnValue(request.promise)
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ state: 'ready',
|
|
|
+ session: { uploadToken: 'upload-token' },
|
|
|
+ queue: [{ id: '1', status: 'success' }]
|
|
|
+ })
|
|
|
+
|
|
|
+ const completing = vm.completeUpload()
|
|
|
+ vm.expireSession()
|
|
|
+ request.resolve({ code: 0 })
|
|
|
+ await completing
|
|
|
+
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ expect(vm.successCount).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('ready and terminal templates expose only the approved public information', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+
|
|
|
+ const template = parseComponent(source).template.content
|
|
|
+ const sessionFields = Array.from(template.matchAll(/session\.([A-Za-z_$][\w$]*)/g), match => match[1])
|
|
|
+
|
|
|
+ expect(source).toContain('session.teamName')
|
|
|
+ expect(source).toContain('session.expiresAt')
|
|
|
+ expect(source).toContain('remainingSeconds')
|
|
|
+ expect(source).toContain('TeamBackgroundUploadQueue')
|
|
|
+ expect(source).toContain('请返回小程序或管理端重新生成上传链接')
|
|
|
+ expect(source).toMatch(/state === 'expired'[\s\S]*请返回小程序或管理端重新生成上传链接/)
|
|
|
+ expect(source).toMatch(/state === 'completed'[\s\S]*successCount/)
|
|
|
+ expect(source).not.toMatch(/teamDescription|teamMembers|coachName|existingFiles|fileList|listTeamBackgroundFiles/)
|
|
|
+ expect(new Set(sessionFields)).toEqual(new Set(['teamName', 'expiresAt']))
|
|
|
+ expect(template).not.toMatch(/{{\s*session\s*}}/)
|
|
|
+ })
|
|
|
+})
|