From 60c154ec23076e92f613d14d09131ee469d8a0e0 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 27 Nov 2025 14:52:48 +0100 Subject: [PATCH 1/2] Feat: Upload Facade --- .../network/upload/upload-facade.service.ts | 58 ++++++ src/services/network/upload/upload.types.ts | 2 +- .../upload/upload-facade.service.test.ts | 184 ++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/services/network/upload/upload-facade.service.ts create mode 100644 test/services/network/upload/upload-facade.service.test.ts 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..82231a3a --- /dev/null +++ b/src/services/network/upload/upload-facade.service.ts @@ -0,0 +1,58 @@ +import { basename } from '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'; + +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') }; + } + + 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..0d3dd5d0 --- /dev/null +++ b/test/services/network/upload/upload-facade.service.test.ts @@ -0,0 +1,184 @@ +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'; + +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(), + }, + }, +})); + +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; + + beforeEach(() => { + vi.clearAllMocks(); + sut = UploadFacade.instance; + vi.mocked(CLIUtils.prepareNetwork).mockReturnValue(mockNetworkFacade); + 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(); + }); + + it('should properly handle the upload of folder and the creation of file and return proper result', async () => { + const folderName = 'test-folder'; + 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, + }); + + const folderMap = new Map([['test-folder', 'folder-uuid-123']]); + vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); + vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(500); + + 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 folderName = 'test-folder'; + 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: 400, + }), + ], + totalItems: 2, + totalBytes: 400, + }); + + const folderMap = new Map([['test-folder', '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 = 400; + emitProgress(); + return 400; + }, + ); + + 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 }); + }); + }); +}); From b23aae6f3fb55d5040e1fec4a4966588f8bb781d Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 27 Nov 2025 15:49:17 +0100 Subject: [PATCH 2/2] Add Sleep 500ms + add node:path to fix sonarcloud issue --- .../network/upload/upload-facade.service.ts | 5 +- .../upload/upload-facade.service.test.ts | 91 +++++++++++-------- 2 files changed, 57 insertions(+), 39 deletions(-) diff --git a/src/services/network/upload/upload-facade.service.ts b/src/services/network/upload/upload-facade.service.ts index 82231a3a..26dcfd48 100644 --- a/src/services/network/upload/upload-facade.service.ts +++ b/src/services/network/upload/upload-facade.service.ts @@ -1,10 +1,11 @@ -import { basename } from 'path'; +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(); @@ -34,6 +35,8 @@ export class UploadFacade { 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, diff --git a/test/services/network/upload/upload-facade.service.test.ts b/test/services/network/upload/upload-facade.service.test.ts index 0d3dd5d0..0b6f74bf 100644 --- a/test/services/network/upload/upload-facade.service.test.ts +++ b/test/services/network/upload/upload-facade.service.test.ts @@ -8,6 +8,7 @@ import { UploadFileService } from '../../../../src/services/network/upload/uploa 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: { @@ -46,6 +47,12 @@ vi.mock('../../../../src/services/network/upload/upload-file.service', () => ({ }, })); +vi.mock('../../../../src/utils/async.utils', () => ({ + AsyncUtils: { + sleep: vi.fn().mockResolvedValue(undefined), + }, +})); + describe('UploadFacade', () => { let sut: UploadFacade; @@ -57,11 +64,27 @@ describe('UploadFacade', () => { 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), }); @@ -93,28 +116,10 @@ describe('UploadFacade', () => { 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 folderName = 'test-folder'; - 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, - }); - - const folderMap = new Map([['test-folder', 'folder-uuid-123']]); - vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); - vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(500); - const result = await sut.uploadFolder({ localPath, destinationFolderUuid, @@ -134,22 +139,7 @@ describe('UploadFacade', () => { }); it('should report progress correctly during upload', async () => { - const folderName = 'test-folder'; - 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: 400, - }), - ], - totalItems: 2, - totalBytes: 400, - }); - - const folderMap = new Map([['test-folder', 'folder-uuid-123']]); + const folderMap = new Map([[folderName, 'folder-uuid-123']]); vi.mocked(UploadFolderService.instance.createFolders).mockImplementation( async ({ currentProgress, emitProgress }) => { @@ -162,9 +152,9 @@ describe('UploadFacade', () => { vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockImplementation( async ({ currentProgress, emitProgress }) => { currentProgress.itemsUploaded = 2; - currentProgress.bytesUploaded = 400; + currentProgress.bytesUploaded = 500; emitProgress(); - return 400; + return 500; }, ); @@ -180,5 +170,30 @@ describe('UploadFacade', () => { 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(); + }); }); });