diff --git a/__mocks__/@nextcloud/auth.js b/__mocks__/@nextcloud/auth.js deleted file mode 100644 index 2651f1770..000000000 --- a/__mocks__/@nextcloud/auth.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -export function getCurrentUser() { - return { - uid: 'test', - displayName: 'Test', - isAdmin: false, - } -} - -export function getRequestToken() { - return 'some-token-string' -} - -export function onRequestTokenUpdate() { - // dummy -} diff --git a/__mocks__/@nextcloud/router.js b/__mocks__/@nextcloud/router.js deleted file mode 100644 index 57dbea150..000000000 --- a/__mocks__/@nextcloud/router.js +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * @param {string} path The path - */ -export const generateRemoteUrl = (path) => `https://localhost/${path}` diff --git a/__tests__/dav/dav.spec.ts b/__tests__/dav/dav.spec.ts index 122c3459d..16bf6b3fa 100644 --- a/__tests__/dav/dav.spec.ts +++ b/__tests__/dav/dav.spec.ts @@ -5,7 +5,7 @@ import type { FileStat, WebDAVClient } from 'webdav' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { defaultRemoteURL, defaultRootPath, @@ -17,14 +17,14 @@ import { File, Folder, NodeStatus } from '../../lib/index.ts' import FAVORITES_INNER_RESPONSE from '../fixtures/favorites-inner-response.json' with { type: 'json' } import FAVORITES_RESPONSE from '../fixtures/favorites-response.json' with { type: 'json' } -const auth = vi.hoisted(() => ({ - getCurrentUser: vi.fn(() => ({ uid: 'test', displayName: 'Test User', isAdmin: false })), - getRequestToken: vi.fn(() => 'test-token'), - onRequestTokenUpdate: vi.fn(), -})) +// The DAV root path and remote URL are computed on import from the current user and the webroot +vi.hoisted(() => { + document.head.dataset.user = 'test' + window._oc_webroot = '' +}) -vi.mock('@nextcloud/auth', () => auth) -vi.mock('@nextcloud/router') +/** The remote URL of the server the tests run on */ +const remoteURL = `${window.location.origin}/remote.php/dav` describe('DAV functions', () => { test('root path is correct', () => { @@ -32,15 +32,11 @@ describe('DAV functions', () => { }) test('remote url is correct', () => { - expect(defaultRemoteURL).toBe('https://localhost/dav') + expect(defaultRemoteURL).toBe(remoteURL) }) }) describe('resultToNode', () => { - afterEach(() => { - vi.resetAllMocks() - }) - /* Result of: getClient().getDirectoryContents(`${defaultRootPath}${path}`, { details: true }) */ @@ -67,7 +63,7 @@ describe('resultToNode', () => { expect(node.basename).toBe(result.basename) expect(node.displayname).toBe(result.props!.displayname) expect(node.extension).toBe('.md') - expect(node.source).toBe('https://localhost/dav/files/test/New folder/Neue Textdatei.md') + expect(node.source).toBe(`${remoteURL}/files/test/New folder/Neue Textdatei.md`) expect(node.root).toBe(defaultRootPath) expect(node.path).toBe('/New folder/Neue Textdatei.md') expect(node.dirname).toBe('/New folder') @@ -82,7 +78,7 @@ describe('resultToNode', () => { expect(node.basename).toBe(remoteResult.basename) expect(node.extension).toBe('.md') expect(node.root).toBe('/root') - expect(node.source).toBe('https://localhost/dav/root/New folder/Neue Textdatei.md') + expect(node.source).toBe(`${remoteURL}/root/New folder/Neue Textdatei.md`) expect(node.path).toBe('/New folder/Neue Textdatei.md') expect(node.dirname).toBe('/New folder') }) @@ -104,10 +100,8 @@ describe('resultToNode', () => { expect(node.displayname).toBe(remoteResult.props!.displayname) }) - // If owner-id is set, it will be used as owner + // If owner-id is set, it will be used as owner instead of the current user test('has correct owner set', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' } remoteResult.props = { ...remoteResult.props, ...{ 'owner-id': 'user1' } } as FileStat['props'] const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav') @@ -117,8 +111,6 @@ describe('resultToNode', () => { }) test('has correct owner set if number', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'admin', displayName: 'admin', isAdmin: true }) - const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' } remoteResult.props = { ...remoteResult.props, ...{ 'owner-id': 123456789 } } as FileStat['props'] const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav') @@ -128,18 +120,15 @@ describe('resultToNode', () => { }) test('has correct owner set if not set on node', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' } const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav') expect(node.isDavResource).toBe(true) - expect(node.owner).toBe('user1') + // falls back to the current user + expect(node.owner).toBe('test') }) test('by default no status is set', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - const remoteResult = { ...result } remoteResult.props!.fileid = 1 const node = resultToNode(remoteResult) @@ -147,8 +136,6 @@ describe('resultToNode', () => { }) test('sets node status on invalid fileid', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - const remoteResult = { ...result } remoteResult.props!.fileid = -1 const node = resultToNode(remoteResult) @@ -156,8 +143,6 @@ describe('resultToNode', () => { }) test('Ignore invalid times', () => { - vi.mocked(auth).getCurrentUser.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - // Invalid dates const remoteResult = { ...result } remoteResult.lastmod = 'invalid' @@ -176,14 +161,6 @@ describe('resultToNode', () => { }) describe('DAV requests', () => { - beforeEach(() => { - vi.mocked(auth).getCurrentUser!.mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false }) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - test('request all favorite files', async () => { // Mock the WebDAV client const client = { diff --git a/__tests__/dav/public-shares.spec.ts b/__tests__/dav/public-shares.spec.ts index f238ff94c..d7635c10a 100644 --- a/__tests__/dav/public-shares.spec.ts +++ b/__tests__/dav/public-shares.spec.ts @@ -4,72 +4,33 @@ */ import type { FileStat } from 'webdav' -import type { resultToNode as IResultToNode } from '../../lib/dav/dav.ts' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' +import { getRemoteURL, getRootPath, resultToNode } from '../../lib/dav/dav.ts' +import { setPublicShare } from '../helpers.ts' -const getCurrentUser = vi.hoisted(() => (vi.fn())) -const router = vi.hoisted(() => ({ generateRemoteUrl: vi.fn() })) -const sharing = vi.hoisted(() => ({ isPublicShare: vi.fn(), getSharingToken: vi.fn() })) - -vi.mock('@nextcloud/auth', async (original) => ({ - ...(await original()), - getCurrentUser, -})) - -vi.mock('@nextcloud/router', () => router) -vi.mock('@nextcloud/sharing/public', () => sharing) - -function restoreMocks() { - vi.resetAllMocks() - router.generateRemoteUrl.mockImplementation((service) => `https://example.com/remote.php/${service}`) -} - -function mockPublicShare() { - getCurrentUser.mockImplementationOnce(() => null) - sharing.isPublicShare.mockImplementation(() => true) - sharing.getSharingToken.mockImplementation(() => 'token-1234') -} - -describe('DAV path functions', () => { - beforeEach(() => { - vi.resetModules() - restoreMocks() - }) - - test('root path is correct on public shares', async () => { - mockPublicShare() +beforeAll(() => { + window._oc_webroot = '' + setPublicShare('token-1234') +}) - const { getRootPath } = await import('../../lib/dav/dav.ts') +describe('DAV path functions on public shares', () => { + test('root path is correct', () => { expect(getRootPath()).toBe('/files/token-1234') }) - test('remote URL is correct on public shares', async () => { - mockPublicShare() - - const { getRemoteURL } = await import('../../lib/dav/dav.ts') - expect(getRemoteURL()).toBe('https://example.com/public.php/dav') + test('remote URL is correct', () => { + expect(getRemoteURL()).toBe(`${window.location.origin}/public.php/dav`) }) }) -describe('on public shares', () => { - beforeEach(() => { - vi.resetAllMocks() - vi.resetModules() - }) - - // Wrapper function as we can not static import the function to allow mocking the modules - const resultToNode = async (...rest: Parameters) => { - const { resultToNode } = await import('../../lib/dav/dav.ts') - return resultToNode(...rest) - } - +describe('resultToNode on public shares', () => { /* * Result of: * davGetClient().getDirectoryContents(`${davRootPath}${path}`, { details: true }) */ const result: FileStat = { - filename: '/files/test/New folder/Neue Textdatei.md', + filename: '/root/New folder/Neue Textdatei.md', basename: 'Neue Textdatei.md', lastmod: 'Tue, 25 Jul 2023 12:29:34 GMT', size: 123, @@ -86,20 +47,10 @@ describe('on public shares', () => { }, } - describe('resultToNode', () => { - beforeEach(() => { - vi.resetModules() - restoreMocks() - }) - - test('has correct owner set on public shares', async () => { - mockPublicShare() - - const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' } - const node = await resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav') + test('has correct owner set', () => { + const node = resultToNode(result, '/root', 'http://example.com/remote.php/dav') - expect(node.isDavResource).toBe(true) - expect(node.owner).toBe('anonymous') - }) + expect(node.isDavResource).toBe(true) + expect(node.owner).toBe('anonymous') }) }) diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts new file mode 100644 index 000000000..b03c23ca7 --- /dev/null +++ b/__tests__/helpers.ts @@ -0,0 +1,130 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/** + * Helpers shared by the unit tests. + * + * Instead of mocking modules, the tests set up the page state the Nextcloud server would provide + * and only mock the network requests. + */ + +import type { AxiosRequestConfig, AxiosResponse } from 'axios' + +import axios from '@nextcloud/axios' +import { AxiosError } from 'axios' +import { vi } from 'vitest' + +declare global { + interface Window { + /** The web root of the server, used by `@nextcloud/router` */ + _oc_webroot?: string + /** The cache of the parsed initial state, used by `@nextcloud/initial-state` */ + _nc_initial_state?: Map + } +} + +/** + * Set the initial state the server provides for the page. + * `loadState` caches the parsed value, so the cache is invalidated to allow changing the state between tests. + * + * @param app - The app providing the state + * @param key - The key of the state + * @param value - The value of the state, `undefined` removes it + */ +export function setInitialState(app: string, key: string, value: unknown): void { + const id = `initial-state-${app}-${key}` + document.getElementById(id)?.remove() + window._nc_initial_state?.delete(`#${id}`) + + if (value !== undefined) { + const input = document.createElement('input') + input.type = 'hidden' + input.id = id + input.value = btoa(JSON.stringify(value)) + document.head.appendChild(input) + } +} + +/** + * Set the capabilities of the server. + * + * @param capabilities - The capabilities as provided by the server + */ +export function setCapabilities(capabilities: Record): void { + setInitialState('core', 'capabilities', capabilities) +} + +/** + * Mark the page as a public share - or as a regular page if no token is given. + * + * @param sharingToken - The token of the public share + */ +export function setPublicShare(sharingToken?: string): void { + setInitialState('files_sharing', 'isPublic', sharingToken !== undefined) + setInitialState('files_sharing', 'sharingToken', sharingToken) +} + +/** + * Mock all requests: no directory exists yet and every other request succeeds. + * Tests adjust the mocked `axios.head` and `axios.request` for their scenario. + */ +export function mockRequests(): void { + vi.spyOn(axios, 'head').mockRejectedValue(httpError(404)) + vi.spyOn(axios, 'request').mockResolvedValue({} as AxiosResponse) +} + +/** + * Create the error thrown by axios when the server responds with the given HTTP status. + * + * @param status - The HTTP status code + */ +export function httpError(status: number): AxiosError { + return new AxiosError(`Request failed with status code ${status}`, AxiosError.ERR_BAD_REQUEST, undefined, undefined, { status } as AxiosResponse) +} + +/** + * Get the configuration of all mocked requests sent with the given method. + * + * @param method - The HTTP method + */ +export function requests(method: 'MKCOL' | 'MOVE' | 'PUT'): AxiosRequestConfig[] { + return vi.mocked(axios.request).mock.calls + .map(([config]) => config) + .filter((config) => config.method === method) +} + +/** + * Get the URLs of all mocked requests sent with the given method. + * + * @param method - The HTTP method + */ +export function requestedUrls(method: 'HEAD' | 'MKCOL' | 'MOVE' | 'PUT'): string[] { + if (method === 'HEAD') { + return vi.mocked(axios.head).mock.calls.map(([url]) => url) + } + return requests(method).map((config) => config.url!) +} + +/** + * Create a file of the given size. + * + * @param size - The file size in bytes + * @param name - The file name + */ +export function createFile(size: number, name = 'file.txt'): File { + return new File([new ArrayBuffer(size)], name) +} + +/** + * Create a file with the given relative path, like the browser does for folder uploads. + * + * @param content - The file content + * @param relativePath - The relative path of the file, e.g. 'subdir/file.txt' + */ +export function fileWithPath(content: string, relativePath: string): File { + const file = new File([content], relativePath.split('/').at(-1)!) + // webkitRelativePath is a read-only prototype getter, so it is shadowed with an own property + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }) + return file +} diff --git a/__tests__/ui/actions/fileAction.spec.ts b/__tests__/ui/actions/fileAction.spec.ts index e12f41b2d..1db6e3e4a 100644 --- a/__tests__/ui/actions/fileAction.spec.ts +++ b/__tests__/ui/actions/fileAction.spec.ts @@ -7,7 +7,7 @@ import type { IFolder, INode } from '@/node/index.ts' import type { IFileAction } from '@/ui/index.ts' import type { View } from '@/ui/navigation/index.ts' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { scopedGlobals } from '@/globalScope.ts' import { DefaultType, getFileActions, getFilesRegistry, registerFileAction } from '@/ui/index.ts' import logger from '@/utils/logger.ts' @@ -19,6 +19,10 @@ describe('FileActions init', () => { delete scopedGlobals.fileActions }) + afterEach(() => { + vi.restoreAllMocks() + }) + test('Getting empty uninitialized FileActions', () => { const fileActions = getFileActions() expect(Array.isArray(fileActions)).toBeTruthy() @@ -63,8 +67,6 @@ describe('FileActions init', () => { }) test('getFileActions() returned array is reactive', () => { - logger.debug = vi.fn() - // is empty for now expect(getFileActions()).toHaveLength(0) @@ -83,7 +85,7 @@ describe('FileActions init', () => { }) test('Duplicate FileAction gets rejected', () => { - logger.error = vi.fn() + const error = vi.spyOn(logger, 'error').mockImplementation(() => {}) const action: IFileAction = { id: 'test', displayName: () => 'Test', @@ -105,7 +107,7 @@ describe('FileActions init', () => { registerFileAction(action2) expect(getFileActions()).toHaveLength(1) expect(getFileActions()[0]).toStrictEqual(action) - expect(logger.error).toHaveBeenCalledWith('FileAction test already registered', { action: action2 }) + expect(error).toHaveBeenCalledWith('FileAction test already registered', { action: action2 }) }) }) @@ -226,7 +228,6 @@ describe('Invalid FileAction registration', () => { describe('FileActions creation', () => { test('create valid FileAction', async () => { - logger.debug = vi.fn() const action: IFileAction = { id: 'test', displayName: () => 'Test', diff --git a/__tests__/ui/filters/listFilter.spec.ts b/__tests__/ui/filters/listFilter.spec.ts index 6d2fe310e..8dcf80227 100644 --- a/__tests__/ui/filters/listFilter.spec.ts +++ b/__tests__/ui/filters/listFilter.spec.ts @@ -65,20 +65,6 @@ describe('File list filter class', () => { const filter = new TestFilter('my:id') expect(() => filter.filter([])).toThrowError() }) - - test('emits chips updated event', () => { - const filter = new TestFilter('my:id', 50) - const chips: IFileListFilterChip[] = [{ text: 'my chip', onclick: () => {} }] - const spy = vi.fn() - - filter.addEventListener('update:chips', spy) - filter.testUpdateChips(chips) - - expect(spy).toBeCalled() - expect(spy.mock.calls[0][0]).toBeInstanceOf(CustomEvent) - expect(spy.mock.calls[0][0].type).toBe('update:chips') - expect(spy.mock.calls[0][0].detail).toBe(chips) - }) }) describe('File list filter functions', () => { diff --git a/__tests__/ui/headers/listHeaders.spec.ts b/__tests__/ui/headers/listHeaders.spec.ts index a56a54142..d9b869ef2 100644 --- a/__tests__/ui/headers/listHeaders.spec.ts +++ b/__tests__/ui/headers/listHeaders.spec.ts @@ -5,7 +5,7 @@ import type { IFileListHeader, IFolder, IView } from '@/index.ts' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { scopedGlobals } from '@/globalScope.ts' import { getFileListHeaders, getFilesRegistry, registerFileListHeader } from '@/ui/index.ts' import logger from '@/utils/logger.ts' @@ -15,6 +15,10 @@ describe('FileListHeader init', () => { delete scopedGlobals.fileListHeaders }) + afterEach(() => { + vi.restoreAllMocks() + }) + test('Getting empty uninitialized FileListHeader', () => { const headers = getFileListHeaders() expect(Array.isArray(headers)).toBe(true) @@ -37,7 +41,6 @@ describe('FileListHeader init', () => { }) test('register FileListHeader emits registry event', () => { - logger.debug = vi.fn() const callback = vi.fn() const header: IFileListHeader = { id: 'test', @@ -75,7 +78,7 @@ describe('FileListHeader init', () => { }) test('Duplicate Header gets rejected', () => { - logger.error = vi.fn() + const error = vi.spyOn(logger, 'error').mockImplementation(() => {}) const header: IFileListHeader = { id: 'test', order: 1, @@ -97,7 +100,7 @@ describe('FileListHeader init', () => { registerFileListHeader(header2) expect(getFileListHeaders()).toHaveLength(1) expect(getFileListHeaders()[0]).toStrictEqual(header) - expect(logger.error).toHaveBeenCalledWith('Header test already registered', { header: header2 }) + expect(error).toHaveBeenCalledWith('Header test already registered', { header: header2 }) }) }) diff --git a/__tests__/ui/navigation/navigation.spec.ts b/__tests__/ui/navigation/navigation.spec.ts index 223eabc58..010a10a0f 100644 --- a/__tests__/ui/navigation/navigation.spec.ts +++ b/__tests__/ui/navigation/navigation.spec.ts @@ -10,19 +10,11 @@ import { View } from '@/index.ts' import { getNavigation, Navigation } from '@/ui/navigation/navigation.ts' describe('getNavigation', () => { - it('creates a new navigation if needed', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete window._nc_navigation - const navigation = getNavigation() - expect(navigation).toBeInstanceOf(Navigation) - }) - - it('stores the navigation globally', () => { + it('creates a new navigation if needed and stores it globally', () => { delete scopedGlobals.navigation const navigation = getNavigation() expect(navigation).toBeInstanceOf(Navigation) - expect(scopedGlobals.navigation).toBeInstanceOf(Navigation) + expect(scopedGlobals.navigation).toBe(navigation) }) it('reuses an existing navigation', () => { diff --git a/__tests__/ui/sidebar/sidebarTab.spec.ts b/__tests__/ui/sidebar/sidebarTab.spec.ts index d9a2e9345..c79283c12 100644 --- a/__tests__/ui/sidebar/sidebarTab.spec.ts +++ b/__tests__/ui/sidebar/sidebarTab.spec.ts @@ -9,9 +9,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { scopedGlobals } from '@/globalScope.ts' import { getSidebarTabs, registerSidebarTab } from '@/ui/index.ts' -// missing in JSDom but supported by every browser! -import 'css.escape' - describe('Sidebar tabs', () => { beforeEach(() => { vi.restoreAllMocks() diff --git a/__tests__/uploader/upload.e2e.spec.ts b/__tests__/uploader/upload.e2e.spec.ts index 63c0906e0..22d0d9caa 100644 --- a/__tests__/uploader/upload.e2e.spec.ts +++ b/__tests__/uploader/upload.e2e.spec.ts @@ -7,16 +7,14 @@ import type { FileStat } from 'webdav' import { describe, expect, it, vi } from 'vitest' import { createDirectoryEntry } from '../fixtures/filesystem.ts' +import { fileWithPath } from '../helpers.ts' import { defaultRemoteURL, getClient } from '@/dav/index.ts' import { Folder } from '@/node/index.ts' import { Uploader, UploaderStatus, UploadStatus } from '@/upload/index.ts' -vi.mock('@nextcloud/auth', async (def) => ({ - ...(await def()), - getCurrentUser: () => ({ uid: 'admin' }), -})) - +// The current user and the webroot are read on import to set up the DAV URLs vi.hoisted(() => { + document.head.dataset.user = 'admin' window._oc_webroot = '/nextcloud' }) @@ -431,17 +429,3 @@ describe('Uploader (current API)', () => { expect(await client.exists('/files/admin/test-cancel/cancel.txt')).toBe(false) }) }) - -/** - * Create a File with a custom webkitRelativePath for simulating folder-input uploads. - * webkitRelativePath is a read-only prototype getter, so we shadow it with an own property. - * - * @param content - The file content - * @param relativePath - The relative path to set on the file, e.g. 'subdir/file.txt' - */ -function fileWithPath(content: string, relativePath: string): File { - const name = relativePath.split('/').at(-1)! - const file = new File([content], name) - Object.defineProperty(file, 'webkitRelativePath', { value: relativePath, configurable: true }) - return file -} diff --git a/__tests__/utils/filename-validation.spec.ts b/__tests__/utils/filename-validation.spec.ts index 67af802ef..87e60f43e 100644 --- a/__tests__/utils/filename-validation.spec.ts +++ b/__tests__/utils/filename-validation.spec.ts @@ -3,18 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later or LGPL-3.0-or-later */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { InvalidFilenameError, InvalidFilenameErrorReason, isFilenameValid, validateFilename } from '../../lib/index.ts' +import { setCapabilities } from '../helpers.ts' -const nextcloudCapabilities = vi.hoisted(() => ({ getCapabilities: vi.fn(() => ({ files: {} })) })) -vi.mock('@nextcloud/capabilities', () => nextcloudCapabilities) +beforeEach(() => { + // by default the server does not provide any restrictions + setCapabilities({ files: {} }) + delete window._oc_config +}) describe('isFilenameValid', () => { - beforeEach(() => { - vi.restoreAllMocks() - delete window._oc_config - }) - it('works for valid filenames', async () => { expect(isFilenameValid('foo.bar')).toBe(true) }) @@ -25,17 +24,12 @@ describe('isFilenameValid', () => { it('does not catch any interal exceptions', async () => { // invalid capability just to get an exception here - nextcloudCapabilities.getCapabilities.mockImplementationOnce(() => ({ files: { forbidden_filename_extensions: 3 } })) + setCapabilities({ files: { forbidden_filename_extensions: 3 } }) expect(() => isFilenameValid('hello')).toThrowError(TypeError) }) }) describe('validateFilename', () => { - beforeEach(() => { - vi.resetAllMocks() - delete window._oc_config - }) - it('works for valid filenames', async () => { expect(() => validateFilename('foo.bar')).not.toThrow() }) @@ -51,7 +45,7 @@ describe('validateFilename', () => { // Nextcloud 30+ it('fetches forbidden characters from capabilities', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_characters: ['=', '?'] } })) + setCapabilities({ files: { forbidden_filename_characters: ['=', '?'] } }) expect(() => validateFilename('foo')).not.toThrow() expect(() => validateFilename('foo?')).toThrowError(InvalidFilenameError) expect(() => validateFilename('foo=bar')).toThrowError(InvalidFilenameError) @@ -59,7 +53,7 @@ describe('validateFilename', () => { }) it('fetches forbidden extensions from capabilities', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_extensions: ['.txt', '.tar.gz'] } })) + setCapabilities({ files: { forbidden_filename_extensions: ['.txt', '.tar.gz'] } }) expect(() => validateFilename('foo.md')).not.toThrow() expect(() => validateFilename('foo.txt')).toThrowError(InvalidFilenameError) expect(() => validateFilename('foo.tar.gz')).toThrowError(InvalidFilenameError) @@ -67,39 +61,39 @@ describe('validateFilename', () => { }) it('fetches forbidden filenames from capabilities', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filenames: ['thumbs.db'] } })) + setCapabilities({ files: { forbidden_filenames: ['thumbs.db'] } }) expect(() => validateFilename('thumbs.png')).not.toThrow() expect(() => validateFilename('thumbs.db')).toThrowError(InvalidFilenameError) }) it('fetches forbidden filename basenames from capabilities', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } })) + setCapabilities({ files: { forbidden_filename_basenames: ['com0'] } }) expect(() => validateFilename('com1.txt')).not.toThrow() expect(() => validateFilename('com0.txt')).toThrowError(InvalidFilenameError) }) it('handles forbidden filenames case-insensitive', () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filenames: ['thumbs.db'] } })) + setCapabilities({ files: { forbidden_filenames: ['thumbs.db'] } }) expect(() => validateFilename('thumbS.db')).toThrowError(InvalidFilenameError) expect(() => validateFilename('thumbs.DB')).toThrowError(InvalidFilenameError) }) it('handles forbidden filename basenames case-insensitive', () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } })) + setCapabilities({ files: { forbidden_filename_basenames: ['com0'] } }) expect(() => validateFilename('COM0')).toThrowError(InvalidFilenameError) expect(() => validateFilename('com0')).toThrowError(InvalidFilenameError) expect(() => validateFilename('com0.namespace')).toThrowError(InvalidFilenameError) }) it('handles forbidden filename extensions case-insensitive', () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_extensions: ['.txt'] } })) + setCapabilities({ files: { forbidden_filename_extensions: ['.txt'] } }) expect(() => validateFilename('file.TXT')).toThrowError(InvalidFilenameError) expect(() => validateFilename('FILE.txt')).toThrowError(InvalidFilenameError) expect(() => validateFilename('FiLe.TxT')).toThrowError(InvalidFilenameError) }) it('handles hidden files correctly', () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['.hidden'], forbidden_filename_extensions: ['.txt'] } })) + setCapabilities({ files: { forbidden_filename_basenames: ['.hidden'], forbidden_filename_extensions: ['.txt'] } }) // forbidden basename '.hidden' expect(() => validateFilename('.hidden')).toThrowError(InvalidFilenameError) expect(() => validateFilename('.hidden.png')).toThrowError(InvalidFilenameError) @@ -123,7 +117,7 @@ describe('validateFilename', () => { }) it('sets error properties correctly on invalid extension', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_extensions: ['.txt'] } })) + setCapabilities({ files: { forbidden_filename_extensions: ['.txt'] } }) try { validateFilename('file.txt') @@ -149,7 +143,7 @@ describe('validateFilename', () => { }) it('sets error properties correctly on invalid basename', async () => { - nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } })) + setCapabilities({ files: { forbidden_filename_basenames: ['com0'] } }) try { validateFilename('com0.namespace') expect(true, 'should not be reached').toBeFalsy() diff --git a/lib/upload/uploader/Eta.spec.ts b/lib/upload/uploader/Eta.spec.ts index 13c30464d..f0918cb38 100644 --- a/lib/upload/uploader/Eta.spec.ts +++ b/lib/upload/uploader/Eta.spec.ts @@ -3,15 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { setLocale } from '@nextcloud/l10n' import { afterAll, beforeAll, describe, expect, it, test, vi } from 'vitest' import { Eta, EtaStatus } from './Eta.ts' -vi.mock('@nextcloud/l10n', async (original) => ({ - ...(await original()), - getCanonicalLocale() { - return 'en-US' - }, -})) +// the readable speed is formatted for the current locale +beforeAll(() => setLocale('en-US')) describe('ETA - status', () => { it('has default set', () => { @@ -198,56 +195,6 @@ describe('ETA - progress', () => { } expect(eta.progress).toBe(100) }) - - it('can autostart in constructor', () => { - const eta = new Eta({ start: true, total: 100 }) - expect(eta.status).toBe(EtaStatus.Running) - expect(eta.progress).toBe(0) - expect(eta.time).toBe(Infinity) - expect(eta.speed).toBe(-1) - }) - - it('can reset', () => { - const eta = new Eta({ start: true, total: 100 }) - expect(eta.status).toBe(EtaStatus.Running) - - eta.add(10) - expect(eta.progress).toBe(10) - - eta.reset() - expect(eta.status).toBe(EtaStatus.Idle) - expect(eta.progress).toBe(0) - }) - - it('does not update when idle', () => { - const eta = new Eta() - expect(eta.progress).toBe(0) - - eta.update(10, 100) - expect(eta.progress).toBe(0) - - eta.add(10) - expect(eta.progress).toBe(0) - expect(eta.status).toBe(EtaStatus.Idle) - }) - - it('does not update when paused', () => { - const eta = new Eta({ start: true, total: 100 }) - eta.add(10) - expect(eta.progress).toBe(10) - - eta.pause() - eta.add(10) - expect(eta.progress).toBe(10) - expect(eta.status).toBe(EtaStatus.Paused) - }) - - it('can resume', () => { - const eta = new Eta() - expect(eta.status).toBe(EtaStatus.Idle) - eta.resume() - expect(eta.status).toBe(EtaStatus.Running) - }) }) describe('ETA - events', () => { diff --git a/lib/upload/uploader/UploadFile.spec.ts b/lib/upload/uploader/UploadFile.spec.ts index f0fdfd473..ef3476630 100644 --- a/lib/upload/uploader/UploadFile.spec.ts +++ b/lib/upload/uploader/UploadFile.spec.ts @@ -1,440 +1,300 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ /*! * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { AxiosProgressEvent, AxiosRequestConfig, AxiosResponse } from 'axios' + import axios from '@nextcloud/axios' -import { CanceledError } from 'axios' +import { AxiosError, CanceledError } from 'axios' +import PQueue from 'p-queue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { UploadStatus } from './Upload.ts' import { UploadFile } from './UploadFile.ts' +import { createFile, mockRequests, requestedUrls, requests, setCapabilities, setPublicShare } from '~/__tests__/helpers.ts' + +// The current user is read on import (by the logger), so it has to be set before +vi.hoisted(() => { + document.head.dataset.user = 'tester' +}) -const isPublicShareMock = vi.hoisted(() => vi.fn()) -vi.mock('@nextcloud/sharing/public', async (original) => ({ ...await original(), isPublicShare: isPublicShareMock })) +/** The maximum chunk size used by the tests - the server does not allow smaller chunks */ +const CHUNK_SIZE = 5 * 1024 * 1024 -const initChunkWorkspaceMock = vi.hoisted(() => vi.fn()) -const uploadDataMock = vi.hoisted(() => vi.fn()) -vi.mock('../utils/upload.ts', async () => ({ - ...(await vi.importActual('../utils/upload.ts')), - initChunkWorkspace: initChunkWorkspaceMock, - uploadData: uploadDataMock, -})) +/** The temporary chunk workspace created for the current user */ +const WORKSPACE = /\/remote\.php\/dav\/uploads\/tester\/web-file-upload-[0-9a-f]{16}/ -const getMaxChunksSizeMock = vi.hoisted(() => vi.fn()) -const supportsPublicChunkingMock = vi.hoisted(() => vi.fn()) -vi.mock('../utils/config.ts', () => ({ - getMaxChunksSize: getMaxChunksSizeMock, - supportsPublicChunking: supportsPublicChunkingMock, -})) +beforeEach(() => { + vi.restoreAllMocks() + // by default this is not a public share, chunking uses the minimum chunk size and all requests succeed + setPublicShare() + setCapabilities({}) + setMaxChunkSize(CHUNK_SIZE) + mockRequests() +}) describe('chunking', () => { it('enables chunking for non-public shares', () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(2048)], 'filename'), { noChunking: false }) + const uploadFile = new UploadFile('/destination', createFile(2 * CHUNK_SIZE)) expect(uploadFile.isChunked).toBe(true) }) it('enables chunking for public shares', () => { - isPublicShareMock.mockReturnValue(true) - supportsPublicChunkingMock.mockReturnValue(true) - getMaxChunksSizeMock.mockReturnValue(1024) - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(2048)], 'filename'), { noChunking: false }) + setPublicShare('token-1234') + setCapabilities({ dav: { public_shares_chunking: true } }) + + const uploadFile = new UploadFile('/destination', createFile(2 * CHUNK_SIZE)) expect(uploadFile.isChunked).toBe(true) }) it('disables chunking if too small', () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1000)], 'filename'), { noChunking: false }) + const uploadFile = new UploadFile('/destination', createFile(CHUNK_SIZE - 1)) expect(uploadFile.isChunked).toBe(false) }) it('disables chunking if explicitly disabled', () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(2048)], 'filename'), { noChunking: true }) + const uploadFile = new UploadFile('/destination', createFile(2 * CHUNK_SIZE), { noChunking: true }) expect(uploadFile.isChunked).toBe(false) }) it('disables chunking if disabled by admin', () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(0) - const uploadFile = new UploadFile('/destination', new File([], 'filename'), { noChunking: true }) + setMaxChunkSize(0) + + const uploadFile = new UploadFile('/destination', createFile(2 * CHUNK_SIZE)) expect(uploadFile.isChunked).toBe(false) }) it('disables chunking if not supported by public shares', () => { - isPublicShareMock.mockReturnValue(true) - supportsPublicChunkingMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(2048)], 'filename'), { noChunking: false }) + setPublicShare('token-1234') + + const uploadFile = new UploadFile('/destination', createFile(2 * CHUNK_SIZE)) expect(uploadFile.isChunked).toBe(false) }) it.each([ [0, 1], - [1024, 1], - [1025, 2], - [2048, 2], - [2049, 3], + [CHUNK_SIZE, 1], + [CHUNK_SIZE + 1, 2], + [2 * CHUNK_SIZE, 2], + [2 * CHUNK_SIZE + 1, 3], ])('calculates number of chunks correctly for file size %i', async (fileSize, expectedChunks) => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(fileSize)], 'filename'), { noChunking: false }) + const uploadFile = new UploadFile('/destination', createFile(fileSize)) expect(uploadFile.isChunked).toBe(expectedChunks > 1) - const { resolve, promise } = Promise.withResolvers() - const queue = { add: vi.fn(() => resolve()) } - uploadFile.start(queue as never) - - // wait for queue to be called - await promise + // the chunks are calculated when the upload is started, the jobs do not need to run for this + await uploadFile.start(createQueue({ autoStart: false })) expect(uploadFile.numberOfChunks).toBe(expectedChunks) }) }) describe('retries', () => { - it('defaults to 5 retries for a plain upload', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024 * 1024) - uploadDataMock.mockResolvedValue(undefined) - - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), {}) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]() - - expect(uploadDataMock).toHaveBeenLastCalledWith('/destination', expect.anything(), expect.objectContaining({ retries: 5 })) - }) - - it('forwards the configured retries to the upload request', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024 * 1024) - uploadDataMock.mockResolvedValue(undefined) - - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { retries: 2 }) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]() - - expect(uploadDataMock).toHaveBeenLastCalledWith('/destination', expect.anything(), expect.objectContaining({ retries: 2 })) + it.each([ + ['the default of 5', {}, 5], + ['the configured', { retries: 2 }, 2], + ])('forwards %s retries to the upload request', async (_label, options, retries) => { + const uploadFile = new UploadFile('/destination', createFile(100), options) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() + + expect(requests('PUT')).toHaveLength(1) + expect(requests('PUT')[0]['axios-retry']).toMatchObject({ retries }) }) it('forwards the configured retries to chunked uploads and the workspace creation', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - initChunkWorkspaceMock.mockResolvedValue('/tmp/temporary') - uploadDataMock.mockResolvedValue(undefined) - vi.spyOn(axios, 'request').mockResolvedValueOnce({}) - - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(4096)], 'bigfile'), { retries: 2 }) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - await uploadFile.start(queue as never) - await Promise.all(queue.add.mock.results.map((r) => r.value)) - - expect(initChunkWorkspaceMock).toHaveBeenLastCalledWith('/destination', 2, false, {}) - expect(uploadDataMock).toHaveBeenLastCalledWith(expect.any(String), expect.anything(), expect.objectContaining({ retries: 2 })) + const uploadFile = new UploadFile('/destination', createFile(4 * CHUNK_SIZE), { retries: 2 }) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() + + expect(requests('MKCOL')).toHaveLength(1) + expect(requests('MKCOL')[0]).toMatchObject({ + url: expect.stringMatching(WORKSPACE), + headers: { Destination: '/destination' }, + 'axios-retry': expect.objectContaining({ retries: 2 }), + }) + expect(requests('PUT')).toHaveLength(4) + for (const upload of requests('PUT')) { + expect(upload['axios-retry']).toMatchObject({ retries: 2 }) + } }) }) describe('upload status and events', () => { - it('initialized', () => { - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(2048)], 'filename'), { noChunking: false }) + it('is initialized', () => { + const uploadFile = new UploadFile('/destination', createFile(100)) expect(uploadFile.status).toBe(UploadStatus.INITIALIZED) }) - it('converts FileSystemFileEntry to File when starting', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024 * 1024) + it('is scheduled once started', async () => { + const uploadFile = new UploadFile('/destination', createFile(100)) + // the queue is not started, so the upload job does not run yet + await uploadFile.start(createQueue({ autoStart: false })) + expect(uploadFile.status).toBe(UploadStatus.SCHEDULED) + }) - const fileEntry = { - file: vi.fn((resolve: (f: File) => void) => resolve(new File(['x'.repeat(1024)], 'entry.txt'))), - } as unknown as FileSystemFileEntry + it('is uploading while the request is running', async () => { + // the request never settles + mockUploads(() => new Promise(() => {})) - uploadDataMock.mockImplementationOnce(() => Promise.resolve()) + const uploadFile = new UploadFile('/destination', createFile(100)) + await uploadFile.start(createQueue()) + expect(uploadFile.status).toBe(UploadStatus.UPLOADING) + }) + it('is finished when the request succeeded', async () => { + const uploadFile = new UploadFile('/destination', createFile(100)) const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', fileEntry, { noChunking: false }) uploadFile.addEventListener('finished', onFinish) - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - expect(fileEntry.file).toHaveBeenCalledOnce() - // run the scheduled upload - await queue.add.mock.calls[0][0]() + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(uploadFile.status).toBe(UploadStatus.FINISHED) expect(onFinish).toHaveBeenCalledOnce() }) + it.each([ + ['cancelled if the request was aborted', new DOMException('Aborted', 'AbortError'), UploadStatus.CANCELLED], + ['cancelled if the request was cancelled by axios', new CanceledError(), UploadStatus.CANCELLED], + ['failed if the request failed', new Error('generic error'), UploadStatus.FAILED], + ])('is %s', async (_label, error, expectedStatus) => { + mockUploads(() => Promise.reject(error)) + + const uploadFile = new UploadFile('/destination', createFile(100)) + const onFinish = vi.fn() + uploadFile.addEventListener('finished', onFinish) + + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() + expect(uploadFile.status).toBe(expectedStatus) + expect(onFinish).toHaveBeenCalledOnce() + }) + it('throws if start called twice', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024 * 1024) + const uploadFile = new UploadFile('/destination', createFile(100)) + const queue = createQueue() + await uploadFile.start(queue) + await expect(uploadFile.start(queue)).rejects.toThrow('Upload already started') + }) + + it('converts FileSystemFileEntry to File when starting', async () => { + const fileEntry = { + file: vi.fn((resolve: (file: File) => void) => resolve(createFile(100, 'entry.txt'))), + } as unknown as FileSystemFileEntry - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(100)], 'filename'), { noChunking: false }) - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - await expect(uploadFile.start(queue as never)).rejects.toThrow('Upload already started') + const uploadFile = new UploadFile('/destination', fileEntry) + const onFinish = vi.fn() + uploadFile.addEventListener('finished', onFinish) + + const queue = createQueue() + await uploadFile.start(queue) + expect(fileEntry.file).toHaveBeenCalledOnce() + + await queue.onIdle() + expect(uploadFile.status).toBe(UploadStatus.FINISHED) + expect(onFinish).toHaveBeenCalledOnce() }) it('resets uploadedBytes on upload retry and emits progress', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024 * 1024) - - // Mock uploadData to call onUploadProgress and onUploadRetry synchronously - uploadDataMock.mockImplementationOnce((_url: string, _chunk: Blob, options: any) => { - options.onUploadProgress?.({ bytes: 100 }) - options.onUploadRetry?.() - return Promise.resolve() + // the first try is retried after some progress was reported + mockUploads(async (config) => { + reportProgress(config, 100) + reportRetry(config) }) + const uploadFile = new UploadFile('/destination', createFile(1024)) const onProgress = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) uploadFile.addEventListener('progress', onProgress) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - await uploadFile.start(queue as never) - // the queued function was executed immediately by our queue stub — wait for it to finish - await queue.add.mock.calls[0][0]() + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(uploadFile.uploadedBytes).toBe(1024) expect(onProgress).toHaveBeenCalled() }) it('chunked assemble finishes when MOVE succeeds', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - // make sure chunking is enabled - initChunkWorkspaceMock.mockResolvedValue('/tmp/temporary') - // each chunk upload succeeds - uploadDataMock.mockImplementation(() => Promise.resolve()) - // axios MOVE succeeds - vi.spyOn(axios, 'request').mockResolvedValueOnce({}) - + const uploadFile = new UploadFile('/destination', createFile(4 * CHUNK_SIZE)) const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(4096)], 'bigfile'), { noChunking: false }) uploadFile.addEventListener('finished', onFinish) - // simple queue that executes tasks immediately and returns their promise - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - - await uploadFile.start(queue as never) - // wait for all queued tasks to finish - await Promise.all(queue.add.mock.results.map((r) => r.value)) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() + // the chunks are uploaded to the workspace and assembled to the destination + expect(requestedUrls('PUT')).toEqual([0, 1, 2, 3].map((chunk) => expect.stringMatching(new RegExp(`${WORKSPACE.source}/${chunk}$`)))) + expect(requests('MOVE')).toHaveLength(1) + expect(requests('MOVE')[0]).toMatchObject({ + url: expect.stringMatching(new RegExp(`${WORKSPACE.source}/.file$`)), + headers: expect.objectContaining({ Destination: '/destination', 'OC-Total-Length': 4 * CHUNK_SIZE }), + }) expect(uploadFile.status).toBe(UploadStatus.FINISHED) expect(onFinish).toHaveBeenCalledOnce() }) it('keeps the source unencoded but encodes the request URL', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(0) - uploadDataMock.mockImplementationOnce(() => Promise.resolve()) - - const uploadFile = new UploadFile('/destination/a b&c.txt', new File(['x'], 'a b&c.txt'), { noChunking: true }) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - - await uploadFile.start(queue as never) - await Promise.all(queue.add.mock.results.map((r) => r.value)) + const uploadFile = new UploadFile('/destination/a b&c.txt', createFile(1, 'a b&c.txt')) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(uploadFile.source).toBe('/destination/a b&c.txt') - expect(uploadDataMock).toHaveBeenCalledWith('/destination/a%20b%26c.txt', expect.anything(), expect.anything()) + expect(requestedUrls('PUT')).toEqual(['/destination/a%20b%26c.txt']) }) it('encodes the destination header of chunked uploads', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - initChunkWorkspaceMock.mockResolvedValue('/tmp/temporary') - uploadDataMock.mockImplementation(() => Promise.resolve()) - const requestSpy = vi.spyOn(axios, 'request').mockResolvedValueOnce({} as never) - - const uploadFile = new UploadFile('/destination/a b&c.txt', new File(['x'.repeat(4096)], 'a b&c.txt'), { noChunking: false }) - const queue = { add: vi.fn((fn: () => Promise) => fn()) } - - await uploadFile.start(queue as never) - await Promise.all(queue.add.mock.results.map((r) => r.value)) + const uploadFile = new UploadFile('/destination/a b&c.txt', createFile(4 * CHUNK_SIZE, 'a b&c.txt')) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(uploadFile.source).toBe('/destination/a b&c.txt') - // the workspace is created with the encoded destination - expect(initChunkWorkspaceMock).toHaveBeenCalledWith('/destination/a%20b%26c.txt', 5, false, {}) + // the workspace is created with the encoded destination … + expect(requests('MKCOL')[0].headers).toMatchObject({ Destination: '/destination/a%20b%26c.txt' }) // … and so is the assemble request - expect(requestSpy.mock.lastCall![0].headers!.Destination).toBe('/destination/a%20b%26c.txt') + expect(requests('MOVE')[0].headers).toMatchObject({ Destination: '/destination/a%20b%26c.txt' }) }) it('rebases the upload to a new destination', () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - - const uploadFile = new UploadFile('/destination/a.txt', new File(['x'], 'a.txt'), { noChunking: true }) + const uploadFile = new UploadFile('/destination/a.txt', createFile(1, 'a.txt')) uploadFile.rebase('/destination/folder (2)/a.txt') expect(uploadFile.source).toBe('/destination/folder (2)/a.txt') }) - - it('scheduled', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - const { resolve, promise } = Promise.withResolvers() - const queue = { add: vi.fn(() => resolve()) } - - uploadFile.start(queue as never) - // wait for queue to be called - await promise - expect(uploadFile.status).toBe(UploadStatus.SCHEDULED) - }) - - it('uploading', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - - const { promise: uploadDataPromise } = Promise.withResolvers() - uploadDataMock.mockImplementationOnce(() => uploadDataPromise) - - const { promise: queuePromise, resolve: queueResolve } = Promise.withResolvers() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - const queue = { add: vi.fn((fn: () => Promise) => (queueResolve(), fn())) } - // start upload and wait for queue to be called - uploadFile.start(queue as never) - await queuePromise - - expect(uploadFile.status).toBe(UploadStatus.UPLOADING) - }) - - it('finished', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - uploadDataMock.mockImplementationOnce(() => Promise.resolve()) - - const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - uploadFile.addEventListener('finished', onFinish) - - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]() - expect(uploadFile.status).toBe(UploadStatus.FINISHED) - expect(onFinish).toHaveBeenCalledOnce() - }) - - it('cancelled by DOM', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - uploadDataMock.mockImplementationOnce(() => Promise.reject(new DOMException('Aborted', 'AbortError'))) - - const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - uploadFile.addEventListener('finished', onFinish) - - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]().catch(() => {}) - expect(uploadFile.status).toBe(UploadStatus.CANCELLED) - expect(onFinish).toHaveBeenCalledOnce() - }) - - it('cancelled by axios', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - uploadDataMock.mockImplementationOnce(() => Promise.reject(new CanceledError())) - - const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - uploadFile.addEventListener('finished', onFinish) - - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]().catch(() => {}) - expect(uploadFile.status).toBe(UploadStatus.CANCELLED) - expect(onFinish).toHaveBeenCalledOnce() - }) - - it('failed', async () => { - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - uploadDataMock.mockImplementationOnce(() => Promise.reject(new Error('generic error'))) - - const onFinish = vi.fn() - const uploadFile = new UploadFile('/destination', new File(['x'.repeat(1024)], 'filename'), { noChunking: false }) - uploadFile.addEventListener('finished', onFinish) - - const queue = { add: vi.fn((_fn: () => Promise) => {}) } - await uploadFile.start(queue as never) - await queue.add.mock.calls[0][0]().catch(() => {}) - expect(uploadFile.status).toBe(UploadStatus.FAILED) - expect(onFinish).toHaveBeenCalledOnce() - }) }) describe('chunked upload progress and status', () => { - // 4096 bytes with a maximum chunk size of 1024 bytes -> four chunks - const fileSize = 4096 - - /** - * Create a queue stub that runs every task immediately and keeps the resulting promise. - */ - function immediateQueue() { - return { add: vi.fn((fn: () => Promise) => fn()) } - } - - /** - * Create a chunked upload of `fileSize` bytes. - */ - function createUpload() { - return new UploadFile('/destination', new File(['x'.repeat(fileSize)], 'bigfile'), { noChunking: false }) - } - - beforeEach(() => { - vi.restoreAllMocks() - uploadDataMock.mockReset() - initChunkWorkspaceMock.mockReset() - - isPublicShareMock.mockReturnValue(false) - getMaxChunksSizeMock.mockReturnValue(1024) - initChunkWorkspaceMock.mockResolvedValue('/tmp/temporary') - }) + // four chunks + const fileSize = 4 * CHUNK_SIZE it('is not finished while other chunks are still uploading', async () => { // the last of the four chunks never settles, all others succeed - const { promise: pendingChunk } = Promise.withResolvers() - const delayed = () => new Promise((resolve) => setTimeout(resolve, 10)) - uploadDataMock - .mockImplementationOnce(delayed) - .mockImplementationOnce(delayed) - .mockImplementationOnce(delayed) - .mockReturnValueOnce(pendingChunk) - vi.spyOn(axios, 'request').mockResolvedValue({} as never) - - const uploadFile = createUpload() - const queue = immediateQueue() - await uploadFile.start(queue as never) - - await vi.waitFor(() => expect(uploadDataMock).toHaveBeenCalledTimes(4)) - // wait for the three succeeding chunks to settle - await new Promise((resolve) => setTimeout(resolve, 30)) + let uploads = 0 + mockUploads(() => (++uploads < 4 ? Promise.resolve() : new Promise(() => {}))) + const uploadFile = new UploadFile('/destination', createFile(fileSize)) + await uploadFile.start(createQueue()) + + // wait for the three succeeding chunks to settle + await vi.waitFor(() => expect(uploadFile.uploadedBytes).toBe(3 * CHUNK_SIZE)) expect(uploadFile.status).toBe(UploadStatus.UPLOADING) - expect(uploadFile.uploadedBytes).toBeLessThan(fileSize) }) it('never reports more uploaded bytes than the file size', async () => { - uploadDataMock.mockImplementation((_url: string, chunk: Blob, options: any) => { - // the whole chunk was sent - options.onUploadProgress?.({ bytes: chunk.size }) - return Promise.resolve() - }) - vi.spyOn(axios, 'request').mockResolvedValue({} as never) + // the whole chunk is reported as sent before the request succeeds + mockUploads(async (config) => reportProgress(config, config.data.size)) - const uploadFile = createUpload() + const uploadFile = new UploadFile('/destination', createFile(fileSize)) const reported: number[] = [] uploadFile.addEventListener('progress', () => { reported.push(uploadFile.uploadedBytes) }) - const queue = immediateQueue() - await uploadFile.start(queue as never) - await Promise.all(queue.add.mock.results.map((r) => r.value)) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(Math.max(...reported)).toBeLessThanOrEqual(fileSize) expect(uploadFile.uploadedBytes).toBe(fileSize) @@ -442,33 +302,25 @@ describe('chunked upload progress and status', () => { }) it('only discards the progress of the retried chunk', async () => { - uploadDataMock.mockImplementation((_url: string, chunk: Blob, options: any) => { - options.onUploadProgress?.({ bytes: chunk.size }) - return Promise.resolve() - }) - // the first chunk is uploaded without any retry … - uploadDataMock.mockImplementationOnce((_url: string, chunk: Blob, options: any) => { - options.onUploadProgress?.({ bytes: chunk.size }) - return Promise.resolve() + // the second chunk has to be retried after it was already fully sent, all other chunks are uploaded without retry + let uploads = 0 + mockUploads(async (config) => { + reportProgress(config, config.data.size) + if (++uploads === 2) { + reportRetry(config) + reportProgress(config, config.data.size) + } }) - // … while the second one has to be retried after it was already fully sent - uploadDataMock.mockImplementationOnce((_url: string, chunk: Blob, options: any) => { - options.onUploadProgress?.({ bytes: chunk.size }) - options.onUploadRetry?.() - options.onUploadProgress?.({ bytes: chunk.size }) - return Promise.resolve() - }) - vi.spyOn(axios, 'request').mockResolvedValue({} as never) - const uploadFile = createUpload() + const uploadFile = new UploadFile('/destination', createFile(fileSize)) const reported: number[] = [] uploadFile.addEventListener('progress', () => { reported.push(uploadFile.uploadedBytes) }) - const queue = immediateQueue() - await uploadFile.start(queue as never) - await Promise.all(queue.add.mock.results.map((r) => r.value)) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() // a retry of one chunk must not drop the progress of the other chunks expect(Math.max(...reported)).toBeLessThanOrEqual(fileSize) @@ -477,17 +329,77 @@ describe('chunked upload progress and status', () => { it('does not overwrite a failed status with a later successful chunk', async () => { // the first chunk fails, all other chunks succeed but only after the failure was handled - uploadDataMock.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 20))) - uploadDataMock.mockRejectedValueOnce(new Error('chunk failed')) - vi.spyOn(axios, 'request').mockResolvedValue({} as never) + let uploads = 0 + mockUploads(() => (++uploads === 1 + ? Promise.reject(new Error('chunk failed')) + : new Promise((resolve) => setTimeout(resolve, 20)))) - const uploadFile = createUpload() - const queue = immediateQueue() - await uploadFile.start(queue as never) - await Promise.allSettled(queue.add.mock.results.map((r) => r.value)) - // wait for the remaining chunks to settle - await new Promise((resolve) => setTimeout(resolve, 50)) + const uploadFile = new UploadFile('/destination', createFile(fileSize)) + const queue = createQueue() + await uploadFile.start(queue) + await queue.onIdle() expect(uploadFile.status).toBe(UploadStatus.FAILED) }) }) + +/** + * Set the maximum chunk size configured by the admin. + * + * @param size - The chunk size in bytes, `0` disables chunking + */ +function setMaxChunkSize(size: number): void { + window.OC = { ...window.OC, appConfig: { files: { max_chunk_size: size } } } as typeof window.OC +} + +/** + * Create the job queue for an upload. + * + * The upload does not await the jobs it adds to the queue, so a failed upload job + * would be reported as unhandled rejection - thus the rejections are handled here. + * + * @param options - The queue options + */ +function createQueue(options?: ConstructorParameters[0]): PQueue { + const queue = new PQueue(options) + const add = queue.add.bind(queue) + queue.add = ((...args: Parameters) => { + const job = add(...args) + job.catch(() => {}) + return job + }) as typeof queue.add + return queue +} + +/** + * Mock the upload requests (PUT) with the given handler, all other requests succeed. + * + * @param handler - Handles the upload request, its result is the result of the request + */ +function mockUploads(handler: (config: AxiosRequestConfig) => Promise): void { + vi.mocked(axios.request).mockImplementation(async (config) => { + if (config.method === 'PUT') { + await handler(config) + } + return {} as AxiosResponse + }) +} + +/** + * Report the given number of bytes of an upload request as sent. + * + * @param config - The request + * @param bytes - The number of bytes sent + */ +function reportProgress(config: AxiosRequestConfig, bytes: number): void { + config.onUploadProgress!({ bytes } as AxiosProgressEvent) +} + +/** + * Report an upload request as retried. + * + * @param config - The request + */ +function reportRetry(config: AxiosRequestConfig): void { + config['axios-retry']!.onRetry!(1, new AxiosError('Network Error'), config) +} diff --git a/lib/upload/uploader/UploadFileTree.spec.ts b/lib/upload/uploader/UploadFileTree.spec.ts index e699de748..1574c4003 100644 --- a/lib/upload/uploader/UploadFileTree.spec.ts +++ b/lib/upload/uploader/UploadFileTree.spec.ts @@ -3,159 +3,69 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type PQueue from 'p-queue' +import type { ConflictsCallback } from './UploadFileTree.ts' +import axios from '@nextcloud/axios' import { CanceledError } from 'axios' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import PQueue from 'p-queue' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { UploadCancelledError } from '../errors/UploadCancelledError.ts' import { UploadFailedError } from '../errors/UploadFailedError.ts' import { Directory } from '../utils/fileTree.ts' import { UploadStatus } from './Upload.ts' - -const axiosRequestMock = vi.hoisted(() => vi.fn()) -const isAxiosErrorMock = vi.hoisted(() => vi.fn()) -const isCancelMock = vi.hoisted(() => vi.fn()) - -const uploadFileMocks = vi.hoisted(() => { - const instances: Array<{ - source: string - signal: AbortSignal - options: unknown - start: ReturnType - cancel: ReturnType - rebase: ReturnType - status: number - }> = [] - - class MockUploadFile { - public source: string - public options: unknown - public status: number = UploadStatus.INITIALIZED - - #abortController = new AbortController() - - public get signal(): AbortSignal { - return this.#abortController.signal - } - - public start = vi.fn(async () => { - this.status = UploadStatus.FINISHED - }) - - public cancel = vi.fn(() => { - this.#abortController.abort() - this.status = UploadStatus.CANCELLED - }) - - public rebase = vi.fn((source: string) => { - this.source = source - }) - - public constructor(source: string, _file: File, options: unknown) { - this.source = source - this.options = options - instances.push(this) - } - } - - return { instances, MockUploadFile } -}) - -vi.mock('@nextcloud/axios', () => ({ - default: { - request: axiosRequestMock, - }, - isCancel: isCancelMock, - isAxiosError: isAxiosErrorMock, -})) - -vi.mock('./UploadFile.ts', () => ({ - UploadFile: uploadFileMocks.MockUploadFile, -})) - import { UploadFileTree } from './UploadFileTree.ts' - -function createQueue(): PQueue { - return { - add: vi.fn((job: () => Promise) => job()), - } as never -} - -async function createDirectoryTree(): Promise { - const root = new Directory('/destination') - const folder = new Directory('/destination/folder') - await folder.addChild(new File(['folder'], 'nested.txt', { lastModified: 1000 })) - - await root.addChildren([ - folder, - new File(['root'], 'root.txt', { lastModified: 2000 }), - ]) - - return root -} +import { httpError, mockRequests, requestedUrls, requests } from '~/__tests__/helpers.ts' beforeEach(() => { - axiosRequestMock.mockReset() - isAxiosErrorMock.mockReset() - uploadFileMocks.instances.length = 0 -}) - -afterEach(() => { vi.restoreAllMocks() + mockRequests() }) describe('UploadFileTree', () => { it('initializes child uploads recursively and exposes a defensive children copy', async () => { - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, {}) - + const tree = new UploadFileTree('/destination', await createDirectoryTree(), {}) expect(tree.isChunked).toBe(false) expect(tree.status).toBe(UploadStatus.INITIALIZED) const children = tree.initialize() - const snapshot = [...children] - - expect(children).toHaveLength(3) - expect(tree.children).toHaveLength(2) - expect(tree.children).not.toBe(children) - - children.pop() - expect(tree.children).toHaveLength(2) - - expect(tree.children).toHaveLength(2) + // the direct children followed by their descendants + expect(children.map((child) => child.source)).toEqual([ + '/destination/folder', + '/destination/root.txt', + '/destination/folder/nested.txt', + ]) expect(tree.children[0]).toBeInstanceOf(UploadFileTree) expect(tree.children.map((child) => child.source)).toEqual([ '/destination/folder', '/destination/root.txt', ]) - expect(snapshot[0].source).toBe('/destination/folder') - expect(snapshot[1].source).toBe('/destination/root.txt') - expect(snapshot[2].source).toBe('/destination/folder/nested.txt') + + // modifying the returned arrays does not affect the tree + children.pop() + tree.children.pop() + expect(tree.children).toHaveLength(2) }) - it('passes the configured retries down to nested child uploads', async () => { - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, { retries: 2 }) + it.each([ + ['the default of 5', {}, 5], + ['the configured', { retries: 2 }, 2], + ])('passes %s retries down to nested child uploads', async (_label, options, retries) => { + const tree = new UploadFileTree('/destination', await createDirectoryTree(), options) tree.initialize() + const queue = new PQueue() + await tree.start(queue) + await queue.onIdle() + // both the direct child file and the one nested in a sub directory - expect(uploadFileMocks.instances).toHaveLength(2) - for (const instance of uploadFileMocks.instances) { - expect(instance.options).toMatchObject({ retries: 2 }) + expect(requests('PUT')).toHaveLength(2) + for (const upload of requests('PUT')) { + expect(upload['axios-retry']).toMatchObject({ retries }) } }) - it('defaults to 5 retries for child uploads', async () => { - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, {}) - tree.initialize() - - expect(uploadFileMocks.instances[0].options).toMatchObject({ retries: 5 }) - }) - it('cancels child uploads when aborted', async () => { - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, {}) + const tree = new UploadFileTree('/destination', await createDirectoryTree(), {}) tree.initialize() const child = tree.children[0] as UploadFileTree @@ -171,86 +81,72 @@ describe('UploadFileTree', () => { }) it('starts once and marks the upload as finished after child uploads resolve', async () => { - axiosRequestMock.mockResolvedValue({}) - isAxiosErrorMock.mockReturnValue(false) - - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, {}) + const tree = new UploadFileTree('/destination', await createDirectoryTree(), {}) const onFinish = vi.fn() tree.addEventListener('finished', onFinish) - tree.initialize() - - const queue = createQueue() + const children = tree.initialize() + const queue = new PQueue() await tree.start(queue) + await queue.onIdle() - expect(axiosRequestMock).toHaveBeenCalledTimes(2) expect(tree.status).toBe(UploadStatus.FINISHED) expect(onFinish).toHaveBeenCalledOnce() - expect(uploadFileMocks.instances).toHaveLength(2) - expect(uploadFileMocks.instances[0].start).toHaveBeenCalledOnce() - expect(uploadFileMocks.instances[1].start).toHaveBeenCalledOnce() + // the directories are created and all files are uploaded + expect(requestedUrls('MKCOL')).toEqual(['/destination', '/destination/folder']) + expect(requestedUrls('PUT').toSorted()).toEqual(['/destination/folder/nested.txt', '/destination/root.txt']) + expect(children.every((child) => child.status === UploadStatus.FINISHED)).toBe(true) await expect(tree.start(queue)).rejects.toThrow('Upload already started') }) it('renames children through the conflict callback when MKCOL reports an existing directory', async () => { - axiosRequestMock.mockRejectedValueOnce({ response: { status: 405 } }) - isAxiosErrorMock.mockReturnValue(true) - - const conflictCallback = vi.fn(async () => ({ - 'root.txt': 'root-renamed.txt', - })) + // the directory was created in the meantime + vi.mocked(axios.request).mockRejectedValueOnce(httpError(405)) + const conflictCallback = vi.fn(async () => ({ 'root.txt': 'root-renamed.txt' })) const directory = new Directory('/destination') await directory.addChild(new File(['root'], 'root.txt')) - const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) tree.initialize() - const queue = createQueue() - + const queue = new PQueue() await tree.start(queue) + await queue.onIdle() - expect(conflictCallback).toHaveBeenCalledOnce() - expect(conflictCallback).toHaveBeenCalledWith(['root.txt'], '/destination') + expect(conflictCallback).toHaveBeenCalledExactlyOnceWith(['root.txt'], '/destination') expect(tree.children[0].source).toBe('/destination/root-renamed.txt') + expect(requestedUrls('PUT')).toEqual(['/destination/root-renamed.txt']) expect(tree.status).toBe(UploadStatus.FINISHED) }) it('rebases already initialized children when a folder is renamed', async () => { - // every MKCOL reports an existing directory so conflicts are resolved on all levels - axiosRequestMock.mockRejectedValue({ response: { status: 405 } }) - isAxiosErrorMock.mockReturnValue(true) - - const conflictCallback = vi.fn(async (nodes: string[]) => Object.fromEntries(nodes.map((node) => [node, node === 'folder' ? 'folder (2)' : node]))) + // every directory already exists so conflicts are resolved on all levels + vi.mocked(axios.head).mockResolvedValue({}) + const conflictCallback = vi.fn(async (nodes) => Object.fromEntries(nodes.map((node) => [node, node === 'folder' ? 'folder (2)' : node]))) - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) - tree.initialize() - - const nested = uploadFileMocks.instances[0] + const tree = new UploadFileTree('/destination', await createDirectoryTree(), { callback: conflictCallback }) + const [, , nested] = tree.initialize() expect(nested.source).toBe('/destination/folder/nested.txt') - await tree.start(createQueue()) + const queue = new PQueue() + await tree.start(queue) + await queue.onIdle() expect(tree.children[0].source).toBe('/destination/folder (2)') // the grandchild was already initialized, but still moved with its parent expect(nested.source).toBe('/destination/folder (2)/nested.txt') - // so the renamed folder is created - and entered - under its new name - expect(axiosRequestMock.mock.calls.map(([{ url }]) => url)).toEqual([ - '/destination', - '/destination/folder%20(2)', - ]) + // so the renamed folder is checked - and entered - under its new name + expect(requestedUrls('HEAD')).toEqual(['/destination', '/destination/folder%20(2)']) expect(conflictCallback).toHaveBeenCalledWith(['nested.txt'], '/destination/folder (2)') + expect(requestedUrls('PUT').toSorted()).toEqual(['/destination/folder%20(2)/nested.txt', '/destination/root.txt']) expect(tree.status).toBe(UploadStatus.FINISHED) }) it('keeps sources unencoded but encodes them for requests', async () => { - axiosRequestMock.mockRejectedValue({ response: { status: 405 } }) - isAxiosErrorMock.mockReturnValue(true) - - const conflictCallback = vi.fn(async (nodes: string[]) => Object.fromEntries(nodes.map((node) => [node, node]))) + // every directory already exists so conflicts are resolved on all levels + vi.mocked(axios.head).mockResolvedValue({}) + const conflictCallback = vi.fn(keepAll) const directory = new Directory('/destination') const folder = new Directory('/destination/sub folder') @@ -259,7 +155,6 @@ describe('UploadFileTree', () => { const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) const children = tree.initialize() - // the sources are the plain names, so the conflict callback can match them expect(children.map((child) => child.source)).toEqual([ '/destination/sub folder', @@ -267,25 +162,23 @@ describe('UploadFileTree', () => { '/destination/sub folder/näme #1.txt', ]) - await tree.start(createQueue()) + const queue = new PQueue() + await tree.start(queue) + await queue.onIdle() expect(conflictCallback).toHaveBeenCalledWith(['sub folder', 'a b&c.txt'], '/destination') expect(conflictCallback).toHaveBeenCalledWith(['näme #1.txt'], '/destination/sub folder') // … while the requests use encoded URLs - expect(axiosRequestMock.mock.calls.map(([{ url }]) => url)).toEqual([ - '/destination', - '/destination/sub%20folder', - ]) + expect(requestedUrls('HEAD')).toEqual(['/destination', '/destination/sub%20folder']) + expect(requestedUrls('PUT').toSorted()).toEqual(['/destination/a%20b%26c.txt', '/destination/sub%20folder/n%C3%A4me%20%231.txt']) expect(tree.status).toBe(UploadStatus.FINISHED) }) it('skips children that the conflict callback did not return', async () => { - // MKCOL fails with 405 so the directory already exists and conflicts need to be resolved - axiosRequestMock.mockRejectedValueOnce({ response: { status: 405 } }) - isAxiosErrorMock.mockReturnValue(true) - - // The callback keeps the existing version of 'root.txt' by not returning it - const conflictCallback = vi.fn(async (nodes: string[]) => Object.fromEntries(nodes + // the root directory already exists so conflicts need to be resolved + vi.mocked(axios.head).mockResolvedValueOnce({}) + // the callback keeps the existing version of 'root.txt' by not returning it + const conflictCallback = vi.fn(async (nodes) => Object.fromEntries(nodes .filter((node) => node !== 'root.txt') .map((node) => [node, node]))) @@ -294,121 +187,108 @@ describe('UploadFileTree', () => { new File(['root'], 'root.txt'), new File(['other'], 'other.txt'), ]) - const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) - tree.initialize() - - const queue = createQueue() + const [skipped, uploaded] = tree.initialize() + const queue = new PQueue() await tree.start(queue) - - expect(conflictCallback).toHaveBeenCalledOnce() - expect(conflictCallback).toHaveBeenCalledWith(['root.txt', 'other.txt'], '/destination') - - // the skipped upload is not started but cancelled, the other one is uploaded - expect(uploadFileMocks.instances[0].source).toBe('/destination/root.txt') - expect(uploadFileMocks.instances[0].start).not.toHaveBeenCalled() - expect(uploadFileMocks.instances[0].status).toBe(UploadStatus.CANCELLED) - expect(uploadFileMocks.instances[1].source).toBe('/destination/other.txt') - expect(uploadFileMocks.instances[1].start).toHaveBeenCalledOnce() - + await queue.onIdle() + + expect(conflictCallback).toHaveBeenCalledExactlyOnceWith(['root.txt', 'other.txt'], '/destination') + // the skipped upload is cancelled instead of started, the other one is uploaded + expect(skipped.source).toBe('/destination/root.txt') + expect(skipped.status).toBe(UploadStatus.CANCELLED) + expect(uploaded.status).toBe(UploadStatus.FINISHED) + expect(requestedUrls('PUT')).toEqual(['/destination/other.txt']) expect(tree.status).toBe(UploadStatus.FINISHED) }) it('skips whole folders - including their children - that the conflict callback did not return', async () => { - // MKCOL fails with 405 so the directory already exists and conflicts need to be resolved - axiosRequestMock.mockRejectedValueOnce({ response: { status: 405 } }) - axiosRequestMock.mockResolvedValue({}) - isAxiosErrorMock.mockReturnValue(true) - - // The callback keeps the existing version of the 'folder' directory by not returning it - const conflictCallback = vi.fn(async (nodes: string[]) => Object.fromEntries(nodes + // the root directory already exists so conflicts need to be resolved + vi.mocked(axios.head).mockResolvedValueOnce({}) + // the callback keeps the existing version of the 'folder' directory by not returning it + const conflictCallback = vi.fn(async (nodes) => Object.fromEntries(nodes .filter((node) => node !== 'folder') .map((node) => [node, node]))) - const directory = await createDirectoryTree() - const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) - tree.initialize() - - const queue = createQueue() + const tree = new UploadFileTree('/destination', await createDirectoryTree(), { callback: conflictCallback }) + const [folder, rootFile, nested] = tree.initialize() + const queue = new PQueue() await tree.start(queue) + await queue.onIdle() // the conflict callback is only called for the root, the skipped folder is never entered - expect(conflictCallback).toHaveBeenCalledOnce() - expect(conflictCallback).toHaveBeenCalledWith(['folder', 'root.txt'], '/destination') - - const folderUpload = tree.children[0] - expect(folderUpload.source).toBe('/destination/folder') - expect(folderUpload.status).toBe(UploadStatus.CANCELLED) - - // no MKCOL for the skipped folder, only the root directory was created - expect(axiosRequestMock).toHaveBeenCalledOnce() - - // the nested file of the skipped folder is not uploaded - expect(uploadFileMocks.instances[0].source).toBe('/destination/folder/nested.txt') - expect(uploadFileMocks.instances[0].start).not.toHaveBeenCalled() - // but the not skipped root file is - expect(uploadFileMocks.instances[1].source).toBe('/destination/root.txt') - expect(uploadFileMocks.instances[1].start).toHaveBeenCalledOnce() - + expect(conflictCallback).toHaveBeenCalledExactlyOnceWith(['folder', 'root.txt'], '/destination') + expect(folder.status).toBe(UploadStatus.CANCELLED) + expect(requestedUrls('HEAD')).toEqual(['/destination']) + expect(requestedUrls('MKCOL')).toEqual([]) + // the nested file of the skipped folder is not uploaded, but the root file is + expect(nested.status).toBe(UploadStatus.CANCELLED) + expect(rootFile.status).toBe(UploadStatus.FINISHED) + expect(requestedUrls('PUT')).toEqual(['/destination/root.txt']) expect(tree.status).toBe(UploadStatus.FINISHED) }) it('cancels the upload when the conflict callback aborts it', async () => { - axiosRequestMock.mockRejectedValueOnce({ response: { status: 405 } }) - isAxiosErrorMock.mockReturnValue(true) + // the root directory already exists so conflicts need to be resolved + vi.mocked(axios.head).mockResolvedValueOnce({}) + const conflictCallback = vi.fn(async () => false) - const conflictCallback = vi.fn(async () => false) const directory = new Directory('/destination') await directory.addChild(new File(['root'], 'root.txt')) + const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback }) + const [child] = tree.initialize() - const tree = new UploadFileTree( - '/destination', - directory, - // @ts-expect-error -- mocked for testing purposes - { callback: conflictCallback }, - ) - tree.initialize() - - const queue = createQueue() - + const queue = new PQueue() await tree.start(queue) + await queue.onIdle() expect(conflictCallback).toHaveBeenCalledOnce() expect(tree.status).toBe(UploadStatus.CANCELLED) - expect(uploadFileMocks.instances[0].start).not.toHaveBeenCalled() + expect(child.status).toBe(UploadStatus.CANCELLED) + expect(requestedUrls('PUT')).toEqual([]) }) it.each([ - ['request cancellation', new CanceledError()], - ['tree cancellation', new UploadCancelledError(new Error('cancelled'))], - ['tree failure', new UploadFailedError(new Error('failed'))], - ])('propagates %s from child uploads', async (_label, rejection) => { - axiosRequestMock.mockResolvedValue({}) - isAxiosErrorMock.mockReturnValue(false) - isCancelMock.mockImplementation((error: unknown) => error instanceof CanceledError) - + ['request cancellation', new CanceledError(), UploadCancelledError, UploadStatus.CANCELLED], + ['tree cancellation', new UploadCancelledError(new Error('cancelled')), UploadCancelledError, UploadStatus.CANCELLED], + ['tree failure', new UploadFailedError(new Error('failed')), UploadFailedError, UploadStatus.FAILED], + ])('propagates %s from child uploads', async (_label, rejection, expectedError, expectedStatus) => { const directory = new Directory('/destination') await directory.addChild(new File(['root'], 'root.txt')) - const tree = new UploadFileTree('/destination', directory, {}) - tree.initialize() - uploadFileMocks.instances[0].start.mockRejectedValueOnce(rejection) - - const queue = createQueue() - - if (rejection instanceof CanceledError) { - await expect(tree.start(queue)).rejects.toBeInstanceOf(UploadCancelledError) - } else { - await expect(tree.start(queue)).rejects.toBe(rejection) - } + const [child] = tree.initialize() + vi.spyOn(child, 'start').mockRejectedValueOnce(rejection) + const cancelSpy = vi.spyOn(child, 'cancel') - if (rejection instanceof UploadFailedError) { - expect(tree.status).toBe(UploadStatus.FAILED) - } else { - expect(tree.status).toBe(UploadStatus.CANCELLED) - } - expect(uploadFileMocks.instances[0].cancel).toHaveBeenCalledOnce() + await expect(tree.start(new PQueue())).rejects.toBeInstanceOf(expectedError) + expect(tree.status).toBe(expectedStatus) + expect(cancelSpy).toHaveBeenCalledOnce() }) }) + +/** + * Create a directory tree with a file in the root and one in a nested folder. + */ +async function createDirectoryTree(): Promise { + const root = new Directory('/destination') + const folder = new Directory('/destination/folder') + await folder.addChild(new File(['folder'], 'nested.txt', { lastModified: 1000 })) + + await root.addChildren([ + folder, + new File(['root'], 'root.txt', { lastModified: 2000 }), + ]) + + return root +} + +/** + * Conflict resolution that keeps all nodes as they are. + * + * @param nodes - The nodes to upload + */ +async function keepAll(nodes: string[]): ReturnType { + return Object.fromEntries(nodes.map((node) => [node, node])) +} diff --git a/lib/upload/uploader/Uploader.spec.ts b/lib/upload/uploader/Uploader.spec.ts index 309dde382..2aa352ce5 100644 --- a/lib/upload/uploader/Uploader.spec.ts +++ b/lib/upload/uploader/Uploader.spec.ts @@ -3,156 +3,38 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IUpload, TUploadStatus } from './Upload.ts' +import type { AxiosProgressEvent, AxiosResponse } from 'axios' +import type { ConflictsCallback } from './UploadFileTree.ts' -import PQueue from 'p-queue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import axios from '@nextcloud/axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Folder } from '../../node/folder.ts' import { UploadStatus } from './Upload.ts' import { Uploader, UploaderStatus } from './Uploader.ts' +import { fileWithPath, mockRequests } from '~/__tests__/helpers.ts' -// Mock auth to provide a current user by default +// The uploader needs the current user to set up its default destination. +// `getCurrentUser()` caches its result, so this can not be toggled through the page state. const authMock = vi.hoisted(() => ({ getCurrentUser: vi.fn<() => ({ uid: string }) | null>(() => ({ uid: 'tester' })), })) -vi.mock('@nextcloud/auth', () => authMock) +vi.mock('@nextcloud/auth', async (original) => ({ ...await original(), ...authMock })) -vi.mock('../../dav/dav.ts', () => ({ - defaultRemoteURL: 'https://localhost/remote.php/dav', - defaultRootPath: '/files/test', -})) - -vi.mock('../../utils/logger.ts', () => ({ default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) - -// Provide simple mocks for UploadFile and UploadFileTree so we can deterministically -// simulate progress/finished events and exercise uploader logic. -// The constructor arguments are captured so we can assert on the resolved upload target. -const uploadFileMock = vi.hoisted(() => ({ - instances: [] as Array<{ destination: string, options: Record }>, -})) - -vi.mock('./UploadFile.ts', () => ({ - UploadFile: class implements IUpload { - source = 'file:///test' - isChunked = false - totalBytes: number - uploadedBytes: number - status: TUploadStatus - response: any - signal = new AbortController().signal - children: IUpload[] = [] - private listeners: Record void)[]> - constructor(..._args: any[]) { - const file = _args[1] - uploadFileMock.instances.push({ destination: _args[0], options: _args[2] ?? {} }) - this.listeners = {} - this.totalBytes = (file && file.size) || 0 - this.uploadedBytes = 0 - this.status = UploadStatus.INITIALIZED - this.response = undefined - } - - addEventListener = ((ev: string, cb: (ev?: CustomEvent) => void) => { - this.listeners[ev] = this.listeners[ev] || [] - this.listeners[ev].push(cb) - }) as any - - removeEventListener() {} - dispatchEvent = (() => true) as any - dispatchTypedEvent = (() => true) as any - - // Mirrors the real `Upload.cancel`: a method that accesses private state, - // so calling it with a foreign `this` throws instead of silently working. - #cancelled = false - get cancelled(): boolean { - return this.#cancelled - } - - cancel() { - this.#cancelled = true - if (this.status !== UploadStatus.FINISHED) { - this.status = UploadStatus.CANCELLED - } - } - - start = async () => { - // simulate progress then finish - this.uploadedBytes = this.totalBytes / 2 - this.listeners.progress?.forEach((cb) => cb(new CustomEvent('progress', { detail: this }))) - this.uploadedBytes = this.totalBytes - this.status = UploadStatus.FINISHED - this.response = { status: 201 } - this.listeners.finished?.forEach((cb) => cb(new CustomEvent('finished', { detail: this }))) - } - }, -})) - -// Capture the arguments the Uploader passes to UploadFileTree so we can assert -// on the (wrapped) conflicts callback. -const uploadFileTreeMock = vi.hoisted(() => ({ - instances: [] as Array<{ destination: string, options: Record }>, -})) - -vi.mock('./UploadFileTree.ts', () => ({ - UploadFileTree: class implements IUpload { - source = 'file:///test' - isChunked = false - totalBytes = 0 - uploadedBytes = 0 - status: TUploadStatus = UploadStatus.FINISHED as TUploadStatus - response = { status: 201 } - signal = new AbortController().signal - children: IUpload[] = [] - private listeners: Record void)[]> = {} - constructor(destination: string, _directory: unknown, options: Record = {}) { - uploadFileTreeMock.instances.push({ destination, options }) - } +/** The destination of the uploader used in the tests */ +const root = 'https://localhost/remote.php/dav/files/tester' - addEventListener = ((ev: string, cb: (ev?: CustomEvent) => void) => { - this.listeners[ev] = this.listeners[ev] || [] - this.listeners[ev].push(cb) - }) as any - - removeEventListener = (() => {}) as any - dispatchEvent = (() => true) as any - dispatchTypedEvent = (() => true) as any - - // Mirrors the real `Upload.cancel`: a method that accesses private state, - // so calling it with a foreign `this` throws instead of silently working. - #cancelled = false - get cancelled(): boolean { - return this.#cancelled - } - - cancel() { - this.#cancelled = true - if (this.status !== UploadStatus.FINISHED) { - this.status = UploadStatus.CANCELLED as TUploadStatus - } - } - - initialize = () => [] - start = async () => { - this.listeners.finished?.forEach((cb) => cb(new CustomEvent('finished', { detail: this }))) - } - }, -})) - -describe('Uploader (current API)', () => { - beforeEach(() => { - authMock.getCurrentUser.mockReturnValue({ uid: 'tester' }) - uploadFileTreeMock.instances.length = 0 - uploadFileMock.instances.length = 0 - }) - - afterEach(() => { - vi.restoreAllMocks() - }) +beforeEach(() => { + vi.restoreAllMocks() + authMock.getCurrentUser.mockReturnValue({ uid: 'tester' }) + mockRequests() +}) +describe('Uploader', () => { it('constructs with default destination and exposes status/destination', () => { const uploader = new Uploader() expect(uploader.status).toBe(UploaderStatus.IDLE) expect(uploader.destination).toBeInstanceOf(Folder) + expect(uploader.destination.owner).toBe('tester') }) it('throws when no user and not public', () => { @@ -207,269 +89,300 @@ describe('Uploader (current API)', () => { expect(uploader.status).toBe(UploaderStatus.IDLE) }) - describe('status (derived from the job queue)', () => { - // The status getter is pure logic over the underlying p-queue state, - // so we drive it by stubbing the queue's getters. - const stubQueue = (state: { isPaused: boolean, pending: number, size: number }) => { - vi.spyOn(PQueue.prototype, 'isPaused', 'get').mockReturnValue(state.isPaused) - vi.spyOn(PQueue.prototype, 'pending', 'get').mockReturnValue(state.pending) - vi.spyOn(PQueue.prototype, 'size', 'get').mockReturnValue(state.size) - } + describe('status', () => { + it('is UPLOADING while an upload is running', async () => { + // the upload request only finishes when the test resolves it + const { promise, resolve } = Promise.withResolvers() + vi.mocked(axios.request).mockReturnValueOnce(promise) - it('is IDLE when the queue is running but empty', () => { - stubQueue({ isPaused: false, pending: 0, size: 0 }) - expect(new Uploader().status).toBe(UploaderStatus.IDLE) - }) + const uploader = createUploader() + const finished = whenFinished(uploader) + await uploader.upload('/hello.txt', new File(['hello'], 'hello.txt')) + expect(uploader.status).toBe(UploaderStatus.UPLOADING) - it('is PAUSED when the queue is paused', () => { - // Paused takes precedence even if jobs are still in flight - stubQueue({ isPaused: true, pending: 2, size: 3 }) - expect(new Uploader().status).toBe(UploaderStatus.PAUSED) + resolve({} as AxiosResponse) + await finished + expect(uploader.status).toBe(UploaderStatus.IDLE) }) - it('is UPLOADING when a single upload is in flight (nothing queued behind it)', () => { - // Regression guard: a lone running job has pending === 1, size === 0 - stubQueue({ isPaused: false, pending: 1, size: 0 }) - expect(new Uploader().status).toBe(UploaderStatus.UPLOADING) - }) + it('is PAUSED even if already started uploads are still running', async () => { + const { promise, resolve } = Promise.withResolvers() + vi.mocked(axios.request).mockReturnValueOnce(promise) - it('is UPLOADING when uploads are queued behind running ones', () => { - stubQueue({ isPaused: false, pending: 5, size: 3 }) - expect(new Uploader().status).toBe(UploaderStatus.UPLOADING) - }) + const uploader = createUploader() + const finished = whenFinished(uploader) + await uploader.upload('/hello.txt', new File(['hello'], 'hello.txt')) + // pausing waits for the running upload, so it can not be awaited here + const paused = uploader.pause() + expect(uploader.status).toBe(UploaderStatus.PAUSED) - it('is UPLOADING when uploads are only waiting in the queue', () => { - stubQueue({ isPaused: false, pending: 0, size: 4 }) - expect(new Uploader().status).toBe(UploaderStatus.UPLOADING) + resolve({} as AxiosResponse) + await paused + await finished }) }) it('uploads a file and emits progress and finished events', async () => { - const uploader = new Uploader() - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) - + const uploader = createUploader() const started = vi.fn() const progress = vi.fn() const finished = vi.fn() + uploader.addEventListener('uploadStarted', started) + uploader.addEventListener('uploadProgress', progress) + uploader.addEventListener('uploadFinished', finished) - uploader.addEventListener('uploadStarted', () => started()) - uploader.addEventListener('uploadProgress', () => progress()) - uploader.addEventListener('uploadFinished', () => finished()) + const allFinished = whenFinished(uploader) + const upload = await uploader.upload('/hello.txt', new File(['hello'], 'hello.txt')) + await allFinished - const upload = await uploader.upload('/hello.txt', file) - - // wait for upload to finish - await vi.waitFor(() => { - expect(upload.status).toBe(UploadStatus.FINISHED) - }) - - expect(started).toHaveBeenCalled() + expect(upload.status).toBe(UploadStatus.FINISHED) + expect(started).toHaveBeenCalledOnce() expect(progress).toHaveBeenCalled() - expect(finished).toHaveBeenCalled() + expect(finished).toHaveBeenCalledOnce() + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT', url: `${root}/hello.txt` })) + }) + + it('uploads the files of a batch upload and creates their directories', async () => { + const uploader = createUploader() + const finished = whenFinished(uploader) + const uploads = await uploader.batchUpload('/dir', [ + new File(['a'], 'a.txt'), + fileWithPath('b', 'sub/b.txt'), + ]) + await finished + + // the child uploads followed by the upload of the batch itself + expect(uploads.map((upload) => upload.source)).toEqual([ + `${root}/dir/a.txt`, + `${root}/dir/sub`, + `${root}/dir/sub/b.txt`, + `${root}/dir`, + ]) + expect(uploads.every((upload) => upload.status === UploadStatus.FINISHED)).toBe(true) + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ method: 'MKCOL', url: `${root}/dir` })) + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ method: 'MKCOL', url: `${root}/dir/sub` })) + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT', url: `${root}/dir/a.txt` })) + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT', url: `${root}/dir/sub/b.txt` })) }) describe('abort signal', () => { it('cancels a single upload when the signal is aborted', async () => { - const uploader = new Uploader() const controller = new AbortController() + // keep the upload queued so it can be cancelled + const uploader = createUploader() + await uploader.pause() const upload = await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { signal: controller.signal }) - expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(false) + expect(upload.signal.aborted).toBe(false) controller.abort() - expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(true) + expect(upload.signal.aborted).toBe(true) + expect(upload.status).toBe(UploadStatus.CANCELLED) }) it('cancels a batch upload when the signal is aborted', async () => { - const uploader = new Uploader() const controller = new AbortController() - - const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) - const root = uploads.at(-1) as unknown as { cancelled: boolean } - expect(root.cancelled).toBe(false) + const uploads = await createUploader().batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) + const batch = uploads.at(-1)! + expect(batch.signal.aborted).toBe(false) controller.abort() - expect(root.cancelled).toBe(true) + expect(batch.signal.aborted).toBe(true) + // the children are cancelled with the batch + expect(uploads[0].signal.aborted).toBe(true) }) - it('cancels a single upload when the signal is already aborted', async () => { - const uploader = new Uploader() + // An already aborted signal cancels the upload before it is started, + // but `start()` then refuses to run a cancelled upload and rejects with "Upload already started". + // These tests document the intended behavior and are expected to fail until this is fixed. + it.fails('cancels a single upload when the signal is already aborted', async () => { const controller = new AbortController() controller.abort() - const upload = await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { signal: controller.signal }) - expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(true) + const upload = await createUploader().upload('/hello.txt', new File(['a'], 'hello.txt'), { signal: controller.signal }) + expect(upload.signal.aborted).toBe(true) + expect(upload.status).toBe(UploadStatus.CANCELLED) }) - it('cancels a batch upload when the signal is already aborted', async () => { - const uploader = new Uploader() + it.fails('cancels a batch upload when the signal is already aborted', async () => { const controller = new AbortController() controller.abort() - const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) - expect((uploads.at(-1) as unknown as { cancelled: boolean }).cancelled).toBe(true) + const uploads = await createUploader().batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) + expect(uploads.at(-1)!.signal.aborted).toBe(true) + expect(uploads.at(-1)!.status).toBe(UploadStatus.CANCELLED) }) }) describe('upload target resolution', () => { - // destination folder source is mocked to https://localhost/remote.php/dav/files/test - const defaultRoot = 'https://localhost/remote.php/dav/files/test' - const otherRoot = 'https://localhost/remote.php/dav/files/test/subfolder' + const otherRoot = `${root}/subfolder` it('uploads relative to the uploader destination by default', async () => { - const uploader = new Uploader() - await uploader.upload('/hello.txt', new File(['a'], 'hello.txt')) - expect(uploadFileMock.instances[0].destination).toBe(`${defaultRoot}/hello.txt`) + const upload = await createUploader().upload('/hello.txt', new File(['a'], 'hello.txt')) + expect(upload.source).toBe(`${root}/hello.txt`) }) it('honours the root override for a single upload', async () => { - const uploader = new Uploader() - await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { root: otherRoot }) - expect(uploadFileMock.instances[0].destination).toBe(`${otherRoot}/hello.txt`) + const uploader = createUploader() + const upload = await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { root: otherRoot }) + expect(upload.source).toBe(`${otherRoot}/hello.txt`) // the override must not leak into the uploader state - expect(uploader.destination.source).toBe(defaultRoot) + expect(uploader.destination.source).toBe(root) }) it('honours the root override for a batch upload', async () => { - const uploader = new Uploader() - await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { root: otherRoot }) - expect(uploadFileTreeMock.instances[0].destination).toBe(`${otherRoot}/dir`) - expect(uploader.destination.source).toBe(defaultRoot) + const uploader = createUploader() + const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { root: otherRoot }) + expect(uploads.map((upload) => upload.source)).toEqual([`${otherRoot}/dir/a.txt`, `${otherRoot}/dir`]) + expect(uploader.destination.source).toBe(root) }) it('normalizes slashes between the root override and the destination', async () => { - const uploader = new Uploader() - await uploader.upload('hello.txt', new File(['a'], 'hello.txt'), { root: `${otherRoot}/` }) - expect(uploadFileMock.instances[0].destination).toBe(`${otherRoot}/hello.txt`) + const upload = await createUploader().upload('hello.txt', new File(['a'], 'hello.txt'), { root: `${otherRoot}/` }) + expect(upload.source).toBe(`${otherRoot}/hello.txt`) }) it('makes the batch upload conflicts callback relative to the overridden root', async () => { - const userCallback = vi.fn(async () => ({})) - const uploader = new Uploader() - await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { root: otherRoot, callback: userCallback }) + // the directories already exist, so conflicts need to be resolved + vi.mocked(axios.head).mockResolvedValue({}) + const callback = vi.fn(async (nodes) => Object.fromEntries(nodes.map((node) => [node, node]))) - const wrapped = uploadFileTreeMock.instances[0].options.callback as (nodes: string[], path: string) => Promise - await wrapped(['file.txt'], `${otherRoot}/dir/sub`) - expect(userCallback).toHaveBeenLastCalledWith(['file.txt'], 'sub') + await createUploader().batchUpload('/dir', [fileWithPath('a', 'sub/file.txt')], { root: otherRoot, callback }) + expect(callback).toHaveBeenCalledWith(['sub'], '') + expect(callback).toHaveBeenCalledWith(['file.txt'], 'sub') }) }) - it('performs batchUpload using UploadFileTree and initializes children', async () => { - const uploader = new Uploader() - const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')]) - expect(Array.isArray(uploads)).toBe(true) - expect(uploads.length).toBeGreaterThanOrEqual(1) - }) - describe('batchUpload conflicts callback', () => { - // destination folder source is mocked to https://localhost/remote.php/dav/files/test - const target = 'https://localhost/remote.php/dav/files/test/dir' - - /** Run a batchUpload with the given callback and return the callback handed to UploadFileTree */ - const getWrappedCallback = async (callback?: unknown) => { - uploadFileTreeMock.instances.length = 0 - const uploader = new Uploader() - await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], callback ? { callback } as any : undefined) - expect(uploadFileTreeMock.instances).toHaveLength(1) - expect(uploadFileTreeMock.instances[0].destination).toBe(target) - return uploadFileTreeMock.instances[0].options.callback as - | ((nodes: string[], path: string) => Promise) - | undefined - } + beforeEach(() => { + // all directories already exist, so conflicts need to be resolved on every level + vi.mocked(axios.head).mockResolvedValue({}) + }) - it('passes no callback to UploadFileTree when none was given', async () => { - const wrapped = await getWrappedCallback() - expect(wrapped).toBeFalsy() + it('uploads everything without resolving conflicts if no callback was given', async () => { + const uploader = createUploader() + const finished = whenFinished(uploader) + const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')]) + await finished + + expect(uploads.map((upload) => upload.status)).toEqual([UploadStatus.FINISHED, UploadStatus.FINISHED]) }) - it('wraps the callback so it receives a clean relative path', async () => { - const userCallback = vi.fn(async () => ({})) - const wrapped = await getWrappedCallback(userCallback) - expect(wrapped).toBeTypeOf('function') + it('calls the callback with the path relative to the upload destination', async () => { + const callback = vi.fn(async (nodes) => Object.fromEntries(nodes.map((node) => [node, node]))) - // the root of the batch upload maps to an empty relative path - await wrapped!(['file.txt'], target) - expect(userCallback).toHaveBeenLastCalledWith(['file.txt'], '') + await createUploader().batchUpload('/dir', [ + new File(['a'], 'a.txt'), + fileWithPath('b', 'sub/b.txt'), + fileWithPath('c', 'sub/deep/c.txt'), + ], { callback }) + // the root of the batch upload maps to an empty relative path + expect(callback).toHaveBeenCalledWith(['a.txt', 'sub'], '') // nested folder: the absolute upload prefix is stripped, no leading slash - await wrapped!(['file.txt'], `${target}/sub`) - expect(userCallback).toHaveBeenLastCalledWith(['file.txt'], 'sub') - + expect(callback).toHaveBeenCalledWith(['b.txt', 'deep'], 'sub') // deeper nesting keeps the inner separators - await wrapped!(['file.txt'], `${target}/sub/deep`) - expect(userCallback).toHaveBeenLastCalledWith(['file.txt'], 'sub/deep') + expect(callback).toHaveBeenCalledWith(['c.txt'], 'sub/deep') + }) + + it('renames the uploads as resolved by the callback', async () => { + const callback = vi.fn(async () => ({ 'a.txt': 'b.txt' })) + + const uploads = await createUploader().batchUpload('/dir', [new File(['a'], 'a.txt')], { callback }) + expect(uploads[0].source).toBe(`${root}/dir/b.txt`) }) - it('forwards the callback result (rename map / false) unchanged', async () => { - const renameMap = { 'a.txt': 'b.txt' } - const wrapped = await getWrappedCallback(vi.fn(async () => renameMap)) - await expect(wrapped!(['a.txt'], target)).resolves.toBe(renameMap) + it('cancels the upload if the callback returns false', async () => { + const callback = vi.fn(async () => false) - const wrappedCancel = await getWrappedCallback(vi.fn(async () => false)) - await expect(wrappedCancel!(['a.txt'], target)).resolves.toBe(false) + const uploads = await createUploader().batchUpload('/dir', [new File(['a'], 'a.txt')], { callback }) + expect(uploads.map((upload) => upload.status)).toEqual([UploadStatus.CANCELLED, UploadStatus.CANCELLED]) + expect(axios.request).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT' })) }) }) describe('statistics', () => { + const defaults = { + eta: Infinity, + progress: 0, + speed: -1, + speedReadable: '', + } + it('exposes default statistics before any upload', () => { - const uploader = new Uploader() - expect(uploader.statistics).toEqual({ - eta: Infinity, - progress: 0, - speed: -1, - speedReadable: '', - }) + expect(createUploader().statistics).toEqual(defaults) }) it('reflects the upload progress in the statistics', async () => { - const uploader = new Uploader() - // 'hello' has a size of 5 bytes, the mock reports half (2.5) before finishing - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) + // the request reports half of the file as sent before it finishes + vi.mocked(axios.request).mockImplementationOnce(async (config) => { + config.onUploadProgress!({ bytes: (config.data as Blob).size / 2 } as AxiosProgressEvent) + return {} as AxiosResponse + }) + const uploader = createUploader() const observedProgress: number[] = [] uploader.addEventListener('uploadProgress', () => { observedProgress.push(uploader.statistics.progress) }) - await uploader.upload('/hello.txt', file) + const finished = whenFinished(uploader) + await uploader.upload('/hello.txt', new File(['x'.repeat(100)], 'hello.txt')) + await finished - // the mock emits progress at half (2.5 / 5 = 50%) and once more when finished (100%) - expect(observedProgress).toContain(50) - expect(observedProgress).toContain(100) + // the progress is reported while uploading, not only once the upload is finished + expect(observedProgress.some((progress) => progress > 0 && progress < 100)).toBe(true) + expect(observedProgress.at(-1)).toBe(100) }) it('resets the statistics once all uploads are finished', async () => { - const uploader = new Uploader() - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) - - await uploader.upload('/hello.txt', file) - - // #onFinished resets the uploader (and its ETA) on the next tick - await vi.waitFor(() => { - expect(uploader.statistics).toEqual({ - eta: Infinity, - progress: 0, - speed: -1, - speedReadable: '', - }) - }) - }) - - it('does not track statistics for uploads queued while paused', async () => { - const uploader = new Uploader() - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) - - await uploader.pause() + const uploader = createUploader() + const finished = whenFinished(uploader) + await uploader.upload('/hello.txt', new File(['hello'], 'hello.txt')) + await finished - const observedProgress: number[] = [] - uploader.addEventListener('uploadProgress', () => { - observedProgress.push(uploader.statistics.progress) - }) - - await uploader.upload('/hello.txt', file) + expect(uploader.statistics).toEqual(defaults) + }) - // while paused the ETA stays idle, so no progress is accumulated - expect(observedProgress.every((progress) => progress === 0)).toBe(true) + it('does not track the progress of running uploads while paused', async () => { + // the first upload keeps running until the test finishes it + const { promise, resolve } = Promise.withResolvers() + vi.mocked(axios.request).mockReturnValueOnce(promise) + + const uploader = createUploader() + const finished = whenFinished(uploader) + await uploader.upload('/a.txt', new File(['x'.repeat(100)], 'a.txt')) + await vi.waitFor(() => expect(axios.request).toHaveBeenCalledOnce()) + // pausing waits for the running upload, so it can not be awaited here + const paused = uploader.pause() + // queuing another upload while paused must not resume the statistics + await uploader.upload('/b.txt', new File(['x'.repeat(100)], 'b.txt')) + + const { onUploadProgress } = vi.mocked(axios.request).mock.calls[0][0] + onUploadProgress!({ bytes: 50 } as AxiosProgressEvent) + expect(uploader.statistics.progress).toBe(0) + + resolve({} as AxiosResponse) + await paused + uploader.start() + await finished }) }) }) + +/** + * Create an uploader with a fixed destination folder. + */ +function createUploader(): Uploader { + return new Uploader(false, new Folder({ owner: 'tester', root: '/files/tester', source: root })) +} + +/** + * Get a promise that resolves once the uploader finished all of its uploads. + * It needs to be created before the upload is started, as small uploads finish immediately. + * + * @param uploader - The uploader to wait for + */ +function whenFinished(uploader: Uploader): Promise { + return new Promise((resolve) => uploader.addEventListener('finished', () => resolve(), { once: true })) +} diff --git a/package-lock.json b/package-lock.json index fbb8b7d71..45c99c37e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "@types/node": "^26.5.0", "@vitest/browser-playwright": "^5.0.0", "@vitest/coverage-istanbul": "^5.0.0", - "css.escape": "^1.5.1", "eslint": "^10.10.0", "fast-xml-parser": "^5.11.1", "tslib": "^2.8.1", @@ -5265,13 +5264,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 821d0cba1..c620962f2 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@types/node": "^26.5.0", "@vitest/browser-playwright": "^5.0.0", "@vitest/coverage-istanbul": "^5.0.0", - "css.escape": "^1.5.1", "eslint": "^10.10.0", "fast-xml-parser": "^5.11.1", "tslib": "^2.8.1",