From 449198baf56ea3d9a2c1b74185998b84e9e0e199 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 25 Aug 2026 20:54:55 -0400 Subject: [PATCH 1/3] feat: merge folder uploads into the existing folder on skip Skipping a colliding folder upload now keeps the existing folder and uploads only the files and subfolders that do not exist yet, merging colliding subfolders recursively so the folder structure is preserved. --- .../nameCollision.actions.test.ts | 51 ++++++++++++++- .../nameCollision.actions.ts | 64 +++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/app/drive/components/NameCollisionDialog/nameCollision.actions.test.ts b/src/app/drive/components/NameCollisionDialog/nameCollision.actions.test.ts index 7428ead3b..a35724a5e 100644 --- a/src/app/drive/components/NameCollisionDialog/nameCollision.actions.test.ts +++ b/src/app/drive/components/NameCollisionDialog/nameCollision.actions.test.ts @@ -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 })); @@ -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, })); @@ -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> = {}): IRoot => ({ name, folderId: null, childrenFiles: [], childrenFolders: [], fullPathEdited: `/${name}`, + ...children, }); const getContext = (overrides: Partial = {}): NameCollisionContext => ({ @@ -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; diff --git a/src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts b/src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts index 99f1ac9cd..1a9594233 100644 --- a/src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts +++ b/src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts @@ -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'; @@ -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[], + destinationUuid: string, + context: NameCollisionContext, +): Promise => { + 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. */ @@ -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); } }; From ae1d2027e4eb57cf49bf9a44722f3c7536a0aec4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 11 Sep 2026 01:31:37 -0400 Subject: [PATCH 2/3] test: add e2e coverage for name collision resolutions Covers keep both for files and folders, replacing a folder, merging a skipped folder into the existing one, replace, keep both and skip for moved files, and versioned replace. The Drive mock now tracks folders, folder creation, moves and a versioning flag; moves are driven by dispatching the HTML5 drag events on the row's drop zone. --- .../DriveExplorerListItem.tsx | 8 +- test/e2e/tests/helper/driveRouteMocks.ts | 102 ++++++++-- test/e2e/tests/helper/mockedDrive.ts | 8 +- test/e2e/tests/pages/drivePage.ts | 22 +++ ...nternxt-name-collision-resolutions.spec.ts | 175 ++++++++++++++++++ ...DRIVE-internxt-name-collision-skip.spec.ts | 2 +- 6 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx index deacb7c3a..e8be138e6 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -196,7 +196,13 @@ const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.E { /* DROPPABLE ZONE */ - isInteractive && connectDropTarget(
) + isInteractive && + connectDropTarget( +
, + ) } {/* AUTO-DELETE (only for trash) */} diff --git a/test/e2e/tests/helper/driveRouteMocks.ts b/test/e2e/tests/helper/driveRouteMocks.ts index b4c2544b1..cc7d6549f 100644 --- a/test/e2e/tests/helper/driveRouteMocks.ts +++ b/test/e2e/tests/helper/driveRouteMocks.ts @@ -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; +export type ExistingFolder = ReturnType; /** * Every request the specs may want to assert on, recorded by the mocked routes. @@ -19,10 +24,18 @@ export type ExistingFile = ReturnType; */ 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}`, @@ -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)); } } @@ -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, {})); } @@ -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 }, @@ -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) => { @@ -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; + requests.moves.push(name ? { uuid, destinationFolder, name } : { uuid, destinationFolder }); return fulfillJson(route, {}); }); @@ -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 => { - const drive = new InMemoryDrive(files); - const requests: RecordedRequests = { trash: [], bridge: [] }; +export const mockDriveRoutes = async (page: Page, options: MockedDriveOptions): Promise => { + 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); diff --git a/test/e2e/tests/helper/mockedDrive.ts b/test/e2e/tests/helper/mockedDrive.ts index 308fb7d19..d524ac94c 100644 --- a/test/e2e/tests/helper/mockedDrive.ts +++ b/test/e2e/tests/helper/mockedDrive.ts @@ -1,6 +1,6 @@ import { expect, Page } from '@playwright/test'; import { logInThroughUI } from './authRouteMocks'; -import { ExistingFile, mockDriveRoutes } from './driveRouteMocks'; +import { MockedDriveOptions, mockDriveRoutes } from './driveRouteMocks'; import { DrivePage } from '../pages/drivePage'; import { NameCollisionDialogPage } from '../pages/nameCollisionDialogPage'; @@ -19,12 +19,12 @@ export const buildUploadFile = (name: string) => ({ * Mocks the API around the given Drive, logs in through the UI and waits until the first * file is listed. Returns the page objects and the recorder of the requests the app made. */ -export const openMockedDrive = async (page: Page, files: ExistingFile[]) => { - const requests = await mockDriveRoutes(page, files); +export const openMockedDrive = async (page: Page, options: MockedDriveOptions) => { + const requests = await mockDriveRoutes(page, options); await logInThroughUI(page); const drivePage = new DrivePage(page); - const [firstFile] = files; + const [firstFile] = options.files ?? []; if (firstFile) { await expect(drivePage.fileRow(`${firstFile.plainName}.${firstFile.type}`)).toBeVisible({ timeout: 10000 }); } diff --git a/test/e2e/tests/pages/drivePage.ts b/test/e2e/tests/pages/drivePage.ts index 46b52a75c..a0169745b 100644 --- a/test/e2e/tests/pages/drivePage.ts +++ b/test/e2e/tests/pages/drivePage.ts @@ -26,6 +26,7 @@ export class DrivePage { private uploadWidgetBorder: Locator; private movingToTrashAndMovedSign: Locator; private fileInput: Locator; + private folderInput: Locator; constructor(page: Page) { this.page = page; @@ -69,6 +70,7 @@ export class DrivePage { ); this.uploadWidgetBorder = this.page.locator('[class$="border-b border-gray-10 bg-gray-5 px-3 py-2.5"]'); this.fileInput = this.page.locator('[data-test="input-file"]'); + this.folderInput = this.page.locator('[data-test="input-folder"]'); } async checkFolder(folderName: string) { const folderLocator = this.allFolderNamesInDrive.filter({ hasText: folderName }); @@ -176,6 +178,26 @@ export class DrivePage { async uploadFiles(files: { name: string; mimeType: string; buffer: Buffer }[]) { await this.fileInput.setInputFiles(files); } + async uploadFolder(directoryPath: string) { + await this.folderInput.setInputFiles(directoryPath); + } + itemRow(itemName: string) { + return this.page.locator('[data-test$="-parent"]', { + has: this.page.getByRole('button', { name: itemName, exact: true }), + }); + } + + async dragItemToFolder(itemName: string, folderName: string) { + const source = this.itemRow(itemName); + const dropZone = this.itemRow(folderName).locator('[data-test$="-drop-zone"]'); + const dataTransfer = await this.page.evaluateHandle(() => new DataTransfer()); + + await source.dispatchEvent('dragstart', { dataTransfer }); + await dropZone.dispatchEvent('dragenter', { dataTransfer }); + await dropZone.dispatchEvent('dragover', { dataTransfer }); + await dropZone.dispatchEvent('drop', { dataTransfer }); + await source.dispatchEvent('dragend', { dataTransfer }); + } fileRow(fileName: string) { return this.page.locator(`[title="${fileName}"]`); } diff --git a/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts b/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts new file mode 100644 index 000000000..92af0e526 --- /dev/null +++ b/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts @@ -0,0 +1,175 @@ +import { expect, test } from '@playwright/test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { buildExistingFile, buildExistingFolder, ROOT_FOLDER_UUID } from '../helper/driveRouteMocks'; +import { buildUploadFile, openMockedDrive } from '../helper/mockedDrive'; +import { staticData } from '../helper/staticData'; + +const archive = buildExistingFolder(1, 'Archive'); +const photos = buildExistingFolder(2, 'Photos'); +const rootReport = buildExistingFile(1, 'report', 'txt'); +const archivedReport = buildExistingFile(3, 'report', 'txt', archive.uuid); +const existingDrive = { + files: [ + rootReport, + buildExistingFile(2, 'invoice', 'pdf'), + archivedReport, + buildExistingFile(4, 'a', 'txt', photos.uuid), + ], + folders: [archive, photos, buildExistingFolder(3, 'Sub', photos.uuid)], +}; + +/** + * A local "Photos" folder that partially overlaps with the existing one: + * Photos/a.txt (exists), Photos/b.txt (new), Photos/Sub/c.txt (Sub exists, c.txt is new), + * Photos/New/d.txt (New is new). + */ +const createPhotosDirectory = () => { + const root = mkdtempSync(join(tmpdir(), 'collision-')); + const photosPath = join(root, 'Photos'); + mkdirSync(join(photosPath, 'Sub'), { recursive: true }); + mkdirSync(join(photosPath, 'New'), { recursive: true }); + writeFileSync(join(photosPath, 'a.txt'), 'a'); + writeFileSync(join(photosPath, 'b.txt'), 'b'); + writeFileSync(join(photosPath, 'Sub', 'c.txt'), 'c'); + writeFileSync(join(photosPath, 'New', 'd.txt'), 'd'); + return { root, photosPath }; +}; + +test.describe('Internxt name collision resolutions', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('TC1: Validate that keeping both on a duplicated file uploads it under a numbered name', async ({ page }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.uploadFiles([buildUploadFile('report.txt')]); + await collisionDialog.resolve('report.txt', staticData.collisionKeepBothOption); + + await collisionDialog.expectClosed(); + await expect(drivePage.taskItem('report (1).txt')).toBeVisible({ timeout: 10000 }); + expect(requests.trash).toHaveLength(0); + }); + + test.describe('Folder uploads', () => { + let directory: ReturnType; + + test.beforeAll(() => { + directory = createPhotosDirectory(); + }); + + test.afterAll(() => { + rmSync(directory.root, { recursive: true, force: true }); + }); + + test('TC2: Validate that keeping both on a duplicated folder creates it under a numbered name', async ({ + page, + }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.uploadFolder(directory.photosPath); + await collisionDialog.resolve('Photos', staticData.collisionKeepBothOption); + + await expect + .poll(() => requests.createdFolders, { timeout: 10000 }) + .toContainEqual({ + plainName: 'Photos (1)', + parentFolderUuid: ROOT_FOLDER_UUID, + }); + expect(requests.trash).toHaveLength(0); + }); + + test('TC3: Validate that replacing a duplicated folder trashes the existing one before creating the new one', async ({ + page, + }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.uploadFolder(directory.photosPath); + await collisionDialog.resolve('Photos', staticData.collisionReplaceOption); + + await expect + .poll(() => requests.createdFolders, { timeout: 10000 }) + .toContainEqual({ + plainName: 'Photos', + parentFolderUuid: ROOT_FOLDER_UUID, + }); + expect(requests.trash).toEqual([{ items: [{ uuid: photos.uuid, type: 'folder' }] }]); + }); + + test('TC4: Validate that skipping a duplicated folder merges only its new content into the existing folder', async ({ + page, + }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.uploadFolder(directory.photosPath); + await collisionDialog.resolve('Photos', staticData.collisionSkipOption); + + await expect(drivePage.taskItem('b.txt')).toBeVisible({ timeout: 10000 }); + await expect(drivePage.taskItem('c.txt')).toBeVisible({ timeout: 10000 }); + await expect + .poll(() => requests.createdFolders, { timeout: 10000 }) + .toEqual([{ plainName: 'New', parentFolderUuid: photos.uuid }]); + await expect(drivePage.taskItem('a.txt')).toHaveCount(0); + expect(requests.trash).toHaveLength(0); + }); + }); + + test.describe('Move collisions', () => { + test('TC5: Validate that replacing a moved file trashes the file it collides with in the destination', async ({ + page, + }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.dragItemToFolder('report.txt', 'Archive'); + await collisionDialog.resolve('report', staticData.collisionReplaceOption); + + await collisionDialog.expectClosed(); + await expect + .poll(() => requests.moves, { timeout: 10000 }) + .toEqual([{ uuid: rootReport.uuid, destinationFolder: archive.uuid }]); + expect(requests.trash).toEqual([{ items: [{ uuid: archivedReport.uuid, type: 'file' }] }]); + }); + + test('TC6: Validate that keeping both on a moved file moves it under a numbered name', async ({ page }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.dragItemToFolder('report.txt', 'Archive'); + await collisionDialog.resolve('report', staticData.collisionKeepBothOption); + + await collisionDialog.expectClosed(); + await expect + .poll(() => requests.moves, { timeout: 10000 }) + .toEqual([{ uuid: rootReport.uuid, destinationFolder: archive.uuid, name: 'report (1)' }]); + expect(requests.trash).toHaveLength(0); + }); + + test('TC7: Validate that skipping a moved file leaves both files where they are', async ({ page }) => { + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, existingDrive); + + await drivePage.dragItemToFolder('report.txt', 'Archive'); + await collisionDialog.resolve('report', staticData.collisionSkipOption); + + await collisionDialog.expectClosed(); + await expect(drivePage.fileRow('report.txt')).toHaveCount(1); + expect(requests.moves).toHaveLength(0); + expect(requests.trash).toHaveLength(0); + }); + }); + + test('TC8: Validate that replacing a versionable file uploads a new version instead of trashing it', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'Bridge requests can only be intercepted in Chromium'); + const { drivePage, collisionDialog, requests } = await openMockedDrive(page, { + ...existingDrive, + isVersioningEnabled: true, + }); + + await drivePage.uploadFiles([buildUploadFile('invoice.pdf')]); + await collisionDialog.resolve('invoice.pdf', staticData.collisionReplaceOption); + + await expect.poll(() => requests.bridge.length, { timeout: 15000 }).toBeGreaterThan(0); + expect(requests.trash).toHaveLength(0); + }); +}); diff --git a/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts b/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts index f93e9bbbd..a89fbf83c 100644 --- a/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts +++ b/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts @@ -22,7 +22,7 @@ test.describe('Internxt name collision skip option', () => { let drive: Awaited>; test.beforeEach('Logging in with existing files in Drive', async ({ page }) => { - drive = await openMockedDrive(page, [existingReport, existingInvoice]); + drive = await openMockedDrive(page, { files: [existingReport, existingInvoice] }); }); test('TC1: Validate that skipping a single duplicated file keeps the existing file and uploads nothing', async () => { From fbb014db73c1c29f510d3f097830c138be1f1c58 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 11 Sep 2026 02:05:28 -0400 Subject: [PATCH 3/3] test: ensure collision dialog closes after resolving file upload --- .../specs/DRIVE-internxt-name-collision-resolutions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts b/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts index 92af0e526..a1eb70c58 100644 --- a/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts +++ b/test/e2e/tests/specs/DRIVE-internxt-name-collision-resolutions.spec.ts @@ -46,8 +46,8 @@ test.describe('Internxt name collision resolutions', () => { await drivePage.uploadFiles([buildUploadFile('report.txt')]); await collisionDialog.resolve('report.txt', staticData.collisionKeepBothOption); - await collisionDialog.expectClosed(); await expect(drivePage.taskItem('report (1).txt')).toBeVisible({ timeout: 10000 }); + await collisionDialog.expectClosed(); expect(requests.trash).toHaveLength(0); });