diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 5e2284e37..6505c5a32 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1373,7 +1373,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Upload fehlgeschlagen" + "upload-failed": "Upload fehlgeschlagen", + "folder-removal-failed": "Der hochgeladene Ordner konnte nicht entfernt werden" }, "generalErrorMessages": { "retryUploadFailed": "Beim erneuten Hochladen ist etwas schiefgegangen. Versuche es erneut", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index e2d137546..3ba79f3bc 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1397,7 +1397,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Upload failed" + "upload-failed": "Upload failed", + "folder-removal-failed": "Couldn't remove the uploaded folder" }, "generalErrorMessages": { "retryUploadFailed": "Something went wrong when retrying the upload. Try uploading again", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index d43941b69..058ae2115 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1377,7 +1377,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Error al subir" + "upload-failed": "Error al subir", + "folder-removal-failed": "No se pudo eliminar la carpeta subida" }, "generalErrorMessages": { "retryUploadFailed": "Algo salió mal al reintentar la subida. Intenta subirlo de nuevo", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index a65aa45ce..3c711b582 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1376,7 +1376,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Échec du téléchargement" + "upload-failed": "Échec du téléchargement", + "folder-removal-failed": "Impossible de supprimer le dossier importé" }, "generalErrorMessages": { "retryUploadFailed": "Quelque chose s'est mal passé en réessayant de télécharger. Essayez de télécharger à nouveau", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index c740263d4..6e592f641 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1397,7 +1397,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Caricamento fallito" + "upload-failed": "Caricamento fallito", + "folder-removal-failed": "Impossibile rimuovere la cartella caricata" }, "generalErrorMessages": { "retryUploadFailed": "Qualcosa è andato storto nel riprovare a caricare. Prova a caricare di nuovo", diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index ceaf96953..7e7808fd3 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -1407,7 +1407,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Falha no envio" + "upload-failed": "Falha no envio", + "folder-removal-failed": "Não foi possível remover a pasta enviada" }, "generalErrorMessages": { "retryUploadFailed": "Algo deu errado ao tentar enviar novamente. Tente fazer o envio de novo", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index e14531e32..2d11a62a0 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1376,7 +1376,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Не удалось загрузить" + "upload-failed": "Не удалось загрузить", + "folder-removal-failed": "Не удалось удалить загруженную папку" }, "generalErrorMessages": { "retryUploadFailed": "Что-то пошло не так при повторной попытке загрузки. Попробуйте загрузить снова", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 5cbdc87a4..6765b9ea9 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1397,7 +1397,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "上傳失敗" + "upload-failed": "上傳失敗", + "folder-removal-failed": "無法移除已上傳的資料夾" }, "generalErrorMessages": { "retryUploadFailed": "重試上傳時出現問題。請重新上傳", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index a864fba01..1e0c1ba20 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1397,7 +1397,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "上传失败" + "upload-failed": "上传失败", + "folder-removal-failed": "无法删除已上传的文件夹" }, "generalErrorMessages": { "retryUploadFailed": "重试上传时出了问题。请再次上传", diff --git a/src/app/tasks/upload/uploadTaskErrors.ts b/src/app/tasks/upload/uploadTaskErrors.ts index ee64ed7d5..2a504063a 100644 --- a/src/app/tasks/upload/uploadTaskErrors.ts +++ b/src/app/tasks/upload/uploadTaskErrors.ts @@ -18,3 +18,8 @@ export const errorMerge = (reason?: UploadFailureReason) => { const subtitle = errorSubtitle(reason); return subtitle !== undefined ? { status: TaskStatus.Error, subtitle } : { status: TaskStatus.Error }; }; + +export const folderRemovalFailedMerge = () => ({ + status: TaskStatus.Error, + subtitle: t('tasks.subtitles.folder-removal-failed') as string, +}); diff --git a/src/app/upload/Upload.test.ts b/src/app/upload/Upload.test.ts new file mode 100644 index 000000000..34d5904b4 --- /dev/null +++ b/src/app/upload/Upload.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; +import uploadFile from 'app/drive/services/file.service/uploadFile'; +import folderService from 'app/drive/services/folder.service'; +import newStorageService from 'app/drive/services/new-storage.service'; +import { IRoot } from 'app/store/slices/storage/types'; +import tasksService from 'app/tasks/services/tasks.service'; +import { TaskStatus, TaskType } from 'app/tasks/types'; +import errorService from 'services/error.service'; +import referralService from 'services/referral.service'; +import { AppError } from '@internxt/sdk'; +import { HTTP_STATUS_CODES } from 'app/core/constants'; +import { + getCreatedFolder, + getFileToUpload, + getFolderToUpload, + getUploadRepository, +} from 'testUtils/fixtures/upload.fixtures'; +import { Upload } from './Upload'; + +vi.mock('app/drive/services/file.service/uploadFile', () => ({ default: vi.fn() })); +vi.mock('app/drive/services/folder.service', () => ({ default: { createFolderByUuid: vi.fn() } })); +vi.mock('services/workspace.service', () => ({ default: { createFolder: vi.fn() } })); +vi.mock('app/drive/services/new-storage.service', () => ({ default: { deleteFolderByUuid: vi.fn() } })); +vi.mock('services/referral.service', () => ({ default: { trackFileUpload: vi.fn(), trackFolderUpload: vi.fn() } })); +vi.mock('app/network/networkInformation', () => ({ logNetworkInfoForUpload: vi.fn() })); +vi.mock('i18next', () => ({ t: (key: string) => key })); + +const DESTINATION_UUID = 'destination-uuid'; + +const uploadRepository = getUploadRepository(); + +const fakeFolderCreationTaking = (delayMs = 0) => + vi.mocked(folderService.createFolderByUuid).mockImplementation((_parentUuid, name) => { + let abortCreation: () => void = () => undefined; + const creation = new Promise((resolve, reject) => { + setTimeout(() => resolve(getCreatedFolder(name)), delayMs); + abortCreation = () => reject(new Error('aborted')); + }); + return [creation, { cancel: () => abortCreation() }] as never; + }); + +const fakeUploadsTaking = (delayMs: number) => + (uploadFile as Mock).mockImplementation( + (file: { name: string }, _progress, options: { abortCallback?: (abort: () => void) => void }) => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve({ uuid: `${file.name}-uuid` }), delayMs); + options.abortCallback?.(() => { + clearTimeout(timeout); + reject(new Error('aborted')); + }); + }), + ); + +const createUpload = ( + roots: IRoot[], + files: File[], + callbacks: Partial[0]> = {}, +) => new Upload({ destinationFolderUuid: DESTINATION_UUID, roots, files, uploadRepository, ...callbacks }); + +const tasksOf = (action: TaskType) => tasksService.getTasks().filter((task) => task.action === action); + +const failUploadsOf = (failingNames: string[], error: unknown = new Error('Server error')) => + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + if (failingNames.includes(file.name)) throw error; + return { uuid: `${file.name}-uuid` }; + }); + +describe('Uploading what the user picked', () => { + beforeEach(() => { + vi.clearAllMocks(); + tasksService.clearTasks(); + vi.spyOn(errorService, 'reportError').mockReturnValue(); + vi.mocked(newStorageService.deleteFolderByUuid).mockResolvedValue(undefined as never); + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ uuid: `${file.name}-uuid` })); + fakeFolderCreationTaking(); + }); + + test('When a folder and a file are uploaded, then each one is tracked by its own task until it succeeds', async () => { + const upload = createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png'), getFileToUpload('city.png')] })], + [getFileToUpload('report.pdf')], + ); + + await upload.run(); + + const [folderTask] = tasksOf(TaskType.UploadFolder); + const [fileTask] = tasksOf(TaskType.UploadFile); + expect(tasksService.getTasks()).toHaveLength(2); + expect(folderTask).toEqual( + expect.objectContaining({ status: TaskStatus.Success, progress: 1, itemUUID: { rootFolderUUID: 'Photos-uuid' } }), + ); + expect(fileTask).toEqual( + expect.objectContaining({ status: TaskStatus.Success, itemUUID: { fileUUID: 'report-uuid' } }), + ); + }); + + test('When the items are uploaded, then each one is reported with the folder it was uploaded into', async () => { + const onItemUploaded = vi.fn(); + const upload = createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], + [getFileToUpload('report.pdf')], + { + onItemUploaded, + }, + ); + + await upload.run(); + + expect(onItemUploaded).toHaveBeenCalledWith( + expect.objectContaining({ uuid: 'Photos-uuid' }), + DESTINATION_UUID, + true, + ); + expect(onItemUploaded).toHaveBeenCalledWith(expect.objectContaining({ name: 'beach' }), 'Photos-uuid', false); + expect(onItemUploaded).toHaveBeenCalledWith(expect.objectContaining({ name: 'report' }), DESTINATION_UUID, false); + }); + + test('When reporting an uploaded item fails, then the upload still finishes and each task succeeds', async () => { + const onItemUploaded = vi.fn(() => { + throw new Error('Listener error'); + }); + vi.mocked(referralService.trackFileUpload).mockImplementation(() => { + throw new Error('Corrupt banner state'); + }); + const upload = createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], + [getFileToUpload('report.pdf')], + { onItemUploaded }, + ); + + await upload.run(); + + expect(tasksOf(TaskType.UploadFolder)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Success, progress: 1, itemUUID: { rootFolderUUID: 'Photos-uuid' } }), + ); + expect(tasksOf(TaskType.UploadFile)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Success, itemUUID: { fileUUID: 'report-uuid' } }), + ); + }); + + test('When a file inside a folder fails, then the folder still succeeds with what could be uploaded', async () => { + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + if (file.name === 'broken') throw new Error('Server error'); + return { uuid: `${file.name}-uuid` }; + }); + const upload = createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })], + [], + ); + + const { failed } = await upload.run(); + + expect(failed).toHaveLength(1); + expect(tasksOf(TaskType.UploadFolder)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Success, progress: 2 / 3 }), + ); + }); + + test('When a folder cannot be created, then its task shows the error', async () => { + vi.mocked(folderService.createFolderByUuid).mockImplementation( + () => [Promise.reject(new Error('Server error')), { cancel: vi.fn() }] as never, + ); + + await createUpload([getFolderToUpload('Photos')], []).run(); + + expect(tasksOf(TaskType.UploadFolder)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Error, subtitle: 'tasks.subtitles.upload-failed' }), + ); + }); + + test('When a file fails, then its task shows the reason', async () => { + (uploadFile as Mock).mockRejectedValue(new Error('Server error')); + + await createUpload([], [getFileToUpload('report.pdf')]).run(); + + expect(tasksOf(TaskType.UploadFile)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Error, subtitle: 'tasks.subtitles.upload-failed' }), + ); + }); + + test('When the user cancels a file, then only that file stops', async () => { + fakeUploadsTaking(500); + const upload = createUpload([], [getFileToUpload('report.pdf'), getFileToUpload('invoice.pdf')]); + + const running = upload.run(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(2)); + const [reportTask, invoiceTask] = tasksOf(TaskType.UploadFile); + await tasksService.cancelTask(reportTask.id); + await running; + + expect(tasksService.findTask(reportTask.id)?.status).toBe(TaskStatus.Cancelled); + expect(tasksService.findTask(invoiceTask.id)?.status).toBe(TaskStatus.Success); + }); + + test('When the user cancels a file, then its saved upload state is removed', async () => { + fakeUploadsTaking(500); + const upload = createUpload([], [getFileToUpload('report.pdf')]); + + const running = upload.run(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + const [reportTask] = tasksOf(TaskType.UploadFile); + await tasksService.cancelTask(reportTask.id); + await running; + + expect(uploadRepository.removeUploadState).toHaveBeenCalledWith(reportTask.id); + }); + + test('When the user cancels a folder, then its upload stops and the folder already created is removed', async () => { + fakeUploadsTaking(500); + const onItemRemoved = vi.fn(); + const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], [], { + onItemRemoved, + }); + + const running = upload.run(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + await tasksService.cancelTask(tasksOf(TaskType.UploadFolder)[0].id); + await running; + + expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid'); + expect(onItemRemoved).toHaveBeenCalledWith(expect.objectContaining({ uuid: 'Photos-uuid' }), DESTINATION_UUID); + expect(tasksOf(TaskType.UploadFolder)[0].status).toBe(TaskStatus.Cancelled); + }); + + test('When the user cancels a folder, then the upload finishes only once the folder is removed', async () => { + fakeUploadsTaking(500); + let finishRemoval: () => void = () => undefined; + vi.mocked(newStorageService.deleteFolderByUuid).mockReturnValue( + new Promise((resolve) => (finishRemoval = resolve)) as never, + ); + const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []); + const onFinished = vi.fn(); + + const running = upload.run().then(onFinished); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + await tasksService.cancelTask(tasksOf(TaskType.UploadFolder)[0].id); + await vi.waitFor(() => expect(newStorageService.deleteFolderByUuid).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 600)); + + expect(onFinished).not.toHaveBeenCalled(); + finishRemoval(); + await running; + expect(onFinished).toHaveBeenCalled(); + }); + + test('When the cancelled folder cannot be removed, then its task shows the error', async () => { + fakeUploadsTaking(500); + vi.mocked(newStorageService.deleteFolderByUuid).mockRejectedValue(new Error('Server error')); + const onItemRemoved = vi.fn(); + const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], [], { + onItemRemoved, + }); + + const running = upload.run(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + await tasksService.cancelTask(tasksOf(TaskType.UploadFolder)[0].id); + await running; + + expect(tasksOf(TaskType.UploadFolder)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Error, subtitle: 'tasks.subtitles.folder-removal-failed' }), + ); + expect(onItemRemoved).not.toHaveBeenCalled(); + }); + + test('When the user cancels a folder while it is being created, then it is removed once it exists', async () => { + fakeFolderCreationTaking(300); + const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []); + + const running = upload.run(); + await vi.waitFor(() => expect(folderService.createFolderByUuid).toHaveBeenCalled()); + await tasksService.cancelTask(tasksOf(TaskType.UploadFolder)[0].id); + await running; + + expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid'); + expect(uploadFile).not.toHaveBeenCalled(); + }); + + test('When the storage fills up while uploading, then the user is told once, however many items fail', async () => { + const onStorageFull = vi.fn(); + failUploadsOf(['report', 'invoice'], new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED)); + + await createUpload([], [getFileToUpload('report.pdf'), getFileToUpload('invoice.pdf')], { onStorageFull }).run(); + + expect(onStorageFull).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/upload/Upload.ts b/src/app/upload/Upload.ts new file mode 100644 index 000000000..73a267586 --- /dev/null +++ b/src/app/upload/Upload.ts @@ -0,0 +1,280 @@ +import newStorageService from 'app/drive/services/new-storage.service'; +import { DriveFolderData } from 'app/drive/types'; +import { logNetworkInfoForUpload } from 'app/network/networkInformation'; +import { OwnerUserAuthenticationData } from 'app/network/types'; +import { + UploadedItem, + UploadFileItem, + UploadFolderItem, + UploadItem, + UploadItemKind, + UploadManagerEvents, + UploadResult, +} from 'app/upload/types'; +import { UploadFailure } from 'app/upload/uploadFailure'; +import { UploadManager } from './UploadManager'; +import { + countItemsIn, + createFileItem, + createFolderItem, + getParentFolderUuid, + getTopLevelItem, + isFolderItem, +} from './uploadItemTree'; +import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; +import { IRoot } from 'app/store/slices/storage/types'; +import tasksService from 'app/tasks/services/tasks.service'; +import { TaskStatus, TaskType, UploadFileTask, UploadFolderTask } from 'app/tasks/types'; +import { errorMerge, folderRemovalFailedMerge } from 'app/tasks/upload/uploadTaskErrors'; +import errorService from 'services/error.service'; +import referralService from 'services/referral.service'; + +interface UploadProps { + destinationFolderUuid: string; + roots: IRoot[]; + files: File[]; + ownerUserAuthenticationData?: OwnerUserAuthenticationData; + uploadRepository: PersistUploadRepository; + onItemUploaded?: (uploadedItem: UploadedItem, parentFolderUuid: string, isFolder: boolean) => void; + onItemRemoved?: (removedItem: UploadedItem, parentFolderUuid: string) => void; + onStorageFull?: () => void; +} + +interface RootFolderUpload { + taskId: string; + totalItems: number; + uploadedItems: number; + isCancelled: boolean; + hasFailedItems: boolean; + createdFolder?: DriveFolderData; +} + +const createItemsToUpload = ({ roots, files, destinationFolderUuid }: UploadProps): UploadItem[] => [ + ...roots.map((root) => createFolderItem(root, destinationFolderUuid)), + ...files.map((file) => createFileItem(file, destinationFolderUuid)), +]; + +const isTaskStopped = (taskId: string): boolean => { + const status = tasksService.findTask(taskId)?.status; + return status === TaskStatus.Cancelled || status === TaskStatus.Paused; +}; + +export class Upload { + private readonly props: UploadProps; + private readonly manager: UploadManager; + private readonly items: UploadItem[]; + private readonly taskIdByItem = new Map(); + private readonly rootFolders = new Map(); + private readonly cancelledRootFolderDeletions: Promise[] = []; + + constructor(props: UploadProps) { + this.props = props; + this.items = createItemsToUpload(props); + this.manager = new UploadManager({ + ownerUserAuthenticationData: props.ownerUserAuthenticationData, + uploadRepository: props.uploadRepository, + getPauseKey: (item) => this.getTaskIdOf(getTopLevelItem(item)) as string, + events: this.createManagerEvents(), + }); + + for (const item of this.items) { + if (item.kind === UploadItemKind.Folder) this.trackRootFolder(item); + else this.trackFile(item); + } + } + + async run(): Promise { + const result = await this.manager.run(this.items); + await Promise.all(this.cancelledRootFolderDeletions); + this.rootFolders.forEach((rootFolder, item) => this.finishRootFolder(item as UploadFolderItem, rootFolder)); + return result; + } + + private createManagerEvents(): UploadManagerEvents { + return { + onItemStarted: (item) => this.onItemStarted(item), + onItemProgress: (item, progress) => this.onItemProgress(item, progress), + onItemSucceeded: (item, uploadedItem) => this.onItemSucceeded(item, uploadedItem), + onItemFailed: (item, failure) => this.onItemFailed(item, failure), + onItemCancelled: (item) => this.onItemCancelled(item), + onStorageFull: () => this.props.onStorageFull?.(), + }; + } + + private getTaskIdOf(item: UploadItem): string | undefined { + return this.taskIdByItem.get(item); + } + + private trackRootFolder(item: UploadFolderItem): void { + const taskId = tasksService.create({ + action: TaskType.UploadFolder, + folderName: item.folder.name, + item: item.folder, + parentFolderId: item.parentFolderUuid, + showNotification: true, + cancellable: true, + stop: () => Promise.resolve(this.cancelRootFolder(item)), + }); + + this.taskIdByItem.set(item, taskId); + this.rootFolders.set(item, { + taskId, + totalItems: countItemsIn(item.folder), + uploadedItems: 0, + isCancelled: false, + hasFailedItems: false, + }); + } + + private trackFile(item: UploadFileItem): void { + const { ownerUserAuthenticationData, destinationFolderUuid } = this.props; + + const taskId = tasksService.create({ + action: TaskType.UploadFile, + item: { uploadFile: item.file.content, parentFolderId: item.file.parentFolderId }, + fileName: item.file.name, + fileType: item.file.type, + isFileNameValidated: true, + showNotification: true, + cancellable: true, + sharedItemAuthenticationData: ownerUserAuthenticationData && { + ownerUserAuthenticationData, + isDeepFolder: false, + currentFolderId: destinationFolderUuid, + }, + stop: () => Promise.resolve(this.manager.cancelItem(item)), + }); + + this.taskIdByItem.set(item, taskId); + } + + private onItemStarted(item: UploadItem): void { + const taskId = this.getTaskIdOf(item); + if (!taskId) return; + + tasksService.updateTask({ taskId, merge: { status: TaskStatus.InProcess } }); + + const isFile = item.kind === UploadItemKind.File; + if (isFile) this.markUploadAsInProcess(taskId); + } + + private markUploadAsInProcess(taskId: string): void { + this.props.uploadRepository + .setUploadState(taskId, TaskStatus.InProcess) + .catch((error) => errorService.reportError(error)); + } + + private forgetUploadState(taskId: string): void { + this.props.uploadRepository.removeUploadState(taskId).catch((error) => errorService.reportError(error)); + } + + private onItemProgress(item: UploadItem, progress: number): void { + const taskId = this.getTaskIdOf(item); + if (taskId && !isTaskStopped(taskId)) tasksService.updateTask({ taskId, merge: { progress } }); + } + + private onItemSucceeded(item: UploadItem, uploadedItem: UploadedItem): void { + const taskId = this.getTaskIdOf(item); + const isFileWithItsOwnTask = !!taskId && !isFolderItem(item); + if (isFileWithItsOwnTask) this.finishFileTask(taskId, { fileUUID: uploadedItem.uuid }); + else this.countUploadedInRootFolder(item, uploadedItem); + + const isFile = item.kind === UploadItemKind.File; + this.props.onItemUploaded?.(uploadedItem, getParentFolderUuid(item), !isFile); + + if (isFile) { + referralService.trackFileUpload(); + logNetworkInfoForUpload({ fileName: item.file.name, fileSize: item.file.size }); + } + } + + private countUploadedInRootFolder(item: UploadItem, uploadedItem: UploadedItem): void { + const topLevelItem = getTopLevelItem(item); + const rootFolder = this.rootFolders.get(topLevelItem) as RootFolderUpload; + + const isRootFolderItself = item === topLevelItem; + if (isRootFolderItself) { + rootFolder.createdFolder = uploadedItem as DriveFolderData; + if (rootFolder.isCancelled) { + this.cancelledRootFolderDeletions.push(this.deleteCancelledRootFolder(item as UploadFolderItem, rootFolder)); + } + } + + rootFolder.uploadedItems += 1; + if (!isTaskStopped(rootFolder.taskId)) { + tasksService.updateTask({ + taskId: rootFolder.taskId, + merge: { progress: rootFolder.uploadedItems / rootFolder.totalItems }, + }); + } + } + + private finishFileTask(taskId: string, itemUUID: { fileUUID: string }): void { + const isTaskCancelled = tasksService.findTask(taskId)?.status === TaskStatus.Cancelled; + if (!isTaskCancelled) tasksService.updateTask({ taskId, merge: { status: TaskStatus.Success, itemUUID } }); + + this.forgetUploadState(taskId); + } + + private onItemFailed(item: UploadItem, failure: UploadFailure): void { + const rootFolder = this.rootFolders.get(getTopLevelItem(item)); + if (rootFolder) rootFolder.hasFailedItems = true; + + const taskId = this.getTaskIdOf(item); + if (!taskId) return; + + tasksService.updateTask({ taskId, merge: errorMerge(failure.reason) }); + this.forgetUploadState(taskId); + } + + private onItemCancelled(item: UploadItem): void { + const taskId = this.getTaskIdOf(item); + const isFileWithItsOwnTask = !!taskId && item.kind === UploadItemKind.File; + if (!isFileWithItsOwnTask) return; + + tasksService.updateTask({ taskId, merge: { status: TaskStatus.Cancelled } }); + this.forgetUploadState(taskId); + } + + private cancelRootFolder(item: UploadFolderItem): void { + const rootFolder = this.rootFolders.get(item) as RootFolderUpload; + rootFolder.isCancelled = true; + this.manager.cancelItem(item); + if (rootFolder.createdFolder) { + this.cancelledRootFolderDeletions.push(this.deleteCancelledRootFolder(item, rootFolder)); + } + } + + private async deleteCancelledRootFolder( + item: UploadFolderItem, + { taskId, createdFolder }: RootFolderUpload, + ): Promise { + if (!createdFolder) return; + + try { + await newStorageService.deleteFolderByUuid(createdFolder.uuid); + this.props.onItemRemoved?.(createdFolder, item.parentFolderUuid); + } catch (error) { + errorService.reportError(error); + tasksService.updateTask({ taskId, merge: folderRemovalFailedMerge() }); + } + } + + private finishRootFolder(item: UploadFolderItem, rootFolder: RootFolderUpload): void { + this.forgetUploadState(rootFolder.taskId); + + const { createdFolder } = rootFolder; + if (rootFolder.isCancelled || !createdFolder) return; + + tasksService.updateTask({ + taskId: rootFolder.taskId, + merge: { status: TaskStatus.Success, itemUUID: { rootFolderUUID: createdFolder.uuid } }, + }); + + const hasEverythingUploaded = !rootFolder.hasFailedItems; + if (hasEverythingUploaded) { + referralService.trackFolderUpload(); + logNetworkInfoForUpload({ folderName: item.folder.name }); + } + } +} diff --git a/src/app/upload/UploadManager.test.ts b/src/app/upload/UploadManager.test.ts index 6593960ef..2d7e36382 100644 --- a/src/app/upload/UploadManager.test.ts +++ b/src/app/upload/UploadManager.test.ts @@ -16,7 +16,8 @@ import { getUploadFileItem, getUploadRepository, } from 'testUtils/fixtures/upload.fixtures'; -import { createFolderItem, UploadManager, WORKSPACE_FOLDER_AVAILABILITY_DELAY_MS } from './UploadManager'; +import { UploadManager, WORKSPACE_FOLDER_AVAILABILITY_DELAY_MS } from './UploadManager'; +import { createFolderItem } from './uploadItemTree'; import { UploadFailureReason, UploadItem, UploadItemKind, UploadManagerEvents } from './types'; vi.mock('app/drive/services/file.service/uploadFile', () => ({ default: vi.fn() })); @@ -185,6 +186,28 @@ describe('Uploading files', () => { expect(failed.every(({ failure }) => failure.reason === UploadFailureReason.NoSpaceLeft)).toBe(true); }); + test('When several files fail because the storage is full, then it is announced only once', async () => { + uploadAtMostAtTheSameTime(2); + (uploadFile as Mock).mockRejectedValue(noSpaceLeftError); + const events: UploadManagerEvents = { onStorageFull: vi.fn() }; + const items = ['report', 'invoice', 'photo'].map((name) => getUploadFileItem(name)); + + await createManager({ events }).run(items); + + expect(events.onStorageFull).toHaveBeenCalledOnce(); + }); + + test('When the storage is still full on a new upload, then it is announced again', async () => { + (uploadFile as Mock).mockRejectedValue(noSpaceLeftError); + const events: UploadManagerEvents = { onStorageFull: vi.fn() }; + const manager = createManager({ events }); + + await manager.run([getUploadFileItem('report')]); + await manager.run([getUploadFileItem('invoice')]); + + expect(events.onStorageFull).toHaveBeenCalledTimes(2); + }); + test('When a file is cancelled while its paused state is being read, then it is never uploaded and is reported as cancelled', async () => { const report = getUploadFileItem('report'); const events: UploadManagerEvents = { onItemCancelled: vi.fn(), onItemFailed: vi.fn() }; diff --git a/src/app/upload/UploadManager.ts b/src/app/upload/UploadManager.ts index 8d61c84a4..78f745a03 100644 --- a/src/app/upload/UploadManager.ts +++ b/src/app/upload/UploadManager.ts @@ -1,4 +1,3 @@ -import { items as itemUtils } from '@internxt/lib'; import { AsyncPriorityQueue, priorityQueue } from 'async'; import errorService from 'services/error.service'; import workspacesService from 'services/workspace.service'; @@ -9,7 +8,6 @@ import folderService from 'app/drive/services/folder.service'; import { toDriveFolderData } from 'app/drive/services/folder.service/toDriveFolderData'; import { DriveFileData, DriveFolderData } from 'app/drive/types'; import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; -import { IRoot } from 'app/store/slices/storage/types'; import { TaskStatus } from 'app/tasks/types'; import { MAX_CONCURRENT_UPLOADS, @@ -29,6 +27,7 @@ import { UploadResult, } from './types'; import { shouldAutoRetry, toUploadFailure, UploadFailure } from './uploadFailure'; +import { createFileItem, createFolderItem, getItemAndItsFolders, isFolderItem, isPartOf } from './uploadItemTree'; const SINGLE_TRANSFER_CONCURRENCY = 1; export const WORKSPACE_FOLDER_AVAILABILITY_DELAY_MS = 600; @@ -53,39 +52,6 @@ class UploadCancelledError extends Error { } } -export const createFileItem = (file: File, parentFolderUuid: string, parent?: UploadFolderItem): UploadFileItem => { - const { filename, extension } = itemUtils.getFilenameAndExt(file.name); - - return { - kind: UploadItemKind.File, - file: { name: filename, type: extension, size: file.size, content: file, parentFolderId: parentFolderUuid }, - parent, - }; -}; - -export const createFolderItem = ( - folder: IRoot, - parentFolderUuid: string, - parent?: UploadFolderItem, -): UploadFolderItem => ({ - kind: UploadItemKind.Folder, - folder, - parentFolderUuid, - parent, -}); - -const getItemAndItsFolders = (item: UploadItem): UploadItem[] => { - const itemAndItsFolders: UploadItem[] = []; - for (let current: UploadItem | undefined = item; current; current = current.parent) { - itemAndItsFolders.push(current); - } - return itemAndItsFolders; -}; - -const isPartOf = (item: UploadItem, otherItem: UploadItem): boolean => getItemAndItsFolders(item).includes(otherItem); - -const isFolderItem = (item: UploadItem): item is UploadFolderItem => item.kind === UploadItemKind.Folder; - const isMultipartUpload = (item: UploadItem) => item.kind === UploadItemKind.File && item.file.size >= USE_MULTIPART_THRESHOLD_BYTES; @@ -266,18 +232,12 @@ export class UploadManager { const workspaceId = this.ownerUserAuthenticationData?.workspaceId; const { parentFolderUuid, folder } = folderItem; - const [createdFolderPromise, requestCanceler] = workspaceId + const [createdFolderPromise] = workspaceId ? workspacesService.createFolder({ workspaceId, parentFolderUuid, plainName: folder.name }) : folderService.createFolderByUuid(parentFolderUuid, folder.name); - this.abortActiveUploadOf.set(folderItem, () => requestCanceler.cancel()); - - try { - const createdFolder = await createdFolderPromise; - return toDriveFolderData(createdFolder, folder.name); - } finally { - this.abortActiveUploadOf.delete(folderItem); - } + const createdFolder = await createdFolderPromise; + return toDriveFolderData(createdFolder, folder.name); } private async uploadFile(fileItem: UploadFileItem): Promise { @@ -326,7 +286,11 @@ export class UploadManager { const failure = toUploadFailure(error); const isStorageFull = failure.reason === UploadFailureReason.NoSpaceLeft; - if (isStorageFull) this.storageFullFailure = failure; + const isFirstTimeStorageIsFull = isStorageFull && !this.storageFullFailure; + if (isFirstTimeStorageIsFull) { + this.storageFullFailure = failure; + await notifyIgnoringListenerErrors(() => this.events?.onStorageFull?.()); + } errorService.reportError(failure.error); await this.fail(item, failure); diff --git a/src/app/upload/types.ts b/src/app/upload/types.ts index 77837be64..bcc1f256b 100644 --- a/src/app/upload/types.ts +++ b/src/app/upload/types.ts @@ -35,6 +35,7 @@ export interface UploadManagerEvents { onItemSucceeded?: (item: UploadItem, uploadedItem: UploadedItem) => void | Promise; onItemFailed?: (item: UploadItem, failure: UploadFailure) => void; onItemCancelled?: (item: UploadItem) => void; + onStorageFull?: () => void; } export interface UploadResult { diff --git a/src/app/upload/uploadItemTree.ts b/src/app/upload/uploadItemTree.ts new file mode 100644 index 000000000..85aa44ce0 --- /dev/null +++ b/src/app/upload/uploadItemTree.ts @@ -0,0 +1,45 @@ +import { items as itemUtils } from '@internxt/lib'; +import { IRoot } from 'app/store/slices/storage/types'; +import { UploadFileItem, UploadFolderItem, UploadItem, UploadItemKind } from './types'; + +export const createFileItem = (file: File, parentFolderUuid: string, parent?: UploadFolderItem): UploadFileItem => { + const { filename, extension } = itemUtils.getFilenameAndExt(file.name); + + return { + kind: UploadItemKind.File, + file: { name: filename, type: extension, size: file.size, content: file, parentFolderId: parentFolderUuid }, + parent, + }; +}; + +export const createFolderItem = ( + folder: IRoot, + parentFolderUuid: string, + parent?: UploadFolderItem, +): UploadFolderItem => ({ + kind: UploadItemKind.Folder, + folder, + parentFolderUuid, + parent, +}); + +export const isFolderItem = (item: UploadItem): item is UploadFolderItem => item.kind === UploadItemKind.Folder; + +export const getItemAndItsFolders = (item: UploadItem): UploadItem[] => { + const itemAndItsFolders: UploadItem[] = []; + for (let current: UploadItem | undefined = item; current; current = current.parent) { + itemAndItsFolders.push(current); + } + return itemAndItsFolders; +}; + +export const getTopLevelItem = (item: UploadItem): UploadItem => getItemAndItsFolders(item).at(-1) as UploadItem; + +export const isPartOf = (item: UploadItem, otherItem: UploadItem): boolean => + getItemAndItsFolders(item).includes(otherItem); + +export const getParentFolderUuid = (item: UploadItem): string => + isFolderItem(item) ? item.parentFolderUuid : item.file.parentFolderId; + +export const countItemsIn = (folder: IRoot): number => + folder.childrenFolders.reduce((count, child) => count + countItemsIn(child), 1 + folder.childrenFiles.length); diff --git a/src/services/referral.service.test.ts b/src/services/referral.service.test.ts index af6ae51f4..d005c9c59 100644 --- a/src/services/referral.service.test.ts +++ b/src/services/referral.service.test.ts @@ -232,6 +232,14 @@ describe('referralService', () => { expectBannerStateSaved(expectedFragment); }); + + it('when the saved banner state is corrupt, then tracking starts again from the default state', () => { + localStorageService.set(LocalStorageItem.BannerStateKey, '{not json'); + + referralService.trackFileUpload(); + + expectBannerStateSaved('"fileUploadCount":1'); + }); }); describe('trackAppOpenDay', () => { diff --git a/src/services/referral.service.ts b/src/services/referral.service.ts index c72b7b72c..2d4cecbdd 100644 --- a/src/services/referral.service.ts +++ b/src/services/referral.service.ts @@ -45,7 +45,13 @@ const DEFAULT_BANNER_STATE: BannerState = { const getBannerState = (): BannerState => { const stored = localStorageService.get(LocalStorageItem.BannerStateKey); - return stored ? { ...DEFAULT_BANNER_STATE, ...JSON.parse(stored) } : { ...DEFAULT_BANNER_STATE }; + if (!stored) return { ...DEFAULT_BANNER_STATE }; + + try { + return { ...DEFAULT_BANNER_STATE, ...JSON.parse(stored) }; + } catch { + return { ...DEFAULT_BANNER_STATE }; + } }; const updateBannerState = (update: Partial): void => {