20029: Add collection batch file delete/copy/move and unit tests
authorStephen Smith <stephen@curii.com>
Wed, 15 Mar 2023 20:19:49 +0000 (16:19 -0400)
committerStephen Smith <stephen@curii.com>
Wed, 15 Mar 2023 20:19:49 +0000 (16:19 -0400)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

src/common/webdav.ts
src/services/collection-service/collection-service.test.ts
src/services/collection-service/collection-service.ts

index bb8a68bdd221e1733d42f0f64b8c61e434863304..c95d8747e5b495d45ca8400a348fb4cb89e1bfa7 100644 (file)
@@ -88,6 +88,15 @@ export class WebDAV {
             method: 'DELETE'
         })
 
+    mkdir = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'MKCOL',
+            headers: {
+                ...config.headers,
+            }
+        })
+
     private request = (config: RequestConfig) => {
         return new Promise<XMLHttpRequest>((resolve, reject) => {
             const r = this.createRequest();
index 4be17213cdeb6e161cf58d867c020e65d92ba8ca..4649437ab7769711a76c230c65c3b4e9ff6781aa 100644 (file)
@@ -23,10 +23,12 @@ describe('collection-service', () => {
         webdavClient = {
             delete: jest.fn(),
             upload: jest.fn(),
+            mkdir: jest.fn(),
         } as any;
         authService = {} as AuthService;
         actions = {
             progressFn: jest.fn(),
+            errorFn: jest.fn(),
         } as any;
         collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
         collectionService.update = jest.fn();
@@ -165,4 +167,165 @@ describe('collection-service', () => {
             expect(webdavClient.delete).toHaveBeenCalledWith("c=zzzzz-tpzed-5o5tg0l9a57gxxx/root/1");
         });
     });
+
+    describe('batch file operations', () => {
+        it('should batch remove files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+
+            // when
+            await collectionService.batchFileDelete(collectionUUID, filePaths);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        '/root/1': '',
+                        '/secondFile': '',
+                        '/barefile.txt': '',
+                    },
+                }
+            );
+        });
+
+        it('should batch copy files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            // const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const collectionPDH = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.batchFileCopy(collectionPDH, filePaths, destinationUuid, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/1`]: `${collectionPDH}/root/1`,
+                        [`${destinationPath}/secondFile`]: `${collectionPDH}/secondFile`,
+                        [`${destinationPath}/barefile.txt`]: `${collectionPDH}/barefile.txt`,
+                    },
+                }
+            );
+        });
+
+        it('should batch move files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPDH = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.batchFileMove(srcCollectionUUID, srcCollectionPDH, filePaths, destinationUuid, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(2);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPDH}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPDH}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPDH}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPDH}/barefile.txt`,
+                    },
+                }
+            );
+            // Verify delete
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${srcCollectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        "/rootFile": "",
+                        "/secondFile": "",
+                        "/subpath/subfile": "",
+                        "/barefile.txt": "",
+                    },
+                }
+            );
+        });
+
+        it('should abort batch move when copy fails', async () => {
+            // Simulate failure to copy
+            serverApi.put = jest.fn(() => Promise.reject({
+                data: {},
+                response: {
+                    "errors": ["error getting snapshot of \"rootFile\" from \"8cd9ce1dfa21c635b620b1bfee7aaa08+180\": file does not exist"]
+                }
+            }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPDH = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            try {
+                await collectionService.batchFileMove(srcCollectionUUID, srcCollectionPDH, filePaths, destinationUuid, destinationPath);
+            } catch {}
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPDH}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPDH}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPDH}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPDH}/barefile.txt`,
+                    },
+                }
+            );
+        });
+    });
+
+    describe('createDirectory', () => {
+        it('creates empty directory', async () => {
+            // given
+            const directoryNames = {
+                'newDir': 'newDir',
+                '/fooDir': 'fooDir',
+                '/anotherPath/': 'anotherPath',
+                'trailingSlash/': 'trailingSlash',
+            };
+            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+            Object.keys(directoryNames).map(async (path) => {
+                // when
+                await collectionService.createDirectory(collectionUUID, path);
+                // then
+                expect(webdavClient.mkdir).toHaveBeenCalledWith(`c=${collectionUUID}/${directoryNames[path]}`);
+            });
+        });
+    });
+
 });
index 1a03d8da3a1f536108b3707239714ec1eea73785..77ad5d385f199ecae7c7c9290b0d6cb03f0482b5 100644 (file)
@@ -12,6 +12,7 @@ import { TrashableResourceService } from "services/common-service/trashable-reso
 import { ApiActions } from "services/api/api-actions";
 import { customEncodeURI } from "common/url";
 import { Session } from "models/session";
+import { CommonService } from "services/common-service/common-service";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
@@ -123,4 +124,65 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         };
         return this.webdavClient.upload(fileURL, [file], requestConfig);
     }
+
+    batchFileDelete(collectionUuid: string, files: string[], showErrors?: boolean) {
+        const payload = {
+            collection: {
+                preserve_version: true
+            },
+            replace_files: files.reduce((obj, filePath) => {
+                const pathStart = filePath.startsWith('/') ? '' : '/';
+                return {
+                    ...obj,
+                    [`${pathStart}${filePath}`]: ''
+                }
+            }, {})
+        };
+
+        return CommonService.defaultResponse(
+            this.serverApi
+                .put<CollectionResource>(`/${this.resourceType}/${collectionUuid}`, payload),
+            this.actions,
+            true, // mapKeys
+            showErrors
+        );
+    }
+
+    batchFileCopy(sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationCollectionPath: string, showErrors?: boolean) {
+        const pathStart = destinationCollectionPath.startsWith('/') ? '' : '/';
+        const separator = destinationCollectionPath.endsWith('/') ? '' : '/';
+        const destinationPath = `${pathStart}${destinationCollectionPath}${separator}`;
+        const payload = {
+            collection: {
+                preserve_version: true
+            },
+            replace_files: files.reduce((obj, sourceFile) => {
+                const sourcePath = sourceFile.startsWith('/') ? sourceFile : `/${sourceFile}`;
+                return {
+                    ...obj,
+                    [`${destinationPath}${sourceFile.split('/').slice(-1)}`]: `${sourcePdh}${sourcePath}`
+                };
+            }, {})
+        };
+
+        return CommonService.defaultResponse(
+            this.serverApi
+                .put<CollectionResource>(`/${this.resourceType}/${destinationCollectionUuid}`, payload),
+            this.actions,
+            true, // mapKeys
+            showErrors
+        );
+    }
+
+    batchFileMove(sourceUuid: string, sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) {
+        return this.batchFileCopy(sourcePdh, files, destinationCollectionUuid, destinationPath, showErrors)
+            .then(() => {
+                return this.batchFileDelete(sourceUuid, files, showErrors);
+            });
+    }
+
+    createDirectory(collectionUuid: string, path: string) {
+        return this.webdavClient.mkdir(`c=${collectionUuid}/${customEncodeURI(path)}`);
+    }
+
 }