From 85eb7adfdcf826838b2b6152aa8e793d6920ab95 Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Thu, 17 Nov 2022 10:15:40 +0100 Subject: [PATCH] Prevent file upload when folder creation failed --- ...ix-file-upload-with-failed-folder-creation | 6 + .../src/helpers/resource/actions/upload.ts | 30 +- .../components/AppBar/CreateAndUpload.spec.js | 551 ------------------ .../components/AppBar/CreateAndUpload.spec.ts | 270 +++++++++ .../helpers/resource/actions/upload.spec.ts | 251 ++++++++ .../web-client/src/helpers/resource/types.ts | 2 +- .../web-runtime/src/components/UploadInfo.vue | 4 + .../src/composables/upload/useUpload.ts | 40 +- .../unit/composables/upload/useUpload.spec.ts | 61 +- 9 files changed, 645 insertions(+), 570 deletions(-) create mode 100644 changelog/unreleased/bugfix-file-upload-with-failed-folder-creation delete mode 100644 packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.js create mode 100644 packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts create mode 100644 packages/web-app-files/tests/unit/helpers/resource/actions/upload.spec.ts diff --git a/changelog/unreleased/bugfix-file-upload-with-failed-folder-creation b/changelog/unreleased/bugfix-file-upload-with-failed-folder-creation new file mode 100644 index 00000000000..fcee6934e31 --- /dev/null +++ b/changelog/unreleased/bugfix-file-upload-with-failed-folder-creation @@ -0,0 +1,6 @@ +Bugfix: Prevent file upload when folder creation failed + +We've fixed a bug where files would try to be uploaded if the creation of their respective folder failed beforehands. + +https://github.com/owncloud/web/pull/7975 +https://github.com/owncloud/web/issues/7957 diff --git a/packages/web-app-files/src/helpers/resource/actions/upload.ts b/packages/web-app-files/src/helpers/resource/actions/upload.ts index 146c39ea8de..fa11b02f86e 100644 --- a/packages/web-app-files/src/helpers/resource/actions/upload.ts +++ b/packages/web-app-files/src/helpers/resource/actions/upload.ts @@ -5,7 +5,8 @@ import { isShareSpaceResource, SpaceResource } from 'web-client/src/helpers' -import { UppyResource } from 'web-runtime/src/composables/upload' +import { CreateDirectoryTreeResult, UppyResource } from 'web-runtime/src/composables/upload' +import { UppyService } from 'web-runtime/src/services/uppyService' import { ConflictDialog, ResolveConflict, resolveFileNameDuplicate, ResolveStrategy } from '..' import { locationPublicLink } from '../../../router/public' import { locationSpacesGeneric } from '../../../router/spaces' @@ -15,13 +16,18 @@ export class ResourcesUpload extends ConflictDialog { private filesToUpload: File[], private currentFiles: Resource[], private inputFilesToUppyFiles: (inputFileOptions) => UppyResource[], - private $uppyService: any, + private $uppyService: UppyService, private space: SpaceResource, private currentFolder: string, private currentFolderId: string | number, private spaces: SpaceResource[], private hasSpaces: boolean, - private createDirectoryTree: any, + private createDirectoryTree: ( + space: SpaceResource, + currentPath: string, + files: UppyResource[], + currentFolderId?: string | number + ) => Promise, createModal: (modal: object) => void, hideModal: () => void, showMessage: (data: object) => void, @@ -77,9 +83,21 @@ export class ResourcesUpload extends ConflictDialog { async handleUppyFileUpload(space: SpaceResource, currentFolder: string, files: UppyResource[]) { this.$uppyService.publish('uploadStarted') - await this.createDirectoryTree(space, currentFolder, files, this.currentFolderId) - this.$uppyService.publish('addedForUpload', files) - this.$uppyService.uploadFiles(files) + const result = await this.createDirectoryTree(space, currentFolder, files, this.currentFolderId) + + let filesToUpload = files + if (result.failed.length) { + filesToUpload = files.filter( + (f) => !result.failed.some((r) => f.meta.relativeFolder.startsWith(r)) + ) + } + + if (filesToUpload.length) { + this.$uppyService.publish('addedForUpload', filesToUpload) + this.$uppyService.uploadFiles(filesToUpload) + } else { + this.$uppyService.publish('uploadCompleted', { successful: [] }) + } } async displayOverwriteDialog(files: UppyResource[], conflicts) { diff --git a/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.js b/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.js deleted file mode 100644 index 543049f0cd8..00000000000 --- a/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.js +++ /dev/null @@ -1,551 +0,0 @@ -import { shallowMount, mount, createLocalVue } from '@vue/test-utils' -import Vuex from 'vuex' -import DesignSystem from 'owncloud-design-system' -import GetTextPlugin from 'vue-gettext' - -import stubs from '@/tests/unit/stubs' -import CreateAndUpload from 'web-app-files/src/components/AppBar/CreateAndUpload' -import { createLocationSpaces } from '../../../../src/router' -import { ResolveStrategy, ResourcesUpload } from '../../../../src/helpers/resource' - -const localVue = createLocalVue() -localVue.use(Vuex) -localVue.use(DesignSystem) -localVue.use(GetTextPlugin, { - translations: 'does-not-matter.json', - silent: true -}) - -const elSelector = { - newFileButton: '#new-file-menu-btn', - newFileDrop: 'oc-drop-stub #new-file-menu-drop', - newFileMenuList: '#create-list > li', - uploadButton: '#upload-menu-btn', - uploadDrop: 'oc-drop-stub #upload-menu-drop', - uploadMenuList: '#upload-list > li', - fileUpload: 'resource-upload-stub', - folderUpload: 'resource-upload-stub', - newFolderBtn: '#new-folder-btn', - newTextFileBtn: '.new-file-btn-txt', - newMdFileBtn: '.new-file-btn-md', - newDrawioFileBtn: '.new-file-btn-drawio' -} - -const personalHomeLocation = createLocationSpaces('files-spaces-generic') - -const newFileHandlers = [ - { - ext: 'txt', - action: { - app: 'text-editor', - newTab: false, - extension: 'txt' - }, - menuTitle: () => 'Plain text file' - }, - { - ext: 'md', - action: { - app: 'text-editor', - newTab: false, - extension: 'md' - }, - menuTitle: () => 'Mark-down file' - }, - { - ext: 'drawio', - action: { - app: 'draw-io', - newTab: true, - routeName: 'draw-io-edit', - extension: 'drawio' - }, - menuTitle: () => 'Draw.io document' - } -] - -const currentFolder = { - path: '/', - canUpload: jest.fn(() => true), - canCreate: jest.fn(() => true), - canBeDeleted: jest.fn(() => true) -} - -describe('CreateAndUpload component', () => { - afterEach(() => { - jest.clearAllMocks() - }) - - const route = { - name: personalHomeLocation.name, - params: { - driveAliasAndItem: 'personal/einstein' - } - } - - let wrapper - - const spyShowCreateResourceModal = jest - .spyOn(CreateAndUpload.methods, 'showCreateResourceModal') - .mockImplementation() - - beforeEach(() => { - const store = createStore({ currentFolder }) - wrapper = getShallowWrapper(route, store) - }) - - it('should show default file menu items', () => { - const fileUpload = wrapper.find(elSelector.fileUpload) - const folderUpload = wrapper.find(elSelector.folderUpload) - const newFolderBtn = wrapper.find(elSelector.newFolderBtn) - - expect(fileUpload.exists()).toBeTruthy() - expect(folderUpload.exists()).toBeTruthy() - expect(newFolderBtn.exists()).toBeTruthy() - }) - - describe('no file handlers available', () => { - it('should show the create folder button standalone (no dropdown)', () => { - const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) - expect(newFileMenuList.exists()).toBeFalsy() - }) - }) - - describe('some file handlers available', () => { - it('should show the create folder button in a dropdown', () => { - const store = createStore({ currentFolder }, newFileHandlers) - wrapper = getShallowWrapper(route, store) - const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) - expect(newFileMenuList.exists()).toBeTruthy() - }) - - it('should show extra file menu items for file handlers', () => { - const store = createStore({ currentFolder }, newFileHandlers) - wrapper = getShallowWrapper(route, store) - const newTextFileBtn = wrapper.find(elSelector.newTextFileBtn) - const newMdFileBtn = wrapper.find(elSelector.newMdFileBtn) - const newDrawioFileBtn = wrapper.find(elSelector.newDrawioFileBtn) - const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) - - expect(newTextFileBtn.exists()).toBeTruthy() - expect(newMdFileBtn.exists()).toBeTruthy() - expect(newDrawioFileBtn.exists()).toBeTruthy() - expect(newFileMenuList.length).toBe(newFileHandlers.length + 1) // + 1 for "create folder" - }) - }) - - describe('button triggers', () => { - it('should trigger "showCreateResourceModal" if new-folder button is clicked', async () => { - const store = createStore({ currentFolder }, newFileHandlers) - wrapper = getWrapper(route, store) - - const newFolderBtn = wrapper.find(elSelector.newFolderBtn) - expect(newFolderBtn.exists()).toBeTruthy() - await newFolderBtn.trigger('click') - - expect(spyShowCreateResourceModal).toHaveBeenCalled() - }) - it.each(newFileHandlers)( - 'should trigger "showCreateResourceModal" if new file button is clicked', - async (fileHandler) => { - const store = createStore({ currentFolder }, newFileHandlers) - wrapper = getWrapper(route, store) - - const button = wrapper.find(getFileHandlerSelector(fileHandler.ext)) - expect(button.exists()).toBeTruthy() - await button.trigger('click') - - expect(spyShowCreateResourceModal).toHaveBeenCalled() - expect(spyShowCreateResourceModal).toHaveBeenCalledWith( - false, - fileHandler.ext, - fileHandler.action - ) - } - ) - }) - - describe('method "checkQuotaExceeded"', () => { - it('should be true if space quota exceeded', () => { - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage') - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - null, - null, - null, - null, - wrapper.vm.spaces, - true, - jest.fn(), - jest.fn(), - jest.fn(), - showMessageStub, - jest.fn(), - jest.fn(), - jest.fn() - ) - expect( - resourcesUpload.checkQuotaExceeded([ - { - data: { - size: 1001 - }, - meta: { - spaceId: '1', - routeName: 'files-spaces-generic' - } - } - ]) - ).toBeTruthy() - expect(showMessageStub).toHaveBeenCalledTimes(1) - }) - - it('should be false if space quota not exceeded', () => { - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage') - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - null, - wrapper.vm.spaces, - null, - null, - null, - showMessageStub, - jest.fn(), - jest.fn(), - jest.fn() - ) - expect( - resourcesUpload.checkQuotaExceeded([ - { - data: { - size: 999 - }, - meta: { - route: { - params: { - storage: 'home' - } - } - } - } - ]) - ).toBeFalsy() - expect(showMessageStub).toHaveBeenCalledTimes(0) - }) - }) - describe('upload conflict dialog', () => { - it.each([ResolveStrategy.REPLACE, ResolveStrategy.KEEP_BOTH])( - 'should upload file if user chooses replace or keep both', - async (strategy) => { - const uppyResourceOne = { - name: 'test', - meta: { - relativeFolder: '' - } - } - const conflict = { - name: uppyResourceOne.name, - type: 'file' - } - - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const handleUppyFileUpload = jest.fn() - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - null, - wrapper.vm.spaces, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ) - resourcesUpload.handleUppyFileUpload = handleUppyFileUpload - const resolveFileConflictMethod = jest.fn(() => - Promise.resolve({ strategy, doForAllConflicts: true }) - ) - resourcesUpload.resolveFileExists = resolveFileConflictMethod - - await resourcesUpload.displayOverwriteDialog([uppyResourceOne], [conflict]) - - expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) - expect(handleUppyFileUpload).toHaveBeenCalledTimes(1) - expect(handleUppyFileUpload).toHaveBeenCalledWith(expect.anything(), expect.anything(), [ - uppyResourceOne - ]) - } - ) - it('should not upload file if user chooses skip', async () => { - const uppyResourceOne = { name: 'test' } - const conflict = { - name: uppyResourceOne.name, - type: 'file' - } - - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - { - clearInputs: jest.fn() - }, - wrapper.vm.spaces, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ) - resourcesUpload.handleUppyFileUpload = jest.fn() - const resolveFileConflictMethod = jest.fn(() => - Promise.resolve({ strategy: ResolveStrategy.SKIP, doForAllConflicts: true }) - ) - resourcesUpload.resolveFileExists = resolveFileConflictMethod - - await resourcesUpload.displayOverwriteDialog([uppyResourceOne], [conflict]) - - expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) - expect(resourcesUpload.handleUppyFileUpload).not.toHaveBeenCalled() - }) - it('should show dialog once if do for all conflicts is ticked', async () => { - const uppyResourceOne = { name: 'test' } - const uppyResourceTwo = { name: 'test2' } - const conflictOne = { - name: uppyResourceOne.name, - type: 'file' - } - const conflictTwo = { - name: uppyResourceTwo.name, - type: 'file' - } - - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - null, - wrapper.vm.spaces, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ) - resourcesUpload.handleUppyFileUpload = jest.fn() - const resolveFileConflictMethod = jest.fn(() => - Promise.resolve({ strategy: ResolveStrategy.REPLACE, doForAllConflicts: true }) - ) - resourcesUpload.resolveFileExists = resolveFileConflictMethod - - await resourcesUpload.displayOverwriteDialog( - [uppyResourceOne, uppyResourceTwo], - [conflictOne, conflictTwo] - ) - - expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) - expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledTimes(1) - expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - [uppyResourceOne, uppyResourceTwo] - ) - }) - it('should show dialog twice if do for all conflicts is ticked and folders and files are uploaded', async () => { - const uppyResourceOne = { name: 'test' } - const uppyResourceTwo = { name: 'folder' } - const conflictOne = { - name: uppyResourceOne.name, - type: 'file', - meta: { relativeFolder: '/' } - } - const conflictTwo = { - name: uppyResourceTwo.name, - type: 'folder' - } - - const store = createStore({ currentFolder }, newFileHandlers) - const wrapper = getShallowWrapper(route, store) - const resourcesUpload = new ResourcesUpload( - [], - [], - [], - null, - wrapper.vm.spaces, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ) - resourcesUpload.handleUppyFileUpload = jest.fn() - resourcesUpload.resolveFileExists = jest.fn(() => - Promise.resolve({ strategy: ResolveStrategy.REPLACE, doForAllConflicts: true }) - ) - - await resourcesUpload.displayOverwriteDialog( - [uppyResourceOne, uppyResourceTwo], - [conflictOne, conflictTwo] - ) - - expect(resourcesUpload.resolveFileExists).toHaveBeenCalledTimes(2) - expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledTimes(1) - expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - [uppyResourceOne, uppyResourceTwo] - ) - }) - }) -}) - -function getFileHandlerSelector(extension) { - const ext = extension.toLowerCase() - if (ext === 'txt') { - return elSelector.newTextFileBtn - } else if (ext === 'md') { - return elSelector.newMdFileBtn - } else if (ext === 'drawio') { - return elSelector.newDrawioFileBtn - } - return null -} - -// TODO: port to ts and simply use mockDeep() -const mockUppyService = () => ({ - subscribe: jest.fn(), - useXhr: jest.fn(), - useDropTarget: jest.fn() -}) - -function getWrapper(route = {}, store = {}) { - return mount(CreateAndUpload, { - localVue, - mocks: { - $route: route, - $router: { - currentRoute: route, - resolve: (r) => { - return { href: r.name } - }, - afterEach: jest.fn() - }, - isUserContext: jest.fn(() => false), - hasSpaces: true, - $uppyService: mockUppyService() - }, - propsData: { - currentPath: '' - }, - props: { - space: {} - }, - stubs: { - ...stubs, - 'oc-button': false, - 'resource-upload': true - }, - store - }) -} - -function getShallowWrapper(route = {}, store = {}) { - return shallowMount(CreateAndUpload, { - localVue, - mocks: { - $route: route, - $router: { - currentRoute: route, - resolve: (r) => { - return { href: r.name } - }, - afterEach: jest.fn() - }, - isUserContext: jest.fn(() => false), - hasSpaces: true, - $uppyService: mockUppyService() - }, - propsData: { - currentPath: '' - }, - props: { - space: {} - }, - store - }) -} - -function createStore(state = { currentFolder: {} }, fileHandlers = []) { - return new Vuex.Store({ - actions: { - createModal: jest.fn(), - hideModal: jest.fn(), - showMessage: jest.fn() - }, - getters: { - user: function () { - return { id: 'alice' } - }, - newFileHandlers: jest.fn(() => fileHandlers) - }, - modules: { - runtime: { - namespaced: true, - modules: { - spaces: { - namespaced: true, - getters: { - spaces: () => [ - { - id: '1', - name: 'admin', - driveType: 'personal', - spaceQuota: { - remaining: 1000 - } - } - ] - } - } - } - }, - Files: { - namespaced: true, - state: { - currentFolder: { - path: '/' - }, - ...state - }, - getters: { - currentFolder: () => state.currentFolder, - clipboardResources: () => [], - selectedFiles: () => [], - files: () => [] - } - } - } - }) -} diff --git a/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts b/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts new file mode 100644 index 00000000000..c000cf28462 --- /dev/null +++ b/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts @@ -0,0 +1,270 @@ +import { shallowMount, mount, createLocalVue } from '@vue/test-utils' +import Vuex, { Store } from 'vuex' +import DesignSystem from 'owncloud-design-system' +import GetTextPlugin from 'vue-gettext' +import CreateAndUpload from 'web-app-files/src/components/AppBar/CreateAndUpload.vue' +import { createLocationSpaces } from '../../../../src/router' +import { defaultStubs } from 'web-test-helpers/src/mocks/defaultStubs' +import { mockDeep } from 'jest-mock-extended' +import { UppyService } from 'web-runtime/src/services/uppyService' + +const localVue = createLocalVue() +localVue.use(Vuex) +localVue.use(DesignSystem) +localVue.use(GetTextPlugin, { + translations: 'does-not-matter.json', + silent: true +}) + +const elSelector = { + newFileButton: '#new-file-menu-btn', + newFileDrop: 'oc-drop-stub #new-file-menu-drop', + newFileMenuList: '#create-list > li', + uploadButton: '#upload-menu-btn', + uploadDrop: 'oc-drop-stub #upload-menu-drop', + uploadMenuList: '#upload-list > li', + fileUpload: 'resource-upload-stub', + folderUpload: 'resource-upload-stub', + newFolderBtn: '#new-folder-btn', + newTextFileBtn: '.new-file-btn-txt', + newMdFileBtn: '.new-file-btn-md', + newDrawioFileBtn: '.new-file-btn-drawio' +} + +const personalHomeLocation = createLocationSpaces('files-spaces-generic') + +const newFileHandlers = [ + { + ext: 'txt', + action: { + app: 'text-editor', + newTab: false, + extension: 'txt' + }, + menuTitle: () => 'Plain text file' + }, + { + ext: 'md', + action: { + app: 'text-editor', + newTab: false, + extension: 'md' + }, + menuTitle: () => 'Mark-down file' + }, + { + ext: 'drawio', + action: { + app: 'draw-io', + newTab: true, + routeName: 'draw-io-edit', + extension: 'drawio' + }, + menuTitle: () => 'Draw.io document' + } +] + +const currentFolder = { + path: '/', + canUpload: jest.fn(() => true), + canCreate: jest.fn(() => true), + canBeDeleted: jest.fn(() => true) +} + +describe('CreateAndUpload component', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const route = { + name: personalHomeLocation.name, + params: { + driveAliasAndItem: 'personal/einstein' + } + } + + let wrapper + + const spyShowCreateResourceModal = jest + .spyOn((CreateAndUpload as any).methods, 'showCreateResourceModal') + .mockImplementation() + + beforeEach(() => { + const store = createStore({ currentFolder }) + wrapper = getShallowWrapper(route, store) + }) + + it('should show default file menu items', () => { + const fileUpload = wrapper.find(elSelector.fileUpload) + const folderUpload = wrapper.find(elSelector.folderUpload) + const newFolderBtn = wrapper.find(elSelector.newFolderBtn) + + expect(fileUpload.exists()).toBeTruthy() + expect(folderUpload.exists()).toBeTruthy() + expect(newFolderBtn.exists()).toBeTruthy() + }) + + describe('no file handlers available', () => { + it('should show the create folder button standalone (no dropdown)', () => { + const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) + expect(newFileMenuList.exists()).toBeFalsy() + }) + }) + + describe('some file handlers available', () => { + it('should show the create folder button in a dropdown', () => { + const store = createStore({ currentFolder }, newFileHandlers) + wrapper = getShallowWrapper(route, store) + const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) + expect(newFileMenuList.exists()).toBeTruthy() + }) + + it('should show extra file menu items for file handlers', () => { + const store = createStore({ currentFolder }, newFileHandlers) + wrapper = getShallowWrapper(route, store) + const newTextFileBtn = wrapper.find(elSelector.newTextFileBtn) + const newMdFileBtn = wrapper.find(elSelector.newMdFileBtn) + const newDrawioFileBtn = wrapper.find(elSelector.newDrawioFileBtn) + const newFileMenuList = wrapper.findAll(elSelector.newFileMenuList) + + expect(newTextFileBtn.exists()).toBeTruthy() + expect(newMdFileBtn.exists()).toBeTruthy() + expect(newDrawioFileBtn.exists()).toBeTruthy() + expect(newFileMenuList.length).toBe(newFileHandlers.length + 1) // + 1 for "create folder" + }) + }) + + describe('button triggers', () => { + it('should trigger "showCreateResourceModal" if new-folder button is clicked', async () => { + const store = createStore({ currentFolder }, newFileHandlers) + wrapper = getWrapper(route, store) + + const newFolderBtn = wrapper.find(elSelector.newFolderBtn) + expect(newFolderBtn.exists()).toBeTruthy() + await newFolderBtn.trigger('click') + + expect(spyShowCreateResourceModal).toHaveBeenCalled() + }) + it.each(newFileHandlers)( + 'should trigger "showCreateResourceModal" if new file button is clicked', + async (fileHandler) => { + const store = createStore({ currentFolder }, newFileHandlers) + wrapper = getWrapper(route, store) + + const button = wrapper.find(getFileHandlerSelector(fileHandler.ext)) + expect(button.exists()).toBeTruthy() + await button.trigger('click') + + expect(spyShowCreateResourceModal).toHaveBeenCalled() + expect(spyShowCreateResourceModal).toHaveBeenCalledWith( + false, + fileHandler.ext, + fileHandler.action + ) + } + ) + }) +}) + +function getFileHandlerSelector(extension) { + const ext = extension.toLowerCase() + if (ext === 'txt') { + return elSelector.newTextFileBtn + } else if (ext === 'md') { + return elSelector.newMdFileBtn + } else if (ext === 'drawio') { + return elSelector.newDrawioFileBtn + } + return null +} + +function getWrapper(route = {}, store: Store = undefined) { + return mount(CreateAndUpload, { + localVue, + mocks: { + $route: route, + $router: { + currentRoute: route, + resolve: (r) => { + return { href: r.name } + }, + afterEach: jest.fn() + }, + isUserContext: jest.fn(() => false), + hasSpaces: true, + $uppyService: mockDeep() + }, + propsData: { + currentPath: '' + }, + props: { + space: {} + }, + stubs: { + ...defaultStubs, + 'oc-button': false, + 'resource-upload': true, + 'oc-drop': true + }, + store + }) +} + +function getShallowWrapper(route = {}, store: Store = undefined) { + return shallowMount(CreateAndUpload, { + localVue, + mocks: { + $route: route, + $router: { + currentRoute: route, + resolve: (r) => { + return { href: r.name } + }, + afterEach: jest.fn() + }, + isUserContext: jest.fn(() => false), + hasSpaces: true, + $uppyService: mockDeep() + }, + propsData: { + currentPath: '' + }, + props: { + space: {} + }, + store + }) +} + +function createStore(state = { currentFolder: {} }, fileHandlers = []): Store { + return new Vuex.Store({ + actions: { + createModal: jest.fn(), + hideModal: jest.fn(), + showMessage: jest.fn() + }, + getters: { + user: function () { + return { id: 'alice' } + }, + newFileHandlers: jest.fn(() => fileHandlers) + }, + modules: { + Files: { + namespaced: true, + state: { + currentFolder: { + path: '/' + }, + ...state + }, + getters: { + currentFolder: () => state.currentFolder, + clipboardResources: () => [], + selectedFiles: () => [], + files: () => [] + } + } + } + }) +} diff --git a/packages/web-app-files/tests/unit/helpers/resource/actions/upload.spec.ts b/packages/web-app-files/tests/unit/helpers/resource/actions/upload.spec.ts new file mode 100644 index 00000000000..bc30274be71 --- /dev/null +++ b/packages/web-app-files/tests/unit/helpers/resource/actions/upload.spec.ts @@ -0,0 +1,251 @@ +import { mockDeep } from 'jest-mock-extended' +import { ResolveStrategy, ResourcesUpload } from 'web-app-files/src/helpers/resource' +import { SpaceResource } from 'web-client/src/helpers' +import { CreateDirectoryTreeResult, UppyResource } from 'web-runtime/src/composables/upload' +import { UppyService } from 'web-runtime/src/services/uppyService' + +const spacesMock = [ + mockDeep({ + id: '1', + name: 'admin', + driveType: 'personal', + spaceQuota: { + remaining: 1000 + } + }) +] + +const getResourcesUploadInstance = ({ + space = mockDeep(), + currentFolder = '/', + spaces = spacesMock, + showMessage = jest.fn(), + uppyService = mockDeep(), + createDirectoryTree = jest.fn().mockImplementation(() => ({ failed: [], successful: [] })) +}: { + space?: SpaceResource + currentFolder?: string + spaces?: SpaceResource[] + showMessage?: () => void + uppyService?: UppyService + createDirectoryTree?: ( + space: SpaceResource, + currentPath: string, + files: UppyResource[], + currentFolderId?: string | number + ) => Promise +} = {}) => { + return new ResourcesUpload( + [], + [], + jest.fn(() => []), + uppyService, + space, + currentFolder, + '', + spaces, + true, + createDirectoryTree, + jest.fn(), + jest.fn(), + showMessage, + jest.fn(), + jest.fn(), + jest.fn() + ) +} + +describe('upload helper', () => { + describe('method "checkQuotaExceeded"', () => { + it('should be true if space quota exceeded', () => { + const showMessageStub = jest.fn() + const resourcesUpload = getResourcesUploadInstance({ showMessage: showMessageStub }) + + expect( + resourcesUpload.checkQuotaExceeded([ + mockDeep({ + data: { + size: 1001 + }, + meta: { + spaceId: '1', + routeName: 'files-spaces-generic' + } + }) + ]) + ).toBeTruthy() + expect(showMessageStub).toHaveBeenCalledTimes(1) + }) + + it('should be false if space quota not exceeded', () => { + const showMessageStub = jest.fn() + const resourcesUpload = getResourcesUploadInstance({ showMessage: showMessageStub }) + + expect( + resourcesUpload.checkQuotaExceeded([mockDeep({ data: { size: 999 } })]) + ).toBeFalsy() + expect(showMessageStub).toHaveBeenCalledTimes(0) + }) + }) + + describe('handleUppyFileUpload method', () => { + it('should call uploadFiles when having files to upload', async () => { + const uploadFilesStub = jest.fn() + const publishStub = jest.fn() + const uppyService = mockDeep({ + uploadFiles: uploadFilesStub, + publish: publishStub + }) + const filesToUpload = [mockDeep()] + const resourcesUpload = getResourcesUploadInstance({ uppyService }) + await resourcesUpload.handleUppyFileUpload(mockDeep(), '/', filesToUpload) + + expect(publishStub).toHaveBeenCalledWith('uploadStarted') + expect(publishStub).toHaveBeenCalledTimes(2) + expect(uploadFilesStub).toHaveBeenCalledWith(filesToUpload) + }) + it('should not call uploadFiles when having no files to upload', async () => { + const uploadFilesStub = jest.fn() + const publishStub = jest.fn() + const uppyService = mockDeep({ + uploadFiles: uploadFilesStub, + publish: publishStub + }) + const filesToUpload = [] + const resourcesUpload = getResourcesUploadInstance({ uppyService }) + await resourcesUpload.handleUppyFileUpload(mockDeep(), '/', filesToUpload) + + expect(publishStub).toHaveBeenCalledWith('uploadStarted') + expect(publishStub).toHaveBeenCalledTimes(2) + expect(uploadFilesStub).toHaveBeenCalledTimes(0) + }) + it('should filter out files of which the folder creation failed', async () => { + const createDirectoryTreeStub = jest + .fn() + .mockImplementation(() => ({ failed: ['/parent'], successful: [] })) + const uploadFilesStub = jest.fn() + const publishStub = jest.fn() + const uppyService = mockDeep({ + uploadFiles: uploadFilesStub, + publish: publishStub + }) + const filesToUpload = [mockDeep({ meta: { relativeFolder: '/parent' } })] + const resourcesUpload = getResourcesUploadInstance({ + uppyService, + createDirectoryTree: createDirectoryTreeStub + }) + await resourcesUpload.handleUppyFileUpload(mockDeep(), '/', filesToUpload) + + expect(publishStub).toHaveBeenCalledWith('uploadStarted') + expect(publishStub).toHaveBeenCalledTimes(2) + expect(uploadFilesStub).toHaveBeenCalledTimes(0) + }) + }) + + describe('upload conflict dialog', () => { + it.each([ResolveStrategy.REPLACE, ResolveStrategy.KEEP_BOTH])( + 'should upload file if user chooses replace or keep both', + async (strategy) => { + const uppyResource = mockDeep({ + name: 'test', + meta: { + relativeFolder: '' + } + }) + const conflict = { + name: uppyResource.name, + type: 'file' + } + + const handleUppyFileUpload = jest.fn() + const space = mockDeep() + const currentFolder = '/' + const resourcesUpload = getResourcesUploadInstance({ space, currentFolder }) + resourcesUpload.handleUppyFileUpload = handleUppyFileUpload + const resolveFileConflictMethod = jest.fn(() => + Promise.resolve({ strategy, doForAllConflicts: true }) + ) + resourcesUpload.resolveFileExists = resolveFileConflictMethod + + await resourcesUpload.displayOverwriteDialog([uppyResource], [conflict]) + + expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) + expect(handleUppyFileUpload).toHaveBeenCalledTimes(1) + expect(handleUppyFileUpload).toHaveBeenCalledWith(space, currentFolder, [uppyResource]) + } + ) + it('should not upload file if user chooses skip', async () => { + const uppyResource = mockDeep({ name: 'test' }) + const conflict = { name: uppyResource.name, type: 'file' } + + const resourcesUpload = getResourcesUploadInstance() + resourcesUpload.handleUppyFileUpload = jest.fn() + const resolveFileConflictMethod = jest.fn(() => + Promise.resolve({ strategy: ResolveStrategy.SKIP, doForAllConflicts: true }) + ) + resourcesUpload.resolveFileExists = resolveFileConflictMethod + + await resourcesUpload.displayOverwriteDialog([uppyResource], [conflict]) + + expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) + expect(resourcesUpload.handleUppyFileUpload).not.toHaveBeenCalled() + }) + it('should show dialog once if do for all conflicts is ticked', async () => { + const uppyResourceOne = mockDeep({ name: 'test' }) + const uppyResourceTwo = mockDeep({ name: 'test2' }) + const conflictOne = { name: uppyResourceOne.name, type: 'file' } + const conflictTwo = { name: uppyResourceTwo.name, type: 'file' } + + const space = mockDeep() + const currentFolder = '/' + const resourcesUpload = getResourcesUploadInstance({ space, currentFolder }) + resourcesUpload.handleUppyFileUpload = jest.fn() + const resolveFileConflictMethod = jest.fn(() => + Promise.resolve({ strategy: ResolveStrategy.REPLACE, doForAllConflicts: true }) + ) + resourcesUpload.resolveFileExists = resolveFileConflictMethod + + await resourcesUpload.displayOverwriteDialog( + [uppyResourceOne, uppyResourceTwo], + [conflictOne, conflictTwo] + ) + + expect(resolveFileConflictMethod).toHaveBeenCalledTimes(1) + expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledTimes(1) + expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledWith(space, currentFolder, [ + uppyResourceOne, + uppyResourceTwo + ]) + }) + it('should show dialog twice if do for all conflicts is ticked and folders and files are uploaded', async () => { + const uppyResourceOne = mockDeep({ name: 'test' }) + const uppyResourceTwo = mockDeep({ name: 'folder' }) + const conflictOne = { + name: uppyResourceOne.name, + type: 'file', + meta: { relativeFolder: '/' } + } + const conflictTwo = { name: uppyResourceTwo.name, type: 'folder' } + + const space = mockDeep() + const currentFolder = '/' + const resourcesUpload = getResourcesUploadInstance({ space, currentFolder }) + resourcesUpload.handleUppyFileUpload = jest.fn() + resourcesUpload.resolveFileExists = jest.fn(() => + Promise.resolve({ strategy: ResolveStrategy.REPLACE, doForAllConflicts: true }) + ) + + await resourcesUpload.displayOverwriteDialog( + [uppyResourceOne, uppyResourceTwo], + [conflictOne, conflictTwo] + ) + + expect(resourcesUpload.resolveFileExists).toHaveBeenCalledTimes(2) + expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledTimes(1) + expect(resourcesUpload.handleUppyFileUpload).toHaveBeenCalledWith(space, currentFolder, [ + uppyResourceOne, + uppyResourceTwo + ]) + }) + }) +}) diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index cce534a51f5..6f189b5caca 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -16,7 +16,7 @@ export interface Resource { spaceRoles?: { [k: string]: any[] } - spaceQuota?: any[] + spaceQuota?: any spaceImageData?: any spaceReadmeData?: any mimeType?: string diff --git a/packages/web-runtime/src/components/UploadInfo.vue b/packages/web-runtime/src/components/UploadInfo.vue index 577144616ce..0e642322fc3 100644 --- a/packages/web-runtime/src/components/UploadInfo.vue +++ b/packages/web-runtime/src/components/UploadInfo.vue @@ -545,6 +545,10 @@ export default defineComponent({ if (error.message.includes('response code: 507')) { errorMessage = this.$gettext('Quota exceeded') } + if (error.message.includes('precondition failed:')) { + errorMessage = this.$gettext('Unknown error') + } + return errorMessage /** diff --git a/packages/web-runtime/src/composables/upload/useUpload.ts b/packages/web-runtime/src/composables/upload/useUpload.ts index acc7162b5dc..23587062d1e 100644 --- a/packages/web-runtime/src/composables/upload/useUpload.ts +++ b/packages/web-runtime/src/composables/upload/useUpload.ts @@ -45,6 +45,11 @@ export interface UppyResource { } } +export interface CreateDirectoryTreeResult { + successful: string[] + failed: string[] +} + interface UploadOptions { uppyService: UppyService } @@ -144,9 +149,10 @@ const createDirectoryTree = ({ currentFolder: string, files: UppyResource[], currentFolderId?: string | number - ) => { + ): Promise => { const { webdav } = clientService const createdFolders = [] + const failedFolders = [] for (const file of files) { const directory = file.meta.relativeFolder @@ -168,6 +174,11 @@ const createDirectoryTree = ({ continue } + if (failedFolders.includes(folderToCreate)) { + // only care about top level folders, no need to go deeper + break + } + const uploadId = createdSubFolders ? uuid.v4() : file.meta.topLevelFolderId const uppyResource = { id: uuid.v4(), @@ -194,21 +205,28 @@ const createDirectoryTree = ({ uppyService.publish('addedForUpload', [uppyResource]) - let folder try { - folder = await webdav.createFolder(space, { path: join(currentFolder, folderToCreate) }) + const folder = await webdav.createFolder(space, { + path: join(currentFolder, folderToCreate) + }) + uppyService.publish('uploadSuccess', { + ...uppyResource, + meta: { ...uppyResource.meta, fileId: folder?.fileId } + }) + + createdSubFolders += `/${subFolder}` + createdFolders.push(createdSubFolders) } catch (error) { console.error(error) + failedFolders.push(folderToCreate) + uppyService.publish('uploadError', { file: uppyResource, error }) } - - uppyService.publish('uploadSuccess', { - ...uppyResource, - meta: { ...uppyResource.meta, fileId: folder?.fileId } - }) - - createdSubFolders += `/${subFolder}` - createdFolders.push(createdSubFolders) } } + + return { + successful: createdFolders, + failed: failedFolders + } } } diff --git a/packages/web-runtime/tests/unit/composables/upload/useUpload.spec.ts b/packages/web-runtime/tests/unit/composables/upload/useUpload.spec.ts index 90f2c799a4d..3aa8fb007b7 100644 --- a/packages/web-runtime/tests/unit/composables/upload/useUpload.spec.ts +++ b/packages/web-runtime/tests/unit/composables/upload/useUpload.spec.ts @@ -10,6 +10,15 @@ import { SpaceResource } from 'web-client/src/helpers' import { mock } from 'jest-mock-extended' describe('useUpload', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + it('should be valid', () => { expect(useUpload).toBeDefined() }) @@ -64,9 +73,59 @@ describe('useUpload', () => { } ] - await wrapper.vm.createDirectoryTree(space, currentFolder, uppyResources) + const result = await wrapper.vm.createDirectoryTree(space, currentFolder, uppyResources) + + expect(result.successful).toContain('/l1') + expect(result.successful).toContain('/l1/l2') + expect(result.successful).toContain('/l1/l2/l3') + expect(result.successful).toContain('/l1/l2/anotherFolder') + expect(result.failed.length).toBe(0) expect(mocks.$clientService.webdav.createFolder).toHaveBeenCalledTimes(4) }) + + it('should contain failed folders in the result', async () => { + const { mocks, wrapper } = createWrapper() + mocks.$clientService.webdav.createFolder.mockRejectedValue(new Error()) + + const space = mock() + const currentFolder = 'currentFolder' + const uppyResources = [ + { + source: 'source', + name: 'file1', + type: 'type', + data: new Blob(), + meta: { + currentFolder: 'currentFolder', + relativeFolder: 'l1', + relativePath: 'relativePath', + route: { name: 'files-personal' }, + tusEndpoint: 'tusEndpoint', + webDavBasePath: 'webDavBasePath' + } + }, + { + source: 'source', + name: 'file2', + type: 'type', + data: new Blob(), + meta: { + currentFolder: 'currentFolder', + relativeFolder: 'l1', + relativePath: 'relativePath', + route: { name: 'files-personal' }, + tusEndpoint: 'tusEndpoint', + webDavBasePath: 'webDavBasePath' + } + } + ] + + const result = await wrapper.vm.createDirectoryTree(space, currentFolder, uppyResources) + + expect(result.failed).toContain('/l1') + expect(result.successful.length).toBe(0) + expect(mocks.$clientService.webdav.createFolder).toHaveBeenCalledTimes(1) + }) }) const createWrapper = () => {