diff --git a/src/services/network/upload/upload-facade.service.ts b/src/services/network/upload/upload-facade.service.ts new file mode 100644 index 00000000..26dcfd48 --- /dev/null +++ b/src/services/network/upload/upload-facade.service.ts @@ -0,0 +1,61 @@ +import { basename } from 'node:path'; +import { CLIUtils } from '../../../utils/cli.utils'; +import { logger } from '../../../utils/logger.utils'; +import { LocalFilesystemService } from '../../local-filesystem/local-filesystem.service'; +import { UploadFolderParams } from './upload.types'; +import { UploadFolderService } from './upload-folder.service'; +import { UploadFileService } from './upload-file.service'; +import { AsyncUtils } from '../../../utils/async.utils'; + +export class UploadFacade { + static readonly instance = new UploadFacade(); + async uploadFolder({ localPath, destinationFolderUuid, loginUserDetails, jsonFlag, onProgress }: UploadFolderParams) { + const timer = CLIUtils.timer(); + const network = CLIUtils.prepareNetwork({ jsonFlag, loginUserDetails }); + const scanResult = await LocalFilesystemService.instance.scanLocalDirectory(localPath); + logger.info( + `Scanned folder ${localPath}: found ${scanResult.totalItems} items, total size ${scanResult.totalBytes} bytes.`, + ); + + const currentProgress = { itemsUploaded: 0, bytesUploaded: 0 }; + const emitProgress = () => { + const itemProgress = currentProgress.itemsUploaded / scanResult.totalItems; + const sizeProgress = currentProgress.bytesUploaded / scanResult.totalBytes; + const percentage = Math.floor((itemProgress * 0.5 + sizeProgress * 0.5) * 100); + onProgress({ percentage }); + }; + + const folderMap = await UploadFolderService.instance.createFolders({ + foldersToCreate: scanResult.folders, + destinationFolderUuid, + currentProgress, + emitProgress, + }); + + if (folderMap.size === 0) { + return { error: 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); + + const totalBytes = await UploadFileService.instance.uploadFilesInChunks({ + network, + filesToUpload: scanResult.files, + folderMap, + bucket: loginUserDetails.bucket, + destinationFolderUuid, + currentProgress, + emitProgress, + }); + + const rootFolderName = basename(localPath); + const rootFolderId = folderMap.get(rootFolderName) ?? ''; + return { + data: { + totalBytes, + rootFolderId, + uploadTimeMs: timer.stop(), + }, + }; + } +} diff --git a/src/services/network/upload/upload.types.ts b/src/services/network/upload/upload.types.ts index 8ac17c6b..d45348f6 100644 --- a/src/services/network/upload/upload.types.ts +++ b/src/services/network/upload/upload.types.ts @@ -8,7 +8,7 @@ export interface UploadResult { uploadTimeMs: number; } -export interface UploadFolderHandlerParams { +export interface UploadFolderParams { localPath: string; destinationFolderUuid: string; loginUserDetails: LoginUserDetails; diff --git a/test/services/network/upload/upload-facade.service.test.ts b/test/services/network/upload/upload-facade.service.test.ts new file mode 100644 index 00000000..0b6f74bf --- /dev/null +++ b/test/services/network/upload/upload-facade.service.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, it, vi, expect } from 'vitest'; +import { UploadFacade } from '../../../../src/services/network/upload/upload-facade.service'; +import { CLIUtils } from '../../../../src/utils/cli.utils'; +import { logger } from '../../../../src/utils/logger.utils'; +import { LocalFilesystemService } from '../../../../src/services/local-filesystem/local-filesystem.service'; +import { UploadFolderService } from '../../../../src/services/network/upload/upload-folder.service'; +import { UploadFileService } from '../../../../src/services/network/upload/upload-file.service'; +import { NetworkFacade } from '../../../../src/services/network/network-facade.service'; +import { LoginUserDetails } from '../../../../src/types/command.types'; +import { createFileSystemNodeFixture } from './upload.service.helpers'; +import { AsyncUtils } from '../../../../src/utils/async.utils'; + +vi.mock('../../../../src/utils/cli.utils', () => ({ + CLIUtils: { + timer: vi.fn(), + prepareNetwork: vi.fn(), + }, +})); + +vi.mock('../../../../src/utils/logger.utils', () => ({ + logger: { + info: vi.fn(), + }, +})); + +vi.mock('../../../../src/services/local-filesystem/local-filesystem.service', () => ({ + LocalFilesystemService: { + instance: { + scanLocalDirectory: vi.fn(), + }, + }, +})); + +vi.mock('../../../../src/services/network/upload/upload-folder.service', () => ({ + UploadFolderService: { + instance: { + createFolders: vi.fn(), + }, + }, +})); + +vi.mock('../../../../src/services/network/upload/upload-file.service', () => ({ + UploadFileService: { + instance: { + uploadFilesInChunks: vi.fn(), + }, + }, +})); + +vi.mock('../../../../src/utils/async.utils', () => ({ + AsyncUtils: { + sleep: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe('UploadFacade', () => { + let sut: UploadFacade; + + const mockNetworkFacade = {} as NetworkFacade; + + const mockLoginUserDetails = { + bridgeUser: 'test-bridge-user', + userId: 'test-user-id', + mnemonic: 'test-mnemonic', + bucket: 'test-bucket', + } as LoginUserDetails; + const folderName = 'test-folder'; + const folderMap = new Map([[folderName, 'folder-uuid-123']]); + beforeEach(() => { + vi.clearAllMocks(); + sut = UploadFacade.instance; + vi.mocked(CLIUtils.prepareNetwork).mockReturnValue(mockNetworkFacade); + vi.mocked(LocalFilesystemService.instance.scanLocalDirectory).mockResolvedValue({ + folders: [createFileSystemNodeFixture({ type: 'folder', name: folderName, relativePath: folderName })], + files: [ + createFileSystemNodeFixture({ + type: 'file', + name: 'file1.txt', + relativePath: `${folderName}/file1.txt`, + size: 500, + }), + ], + totalItems: 2, + totalBytes: 500, + }); + vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); + vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(500); + vi.mocked(CLIUtils.timer).mockReturnValue({ + stop: vi.fn().mockReturnValue(1000), + }); + }); + + describe('uploadFolder', () => { + const localPath = '/local/test-folder'; + const destinationFolderUuid = 'dest-uuid'; + const onProgress = vi.fn(); + + it('should properly return an error if createFolders returns an empty map', async () => { + vi.mocked(LocalFilesystemService.instance.scanLocalDirectory).mockResolvedValue({ + folders: [createFileSystemNodeFixture({ type: 'folder', name: 'test', relativePath: 'test' })], + files: [], + totalItems: 1, + totalBytes: 0, + }); + + vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(new Map()); + + const result = await sut.uploadFolder({ + localPath, + destinationFolderUuid, + loginUserDetails: mockLoginUserDetails, + jsonFlag: false, + onProgress, + }); + + 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(); + }); + + it('should properly handle the upload of folder and the creation of file and return proper result', async () => { + const result = await sut.uploadFolder({ + localPath, + destinationFolderUuid, + loginUserDetails: mockLoginUserDetails, + jsonFlag: false, + 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(UploadFolderService.instance.createFolders).toHaveBeenCalled(); + expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(`Scanned folder ${localPath}: found 2 items, total size 500 bytes.`); + }); + + it('should report progress correctly during upload', async () => { + const folderMap = new Map([[folderName, 'folder-uuid-123']]); + + vi.mocked(UploadFolderService.instance.createFolders).mockImplementation( + async ({ currentProgress, emitProgress }) => { + currentProgress.itemsUploaded = 1; + emitProgress(); + return folderMap; + }, + ); + + vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockImplementation( + async ({ currentProgress, emitProgress }) => { + currentProgress.itemsUploaded = 2; + currentProgress.bytesUploaded = 500; + emitProgress(); + return 500; + }, + ); + + await sut.uploadFolder({ + localPath, + destinationFolderUuid, + loginUserDetails: mockLoginUserDetails, + jsonFlag: false, + onProgress, + }); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, { percentage: 25 }); + expect(onProgress).toHaveBeenNthCalledWith(2, { percentage: 100 }); + }); + + it('should wait 500ms between folder creation and file upload to prevent backend indexing issues', async () => { + vi.useFakeTimers(); + + vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); + vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(100); + + const uploadPromise = sut.uploadFolder({ + localPath, + destinationFolderUuid, + loginUserDetails: mockLoginUserDetails, + jsonFlag: false, + onProgress, + }); + + await vi.advanceTimersByTimeAsync(500); + await uploadPromise; + + expect(AsyncUtils.sleep).toHaveBeenCalledWith(500); + expect(AsyncUtils.sleep).toHaveBeenCalledTimes(1); + expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); + expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); +});