Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
654605e
refactor(upload): let the user retry what failed in an upload, upload…
xabg2 Nov 2, 2026
4e097a9
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 3, 2026
a7a45d5
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 4, 2026
e744b38
refactor(upload): give each task its retry when it is created
xabg2 Nov 4, 2026
9682335
fix(upload): retry everything only retries the failures that retrying…
xabg2 Nov 4, 2026
15eb1a4
refactor(upload): let the user retry what failed in an upload, upload…
xabg2 Nov 2, 2026
9e45bde
refactor(upload): give each task its retry when it is created
xabg2 Nov 4, 2026
5bf545f
fix(upload): retry everything only retries the failures that retrying…
xabg2 Nov 4, 2026
baf222d
Merge branch 'refactor/upload-action-retry' of github.com:internxt/dr…
xabg2 Nov 6, 2026
0ee4887
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 6, 2026
cd0c921
refactor(upload): simplify the retry flow
xabg2 Nov 6, 2026
e6c2586
fix(upload): retry only finished tasks
xabg2 Nov 6, 2026
a563214
refactor(upload): drop unused shared auth data
xabg2 Nov 9, 2026
efddfe8
fix(upload): stop showing cancelled items retrying
xabg2 Nov 9, 2026
f37e4fb
fix(upload): finish a folder after its last run
xabg2 Nov 9, 2026
2d07964
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 9, 2026
d990886
fix(upload): track a folder completed by a retry
xabg2 Nov 11, 2026
b14759d
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 11, 2026
5ccb6e9
Merge branch 'refactor/upload-action' into refactor/upload-action-retry
xabg2 Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app/tasks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export enum TaskEvent {
TaskError = 'task-error',
}

export interface FailedUploadItem {
name: string;
type?: string;
size: number;
modifiedAt: number;
isFolder: boolean;
isRetryable: boolean;
isRetrying: boolean;
retry: () => Promise<void>;
}

export interface BaseTask {
id: string;
relatedTaskId?: string;
Expand All @@ -51,6 +62,8 @@ export interface BaseTask {
downloadedProgress?: number;
subtitle?: string;
stop?: () => Promise<void>;
failedItems?: FailedUploadItem[];
retry?: () => Promise<void>;
}

export interface CreateFolderTask extends BaseTask {
Expand Down
295 changes: 290 additions & 5 deletions src/app/upload/Upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const createUpload = (
callbacks: Partial<ConstructorParameters<typeof Upload>[0]> = {},
) => new Upload({ destinationFolderUuid: DESTINATION_UUID, roots, files, uploadRepository, ...callbacks });

const uploadedNames = () => vi.mocked(uploadFile).mock.calls.map(([file]) => file.name);

const tasksOf = (action: TaskType) => tasksService.getTasks().filter((task) => task.action === action);

const failUploadsOf = (failingNames: string[], error: unknown = new Error('Server error')) =>
Expand All @@ -65,13 +67,16 @@ const failUploadsOf = (failingNames: string[], error: unknown = new Error('Serve
return { uuid: `${file.name}-uuid` };
});

const uploadSucceeds = () =>
(uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ 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` }));
uploadSucceeds();
fakeFolderCreationTaking();
});

Expand Down Expand Up @@ -115,6 +120,12 @@ describe('Uploading what the user picked', () => {
expect(onItemUploaded).toHaveBeenCalledWith(expect.objectContaining({ name: 'report' }), DESTINATION_UUID, false);
});

test('When a folder is uploaded with everything in it, then it is counted as a folder upload', async () => {
await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []).run();

expect(referralService.trackFolderUpload).toHaveBeenCalledOnce();
});

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');
Expand All @@ -139,10 +150,7 @@ describe('Uploading what the user picked', () => {
});

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` };
});
failUploadsOf(['broken']);
const upload = createUpload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })],
[],
Expand Down Expand Up @@ -274,6 +282,211 @@ describe('Uploading what the user picked', () => {
expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid');
expect(uploadFile).not.toHaveBeenCalled();
});
});

describe('Retrying what failed', () => {
beforeEach(() => {
vi.clearAllMocks();
tasksService.clearTasks();
vi.spyOn(errorService, 'reportError').mockReturnValue();
vi.mocked(newStorageService.deleteFolderByUuid).mockResolvedValue(undefined as never);
fakeFolderCreationTaking();
});

test('When files inside a folder fail, then they are listed on the folder task', async () => {
failUploadsOf(['broken']);

await createUpload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })],
[],
).run();

expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([
expect.objectContaining({ name: 'broken', type: 'png', isFolder: false, isRetryable: true, isRetrying: false }),
]);
});

test('When a folder and a file both fail, then each task lists only its own failed items', async () => {
failUploadsOf(['broken', 'report']);

await createUpload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })],
[getFileToUpload('report.pdf')],
).run();

expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([expect.objectContaining({ name: 'broken' })]);
expect(tasksOf(TaskType.UploadFile)[0].failedItems).toEqual([expect.objectContaining({ name: 'report' })]);
});

test('When the user retries a folder, then its failed files are uploaded again into it and the folder ends with nothing failed', async () => {
failUploadsOf(['broken']);
const upload = createUpload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })],
[],
);
await upload.run();
uploadSucceeds();

await tasksOf(TaskType.UploadFolder)[0].retry?.();

const [folderTask] = tasksOf(TaskType.UploadFolder);
expect(uploadFile).toHaveBeenLastCalledWith(
expect.objectContaining({ name: 'broken', parentFolderId: 'Photos-uuid' }),
expect.any(Function),
expect.any(Object),
expect.any(Object),
);
expect(folderTask).toEqual(expect.objectContaining({ status: TaskStatus.Success, progress: 1, failedItems: [] }));
});

test('When the user cancels a folder while it is being retried, then the retry finishes only once the folder is removed', async () => {
failUploadsOf(['broken']);
const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], []);
await upload.run();
vi.mocked(uploadFile).mockClear();
fakeUploadsTaking(500);
let finishRemoval: () => void = () => undefined;
vi.mocked(newStorageService.deleteFolderByUuid).mockReturnValue(
new Promise<void>((resolve) => (finishRemoval = resolve)) as never,
);
const onRetryFinished = vi.fn();
const [folderTask] = tasksOf(TaskType.UploadFolder);

const retrying = folderTask.retry?.().then(onRetryFinished);
await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled());
await tasksService.cancelTask(folderTask.id);
await vi.waitFor(() => expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid'));
await new Promise((resolve) => setTimeout(resolve, 100));

expect(onRetryFinished).not.toHaveBeenCalled();
finishRemoval();
await retrying;
expect(onRetryFinished).toHaveBeenCalled();
});

test('When the user retries one failed file, then only that file is uploaded again', async () => {
failUploadsOf(['retried', 'still-failing']);
await createUpload(
[
getFolderToUpload('Photos', {
childrenFiles: [getFileToUpload('retried.png'), getFileToUpload('still-failing.png')],
}),
],
[],
).run();
uploadSucceeds();
vi.mocked(uploadFile).mockClear();
const [folderTask] = tasksOf(TaskType.UploadFolder);
const retriedFailure = folderTask.failedItems?.find(({ name }) => name === 'retried');

await retriedFailure?.retry();

expect(uploadedNames()).toEqual(['retried']);
expect(tasksService.findTask(folderTask.id)?.failedItems).toEqual([
expect.objectContaining({ name: 'still-failing' }),
]);
});

test('When the user retries everything, then the files that retrying cannot fix are not uploaded again', async () => {
(uploadFile as Mock).mockImplementation(async (file: { name: string }) => {
if (file.name === 'over-plan-limit') throw new AppError('Plan limit reached', HTTP_STATUS_CODES.PAYMENT_REQUIRED);
if (file.name === 'broken') throw new Error('Server error');
return { uuid: `${file.name}-uuid` };
});
await createUpload(
[
getFolderToUpload('Photos', {
childrenFiles: [getFileToUpload('over-plan-limit.png'), getFileToUpload('broken.png')],
}),
],
[],
).run();
uploadSucceeds();
vi.mocked(uploadFile).mockClear();

await tasksOf(TaskType.UploadFolder)[0].retry?.();

expect(uploadedNames()).toEqual(['broken']);
expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([
expect.objectContaining({ name: 'over-plan-limit', isRetrying: false }),
]);
});

test('When the user retries everything while a file is already being retried, then that file is uploaded only once', async () => {
failUploadsOf(['retrying', 'waiting']);
await createUpload(
[
getFolderToUpload('Photos', {
childrenFiles: [getFileToUpload('retrying.png'), getFileToUpload('waiting.png')],
}),
],
[],
).run();
fakeUploadsTaking(100);
vi.mocked(uploadFile).mockClear();
const [folderTask] = tasksOf(TaskType.UploadFolder);
const retryingFailure = folderTask.failedItems?.find(({ name }) => name === 'retrying');

const retryingOne = retryingFailure?.retry();
const retryingEverything = tasksService.findTask(folderTask.id)?.retry?.();
await Promise.all([retryingOne, retryingEverything]);

expect(uploadedNames()).toEqual(['retrying', 'waiting']);
});

test('When a folder is only fully uploaded after a retry, then it is not counted as a folder upload', async () => {
failUploadsOf(['report']);
await createUpload([getFolderToUpload('Docs', { childrenFiles: [getFileToUpload('report.pdf')] })], []).run();
uploadSucceeds();

await tasksOf(TaskType.UploadFolder)[0].retry?.();

expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([]);
expect(referralService.trackFolderUpload).not.toHaveBeenCalled();
});

test('When a folder that could not be created is retried, then it is created with all its content', async () => {
vi.mocked(folderService.createFolderByUuid).mockImplementation(
() => [Promise.reject(new Error('Server error')), { cancel: vi.fn() }] as never,
);
await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []).run();
fakeFolderCreationTaking();

await tasksOf(TaskType.UploadFolder)[0].retry?.();

expect(uploadFile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'beach', parentFolderId: 'Photos-uuid' }),
expect.any(Function),
expect.any(Object),
expect.any(Object),
);
expect(tasksOf(TaskType.UploadFolder)[0]).toEqual(expect.objectContaining({ status: TaskStatus.Success }));
});

test('When a failed file is retried, then its own task succeeds', async () => {
failUploadsOf(['report']);
await createUpload([], [getFileToUpload('report.pdf')]).run();
uploadSucceeds();

await tasksOf(TaskType.UploadFile)[0].retry?.();

expect(tasksOf(TaskType.UploadFile)[0]).toEqual(
expect.objectContaining({ status: TaskStatus.Success, subtitle: undefined, failedItems: [] }),
);
});

test('When the storage was full and the user retries after freeing space, then the files upload', async () => {
failUploadsOf(['report', 'invoice'], new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED));
await createUpload(
[getFolderToUpload('Docs', { childrenFiles: [getFileToUpload('report.pdf'), getFileToUpload('invoice.pdf')] })],
[],
).run();
uploadSucceeds();

await tasksOf(TaskType.UploadFolder)[0].retry?.();

expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([]);
});

test('When the storage fills up while uploading, then the user is told once, however many items fail', async () => {
const onStorageFull = vi.fn();
Expand All @@ -283,4 +496,76 @@ describe('Uploading what the user picked', () => {

expect(onStorageFull).toHaveBeenCalledOnce();
});

test('When a retry also finds the storage full, then the user is told again', async () => {
const onStorageFull = vi.fn();
failUploadsOf(['report'], new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED));
await createUpload([], [getFileToUpload('report.pdf')], { onStorageFull }).run();

await tasksOf(TaskType.UploadFile)[0].retry?.();

expect(onStorageFull).toHaveBeenCalledTimes(2);
});

test('When a cancelled folder is retried, then nothing is uploaded again', async () => {
failUploadsOf(['broken']);
await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], []).run();
const [folderTask] = tasksOf(TaskType.UploadFolder);
await tasksService.cancelTask(folderTask.id);
vi.mocked(uploadFile).mockClear();

await folderTask.retry?.();

expect(uploadFile).not.toHaveBeenCalled();
expect(tasksService.findTask(folderTask.id)).toEqual(
expect.objectContaining({
status: TaskStatus.Cancelled,
failedItems: [expect.objectContaining({ name: 'broken', isRetrying: false })],
}),
);
});

test('When the user frees space and retries while another file is still uploading, then the retried file uploads', async () => {
let isStorageFull = true;
(uploadFile as Mock).mockImplementation(async (file: { name: string }) => {
if (file.name === 'video') await new Promise((resolve) => setTimeout(resolve, 500));
else if (isStorageFull) throw new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED);
return { uuid: `${file.name}-uuid` };
});
const upload = createUpload([], [getFileToUpload('report.pdf'), getFileToUpload('video.mp4')]);
const running = upload.run();
const [reportTask, videoTask] = tasksOf(TaskType.UploadFile);
await vi.waitFor(() => expect(tasksService.findTask(reportTask.id)?.status).toBe(TaskStatus.Error));
isStorageFull = false;

await reportTask.retry?.();

expect(tasksService.findTask(reportTask.id)?.status).toBe(TaskStatus.Success);
expect(tasksService.findTask(videoTask.id)?.status).toBe(TaskStatus.InProcess);
await running;
});

test('When the failed files of two folders are retried, then each folder finishes when its own files do', async () => {
failUploadsOf(['broken', 'movie']);
await createUpload(
[
getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] }),
getFolderToUpload('Videos', { childrenFiles: [getFileToUpload('movie.mp4')] }),
],
[],
).run();
(uploadFile as Mock).mockImplementation(async (file: { name: string }) => {
if (file.name === 'movie') await new Promise((resolve) => setTimeout(resolve, 500));
return { uuid: `${file.name}-uuid` };
});
const [photosTask, videosTask] = tasksOf(TaskType.UploadFolder);

const retryingVideos = videosTask.retry?.();
await photosTask.retry?.();

expect(tasksService.findTask(photosTask.id)?.status).toBe(TaskStatus.Success);
expect(tasksService.findTask(videosTask.id)?.status).toBe(TaskStatus.InProcess);
await retryingVideos;
expect(tasksService.findTask(videosTask.id)?.status).toBe(TaskStatus.Success);
});
});
Loading
Loading