From 4d7ebb7bfbc44bcc6da24df51b43b536ead54f87 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 24 Nov 2025 12:15:39 +0100 Subject: [PATCH 1/3] Feat: Local filesystem service + auxiliar methods for upload folder --- src/commands/upload-file.ts | 41 +---- .../local-filesystem.service.ts | 67 ++++++++ .../local-filesystem.types.ts | 14 ++ src/utils/cli.utils.ts | 40 ++++- src/utils/errors.utils.ts | 6 + .../local-filesystem.service.test.ts | 154 ++++++++++++++++++ test/utils/errors.utils.test.ts | 22 ++- 7 files changed, 310 insertions(+), 34 deletions(-) create mode 100644 src/services/local-filesystem/local-filesystem.service.ts create mode 100644 src/services/local-filesystem/local-filesystem.types.ts create mode 100644 test/services/local-filesystem/local-filesystem.service.test.ts diff --git a/src/commands/upload-file.ts b/src/commands/upload-file.ts index ac703d98..995e6402 100644 --- a/src/commands/upload-file.ts +++ b/src/commands/upload-file.ts @@ -11,7 +11,7 @@ import { DriveFileService } from '../services/drive/drive-file.service'; import { CryptoService } from '../services/crypto.service'; import { DownloadService } from '../services/network/download.service'; import { ErrorUtils } from '../utils/errors.utils'; -import { NotValidDirectoryError, NotValidFolderUuidError } from '../types/command.types'; +import { NotValidDirectoryError } from '../types/command.types'; import { ValidationService } from '../services/validation.service'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { ThumbnailService } from '../services/thumbnail.service'; @@ -58,11 +58,14 @@ export default class UploadFile extends Command { const fileInfo = path.parse(filePath); const fileType = fileInfo.ext.replaceAll('.', ''); - let destinationFolderUuid = await this.getDestinationFolderUuid(flags['destination'], nonInteractive); - if (destinationFolderUuid.trim().length === 0) { - // destinationFolderUuid is empty from flags&prompt, which means we should use RootFolderUuid - destinationFolderUuid = user.rootFolderId; - } + // If destinationFolderUuid is empty from flags&prompt, means we should use RootFolderUuid + const destinationFolderUuid = + (await CLIUtils.getDestinationFolderUuid({ + destinationFolderUuidFlag: flags['destination'], + destinationFlagName: UploadFile.flags['destination'].name, + nonInteractive, + reporter: this.log.bind(this), + })) ?? user.rootFolderId; // 1. Prepare the network CLIUtils.doing('Preparing Network', flags['json']); @@ -189,32 +192,6 @@ export default class UploadFile extends Command { this.exit(1); }; - private getDestinationFolderUuid = async ( - destinationFolderUuidFlag: string | undefined, - nonInteractive: boolean, - ): Promise => { - const destinationFolderUuid = await CLIUtils.getValueFromFlag( - { - value: destinationFolderUuidFlag, - name: UploadFile.flags['destination'].name, - }, - { - nonInteractive, - prompt: { - message: 'What is the destination folder id? (leave empty for the root folder)', - options: { type: 'input' }, - }, - }, - { - validate: ValidationService.instance.validateUUIDv4, - error: new NotValidFolderUuidError(), - canBeEmpty: true, - }, - this.log.bind(this), - ); - return destinationFolderUuid; - }; - private getFilePath = async (fileFlag: string | undefined, nonInteractive: boolean): Promise => { const filePath = await CLIUtils.getValueFromFlag( { diff --git a/src/services/local-filesystem/local-filesystem.service.ts b/src/services/local-filesystem/local-filesystem.service.ts new file mode 100644 index 00000000..dbc1ddb4 --- /dev/null +++ b/src/services/local-filesystem/local-filesystem.service.ts @@ -0,0 +1,67 @@ +import { promises } from 'fs'; +import { basename, dirname, join, relative, parse } from 'path'; +import { FileSystemNode, ScanResult } from './local-filesystem.types'; +import { logger } from '../../utils/logger.utils'; + +export class LocalFilesystemService { + static readonly instance = new LocalFilesystemService(); + + async scanLocalDirectory(path: string): Promise { + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + + const parentPath = dirname(path); + const totalBytes = await this.scanRecursive(path, parentPath, folders, files); + return { + folders, + files, + totalItems: folders.length + files.length, + totalBytes, + }; + } + async scanRecursive( + currentPath: string, + parentPath: string, + folders: FileSystemNode[], + files: FileSystemNode[], + ): Promise { + try { + const stats = await promises.stat(currentPath); + const relativePath = relative(parentPath, currentPath); + + if (stats.isFile()) { + const fileInfo = parse(currentPath); + files.push({ + type: 'file', + name: fileInfo.name, + absolutePath: currentPath, + relativePath, + size: stats.size, + }); + return stats.size; + } + + if (stats.isDirectory()) { + folders.push({ + type: 'folder', + name: basename(currentPath), + absolutePath: currentPath, + relativePath, + size: 0, + }); + const entries = await promises.readdir(currentPath, { withFileTypes: true }); + const validEntries = entries.filter((e) => !e.isSymbolicLink()); + const bytesArray = await Promise.all( + validEntries.map((e) => this.scanRecursive(join(currentPath, e.name), parentPath, folders, files)), + ); + + return bytesArray.reduce((sum, bytes) => sum + bytes, 0); + } + + return 0; + } catch (error: unknown) { + logger.warn(`Error scanning path ${currentPath}: ${(error as Error).message} - skipping...`); + return 0; + } + } +} diff --git a/src/services/local-filesystem/local-filesystem.types.ts b/src/services/local-filesystem/local-filesystem.types.ts new file mode 100644 index 00000000..01c4da67 --- /dev/null +++ b/src/services/local-filesystem/local-filesystem.types.ts @@ -0,0 +1,14 @@ +export interface FileSystemNode { + type: 'file' | 'folder'; + name: string; + size: number; + absolutePath: string; + relativePath: string; +} + +export interface ScanResult { + folders: FileSystemNode[]; + files: FileSystemNode[]; + totalItems: number; + totalBytes: number; +} diff --git a/src/utils/cli.utils.ts b/src/utils/cli.utils.ts index e3c1cfb8..cc18d9d4 100644 --- a/src/utils/cli.utils.ts +++ b/src/utils/cli.utils.ts @@ -1,9 +1,10 @@ import { ux, Flags } from '@oclif/core'; import cliProgress from 'cli-progress'; import Table, { Header } from 'tty-table'; -import { PromptOptions } from '../types/command.types'; +import { NotValidFolderUuidError, PromptOptions } from '../types/command.types'; import { InquirerUtils } from './inquirer.utils'; import { ErrorUtils } from './errors.utils'; +import { ValidationService } from '../services/validation.service'; export class CLIUtils { static readonly clearPreviousLine = (jsonFlag?: boolean) => { @@ -125,6 +126,43 @@ export class CLIUtils { } }; + static readonly getDestinationFolderUuid = async ({ + destinationFolderUuidFlag, + destinationFlagName, + nonInteractive, + reporter, + }: { + destinationFolderUuidFlag: string | undefined; + destinationFlagName: string; + nonInteractive: boolean; + reporter: (message: string) => void; + }): Promise => { + const destinationFolderUuid = await this.getValueFromFlag( + { + value: destinationFolderUuidFlag, + name: destinationFlagName, + }, + { + nonInteractive, + prompt: { + message: 'What is the destination folder id? (leave empty for the root folder)', + options: { type: 'input' }, + }, + }, + { + validate: ValidationService.instance.validateUUIDv4, + error: new NotValidFolderUuidError(), + canBeEmpty: true, + }, + reporter, + ); + if (destinationFolderUuid.trim().length === 0) { + return undefined; + } else { + return destinationFolderUuid; + } + }; + private static readonly promptWithAttempts = async ( prompt: { message: string; options: PromptOptions }, maxAttempts: number, diff --git a/src/utils/errors.utils.ts b/src/utils/errors.utils.ts index be84edfd..b58ddbdc 100644 --- a/src/utils/errors.utils.ts +++ b/src/utils/errors.utils.ts @@ -5,6 +5,12 @@ export function isError(error: unknown): error is Error { return types.isNativeError(error); } +export function isAlreadyExistsError(error: unknown): error is Error { + return ( + (isError(error) && error.message.includes('already exists')) || + (typeof error === 'object' && error !== null && 'status' in error && error.status === 409) + ); +} export class ErrorUtils { static report(error: unknown, props: Record = {}) { if (isError(error)) { diff --git a/test/services/local-filesystem/local-filesystem.service.test.ts b/test/services/local-filesystem/local-filesystem.service.test.ts new file mode 100644 index 00000000..290cec02 --- /dev/null +++ b/test/services/local-filesystem/local-filesystem.service.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { LocalFilesystemService } from '../../../src/services/local-filesystem/local-filesystem.service'; +import { Dirent, promises, Stats } from 'fs'; +import { logger } from '../../../src/utils/logger.utils'; +import { FileSystemNode } from '../../../src/services/local-filesystem/local-filesystem.types'; + +vi.mock('fs', () => ({ + promises: { + stat: vi.fn(), + readdir: vi.fn(), + }, +})); + +vi.mock('../../../src/utils/logger.utils', () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +describe('Local Filesystem Service', () => { + let service: LocalFilesystemService; + const mockStat = vi.mocked(promises.stat); + const mockReaddir = vi.mocked(promises.readdir) as unknown as MockedFunction<() => Promise[]>>; + + const createMockStats = (isFile: boolean, size: number): Stats => + ({ + isFile: () => isFile, + isDirectory: () => !isFile, + size, + }) as Stats; + + const createMockDirent = (name: string, isSymlink = false) => + ({ + name, + isSymbolicLink: () => isSymlink, + isFile: () => false, + isDirectory: () => false, + }) as unknown as Dirent; + + beforeEach(() => { + service = LocalFilesystemService.instance; + vi.clearAllMocks(); + mockReaddir.mockResolvedValue([]); + }); + + describe('scanRecursive', () => { + it('should handle a single file', async () => { + mockStat.mockResolvedValue(createMockStats(true, 100)); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + const bytes = await service.scanRecursive('/path/file.txt', '/path', folders, files); + + expect(bytes).toBe(100); + expect(files).toHaveLength(1); + expect(folders).toHaveLength(0); + }); + + it('should handle an empty directory', async () => { + mockStat.mockResolvedValue(createMockStats(false, 0)); + mockReaddir.mockResolvedValue([]); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + const bytes = await service.scanRecursive('/path/folder', '/path', folders, files); + + expect(bytes).toBe(0); + expect(folders).toHaveLength(1); + expect(folders[0]).toMatchObject({ + type: 'folder', + name: 'folder', + }); + expect(files).toHaveLength(0); + }); + + it('should handle a directory with files', async () => { + mockStat + .mockResolvedValueOnce(createMockStats(false, 0)) + .mockResolvedValueOnce(createMockStats(true, 50)) + .mockResolvedValueOnce(createMockStats(true, 75)); + + mockReaddir.mockResolvedValueOnce([createMockDirent('file1.txt', false), createMockDirent('file2.txt', false)]); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + const bytes = await service.scanRecursive('/path/folder', '/path', folders, files); + + expect(bytes).toBe(125); + expect(folders).toHaveLength(1); + expect(files).toHaveLength(2); + }); + + it('should skip symbolic links', async () => { + mockStat.mockResolvedValueOnce(createMockStats(false, 0)).mockResolvedValueOnce(createMockStats(true, 100)); + mockReaddir.mockResolvedValueOnce([createMockDirent('symlink', true), createMockDirent('file.txt', false)]); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + await service.scanRecursive('/path/folder', '/path', folders, files); + + expect(mockStat).toHaveBeenCalledTimes(2); + expect(files).toHaveLength(1); + }); + + it('should handle errors gracefully', async () => { + mockStat.mockRejectedValue(new Error('Permission denied')); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + const bytes = await service.scanRecursive('/path/forbidden', '/path', folders, files); + + expect(bytes).toBe(0); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Permission denied')); + }); + + it('should handle nested directories', async () => { + mockStat + .mockResolvedValueOnce(createMockStats(false, 0)) + .mockResolvedValueOnce(createMockStats(false, 0)) + .mockResolvedValueOnce(createMockStats(true, 200)); + + const subfolder = [createMockDirent('subfolder', false)]; + const file = [createMockDirent('file.txt', false)]; + + mockReaddir.mockResolvedValueOnce(subfolder).mockResolvedValueOnce(file); + + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + const bytes = await service.scanRecursive('/path/folder', '/path', folders, files); + + expect(bytes).toBe(200); + expect(folders).toHaveLength(2); + expect(files).toHaveLength(1); + }); + }); + describe('scanLocalDirectory', () => { + it('should scan a directory and return complete results', async () => { + mockStat.mockResolvedValueOnce(createMockStats(false, 0)).mockResolvedValueOnce(createMockStats(true, 100)); + + mockReaddir.mockResolvedValueOnce([createMockDirent('file.txt', false)]); + + const result = await service.scanLocalDirectory('/test/folder'); + + expect(result).toMatchObject({ + totalItems: 2, + totalBytes: 100, + }); + expect(result.folders).toHaveLength(1); + expect(result.files).toHaveLength(1); + }); + }); +}); diff --git a/test/utils/errors.utils.test.ts b/test/utils/errors.utils.test.ts index 4ad245d2..6430bcfe 100644 --- a/test/utils/errors.utils.test.ts +++ b/test/utils/errors.utils.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ErrorUtils } from '../../src/utils/errors.utils'; +import { ErrorUtils, isAlreadyExistsError } from '../../src/utils/errors.utils'; import { logger } from '../../src/utils/logger.utils'; vi.mock('../../src/utils/logger.utils', () => ({ @@ -36,4 +36,24 @@ describe('Errors Utils', () => { `[REPORTED_ERROR]: ${JSON.stringify(error)}\nProperties => ${JSON.stringify(props, null, 2)}\n`, ); }); + describe('isAlreadyExistsError', () => { + it('should properly detect an error object that has an already exists as message', () => { + const error = new Error('File already exists'); + + expect(isAlreadyExistsError(error)).toBe(true); + }); + + it('should properly detect an error object that has 409 as status', () => { + const error = { status: 409, message: 'Conflict' }; + + expect(isAlreadyExistsError(error)).toBe(true); + }); + + it('should return false if the passed error is not an object', () => { + expect(isAlreadyExistsError('string error')).toBe(false); + expect(isAlreadyExistsError(123)).toBe(false); + expect(isAlreadyExistsError(null)).toBe(false); + expect(isAlreadyExistsError(undefined)).toBe(false); + }); + }); }); From 230618594dfda9d692fe4bbbc835c1bfc2a4163e Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 24 Nov 2025 12:52:10 +0100 Subject: [PATCH 2/3] Fix: sonarcloud issues --- src/services/local-filesystem/local-filesystem.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/local-filesystem/local-filesystem.service.ts b/src/services/local-filesystem/local-filesystem.service.ts index dbc1ddb4..6fb1d5f7 100644 --- a/src/services/local-filesystem/local-filesystem.service.ts +++ b/src/services/local-filesystem/local-filesystem.service.ts @@ -1,5 +1,5 @@ -import { promises } from 'fs'; -import { basename, dirname, join, relative, parse } from 'path'; +import { promises } from 'node:fs'; +import { basename, dirname, join, relative, parse } from 'node:path'; import { FileSystemNode, ScanResult } from './local-filesystem.types'; import { logger } from '../../utils/logger.utils'; From 65ae3c605ed0413ef97bc81408258985c041b119 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 24 Nov 2025 13:02:32 +0100 Subject: [PATCH 3/3] Feat: skip empty files to be uploaded --- .../local-filesystem/local-filesystem.service.ts | 2 +- .../local-filesystem.service.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/services/local-filesystem/local-filesystem.service.ts b/src/services/local-filesystem/local-filesystem.service.ts index 6fb1d5f7..660e3a49 100644 --- a/src/services/local-filesystem/local-filesystem.service.ts +++ b/src/services/local-filesystem/local-filesystem.service.ts @@ -29,7 +29,7 @@ export class LocalFilesystemService { const stats = await promises.stat(currentPath); const relativePath = relative(parentPath, currentPath); - if (stats.isFile()) { + if (stats.isFile() && stats.size > 0) { const fileInfo = parse(currentPath); files.push({ type: 'file', diff --git a/test/services/local-filesystem/local-filesystem.service.test.ts b/test/services/local-filesystem/local-filesystem.service.test.ts index 290cec02..8f1bab17 100644 --- a/test/services/local-filesystem/local-filesystem.service.test.ts +++ b/test/services/local-filesystem/local-filesystem.service.test.ts @@ -134,6 +134,20 @@ describe('Local Filesystem Service', () => { expect(folders).toHaveLength(2); expect(files).toHaveLength(1); }); + it('should properly skip empty files', async () => { + mockStat + .mockResolvedValueOnce(createMockStats(false, 0)) + .mockResolvedValueOnce(createMockStats(true, 0)) + .mockResolvedValueOnce(createMockStats(true, 200)); + const folders: FileSystemNode[] = []; + const files: FileSystemNode[] = []; + mockReaddir.mockResolvedValueOnce([createMockDirent('file1.txt', false), createMockDirent('file2.txt', false)]); + const bytes = await service.scanRecursive('/path/folder', '/path', folders, files); + + expect(bytes).toBe(200); + expect(folders).toHaveLength(1); + expect(files).toHaveLength(1); + }); }); describe('scanLocalDirectory', () => { it('should scan a directory and return complete results', async () => {