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*}}/) }) })