Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/commands/upload-folder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 = await UploadFacade.instance.uploadFolder({
localPath: flags['folder'],
destinationFolderUuid,
loginUserDetails: user,
jsonFlag: flags['json'],
onProgress: (progress) => {
progressBar?.update(progress.percentage);
},
});

progressBar?.update(100);
progressBar?.stop();

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);
};
}
10 changes: 4 additions & 6 deletions src/services/network/upload/upload-facade.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(),
};
}
}
137 changes: 137 additions & 0 deletions test/commands/upload-folder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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<LoginCredentials>>;
let validateDirectoryExistsSpy: MockInstance<(path: string) => Promise<boolean>>;
let getDestinationFolderUuidSpy: MockInstance<() => Promise<string | undefined>>;
let UploadFacadeSpy: MockInstance<() => Promise<UploadResult>>;
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(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 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();
});
});
29 changes: 14 additions & 15 deletions test/services/network/upload/upload-facade.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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();
});
Expand All @@ -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.`);
Expand Down