Skip to content

Commit c6513fa

Browse files
authored
[PB-5111] Feat: upload folder command (#421)
* Feat: upload-folder command * dont return as error in facade, throw instead
1 parent 464a9bf commit c6513fa

File tree

4 files changed

+238
-21
lines changed

4 files changed

+238
-21
lines changed

src/commands/upload-folder.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Command, Flags } from '@oclif/core';
2+
import { CLIUtils } from '../utils/cli.utils';
3+
import { AuthService } from '../services/auth.service';
4+
import { ValidationService } from '../services/validation.service';
5+
import { ConfigService } from '../services/config.service';
6+
import { UploadFacade } from '../services/network/upload/upload-facade.service';
7+
8+
export default class UploadFolder extends Command {
9+
static readonly args = {};
10+
static readonly description = 'Upload a folder to Internxt Drive';
11+
static readonly aliases = ['upload:folder'];
12+
static readonly examples = ['<%= config.bin %> <%= command.id %>'];
13+
static readonly flags = {
14+
...CLIUtils.CommonFlags,
15+
folder: Flags.string({
16+
char: 'f',
17+
description: 'The path to the folder on your system.',
18+
required: true,
19+
}),
20+
destination: Flags.string({
21+
char: 'i',
22+
description: 'The folder id where the folder is going to be uploaded to. Leave empty for the root folder.',
23+
required: false,
24+
parse: CLIUtils.parseEmpty,
25+
}),
26+
};
27+
static readonly enableJsonFlag = true;
28+
29+
public run = async () => {
30+
const { user } = await AuthService.instance.getAuthDetails();
31+
const { flags } = await this.parse(UploadFolder);
32+
const doesDirectoryExist = await ValidationService.instance.validateDirectoryExists(flags['folder']);
33+
if (!doesDirectoryExist) {
34+
throw new Error(`The provided folder path is not a valid directory: ${flags['folder']}`);
35+
}
36+
37+
// If destinationFolderUuid is empty from flags&prompt, means we should use RootFolderUuid
38+
const destinationFolderUuid =
39+
(await CLIUtils.getDestinationFolderUuid({
40+
destinationFolderUuidFlag: flags['destination'],
41+
destinationFlagName: UploadFolder.flags['destination'].name,
42+
nonInteractive: flags['non-interactive'],
43+
reporter: this.log.bind(this),
44+
})) ?? user.rootFolderId;
45+
46+
const progressBar = CLIUtils.progress(
47+
{
48+
format: 'Uploading folder [{bar}] {percentage}%',
49+
linewrap: true,
50+
},
51+
flags['json'],
52+
);
53+
progressBar?.start(100, 0);
54+
const data = await UploadFacade.instance.uploadFolder({
55+
localPath: flags['folder'],
56+
destinationFolderUuid,
57+
loginUserDetails: user,
58+
jsonFlag: flags['json'],
59+
onProgress: (progress) => {
60+
progressBar?.update(progress.percentage);
61+
},
62+
});
63+
64+
progressBar?.update(100);
65+
progressBar?.stop();
66+
67+
const driveUrl = ConfigService.instance.get('DRIVE_WEB_URL');
68+
const folderUrl = `${driveUrl}/folder/${data.rootFolderId}`;
69+
const message = `Folder uploaded in ${data.uploadTimeMs}ms, view it at ${folderUrl} (${data.totalBytes} bytes)`;
70+
CLIUtils.success(this.log.bind(this), message);
71+
};
72+
73+
public catch = async (error: Error) => {
74+
const { flags } = await this.parse(UploadFolder);
75+
CLIUtils.catchError({
76+
error,
77+
command: this.id,
78+
logReporter: this.log.bind(this),
79+
jsonFlag: flags['json'],
80+
});
81+
this.exit(1);
82+
};
83+
}

src/services/network/upload/upload-facade.service.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class UploadFacade {
3333
});
3434

3535
if (folderMap.size === 0) {
36-
return { error: new Error('Failed to create folders, cannot upload files') };
36+
throw new Error('Failed to create folders, cannot upload files');
3737
}
3838
// This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446
3939
await AsyncUtils.sleep(500);
@@ -51,11 +51,9 @@ export class UploadFacade {
5151
const rootFolderName = basename(localPath);
5252
const rootFolderId = folderMap.get(rootFolderName) ?? '';
5353
return {
54-
data: {
55-
totalBytes,
56-
rootFolderId,
57-
uploadTimeMs: timer.stop(),
58-
},
54+
totalBytes,
55+
rootFolderId,
56+
uploadTimeMs: timer.stop(),
5957
};
6058
}
6159
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
2+
import UploadFolder from '../../src/commands/upload-folder';
3+
import { AuthService } from '../../src/services/auth.service';
4+
import { LoginCredentials, MissingCredentialsError } from '../../src/types/command.types';
5+
import { ValidationService } from '../../src/services/validation.service';
6+
import { UserFixture } from '../fixtures/auth.fixture';
7+
import { CLIUtils } from '../../src/utils/cli.utils';
8+
import { UploadResult } from '../../src/services/network/upload/upload.types';
9+
import { UploadFacade } from '../../src/services/network/upload/upload-facade.service';
10+
11+
vi.mock('../../src/utils/async.utils', () => ({
12+
AsyncUtils: {
13+
sleep: vi.fn().mockResolvedValue(undefined),
14+
},
15+
}));
16+
17+
describe('Upload Folder Command', () => {
18+
let getAuthDetailsSpy: MockInstance<() => Promise<LoginCredentials>>;
19+
let validateDirectoryExistsSpy: MockInstance<(path: string) => Promise<boolean>>;
20+
let getDestinationFolderUuidSpy: MockInstance<() => Promise<string | undefined>>;
21+
let UploadFacadeSpy: MockInstance<() => Promise<UploadResult>>;
22+
let cliSuccessSpy: MockInstance<() => void>;
23+
const uploadedResult: UploadResult = {
24+
totalBytes: 1024,
25+
rootFolderId: 'root-folder-id',
26+
uploadTimeMs: 1500,
27+
};
28+
beforeEach(() => {
29+
vi.restoreAllMocks();
30+
getAuthDetailsSpy = vi.spyOn(AuthService.instance, 'getAuthDetails').mockResolvedValue({
31+
user: UserFixture,
32+
token: 'mock-token',
33+
});
34+
validateDirectoryExistsSpy = vi
35+
.spyOn(ValidationService.instance, 'validateDirectoryExists')
36+
.mockResolvedValue(true);
37+
getDestinationFolderUuidSpy = vi.spyOn(CLIUtils, 'getDestinationFolderUuid').mockResolvedValue(undefined);
38+
UploadFacadeSpy = vi.spyOn(UploadFacade.instance, 'uploadFolder').mockResolvedValue(uploadedResult);
39+
cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {});
40+
});
41+
42+
it('should call UploadFacade when user uploads a folder with valid path', async () => {
43+
await UploadFolder.run(['--folder=/valid/folder/path']);
44+
45+
expect(getAuthDetailsSpy).toHaveBeenCalledOnce();
46+
expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path');
47+
expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce();
48+
expect(UploadFacadeSpy).toHaveBeenCalledWith(
49+
expect.objectContaining({
50+
localPath: '/valid/folder/path',
51+
destinationFolderUuid: UserFixture.rootFolderId,
52+
loginUserDetails: UserFixture,
53+
}),
54+
);
55+
expect(cliSuccessSpy).toHaveBeenCalledOnce();
56+
});
57+
58+
it('should use provided destination folder UUID when destination flag is passed', async () => {
59+
const customDestinationId = 'custom-folder-uuid-123';
60+
61+
const getDestinationFolderUuidSpy = vi
62+
.spyOn(CLIUtils, 'getDestinationFolderUuid')
63+
.mockResolvedValue(customDestinationId);
64+
65+
await UploadFolder.run(['--folder=/valid/folder/path', `--destination=${customDestinationId}`]);
66+
67+
expect(getAuthDetailsSpy).toHaveBeenCalledOnce();
68+
expect(validateDirectoryExistsSpy).toHaveBeenCalledWith('/valid/folder/path');
69+
expect(getDestinationFolderUuidSpy).toHaveBeenCalledOnce();
70+
expect(UploadFacadeSpy).toHaveBeenCalledWith(
71+
expect.objectContaining({
72+
destinationFolderUuid: customDestinationId,
73+
}),
74+
);
75+
expect(cliSuccessSpy).toHaveBeenCalledOnce();
76+
});
77+
78+
it('should default to user.rootFolderId when no destination is passed', async () => {
79+
await UploadFolder.run(['--folder=/valid/folder/path']);
80+
81+
expect(UploadFacadeSpy).toHaveBeenCalledWith(
82+
expect.objectContaining({
83+
destinationFolderUuid: UserFixture.rootFolderId,
84+
}),
85+
);
86+
});
87+
88+
it('should call CLIUtils.success with proper message when upload succeeds', async () => {
89+
const cliSuccessSpy = vi.spyOn(CLIUtils, 'success').mockImplementation(() => {});
90+
91+
await UploadFolder.run(['--folder=/valid/folder/path']);
92+
93+
expect(cliSuccessSpy).toHaveBeenCalledOnce();
94+
expect(cliSuccessSpy).toHaveBeenCalledWith(
95+
expect.any(Function),
96+
expect.stringContaining(`Folder uploaded in ${uploadedResult.uploadTimeMs}ms`),
97+
);
98+
expect(cliSuccessSpy).toHaveBeenCalledWith(
99+
expect.any(Function),
100+
expect.stringContaining(`folder/${uploadedResult.rootFolderId}`),
101+
);
102+
});
103+
104+
it('should throw an error when user does not provide a valid path', async () => {
105+
const validateDirectoryExistsSpy = vi
106+
.spyOn(ValidationService.instance, 'validateDirectoryExists')
107+
.mockResolvedValue(false);
108+
109+
const invalidPath = '/invalid/folder/path.txt';
110+
const result = UploadFolder.run([`--folder=${invalidPath}`]);
111+
112+
await expect(result).rejects.toMatchObject({
113+
message: expect.stringContaining('EEXIT: 1'),
114+
oclif: { exit: 1 },
115+
});
116+
117+
expect(getAuthDetailsSpy).toHaveBeenCalledOnce();
118+
expect(validateDirectoryExistsSpy).toHaveBeenCalledWith(invalidPath);
119+
expect(UploadFacadeSpy).not.toHaveBeenCalled();
120+
});
121+
122+
it('should throw an error when user does not have credentials', async () => {
123+
const getAuthDetailsSpy = vi
124+
.spyOn(AuthService.instance, 'getAuthDetails')
125+
.mockRejectedValue(new MissingCredentialsError());
126+
127+
const result = UploadFolder.run(['--folder=/some/folder/path']);
128+
await expect(result).rejects.toMatchObject({
129+
message: expect.stringContaining('EEXIT: 1'),
130+
oclif: { exit: 1 },
131+
});
132+
133+
expect(getAuthDetailsSpy).toHaveBeenCalledOnce();
134+
expect(validateDirectoryExistsSpy).not.toHaveBeenCalled();
135+
expect(UploadFacadeSpy).not.toHaveBeenCalled();
136+
});
137+
});

test/services/network/upload/upload-facade.service.test.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('UploadFacade', () => {
9595
const destinationFolderUuid = 'dest-uuid';
9696
const onProgress = vi.fn();
9797

98-
it('should properly return an error if createFolders returns an empty map', async () => {
98+
it('should throw an error if createFolders returns an empty map', async () => {
9999
vi.mocked(LocalFilesystemService.instance.scanLocalDirectory).mockResolvedValue({
100100
folders: [createFileSystemNodeFixture({ type: 'folder', name: 'test', relativePath: 'test' })],
101101
files: [],
@@ -105,16 +105,16 @@ describe('UploadFacade', () => {
105105

106106
vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(new Map());
107107

108-
const result = await sut.uploadFolder({
109-
localPath,
110-
destinationFolderUuid,
111-
loginUserDetails: mockLoginUserDetails,
112-
jsonFlag: false,
113-
onProgress,
114-
});
108+
await expect(
109+
sut.uploadFolder({
110+
localPath,
111+
destinationFolderUuid,
112+
loginUserDetails: mockLoginUserDetails,
113+
jsonFlag: false,
114+
onProgress,
115+
}),
116+
).rejects.toThrow('Failed to create folders, cannot upload files');
115117

116-
expect(result.error).toBeInstanceOf(Error);
117-
expect(result.error?.message).toBe('Failed to create folders, cannot upload files');
118118
expect(UploadFolderService.instance.createFolders).toHaveBeenCalled();
119119
expect(UploadFileService.instance.uploadFilesInChunks).not.toHaveBeenCalled();
120120
});
@@ -128,11 +128,10 @@ describe('UploadFacade', () => {
128128
onProgress,
129129
});
130130

131-
expect(result.error).toBeUndefined();
132-
expect(result.data).toBeDefined();
133-
expect(result.data?.totalBytes).toBe(500);
134-
expect(result.data?.rootFolderId).toBe('folder-uuid-123');
135-
expect(result.data?.uploadTimeMs).toBe(1000);
131+
expect(result).toBeDefined();
132+
expect(result.totalBytes).toBe(500);
133+
expect(result.rootFolderId).toBe('folder-uuid-123');
134+
expect(result.uploadTimeMs).toBe(1000);
136135
expect(UploadFolderService.instance.createFolders).toHaveBeenCalled();
137136
expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled();
138137
expect(logger.info).toHaveBeenCalledWith(`Scanned folder ${localPath}: found 2 items, total size 500 bytes.`);

0 commit comments

Comments
 (0)