Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
87 changes: 87 additions & 0 deletions src/commands/upload-folder.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
160 changes: 160 additions & 0 deletions test/commands/upload-folder.test.ts
Original file line number Diff line number Diff line change
@@ -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<LoginCredentials>>;
let validateDirectoryExistsSpy: MockInstance<(path: string) => Promise<boolean>>;
let getDestinationFolderUuidSpy: MockInstance<() => Promise<string | undefined>>;
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();
});
});