From d30b908c7e8a0e2fd95827331d61ef2cf518e08b Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 27 Nov 2025 17:36:16 +0100 Subject: [PATCH 1/2] Feat: upload-folder command --- src/commands/upload-folder.ts | 87 +++++++++++++++ test/commands/upload-folder.test.ts | 160 ++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 src/commands/upload-folder.ts create mode 100644 test/commands/upload-folder.test.ts diff --git a/src/commands/upload-folder.ts b/src/commands/upload-folder.ts new file mode 100644 index 00000000..53fda6c0 --- /dev/null +++ b/src/commands/upload-folder.ts @@ -0,0 +1,87 @@ +import { Command, Flags } from '@oclif/core'; +import { CLIUtils } from '../utils/cli.utils'; +import { AuthService } from '../services/auth.service'; +import { ValidationService } from '../services/validation.service'; +import { ConfigService } from '../services/config.service'; +import { UploadFacade } from '../services/network/upload/upload-facade.service'; + +export default class UploadFolder extends Command { + static readonly args = {}; + static readonly description = 'Upload a folder to Internxt Drive'; + static readonly aliases = ['upload:folder']; + static readonly examples = ['<%= config.bin %> <%= command.id %>']; + static readonly flags = { + ...CLIUtils.CommonFlags, + folder: Flags.string({ + char: 'f', + description: 'The path to the folder on your system.', + required: true, + }), + destination: Flags.string({ + char: 'i', + description: 'The folder id where the folder is going to be uploaded to. Leave empty for the root folder.', + required: false, + parse: CLIUtils.parseEmpty, + }), + }; + static readonly enableJsonFlag = true; + + public run = async () => { + const { user } = await AuthService.instance.getAuthDetails(); + const { flags } = await this.parse(UploadFolder); + const doesDirectoryExist = await ValidationService.instance.validateDirectoryExists(flags['folder']); + if (!doesDirectoryExist) { + throw new Error(`The provided folder path is not a valid directory: ${flags['folder']}`); + } + + // If destinationFolderUuid is empty from flags&prompt, means we should use RootFolderUuid + const destinationFolderUuid = + (await CLIUtils.getDestinationFolderUuid({ + destinationFolderUuidFlag: flags['destination'], + destinationFlagName: UploadFolder.flags['destination'].name, + nonInteractive: flags['non-interactive'], + reporter: this.log.bind(this), + })) ?? user.rootFolderId; + + const progressBar = CLIUtils.progress( + { + format: 'Uploading folder [{bar}] {percentage}%', + linewrap: true, + }, + flags['json'], + ); + progressBar?.start(100, 0); + const { data, error } = await UploadFacade.instance.uploadFolder({ + localPath: flags['folder'], + destinationFolderUuid, + loginUserDetails: user, + jsonFlag: flags['json'], + onProgress: (progress) => { + progressBar?.update(progress.percentage); + }, + }); + + progressBar?.update(100); + progressBar?.stop(); + + if (error) { + throw error; + } + + const driveUrl = ConfigService.instance.get('DRIVE_WEB_URL'); + const folderUrl = `${driveUrl}/folder/${data.rootFolderId}`; + const message = `Folder uploaded in ${data.uploadTimeMs}ms, view it at ${folderUrl} (${data.totalBytes} bytes)`; + CLIUtils.success(this.log.bind(this), message); + }; + + public catch = async (error: Error) => { + const { flags } = await this.parse(UploadFolder); + CLIUtils.catchError({ + error, + command: this.id, + logReporter: this.log.bind(this), + jsonFlag: flags['json'], + }); + this.exit(1); + }; +} diff --git a/test/commands/upload-folder.test.ts b/test/commands/upload-folder.test.ts new file mode 100644 index 00000000..b8c9e6be --- /dev/null +++ b/test/commands/upload-folder.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, MockInstance, vi } from 'vitest'; +import UploadFolder from '../../src/commands/upload-folder'; +import { AuthService } from '../../src/services/auth.service'; +import { LoginCredentials, MissingCredentialsError } from '../../src/types/command.types'; +import { ValidationService } from '../../src/services/validation.service'; +import { UserFixture } from '../fixtures/auth.fixture'; +import { CLIUtils } from '../../src/utils/cli.utils'; +import { UploadResult } from '../../src/services/network/upload/upload.types'; +import { UploadFacade } from '../../src/services/network/upload/upload-facade.service'; + +vi.mock('../../src/utils/async.utils', () => ({ + AsyncUtils: { + sleep: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe('Upload Folder Command', () => { + let getAuthDetailsSpy: MockInstance<() => Promise>; + let validateDirectoryExistsSpy: MockInstance<(path: string) => Promise>; + let getDestinationFolderUuidSpy: MockInstance<() => Promise>; + let UploadFacadeSpy: MockInstance< + () => Promise<{ data: UploadResult; error?: undefined } | { error: Error; data?: undefined }> + >; + let cliSuccessSpy: MockInstance<() => void>; + const uploadedResult: UploadResult = { + totalBytes: 1024, + rootFolderId: 'root-folder-id', + uploadTimeMs: 1500, + }; + beforeEach(() => { + vi.restoreAllMocks(); + getAuthDetailsSpy = vi.spyOn(AuthService.instance, 'getAuthDetails').mockResolvedValue({ + user: UserFixture, + token: 'mock-token', + }); + validateDirectoryExistsSpy = vi + .spyOn(ValidationService.instance, 'validateDirectoryExists') + .mockResolvedValue(true); + getDestinationFolderUuidSpy = vi.spyOn(CLIUtils, 'getDestinationFolderUuid').mockResolvedValue(undefined); + UploadFacadeSpy = vi.spyOn(UploadFacade.instance, 'uploadFolder').mockResolvedValue({ + data: uploadedResult, + }); + cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {}); + }); + + it('should call UploadFacade when user uploads a folder with valid path', async () => { + await UploadFolder.run(['--folder=/valid/folder/path']); + + expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); + expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path'); + expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce(); + expect(UploadFacadeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + localPath: '/valid/folder/path', + destinationFolderUuid: UserFixture.rootFolderId, + loginUserDetails: UserFixture, + }), + ); + expect(cliSuccessSpy).toHaveBeenCalledOnce(); + }); + + it('should use provided destination folder UUID when destination flag is passed', async () => { + const customDestinationId = 'custom-folder-uuid-123'; + + const getDestinationFolderUuidSpy = vi + .spyOn(CLIUtils, 'getDestinationFolderUuid') + .mockResolvedValue(customDestinationId); + + await UploadFolder.run(['--folder=/valid/folder/path', `--destination=${customDestinationId}`]); + + expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); + expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path'); + expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce(); + expect(UploadFacadeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + destinationFolderUuid: customDestinationId, + }), + ); + expect(cliSuccessSpy).toHaveBeenCalledOnce(); + }); + + it('should default to user.rootFolderId when no destination is passed', async () => { + await UploadFolder.run(['--folder=/valid/folder/path']); + + expect(UploadFacadeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + destinationFolderUuid: UserFixture.rootFolderId, + }), + ); + }); + + it('should rethrow any error returned by UploadFacade.uploadFolder', async () => { + const uploadError = new Error('Unhandled upload error'); + UploadFacadeSpy.mockResolvedValue({ + error: uploadError, + }); + + const result = UploadFolder.run(['--folder=/valid/folder/path']); + + await expect(result).rejects.toMatchObject({ + message: expect.stringContaining('EEXIT: 1'), + oclif: { exit: 1 }, + }); + + expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); + expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path'); + expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce(); + expect(UploadFacadeSpy).toHaveBeenCalledOnce(); + }); + + it('should call CLIUtils.success with proper message when upload succeeds', async () => { + const cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {}); + + await UploadFolder.run(['--folder=/valid/folder/path']); + + expect(cliSuccessSpy).toHaveBeenCalledOnce(); + expect(cliSuccessSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining(`Folder uploaded in ${uploadedResult.uploadTimeMs}ms`), + ); + expect(cliSuccessSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining(`folder/${uploadedResult.rootFolderId}`), + ); + }); + + it('should throw an error when user does not provide a valid path', async () => { + const validateDirectoryExistsSpy = vi + .spyOn(ValidationService.instance, 'validateDirectoryExists') + .mockResolvedValue(false); + + const invalidPath = '/invalid/folder/path.txt'; + const result = UploadFolder.run([`--folder=${invalidPath}`]); + + await expect(result).rejects.toMatchObject({ + message: expect.stringContaining('EEXIT: 1'), + oclif: { exit: 1 }, + }); + + expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); + expect(validateDirectoryExistsSpy).toHaveBeenCalledWith(invalidPath); + expect(UploadFacadeSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error when user does not have credentials', async () => { + const getAuthDetailsSpy = vi + .spyOn(AuthService.instance, 'getAuthDetails') + .mockRejectedValue(new MissingCredentialsError()); + + const result = UploadFolder.run(['--folder=/some/folder/path']); + await expect(result).rejects.toMatchObject({ + message: expect.stringContaining('EEXIT: 1'), + oclif: { exit: 1 }, + }); + + expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); + expect(validateDirectoryExistsSpy).not.toHaveBeenCalled(); + expect(UploadFacadeSpy).not.toHaveBeenCalled(); + }); +}); From 368000f6bb5ba42b18c40ec32de30d28f562b869 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 27 Nov 2025 18:40:23 +0100 Subject: [PATCH 2/2] dont return as error in facade, throw instead --- src/commands/upload-folder.ts | 6 +--- .../network/upload/upload-facade.service.ts | 10 +++---- test/commands/upload-folder.test.ts | 27 ++--------------- .../upload/upload-facade.service.test.ts | 29 +++++++++---------- 4 files changed, 21 insertions(+), 51 deletions(-) diff --git a/src/commands/upload-folder.ts b/src/commands/upload-folder.ts index 53fda6c0..cb008953 100644 --- a/src/commands/upload-folder.ts +++ b/src/commands/upload-folder.ts @@ -51,7 +51,7 @@ export default class UploadFolder extends Command { flags['json'], ); progressBar?.start(100, 0); - const { data, error } = await UploadFacade.instance.uploadFolder({ + const data = await UploadFacade.instance.uploadFolder({ localPath: flags['folder'], destinationFolderUuid, loginUserDetails: user, @@ -64,10 +64,6 @@ export default class UploadFolder extends Command { progressBar?.update(100); progressBar?.stop(); - if (error) { - throw error; - } - const driveUrl = ConfigService.instance.get('DRIVE_WEB_URL'); const folderUrl = `${driveUrl}/folder/${data.rootFolderId}`; const message = `Folder uploaded in ${data.uploadTimeMs}ms, view it at ${folderUrl} (${data.totalBytes} bytes)`; diff --git a/src/services/network/upload/upload-facade.service.ts b/src/services/network/upload/upload-facade.service.ts index 26dcfd48..25bed0dc 100644 --- a/src/services/network/upload/upload-facade.service.ts +++ b/src/services/network/upload/upload-facade.service.ts @@ -33,7 +33,7 @@ export class UploadFacade { }); if (folderMap.size === 0) { - return { error: new Error('Failed to create folders, cannot upload files') }; + throw new Error('Failed to create folders, cannot upload files'); } // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 await AsyncUtils.sleep(500); @@ -51,11 +51,9 @@ export class UploadFacade { const rootFolderName = basename(localPath); const rootFolderId = folderMap.get(rootFolderName) ?? ''; return { - data: { - totalBytes, - rootFolderId, - uploadTimeMs: timer.stop(), - }, + totalBytes, + rootFolderId, + uploadTimeMs: timer.stop(), }; } } diff --git a/test/commands/upload-folder.test.ts b/test/commands/upload-folder.test.ts index b8c9e6be..abd6af7e 100644 --- a/test/commands/upload-folder.test.ts +++ b/test/commands/upload-folder.test.ts @@ -18,9 +18,7 @@ describe('Upload Folder Command', () => { let getAuthDetailsSpy: MockInstance<() => Promise>; let validateDirectoryExistsSpy: MockInstance<(path: string) => Promise>; let getDestinationFolderUuidSpy: MockInstance<() => Promise>; - let UploadFacadeSpy: MockInstance< - () => Promise<{ data: UploadResult; error?: undefined } | { error: Error; data?: undefined }> - >; + let UploadFacadeSpy: MockInstance<() => Promise>; let cliSuccessSpy: MockInstance<() => void>; const uploadedResult: UploadResult = { totalBytes: 1024, @@ -37,9 +35,7 @@ describe('Upload Folder Command', () => { .spyOn(ValidationService.instance, 'validateDirectoryExists') .mockResolvedValue(true); getDestinationFolderUuidSpy = vi.spyOn(CLIUtils, 'getDestinationFolderUuid').mockResolvedValue(undefined); - UploadFacadeSpy = vi.spyOn(UploadFacade.instance, 'uploadFolder').mockResolvedValue({ - data: uploadedResult, - }); + UploadFacadeSpy = vi.spyOn(UploadFacade.instance, 'uploadFolder').mockResolvedValue(uploadedResult); cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {}); }); @@ -89,25 +85,6 @@ describe('Upload Folder Command', () => { ); }); - it('should rethrow any error returned by UploadFacade.uploadFolder', async () => { - const uploadError = new Error('Unhandled upload error'); - UploadFacadeSpy.mockResolvedValue({ - error: uploadError, - }); - - const result = UploadFolder.run(['--folder=/valid/folder/path']); - - await expect(result).rejects.toMatchObject({ - message: expect.stringContaining('EEXIT: 1'), - oclif: { exit: 1 }, - }); - - expect(getAuthDetailsSpy).toHaveBeenCalledOnce(); - expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path'); - expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce(); - expect(UploadFacadeSpy).toHaveBeenCalledOnce(); - }); - it('should call CLIUtils.success with proper message when upload succeeds', async () => { const cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {}); diff --git a/test/services/network/upload/upload-facade.service.test.ts b/test/services/network/upload/upload-facade.service.test.ts index 0b6f74bf..413864a8 100644 --- a/test/services/network/upload/upload-facade.service.test.ts +++ b/test/services/network/upload/upload-facade.service.test.ts @@ -95,7 +95,7 @@ describe('UploadFacade', () => { const destinationFolderUuid = 'dest-uuid'; const onProgress = vi.fn(); - it('should properly return an error if createFolders returns an empty map', async () => { + it('should throw an error if createFolders returns an empty map', async () => { vi.mocked(LocalFilesystemService.instance.scanLocalDirectory).mockResolvedValue({ folders: [createFileSystemNodeFixture({ type: 'folder', name: 'test', relativePath: 'test' })], files: [], @@ -105,16 +105,16 @@ describe('UploadFacade', () => { vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(new Map()); - const result = await sut.uploadFolder({ - localPath, - destinationFolderUuid, - loginUserDetails: mockLoginUserDetails, - jsonFlag: false, - onProgress, - }); + await expect( + sut.uploadFolder({ + localPath, + destinationFolderUuid, + loginUserDetails: mockLoginUserDetails, + jsonFlag: false, + onProgress, + }), + ).rejects.toThrow('Failed to create folders, cannot upload files'); - expect(result.error).toBeInstanceOf(Error); - expect(result.error?.message).toBe('Failed to create folders, cannot upload files'); expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); expect(UploadFileService.instance.uploadFilesInChunks).not.toHaveBeenCalled(); }); @@ -128,11 +128,10 @@ describe('UploadFacade', () => { onProgress, }); - expect(result.error).toBeUndefined(); - expect(result.data).toBeDefined(); - expect(result.data?.totalBytes).toBe(500); - expect(result.data?.rootFolderId).toBe('folder-uuid-123'); - expect(result.data?.uploadTimeMs).toBe(1000); + expect(result).toBeDefined(); + expect(result.totalBytes).toBe(500); + expect(result.rootFolderId).toBe('folder-uuid-123'); + expect(result.uploadTimeMs).toBe(1000); expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(`Scanned folder ${localPath}: found 2 items, total size 500 bytes.`);