Skip to content

Commit 2c4eb92

Browse files
committed
test: add unit tests for uploader
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent e43053b commit 2c4eb92

2 files changed

Lines changed: 322 additions & 0 deletions

File tree

lib/upload/uploader/Upload.spec.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { Upload, UploadStatus } from './Upload.ts'
8+
9+
describe('Upload', () => {
10+
beforeEach(() => {
11+
(window as any).OC = {
12+
appConfig: {
13+
files: {
14+
max_chunk_size: 5 * 1024 * 1024,
15+
},
16+
},
17+
}
18+
})
19+
20+
afterEach(() => {
21+
delete (window as any).OC
22+
vi.useRealTimers()
23+
})
24+
25+
it('initializes non-chunked uploads by default', () => {
26+
const file = new File(['data'], 'document.txt')
27+
const upload = new Upload('/remote.php/dav/files/user/document.txt', false, 1024, file)
28+
29+
expect(upload.source).toBe('/remote.php/dav/files/user/document.txt')
30+
expect(upload.file).toBe(file)
31+
expect(upload.size).toBe(1024)
32+
expect(upload.isChunked).toBe(false)
33+
expect(upload.chunks).toBe(1)
34+
expect(upload.status).toBe(UploadStatus.INITIALIZED)
35+
expect(upload.uploaded).toBe(0)
36+
expect(upload.startTime).toBe(0)
37+
})
38+
39+
it('enables chunking when configured and multiple chunks are needed', () => {
40+
const file = new File(['data'], 'video.mp4')
41+
const upload = new Upload('/remote.php/dav/files/user/video.mp4', true, 12 * 1024 * 1024, file)
42+
43+
expect(upload.isChunked).toBe(true)
44+
expect(upload.chunks).toBe(3)
45+
})
46+
47+
it('limits the number of chunks to 10000', () => {
48+
const file = new File(['data'], 'huge.bin')
49+
const upload = new Upload('/remote.php/dav/files/user/huge.bin', true, 5 * 1024 * 1024 * 20000, file)
50+
51+
expect(upload.isChunked).toBe(true)
52+
expect(upload.chunks).toBe(10000)
53+
})
54+
55+
it('tracks upload progress and keeps first start time', () => {
56+
vi.useFakeTimers()
57+
vi.setSystemTime(new Date('2026-03-09T10:00:00.000Z'))
58+
59+
const file = new File(['data'], 'archive.zip')
60+
const upload = new Upload('/remote.php/dav/files/user/archive.zip', false, 100, file)
61+
upload.uploaded = 10
62+
63+
expect(upload.status).toBe(UploadStatus.UPLOADING)
64+
expect(upload.uploaded).toBe(10)
65+
expect(upload.startTime).toBe(new Date('2026-03-09T10:00:00.000Z').getTime())
66+
67+
vi.setSystemTime(new Date('2026-03-09T10:00:10.000Z'))
68+
upload.uploaded = 20
69+
70+
expect(upload.status).toBe(UploadStatus.UPLOADING)
71+
expect(upload.uploaded).toBe(20)
72+
expect(upload.startTime).toBe(new Date('2026-03-09T10:00:00.000Z').getTime())
73+
})
74+
75+
it('marks non-chunked uploads as finished when all bytes are uploaded', () => {
76+
const file = new File(['data'], 'photo.jpg')
77+
const upload = new Upload('/remote.php/dav/files/user/photo.jpg', false, 10, file)
78+
79+
upload.uploaded = 10
80+
81+
expect(upload.status).toBe(UploadStatus.FINISHED)
82+
expect(upload.uploaded).toBe(10)
83+
})
84+
85+
it('marks chunked uploads as assembling when all bytes are uploaded', () => {
86+
const file = new File(['data'], 'dataset.csv')
87+
const upload = new Upload('/remote.php/dav/files/user/dataset.csv', true, 12 * 1024 * 1024, file)
88+
89+
upload.uploaded = 12 * 1024 * 1024
90+
91+
expect(upload.status).toBe(UploadStatus.ASSEMBLING)
92+
expect(upload.uploaded).toBe(12 * 1024 * 1024)
93+
})
94+
95+
it('stores and exposes the server response', () => {
96+
const file = new File(['data'], 'result.txt')
97+
const upload = new Upload('/remote.php/dav/files/user/result.txt', false, 10, file)
98+
const response = {
99+
status: 201,
100+
statusText: 'Created',
101+
headers: {},
102+
config: { headers: {} },
103+
data: { ok: true },
104+
} as any
105+
106+
expect(upload.response).toBeNull()
107+
upload.response = response
108+
expect(upload.response).toBe(response)
109+
110+
upload.response = null
111+
expect(upload.response).toBeNull()
112+
})
113+
114+
it('can update status directly', () => {
115+
const file = new File(['data'], 'manual.txt')
116+
const upload = new Upload('/remote.php/dav/files/user/manual.txt', false, 10, file)
117+
118+
upload.status = UploadStatus.FAILED
119+
120+
expect(upload.status).toBe(UploadStatus.FAILED)
121+
})
122+
123+
it('aborts signal and marks upload as cancelled', () => {
124+
const file = new File(['data'], 'cancelled.txt')
125+
const upload = new Upload('/remote.php/dav/files/user/cancelled.txt', false, 10, file)
126+
127+
expect(upload.signal.aborted).toBe(false)
128+
129+
upload.cancel()
130+
131+
expect(upload.signal.aborted).toBe(true)
132+
expect(upload.status).toBe(UploadStatus.CANCELLED)
133+
})
134+
})
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { Folder } from '../../node/folder.ts'
8+
import { Permission } from '../../permissions.ts'
9+
import { UploadCancelledError } from '../errors/UploadCancelledError.ts'
10+
import { UploadStatus } from './Upload.ts'
11+
import { Uploader, UploaderStatus } from './Uploader.ts'
12+
13+
const nextcloudAuth = vi.hoisted(() => ({
14+
getCurrentUser: vi.fn(() => ({ uid: 'test', displayName: 'Test', isAdmin: false })),
15+
}))
16+
vi.mock('@nextcloud/auth', () => nextcloudAuth)
17+
18+
const nextcloudCapabilities = vi.hoisted(() => ({
19+
getCapabilities: vi.fn(() => ({
20+
files: { chunked_upload: { max_parallel_count: 2 } },
21+
dav: { public_shares_chunking: true },
22+
})),
23+
}))
24+
vi.mock('@nextcloud/capabilities', () => nextcloudCapabilities)
25+
26+
const nextcloudAxios = vi.hoisted(() => ({
27+
default: {
28+
request: vi.fn(),
29+
},
30+
isCancel: vi.fn(() => false),
31+
}))
32+
vi.mock('@nextcloud/axios', () => nextcloudAxios)
33+
34+
const dav = vi.hoisted(() => ({
35+
getClient: vi.fn(() => ({
36+
createDirectory: vi.fn(),
37+
})),
38+
defaultRemoteURL: 'https://localhost/remote.php/dav',
39+
defaultRootPath: '/files/test',
40+
}))
41+
vi.mock('../../dav/dav.ts', () => dav)
42+
43+
const uploadUtils = vi.hoisted(() => ({
44+
getChunk: vi.fn(async (file: File) => new Blob([file], { type: file.type || 'application/octet-stream' })),
45+
initChunkWorkspace: vi.fn(),
46+
uploadData: vi.fn(async (_url: string, _blob: Blob, options: any) => {
47+
options?.onUploadProgress?.({ bytes: 50 })
48+
return {
49+
status: 201,
50+
statusText: 'Created',
51+
headers: {},
52+
config: { headers: {} },
53+
data: { ok: true },
54+
}
55+
}),
56+
}))
57+
vi.mock('../utils/upload.ts', () => uploadUtils)
58+
59+
vi.mock('../utils/filesystem.ts', () => ({
60+
isFileSystemDirectoryEntry: vi.fn(() => false),
61+
isFileSystemFileEntry: vi.fn(() => false),
62+
}))
63+
64+
vi.mock('../../utils/logger.ts', () => ({
65+
default: {
66+
debug: vi.fn(),
67+
info: vi.fn(),
68+
warn: vi.fn(),
69+
error: vi.fn(),
70+
},
71+
}))
72+
73+
describe('Uploader', () => {
74+
beforeEach(() => {
75+
(window as any).OC = {
76+
appConfig: {
77+
files: {
78+
max_chunk_size: 0,
79+
},
80+
},
81+
}
82+
83+
nextcloudAuth.getCurrentUser.mockReturnValue({ uid: 'test', displayName: 'Test', isAdmin: false })
84+
nextcloudCapabilities.getCapabilities.mockReturnValue({
85+
files: { chunked_upload: { max_parallel_count: 2 } },
86+
dav: { public_shares_chunking: true },
87+
})
88+
nextcloudAxios.isCancel.mockReturnValue(false)
89+
uploadUtils.getChunk.mockClear()
90+
uploadUtils.uploadData.mockClear()
91+
dav.getClient.mockClear()
92+
})
93+
94+
afterEach(() => {
95+
delete (window as any).OC
96+
vi.restoreAllMocks()
97+
})
98+
99+
it('initializes with the default destination for logged in users', () => {
100+
const uploader = new Uploader()
101+
102+
expect(uploader.destination).toBeInstanceOf(Folder)
103+
expect(uploader.destination.owner).toBe('test')
104+
expect(uploader.root).toBe('https://localhost/remote.php/dav/files/test')
105+
expect(uploader.info.status).toBe(UploaderStatus.IDLE)
106+
})
107+
108+
it('throws when no user is logged in and uploader is not public', () => {
109+
nextcloudAuth.getCurrentUser.mockReturnValue(null as any)
110+
111+
expect(() => new Uploader(false)).toThrowError('User is not logged in')
112+
})
113+
114+
it('uses anonymous owner in public mode', () => {
115+
nextcloudAuth.getCurrentUser.mockReturnValue(null as any)
116+
const uploader = new Uploader(true)
117+
118+
expect(uploader.destination.owner).toBe('anonymous')
119+
})
120+
121+
it('manages custom headers through a cloned public getter', () => {
122+
const uploader = new Uploader()
123+
uploader.setCustomHeader('X-NC-Test', '1')
124+
125+
const headers = uploader.customHeaders
126+
headers['X-NC-Test'] = '2'
127+
128+
expect(uploader.customHeaders['X-NC-Test']).toBe('1')
129+
130+
uploader.deleteCustomerHeader('X-NC-Test')
131+
expect(uploader.customHeaders['X-NC-Test']).toBeUndefined()
132+
})
133+
134+
it('rejects invalid destination values', () => {
135+
const uploader = new Uploader()
136+
137+
expect(() => {
138+
uploader.destination = { type: null, source: '' } as any
139+
}).toThrowError('Invalid destination folder')
140+
})
141+
142+
it('uploads files in regular mode and notifies listeners', async () => {
143+
const uploader = new Uploader()
144+
uploader.setCustomHeader('X-NC-Test', 'value')
145+
const notifier = vi.fn()
146+
uploader.addNotifier(notifier)
147+
148+
const file = new File(['x'.repeat(100)], 'report.txt', { type: 'text/plain', lastModified: 1000 })
149+
const upload = await uploader.upload('/docs/report.txt', file)
150+
151+
await vi.waitFor(() => {
152+
expect(upload.status).toBe(UploadStatus.FINISHED)
153+
})
154+
155+
expect(upload.uploaded).toBe(upload.size)
156+
expect(upload.response?.status).toBe(201)
157+
expect(uploadUtils.getChunk).toHaveBeenCalledTimes(1)
158+
expect(uploadUtils.uploadData).toHaveBeenCalledTimes(1)
159+
expect(notifier).toHaveBeenCalledTimes(1)
160+
expect(notifier).toHaveBeenCalledWith(upload)
161+
162+
await vi.waitFor(() => {
163+
expect(uploader.info.status).toBe(UploaderStatus.IDLE)
164+
})
165+
})
166+
167+
it('converts callback cancellation in batch upload to UploadCancelledError', async () => {
168+
const uploader = new Uploader(false, new Folder({
169+
id: 1,
170+
owner: 'test',
171+
permissions: Permission.ALL,
172+
root: '/files/test',
173+
source: 'https://localhost/dav/files/test',
174+
}))
175+
176+
const notifier = vi.fn()
177+
uploader.addNotifier(notifier)
178+
const file = new File(['data'], 'a.txt', { type: 'text/plain' })
179+
180+
await expect(uploader.batchUpload('/uploads', [file], {
181+
callback: async () => false,
182+
})).rejects.toBeInstanceOf(UploadCancelledError)
183+
184+
expect(dav.getClient).toHaveBeenCalledTimes(1)
185+
expect(notifier).toHaveBeenCalledTimes(1)
186+
expect(notifier.mock.calls[0][0].status).toBe(UploadStatus.CANCELLED)
187+
})
188+
})

0 commit comments

Comments
 (0)