Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions src/app/store/slices/workspaces/workspaces.selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest';
import { RootState } from '../..';
import workspacesSelectors from './workspaces.selectors';

const stateWith = (workspaces: Partial<RootState['workspaces']>) => ({ workspaces }) as RootState;

describe('Credentials to act on behalf of the workspace owner', () => {
test('When no workspace is selected, then there are no workspace credentials', () => {
const state = stateWith({ selectedWorkspace: null, workspaceCredentials: null });

expect(workspacesSelectors.getOwnerUserAuthenticationData(state)).toBeUndefined();
});

test('When a workspace is selected, then its network credentials, bucket, key and token are used', () => {
const state = stateWith({
selectedWorkspace: { workspace: { id: 'workspace-id' }, workspaceUser: { key: 'workspace-key' } } as never,
workspaceCredentials: {
credentials: { networkUser: 'network-user', networkPass: 'network-pass' },
bucket: 'workspace-bucket',
tokenHeader: 'workspace-token',
} as never,
});

expect(workspacesSelectors.getOwnerUserAuthenticationData(state)).toEqual({
bridgeUser: 'network-user',
bridgePass: 'network-pass',
encryptionKey: 'workspace-key',
bucketId: 'workspace-bucket',
workspaceId: 'workspace-id',
workspacesToken: 'workspace-token',
});
});
});
16 changes: 16 additions & 0 deletions src/app/store/slices/workspaces/workspaces.selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces';
import { OwnerUserAuthenticationData } from 'app/network/types';
import { RootState } from '../..';

const workspacesSelectors = {
Expand All @@ -20,6 +21,21 @@ const workspacesSelectors = {
getFirstWorkspace(state: RootState): WorkspaceData | null {
return state.workspaces.workspaces[0];
},
getOwnerUserAuthenticationData(state: RootState): OwnerUserAuthenticationData | undefined {
const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state);
const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state);
const workspaceId = selectedWorkspace?.workspace?.id;
if (!workspaceId) return undefined;

return {
bridgeUser: workspaceCredentials?.credentials.networkUser,
bridgePass: workspaceCredentials?.credentials.networkPass,
encryptionKey: selectedWorkspace?.workspaceUser?.key,
bucketId: workspaceCredentials?.bucket,
workspaceId,
workspacesToken: workspaceCredentials?.tokenHeader,
} as OwnerUserAuthenticationData;
},
};

export default workspacesSelectors;
55 changes: 55 additions & 0 deletions src/app/upload/collisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DriveItemData } from 'app/drive/types';
import { CollisionGroup } from 'app/store/slices/storage/storage.model';
import {
handleRepeatedUploadingFiles,
handleRepeatedUploadingFolders,
} from 'app/store/slices/storage/storage.thunks/renameItemsThunk';
import { IRoot } from 'app/store/slices/storage/types';

interface RepeatedItemsCheck<T> {
repeatedItems: T[];
existingItems: DriveItemData[];
unrepeatedItems: T[];
}

interface NameCollisions {
collisionGroups: CollisionGroup[];
rootsWithoutCollision: IRoot[];
filesWithoutCollision: File[];
}

const noRepeatedItems = <T>(): RepeatedItemsCheck<T> => ({
repeatedItems: [],
existingItems: [],
unrepeatedItems: [],
});

const buildCollisionGroups = (destinationUuid: string, checks: RepeatedItemsCheck<unknown>[]): CollisionGroup[] =>
checks
.filter((check) => check.repeatedItems.length > 0)
.map(
(check) =>
({
destinationUuid,
duplicatedItems: check.repeatedItems,
existingItems: check.existingItems,
unrepeatedItems: check.unrepeatedItems,
}) as CollisionGroup,
);

export const findNameCollisions = async (
destinationFolderUuid: string,
roots: IRoot[],
files: File[],
): Promise<NameCollisions> => {
const [filesCheck, foldersCheck] = await Promise.all([
files.length > 0 ? handleRepeatedUploadingFiles(files, destinationFolderUuid) : noRepeatedItems<File>(),
roots.length > 0 ? handleRepeatedUploadingFolders(roots, destinationFolderUuid) : noRepeatedItems<IRoot>(),
]);

return {
collisionGroups: buildCollisionGroups(destinationFolderUuid, [filesCheck, foldersCheck]),
rootsWithoutCollision: foldersCheck.unrepeatedItems as IRoot[],
filesWithoutCollision: filesCheck.unrepeatedItems as File[],
};
};
8 changes: 8 additions & 0 deletions src/app/upload/notifyUploadFailed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import notificationsService, { ToastType } from 'app/notifications/services/notifications.service';
import errorService from 'services/error.service';

export const notifyUploadFailed = (error: unknown) => {
const uploadError = errorService.castError(error);
errorService.reportError(uploadError);
notificationsService.show({ text: uploadError.message, type: ToastType.Error, requestId: uploadError.requestId });
};
48 changes: 48 additions & 0 deletions src/app/upload/uploadChecks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { filterFilesByMaxSize } from 'app/store/slices/storage/fileUtils/filterFilesByMaxSize';
import { IRoot } from 'app/store/slices/storage/types';

export const UPLOAD_ITEMS_LIMIT = 3000;

export interface PlanStorage {
limit: number;
usage: number;
}

export const isHiddenFile = (file: File) => file.name.startsWith('.');

/**
* Drops the files that are too large to upload and, inside folders, the hidden files.
*/
export const getAllowedFilesToUpload = (roots: IRoot[], files: File[], maxUploadFileSize: number) => {
const exceededFiles: File[] = [];

const keepAllowedFiles = (candidates: File[]) => {
const { allowedFilesToUpload, exceededFiles: exceeded } = filterFilesByMaxSize({
files: candidates,
maxUploadFileSize,
});
exceededFiles.push(...exceeded);
return allowedFilesToUpload;
};

const keepAllowedContent = (folder: IRoot): IRoot => ({
...folder,
childrenFiles: keepAllowedFiles(folder.childrenFiles.filter((file) => !isHiddenFile(file))),
childrenFolders: folder.childrenFolders.map(keepAllowedContent),
});

return { roots: roots.map(keepAllowedContent), files: keepAllowedFiles(files), exceededFiles };
};

export const getTotalSize = (roots: IRoot[], files: File[]): number => {
const sizeOfFiles = (candidates: File[]) => candidates.reduce((total, file) => total + file.size, 0);
const sizeOfFolder = (folder: IRoot): number =>
folder.childrenFolders.reduce((total, child) => total + sizeOfFolder(child), sizeOfFiles(folder.childrenFiles));

return roots.reduce((total, root) => total + sizeOfFolder(root), sizeOfFiles(files));
};

export const hasStorageAvailable = ({ limit, usage }: PlanStorage, uploadSize: number): boolean => {
const hasUnlimitedStorage = !limit;
return hasUnlimitedStorage || usage + uploadSize < limit;
};
207 changes: 207 additions & 0 deletions src/app/upload/uploadItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, Mock, test, vi } from 'vitest';
import { AppError } from '@internxt/sdk';
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 { HTTP_STATUS_CODES } from 'app/core/constants';
import { OwnerUserAuthenticationData } from 'app/network/types';
import DatabaseUploadRepository from 'app/repositories/DatabaseUploadRepository';
import tasksService from 'app/tasks/services/tasks.service';
import errorService from 'services/error.service';
import referralService from 'services/referral.service';
import {
getCreatedFolder,
getFileToUpload,
getFolderToUpload,
getUploadRepository,
} from 'testUtils/fixtures/upload.fixtures';
import { UPLOAD_ITEMS_LIMIT } from './uploadChecks';
import { UploadContext, uploadItems } from './uploadItems';

vi.mock('app/drive/services/file.service/uploadFile', () => ({ default: vi.fn() }));
vi.mock('app/network/networkInformation', () => ({ logNetworkInfoForUpload: vi.fn() }));

const DESTINATION_UUID = 'destination-uuid';
const MAX_FILE_SIZE = 100;
const noSpaceLeftError = new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED);

const getUploadContext = (overrides: Partial<UploadContext> = {}): UploadContext => ({
maxUploadFileSize: MAX_FILE_SIZE,
planStorage: { limit: 0, usage: 0 },
onUploadItemsLimitExceeded: vi.fn(),
onFilesTooLarge: vi.fn(),
onPlanLimitReached: vi.fn(),
onNameCollisions: vi.fn(),
onItemUploaded: vi.fn(),
onItemRemoved: vi.fn(),
onUploadFinished: vi.fn(),
...overrides,
});

const upload = (roots: ReturnType<typeof getFolderToUpload>[], files: File[], context = getUploadContext()) =>
uploadItems({ destinationFolderUuid: DESTINATION_UUID, roots, files }, context);

const uploadedFiles = () =>
vi.mocked(uploadFile).mock.calls.map(([file]) => ({ name: file.name, folder: file.parentFolderId }));

const createdFolders = () =>
vi.mocked(folderService.createFolderByUuid).mock.calls.map(([parentUuid, name]) => ({ name, parent: parentUuid }));

const existingInDestination = ({ files = [], folders = [] }: { files?: string[]; folders?: string[] }) => {
vi.spyOn(newStorageService, 'checkDuplicatedFiles').mockResolvedValue({
existentFiles: files.map((name) => ({ plainName: name, type: 'pdf' })),
} as never);
vi.spyOn(newStorageService, 'checkDuplicatedFolders').mockResolvedValue({
existentFolders: folders.map((name) => ({ plainName: name })),
} as never);
};

describe('Uploading items into a folder', () => {
beforeEach(() => {
vi.clearAllMocks();
tasksService.clearTasks();
vi.spyOn(errorService, 'reportError').mockReturnValue();
vi.spyOn(referralService, 'trackFileUpload').mockReturnValue();
vi.spyOn(referralService, 'trackFolderUpload').mockReturnValue();
vi.spyOn(DatabaseUploadRepository, 'getInstance').mockReturnValue(getUploadRepository() as never);
(uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ uuid: `${file.name}-uuid` }));
vi.spyOn(folderService, 'createFolderByUuid').mockImplementation(
(_parentUuid, name) => [Promise.resolve(getCreatedFolder(name)), { cancel: vi.fn() }] as never,
);
existingInDestination({});
});

test('When the items have no name conflicts, then they are uploaded into the folder and it is refreshed shortly after', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const context = getUploadContext();

await upload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })],
[getFileToUpload('report.pdf')],
context,
);
vi.runAllTimers();
vi.useRealTimers();

expect(createdFolders()).toEqual([{ name: 'Photos', parent: DESTINATION_UUID }]);
expect(uploadedFiles()).toEqual(
expect.arrayContaining([
{ name: 'report', folder: DESTINATION_UUID },
{ name: 'beach', folder: 'Photos-uuid' },
]),
);
expect(context.onUploadFinished).toHaveBeenCalledWith(DESTINATION_UUID);
});

test('When some items already exist in the folder, then the user is asked how to resolve them and only the rest are uploaded', async () => {
const context = getUploadContext();
const [conflictingFile, newFile] = [getFileToUpload('report.pdf'), getFileToUpload('notes.txt')];
existingInDestination({ files: ['report'] });

await upload([], [conflictingFile, newFile], context);

expect(context.onNameCollisions).toHaveBeenCalledWith([
expect.objectContaining({ destinationUuid: DESTINATION_UUID, duplicatedItems: [conflictingFile] }),
]);
expect(uploadedFiles()).toEqual([{ name: 'notes', folder: DESTINATION_UUID }]);
});

test('When every item already exists, then nothing is uploaded', async () => {
existingInDestination({ files: ['report'], folders: ['Photos'] });

await upload([getFolderToUpload('Photos')], [getFileToUpload('report.pdf')]);

expect(createdFolders()).toEqual([]);
expect(uploadedFiles()).toEqual([]);
});

test('When there are more files than the upload limit, then the user is warned and nothing is uploaded', async () => {
const context = getUploadContext();
const files = Array.from({ length: UPLOAD_ITEMS_LIMIT + 1 }, (_, index) => getFileToUpload(`${index}.txt`));

await upload([], files, context);

expect(context.onUploadItemsLimitExceeded).toHaveBeenCalled();
expect(uploadedFiles()).toEqual([]);
});

test('When some files are too large, anywhere in the folders, then the user is told about all of them at once and the rest are uploaded', async () => {
const context = getUploadContext();
const [largeLooseFile, largeNestedFile] = [getFileToUpload('movie.mp4', 200), getFileToUpload('raw.mov', 200)];
const videos = getFolderToUpload('Videos', {
childrenFolders: [getFolderToUpload('Raw', { childrenFiles: [largeNestedFile] })],
});

await upload([videos], [largeLooseFile, getFileToUpload('notes.txt')], context);

expect(context.onFilesTooLarge).toHaveBeenCalledWith([largeNestedFile, largeLooseFile]);
expect(uploadedFiles()).toEqual([{ name: 'notes', folder: DESTINATION_UUID }]);
expect(createdFolders()).toEqual([
{ name: 'Videos', parent: DESTINATION_UUID },
{ name: 'Raw', parent: 'Videos-uuid' },
]);
});

test('When a folder contains hidden files, then they are left out', async () => {
await upload(
[getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('.DS_Store'), getFileToUpload('beach.png')] })],
[],
);

expect(uploadedFiles()).toEqual([{ name: 'beach', folder: 'Photos-uuid' }]);
});

test('When there is not enough storage left, then the user is told and nothing is uploaded', async () => {
const context = getUploadContext({ planStorage: { limit: 1000, usage: 995 } });

await upload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png', 10)] })], [], context);

expect(context.onPlanLimitReached).toHaveBeenCalled();
expect(createdFolders()).toEqual([]);
expect(uploadedFiles()).toEqual([]);
});

test('When uploading in a workspace, then the files are uploaded with the workspace credentials', async () => {
const ownerUserAuthenticationData = { workspaceId: 'workspace-id' } as OwnerUserAuthenticationData;

await upload([], [getFileToUpload('report.pdf')], getUploadContext({ ownerUserAuthenticationData }));

expect(uploadFile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'report' }),
expect.any(Function),
expect.objectContaining({ isTeam: true, ownerUserAuthenticationData }),
expect.any(Object),
);
});

test('When items finish uploading, then the files are listed in their folder and only the folders created in the destination are listed', async () => {
const context = getUploadContext();
const photos = getFolderToUpload('Photos', {
childrenFiles: [getFileToUpload('beach.png')],
childrenFolders: [getFolderToUpload('Summer')],
});

await upload([photos], [], context);

const listedItems = vi.mocked(context.onItemUploaded).mock.calls.map(([item, folderUuid]) => ({
uuid: item.uuid,
folderUuid,
}));
expect(listedItems).toEqual(
expect.arrayContaining([
{ uuid: 'Photos-uuid', folderUuid: DESTINATION_UUID },
{ uuid: 'beach-uuid', folderUuid: 'Photos-uuid' },
]),
);
expect(listedItems).not.toContainEqual({ uuid: 'Summer-uuid', folderUuid: 'Photos-uuid' });
});

test('When the storage fills up while the items upload, then the user is told the plan limit was reached', async () => {
const context = getUploadContext();
(uploadFile as Mock).mockRejectedValue(noSpaceLeftError);

await upload([], [getFileToUpload('report.pdf'), getFileToUpload('notes.txt')], context);

expect(context.onPlanLimitReached).toHaveBeenCalledOnce();
});
});
Loading
Loading