|
|
@@ -92,14 +92,24 @@ function expectPage(source) {
|
|
|
return source !== ''
|
|
|
}
|
|
|
|
|
|
-function loadPageOptions(api) {
|
|
|
+function loadPageModule(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 loadPageOptions(api) {
|
|
|
+ return loadPageModule(api).default
|
|
|
+}
|
|
|
+
|
|
|
+function loadExpiryParser(api) {
|
|
|
+ const parser = loadPageModule(api).parseUploadExpiry
|
|
|
+ expect(parser).toEqual(expect.any(Function))
|
|
|
+ return parser
|
|
|
}
|
|
|
|
|
|
function createPageVm(api, overrides = {}) {
|
|
|
@@ -120,6 +130,14 @@ function computed(vm, name) {
|
|
|
return vm.$componentOptions.computed[name].call(vm)
|
|
|
}
|
|
|
|
|
|
+function invokeWatcher(vm, name, value, oldValue) {
|
|
|
+ const watcher = vm.$componentOptions.watch && vm.$componentOptions.watch[name]
|
|
|
+ expect(watcher).toBeDefined()
|
|
|
+ if (!watcher) return
|
|
|
+ const handler = typeof watcher === 'function' ? watcher : watcher.handler
|
|
|
+ return handler.call(vm, value, oldValue)
|
|
|
+}
|
|
|
+
|
|
|
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()
|
|
|
@@ -158,6 +176,59 @@ describe('public team background upload page state machine', () => {
|
|
|
jest.useRealTimers()
|
|
|
})
|
|
|
|
|
|
+ test('parses the backend timestamp as GMT+8 at the countdown boundary', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const parseUploadExpiry = loadExpiryParser(api)
|
|
|
+ if (typeof parseUploadExpiry !== 'function') return
|
|
|
+
|
|
|
+ expect(parseUploadExpiry('2026-07-21 10:00:02')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
|
|
|
+
|
|
|
+ jest.useFakeTimers()
|
|
|
+ jest.setSystemTime(new Date('2026-07-21T02:00:00.000Z'))
|
|
|
+ const vm = createPageVm(api)
|
|
|
+ vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21 10:00:02' })
|
|
|
+
|
|
|
+ expect(vm.remainingSeconds).toBe(2)
|
|
|
+ jest.advanceTimersByTime(2000)
|
|
|
+ expect(vm.state).toBe('expired')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('retains valid ISO timestamps with explicit offsets', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const parseUploadExpiry = loadExpiryParser(api)
|
|
|
+ if (typeof parseUploadExpiry !== 'function') return
|
|
|
+
|
|
|
+ expect(parseUploadExpiry('2026-07-21T02:00:02.500Z')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2, 500))
|
|
|
+ expect(parseUploadExpiry('2026-07-21T10:00:02+08:00')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
|
|
|
+ })
|
|
|
+
|
|
|
+ test('rejects malformed, timezone-free and impossible expiry values', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const parseUploadExpiry = loadExpiryParser(api)
|
|
|
+ if (typeof parseUploadExpiry !== 'function') return
|
|
|
+
|
|
|
+ for (const invalid of [
|
|
|
+ '',
|
|
|
+ '2026-02-29 10:00:00',
|
|
|
+ '2026-13-01 10:00:00',
|
|
|
+ '2026-07-21 24:00:00',
|
|
|
+ '2026-02-30T10:00:00Z',
|
|
|
+ '2026-07-21T10:00:00',
|
|
|
+ 'not-a-date'
|
|
|
+ ]) {
|
|
|
+ expect(Number.isNaN(parseUploadExpiry(invalid))).toBe(true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test('preserves valid leap days and one-second calendar rollover', () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const parseUploadExpiry = loadExpiryParser(api)
|
|
|
+ if (typeof parseUploadExpiry !== 'function') return
|
|
|
+
|
|
|
+ expect(parseUploadExpiry('2028-02-29 08:00:00')).toBe(Date.UTC(2028, 1, 29, 0, 0, 0))
|
|
|
+ expect(parseUploadExpiry('2027-01-01 00:00:00') - parseUploadExpiry('2026-12-31 23:59:59')).toBe(1000)
|
|
|
+ })
|
|
|
+
|
|
|
test('auto-validates a token and ignores its deferred result after expiry', async () => {
|
|
|
if (!expectPage(source)) return
|
|
|
const request = deferred()
|
|
|
@@ -176,6 +247,183 @@ describe('public team background upload page state machine', () => {
|
|
|
expect(vm.session).toBeNull()
|
|
|
})
|
|
|
|
|
|
+ test('renews an expired component for a changed token and ignores the old verification', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const oldRequest = deferred()
|
|
|
+ const newRequest = deferred()
|
|
|
+ api.getPublicUploadSession
|
|
|
+ .mockReturnValueOnce(oldRequest.promise)
|
|
|
+ .mockReturnValueOnce(newRequest.promise)
|
|
|
+ const vm = createPageVm(api, { $route: { query: { token: 'old-token' } } })
|
|
|
+
|
|
|
+ const initial = vm.$componentOptions.created.call(vm)
|
|
|
+ expect(api.getPublicUploadSession).toHaveBeenCalledTimes(1)
|
|
|
+ vm.expireSession()
|
|
|
+ vm.$route.query.token = ' new-token '
|
|
|
+ const renewal = invokeWatcher(vm, '$route.query.token', ' new-token ', 'old-token')
|
|
|
+ if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
|
|
|
+
|
|
|
+ expect(api.getPublicUploadSession.mock.calls).toEqual([['old-token'], ['new-token']])
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+ expect(vm.session).toBeNull()
|
|
|
+
|
|
|
+ oldRequest.resolve({ code: 0, data: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' } })
|
|
|
+ await initial
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+
|
|
|
+ newRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
|
|
|
+ await renewal
|
|
|
+ expect(vm.state).toBe('ready')
|
|
|
+ expect(vm.session.uploadToken).toBe('new-upload-token')
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('resets a completed component to code entry for a blank token without duplicate initial verification', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ jest.useFakeTimers()
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ $route: { query: { token: 'finished-token' } },
|
|
|
+ state: 'completed',
|
|
|
+ session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
|
|
|
+ queue: [{ id: 'old', status: 'success' }],
|
|
|
+ successCount: 1,
|
|
|
+ code: '123456',
|
|
|
+ completing: true,
|
|
|
+ remainingSeconds: 30,
|
|
|
+ countdownTimer: setInterval(() => {}, 1000)
|
|
|
+ })
|
|
|
+ const generation = vm.asyncGeneration
|
|
|
+ const requestId = vm.verificationRequestId
|
|
|
+
|
|
|
+ vm.$route.query.token = ' '
|
|
|
+ const resetting = invokeWatcher(vm, '$route.query.token', ' ', 'finished-token')
|
|
|
+ if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
|
|
|
+ await resetting
|
|
|
+
|
|
|
+ expect(vm).toMatchObject({
|
|
|
+ state: 'code',
|
|
|
+ session: null,
|
|
|
+ queue: [],
|
|
|
+ successCount: 0,
|
|
|
+ code: '',
|
|
|
+ completing: false,
|
|
|
+ remainingSeconds: 0,
|
|
|
+ countdownTimer: null
|
|
|
+ })
|
|
|
+ expect(vm.asyncGeneration).toBeGreaterThan(generation)
|
|
|
+ expect(vm.verificationRequestId).toBeGreaterThan(requestId)
|
|
|
+ expect(jest.getTimerCount()).toBe(0)
|
|
|
+ expect(api.getPublicUploadSession).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('route renewal from ready ignores old upload progress and result', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const uploadRequest = deferred()
|
|
|
+ const renewalRequest = deferred()
|
|
|
+ let oldProgress
|
|
|
+ api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, onProgress) => {
|
|
|
+ oldProgress = onProgress
|
|
|
+ return uploadRequest.promise
|
|
|
+ })
|
|
|
+ api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
|
|
|
+ const item = { id: 'old', file: { name: 'old.pdf', size: 1 }, name: 'old.pdf', progress: 0, status: 'pending', error: '' }
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ $route: { query: { token: 'old-link-token' } },
|
|
|
+ state: 'ready',
|
|
|
+ session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
|
|
|
+ queue: [item],
|
|
|
+ code: '123456',
|
|
|
+ successCount: 2
|
|
|
+ })
|
|
|
+
|
|
|
+ const uploading = vm.uploadOne(item)
|
|
|
+ expect(item.status).toBe('uploading')
|
|
|
+ vm.$route.query.token = 'new-link-token'
|
|
|
+ const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
|
|
|
+ if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
|
|
|
+
|
|
|
+ expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], code: '', successCount: 0 })
|
|
|
+ oldProgress({ loaded: 4, total: 5 })
|
|
|
+ uploadRequest.resolve({ code: 0, data: {} })
|
|
|
+ await uploading
|
|
|
+ expect(item).toMatchObject({ status: 'uploading', progress: 0 })
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+
|
|
|
+ renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
|
|
|
+ await renewal
|
|
|
+ expect(vm.state).toBe('ready')
|
|
|
+ expect(vm.session.uploadToken).toBe('new-upload-token')
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('route renewal cannot resume remaining files from an old batch with the new token', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const oldFirstUpload = deferred()
|
|
|
+ const renewalRequest = deferred()
|
|
|
+ api.uploadPublicTeamBackgroundFile
|
|
|
+ .mockReturnValueOnce(oldFirstUpload.promise)
|
|
|
+ .mockResolvedValue({ code: 0, data: {} })
|
|
|
+ api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ $route: { query: { token: 'old-link-token' } },
|
|
|
+ state: 'ready',
|
|
|
+ session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' }
|
|
|
+ })
|
|
|
+
|
|
|
+ const oldBatch = vm.handleFileChange({
|
|
|
+ target: { files: [{ name: 'old-one.pdf', size: 1 }, { name: 'old-two.pdf', size: 1 }] }
|
|
|
+ })
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledWith('old-upload-token', expect.objectContaining({ name: 'old-one.pdf' }), expect.any(Function))
|
|
|
+
|
|
|
+ vm.$route.query.token = 'new-link-token'
|
|
|
+ const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
|
|
|
+ renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
|
|
|
+ await renewal
|
|
|
+ expect(vm.state).toBe('ready')
|
|
|
+ expect(vm.session.uploadToken).toBe('new-upload-token')
|
|
|
+
|
|
|
+ oldFirstUpload.resolve({ code: 0, data: {} })
|
|
|
+ await oldBatch
|
|
|
+
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
|
|
|
+ expect(vm.queue).toEqual([])
|
|
|
+ expect(vm.session.uploadToken).toBe('new-upload-token')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('route renewal from ready ignores an old deferred completion', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const completionRequest = deferred()
|
|
|
+ const renewalRequest = deferred()
|
|
|
+ api.completePublicUpload.mockReturnValue(completionRequest.promise)
|
|
|
+ api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ $route: { query: { token: 'old-link-token' } },
|
|
|
+ state: 'ready',
|
|
|
+ session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
|
|
|
+ queue: [{ id: 'old', status: 'success' }]
|
|
|
+ })
|
|
|
+
|
|
|
+ const completing = vm.completeUpload()
|
|
|
+ expect(vm.completing).toBe(true)
|
|
|
+ vm.$route.query.token = 'new-link-token'
|
|
|
+ const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
|
|
|
+ if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
|
|
|
+
|
|
|
+ expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], completing: false })
|
|
|
+ completionRequest.resolve({ code: 0 })
|
|
|
+ await completing
|
|
|
+ expect(vm.state).toBe('verifying')
|
|
|
+ expect(vm.successCount).toBe(0)
|
|
|
+
|
|
|
+ renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
|
|
|
+ await renewal
|
|
|
+ expect(vm.state).toBe('ready')
|
|
|
+ expect(vm.session.uploadToken).toBe('new-upload-token')
|
|
|
+ vm.$componentOptions.beforeDestroy.call(vm)
|
|
|
+ })
|
|
|
+
|
|
|
test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
|
|
|
if (!expectPage(source)) return
|
|
|
const request = deferred()
|
|
|
@@ -299,6 +547,47 @@ describe('public team background upload page state machine', () => {
|
|
|
expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
|
|
|
})
|
|
|
|
|
|
+ test('rejects overlapping batches, retry and completion until the active upload settles', async () => {
|
|
|
+ if (!expectPage(source)) return
|
|
|
+ const activeRequest = deferred()
|
|
|
+ api.uploadPublicTeamBackgroundFile
|
|
|
+ .mockReturnValueOnce(activeRequest.promise)
|
|
|
+ .mockResolvedValue({ code: 0, data: {} })
|
|
|
+ api.completePublicUpload.mockResolvedValue({ code: 0 })
|
|
|
+ const failed = { id: 'failed', file: { name: 'retry.pdf', size: 1 }, name: 'retry.pdf', progress: 0, status: 'failed', error: '网络失败' }
|
|
|
+ const vm = createPageVm(api, {
|
|
|
+ state: 'ready',
|
|
|
+ session: { uploadToken: 'upload-token' },
|
|
|
+ queue: [failed]
|
|
|
+ })
|
|
|
+ const firstEvent = { target: { value: 'first', files: [{ name: 'first.pdf', size: 1 }] } }
|
|
|
+
|
|
|
+ const firstBatch = vm.handleFileChange(firstEvent)
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
|
|
|
+ expect(vm.queue[1]).toMatchObject({ name: 'first.pdf', status: 'uploading' })
|
|
|
+ expect(computed(vm, 'uploadDisabled')).toBe(true)
|
|
|
+
|
|
|
+ const secondEvent = { target: { value: 'second', files: [{ name: 'second.pdf', size: 1 }] } }
|
|
|
+ await vm.handleFileChange(secondEvent)
|
|
|
+ await vm.retryUpload(failed)
|
|
|
+ await vm.completeUpload()
|
|
|
+
|
|
|
+ expect(secondEvent.target.value).toBe('')
|
|
|
+ expect(vm.queue).toHaveLength(2)
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
|
|
|
+ expect(api.completePublicUpload).not.toHaveBeenCalled()
|
|
|
+ expect(failed.status).toBe('failed')
|
|
|
+ expect(vm.$message.error).toHaveBeenCalledWith('文件正在上传,请稍后再试')
|
|
|
+
|
|
|
+ activeRequest.resolve({ code: 0, data: {} })
|
|
|
+ await firstBatch
|
|
|
+ expect(computed(vm, 'uploadDisabled')).toBe(false)
|
|
|
+
|
|
|
+ await vm.retryUpload(failed)
|
|
|
+ expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(2)
|
|
|
+ expect(failed).toMatchObject({ status: 'success', progress: 100, error: '' })
|
|
|
+ })
|
|
|
+
|
|
|
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: '上传会话已过期' })
|