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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({
getEnvironmentConfig: vi.fn(),
networkUploadFile: vi.fn(),
replaceFile: vi.fn(),
handleRepeatedUploadingFiles: vi.fn(),
handleRepeatedUploadingFolders: vi.fn(),
}));

vi.mock('views/Trash/services', () => ({ moveItemsToTrash: mocks.moveItemsToTrash }));
Expand All @@ -40,6 +42,10 @@ vi.mock('app/store/slices/storage/folderUtils/checkFolderDuplicated', () => ({
vi.mock('app/store/slices/storage/folderUtils/getUniqueFolderName', () => ({
getUniqueFolderName: mocks.getUniqueFolderName,
}));
vi.mock('app/store/slices/storage/storage.thunks/renameItemsThunk', () => ({
handleRepeatedUploadingFiles: mocks.handleRepeatedUploadingFiles,
handleRepeatedUploadingFolders: mocks.handleRepeatedUploadingFolders,
}));
vi.mock('app/drive/services/folder.service/uploadFoldersWithTracking', () => ({
uploadFoldersWithTracking: mocks.uploadFoldersWithTracking,
}));
Expand All @@ -62,12 +68,13 @@ const asUploadAction = (payload: unknown) => ({ type: 'upload', payload });
const asRefreshAction = (payload: unknown) => ({ type: 'refresh', payload });
const asInvalidateCacheAction = (payload: unknown) => ({ type: 'invalidateCache', payload });

const getRoot = (name = 'Photos'): IRoot => ({
const getRoot = (name = 'Photos', children: Partial<Pick<IRoot, 'childrenFiles' | 'childrenFolders'>> = {}): IRoot => ({
name,
folderId: null,
childrenFiles: [],
childrenFolders: [],
fullPathEdited: `/${name}`,
...children,
});

const getContext = (overrides: Partial<NameCollisionContext> = {}): NameCollisionContext => ({
Expand Down Expand Up @@ -254,6 +261,48 @@ describe('resolveCollision', () => {
]);
});

test('when uploading with skip, then skipped files are ignored and skipped folders merge their new content into the existing folder recursively', async () => {
const existingFile = new File(['a'], 'a.txt');
const newFile = new File(['b'], 'b.txt');
const nestedFile = new File(['c'], 'c.txt');
const collidingSubfolder = getRoot('Sub', { childrenFiles: [nestedFile] });
const newSubfolder = getRoot('New');
const root = getRoot('Photos', {
childrenFiles: [existingFile, newFile],
childrenFolders: [collidingSubfolder, newSubfolder],
});
const existingPdf = getDriveItemData({ uuid: 'existing-pdf', plainName: 'report', type: 'pdf' });
const existingFolder = getDriveItemData({ uuid: 'photos-uuid', plainName: 'Photos', isFolder: true });
mocks.handleRepeatedUploadingFiles.mockImplementation(async (files: File[]) => ({
unrepeatedItems: files.filter((file) => file !== existingFile),
repeatedItems: [],
existingItems: [],
}));
mocks.handleRepeatedUploadingFolders.mockImplementation(async (folders: IRoot[]) => ({
unrepeatedItems: folders.filter((folder) => folder !== collidingSubfolder),
repeatedItems: folders.filter((folder) => folder === collidingSubfolder),
existingItems: folders.includes(collidingSubfolder) ? [{ uuid: 'sub-uuid', plainName: 'Sub' }] : [],
}));

await resolve({
operationType: 'upload',
operation: 'skip',
items: [new File(['content'], 'report.pdf'), root],
existingItems: [existingPdf, existingFolder],
});

expect(mocks.moveItemsToTrash).not.toHaveBeenCalled();
expect(mocks.uploadFoldersWithTracking).toHaveBeenCalledTimes(1);
expect(mocks.uploadFoldersWithTracking).toHaveBeenCalledWith(
expect.objectContaining({ payload: [{ root: { ...newSubfolder }, currentFolderId: 'photos-uuid' }] }),
);
expect(mocks.dispatch.mock.calls).toEqual([
[asUploadAction({ files: [newFile], parentFolderId: 'photos-uuid', options: { disableDuplicatedNamesCheck: true } })],
[asUploadAction({ files: [nestedFile], parentFolderId: 'sub-uuid', options: { disableDuplicatedNamesCheck: true } })],
[asRefreshAction(DESTINATION)],
]);
});

test('when several files are versioned, then they are replaced one at a time', async () => {
let inFlight = 0;
let maxInFlight = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { getUniqueFolderName } from 'app/store/slices/storage/folderUtils/getUni
import storageThunks from 'app/store/slices/storage/storage.thunks';
import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk';
import { MoveItemPayload } from 'app/store/slices/storage/storage.thunks/moveItemsThunk';
import {
handleRepeatedUploadingFiles,
handleRepeatedUploadingFolders,
} from 'app/store/slices/storage/storage.thunks/renameItemsThunk';
import { IRoot } from 'app/store/slices/storage/types';
import { isVersioningExtensionAllowed } from 'views/Drive/components/VersionHistory/utils';
import replaceFileService from 'views/Drive/services/replaceFile.service';
Expand Down Expand Up @@ -227,6 +231,52 @@ const replaceAndUploadItems = async (
context.dispatch(fetchSortedFolderContentThunk(destinationUuid));
};

const uploadNewFilesOnly = async (files: File[], destinationUuid: string, context: NameCollisionContext) => {
const { unrepeatedItems: newFiles } = await handleRepeatedUploadingFiles(files, destinationUuid);
await uploadFiles(newFiles as File[], destinationUuid, context, true);
};

/**
* Merges a skipped folder upload into its existing counterpart: files that already
* exist are left untouched, new files and new subfolders are uploaded into the
* existing folder, and colliding subfolders are merged recursively so the folder
* structure is preserved.
*/
const mergeSkipFolderUpload = async (root: IRoot, existingFolderUuid: string, context: NameCollisionContext) => {
await uploadNewFilesOnly(root.childrenFiles, existingFolderUuid, context);

const {
unrepeatedItems: newFolders,
repeatedItems: collidingFolders,
existingItems: existingFolders,
} = await handleRepeatedUploadingFolders(root.childrenFolders, existingFolderUuid);

await uploadFolders(newFolders as IRoot[], existingFolderUuid, context);

for (const collidingFolder of collidingFolders as IRoot[]) {
const existingFolder = existingFolders.find((folder) => folder.plainName === collidingFolder.name);
if (existingFolder) {
await mergeSkipFolderUpload(collidingFolder, existingFolder.uuid, context);
}
}
};

/**
* Skipping uploaded files is a no-op (the existing files stay untouched), while
* skipping uploaded folders merges their new content into the existing folders.
*/
const skipAndUploadItems = async (
pairs: CollisionPair<IRoot | File>[],
destinationUuid: string,
context: NameCollisionContext,
): Promise<void> => {
const folderPairs = pairs.filter((pair) => isFolderUpload(pair.item));
if (folderPairs.length === 0) return;

await Promise.all(folderPairs.map((pair) => mergeSkipFolderUpload(pair.item as IRoot, pair.existing.uuid, context)));
context.dispatch(fetchSortedFolderContentThunk(destinationUuid));
};

/**
* Uploads the items next to the existing ones, letting the upload flow pick a unique name.
*/
Expand All @@ -242,19 +292,23 @@ const keepAndUploadItems = async (
};

/**
* Applies the chosen resolution to items that collide while being uploaded. Skipped items are
* left untouched.
* Applies the chosen resolution to items that collide while being uploaded. Skipped files are
* left untouched, while skipped folders merge their new content into the existing folder.
*/
const resolveUploadCollision = async (
{ operation, items, existingItems, destinationUuid }: ResolveUploadCollisionParams,
context: NameCollisionContext,
) => {
if (operation === 'skip') return;

if (operation === 'keep') {
await keepAndUploadItems(items, destinationUuid, context);
return;
}

const pairs = getCollisionPairs(items, existingItems);
if (operation === 'replace') {
await replaceAndUploadItems(pairs, destinationUuid, context);
} else {
await replaceAndUploadItems(getCollisionPairs(items, existingItems), destinationUuid, context);
await skipAndUploadItems(pairs, destinationUuid, context);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,13 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E

{
/* DROPPABLE ZONE */
isInteractive && connectDropTarget(<div className="absolute top-0 h-full w-1/2 group-hover:invisible"></div>)
isInteractive &&
connectDropTarget(
<div
className="absolute top-0 h-full w-1/2 group-hover:invisible"
data-test={`${basicFileDataTest}-drop-zone`}
></div>,
)
}

{/* AUTO-DELETE (only for trash) */}
Expand Down
102 changes: 86 additions & 16 deletions test/e2e/tests/helper/driveRouteMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ const BRIDGE_URL = process.env.REACT_APP_STORJ_BRIDGE;

const loggedUser = getLoggedUser();

export const ROOT_FOLDER_UUID: string = loggedUser.user.rootFolderId;

export type TrashRequest = { items: { uuid: string; type: string }[] };
export type MoveRequest = { uuid: string; destinationFolder: string; name?: string };
export type CreateFolderRequest = { plainName: string; parentFolderUuid: string };
export type ExistingFile = ReturnType<typeof buildExistingFile>;
export type ExistingFolder = ReturnType<typeof buildExistingFolder>;

/**
* Every request the specs may want to assert on, recorded by the mocked routes.
Expand All @@ -19,10 +24,18 @@ export type ExistingFile = ReturnType<typeof buildExistingFile>;
*/
export type RecordedRequests = {
trash: TrashRequest[];
moves: MoveRequest[];
createdFolders: CreateFolderRequest[];
bridge: string[];
};

export const buildExistingFile = (id: number, plainName: string, type: string) => ({
export type MockedDriveOptions = {
files?: ExistingFile[];
folders?: ExistingFolder[];
isVersioningEnabled?: boolean;
};

export const buildExistingFile = (id: number, plainName: string, type: string, folderUuid = ROOT_FOLDER_UUID) => ({
id,
uuid: `existing-file-uuid-${id}`,
fileId: `existing-bridge-file-${id}`,
Expand All @@ -32,31 +45,61 @@ export const buildExistingFile = (id: number, plainName: string, type: string) =
type,
size: 1024,
bucket: loggedUser.user.bucket,
folderUuid: loggedUser.user.rootFolderId,
folderUuid,
createdAt: '2026-08-01T10:00:00.000Z',
updatedAt: '2026-08-01T10:00:00.000Z',
status: 'EXISTS',
thumbnails: [],
});

export const buildExistingFolder = (id: number, plainName: string, parentUuid = ROOT_FOLDER_UUID) => ({
id,
uuid: `existing-folder-uuid-${id}`,
name: plainName,
plainName,
plain_name: plainName,
parentUuid,
parentId: null,
bucket: loggedUser.user.bucket,
createdAt: '2026-08-01T10:00:00.000Z',
updatedAt: '2026-08-01T10:00:00.000Z',
deleted: false,
removed: false,
});

/**
* The Drive the mocked API serves. Trashing removes items, so follow-up listings and
* duplicate checks see the new state.
* The Drive the mocked API serves. Trashing removes items and creating a folder adds it,
* so follow-up listings and duplicate checks see the new state.
*/
class InMemoryDrive {
private files: ExistingFile[];
private folders: ExistingFolder[];
private createdFoldersCount = 0;

constructor(files: ExistingFile[]) {
constructor({ files = [], folders = [] }: MockedDriveOptions) {
this.files = [...files];
this.folders = [...folders];
}

filesIn(folderUuid: string) {
return this.files.filter((file) => file.folderUuid === folderUuid);
}

foldersIn(folderUuid: string) {
return this.folders.filter((folder) => folder.parentUuid === folderUuid);
}

createFolder({ plainName, parentFolderUuid }: CreateFolderRequest) {
this.createdFoldersCount += 1;
const folder = buildExistingFolder(1000 + this.createdFoldersCount, plainName, parentFolderUuid);
this.folders.push(folder);
return folder;
}

trash(uuids: string[]) {
const trashed = new Set(uuids);
this.files = this.files.filter((file) => !trashed.has(file.uuid));
this.folders = this.folders.filter((folder) => !trashed.has(folder.uuid));
}
}

Expand All @@ -70,7 +113,7 @@ const getFolderUuidFromUrl = (url: string) => /\/folders\/content\/([^/?]+)/.exe
* payloads, and any other API call with an empty 200. The app logs the user out on any
* 401, and the mocked session token is not valid against a real backend.
*/
const mockAppBootstrapCalls = async (page: Page) => {
const mockAppBootstrapCalls = async (page: Page, isVersioningEnabled: boolean) => {
for (const baseUrl of [BASE_API_URL, OLD_API_URL, PAYMENTS_API_URL]) {
await page.route(`${baseUrl}/**`, (route) => fulfillJson(route, {}));
}
Expand All @@ -79,7 +122,7 @@ const mockAppBootstrapCalls = async (page: Page) => {
'workspaces/': { availableWorkspaces: [], pendingWorkspaces: [] },
'sharings/invites**': { invites: [] },
'sharings/roles': [],
'files/limits': { versioning: { enabled: false, maxVersions: 0 }, maxUploadFileSize: 21474836480 },
'files/limits': { versioning: { enabled: isVersioningEnabled, maxVersions: 5 }, maxUploadFileSize: 21474836480 },
'users/limit': { maxSpaceBytes: 10737418240 },
'users/usage': { drive: 3072, backups: 0, total: 3072 },
'users/me/upload-status': { hasUploadedFiles: true },
Expand All @@ -93,7 +136,8 @@ const mockAppBootstrapCalls = async (page: Page) => {
};

/**
* Folder listings and the file duplicate check, scoped to the folder in the URL.
* Folder listings and the file and folder duplicate checks, all scoped to the folder in
* the URL.
*/
const mockFolderContentRoutes = (page: Page, drive: InMemoryDrive) =>
page.route(`${BASE_API_URL}/folders/content/**`, (route, request) => {
Expand All @@ -111,9 +155,33 @@ const mockFolderContentRoutes = (page: Page, drive: InMemoryDrive) =>
return fulfillJson(route, { existentFiles });
}

if (isExistenceCheck) return fulfillJson(route, { existentFolders: [] });
if (isExistenceCheck && url.endsWith('/folders/existence')) {
const { plainNames } = request.postDataJSON() as { plainNames: string[] };
const existentFolders = drive.foldersIn(folderUuid).filter((existing) => plainNames.includes(existing.plainName));
return fulfillJson(route, { existentFolders });
}

if (url.includes('/files/')) return fulfillJson(route, { files: drive.filesIn(folderUuid) });
if (url.includes('/folders/')) return fulfillJson(route, { folders: [] });
if (url.includes('/folders/')) return fulfillJson(route, { folders: drive.foldersIn(folderUuid) });
return fulfillJson(route, {});
});

const mockCreateFolderRoute = (page: Page, drive: InMemoryDrive, requests: RecordedRequests) =>
page.route(`${BASE_API_URL}/folders`, (route, request) => {
if (request.method() !== 'POST') return route.fallback();

const createFolderRequest = request.postDataJSON() as CreateFolderRequest;
requests.createdFolders.push(createFolderRequest);
return fulfillJson(route, drive.createFolder(createFolderRequest));
});

const mockMoveFileRoute = (page: Page, requests: RecordedRequests) =>
page.route(`${BASE_API_URL}/files/*`, (route, request) => {
if (request.method() !== 'PATCH') return route.fallback();

const uuid = request.url().split('/').pop() ?? '';
const { destinationFolder, name } = request.postDataJSON() as Omit<MoveRequest, 'uuid'>;
requests.moves.push(name ? { uuid, destinationFolder, name } : { uuid, destinationFolder });
return fulfillJson(route, {});
});

Expand All @@ -133,18 +201,20 @@ const mockBridgeRoute = (page: Page, requests: RecordedRequests) =>

/**
* Mocks everything a logged-in Drive view needs on top of an in-memory Drive made of the
* given files, blocks bucket uploads to the bridge, and returns the recorder of every
* request a spec may assert on.
* given files and folders, blocks bucket uploads to the bridge, and returns the recorder
* of every request a spec may assert on.
* Routes are registered from the most generic to the most specific because Playwright
* matches the last registered route first.
*/
export const mockDriveRoutes = async (page: Page, files: ExistingFile[]): Promise<RecordedRequests> => {
const drive = new InMemoryDrive(files);
const requests: RecordedRequests = { trash: [], bridge: [] };
export const mockDriveRoutes = async (page: Page, options: MockedDriveOptions): Promise<RecordedRequests> => {
const drive = new InMemoryDrive(options);
const requests: RecordedRequests = { trash: [], moves: [], createdFolders: [], bridge: [] };

await mockAppBootstrapCalls(page);
await mockAppBootstrapCalls(page, options.isVersioningEnabled ?? false);
await mockAuthRoutes(page);
await mockFolderContentRoutes(page, drive);
await mockCreateFolderRoute(page, drive, requests);
await mockMoveFileRoute(page, requests);
await mockTrashRoute(page, drive, requests);
await mockBridgeRoute(page, requests);

Expand Down
Loading
Loading