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