diff --git a/src/app/tasks/components/TaskLogger/TaskLogger.tsx b/src/app/tasks/components/TaskLogger/TaskLogger.tsx
index 33c3440ce2..5615b055b8 100644
--- a/src/app/tasks/components/TaskLogger/TaskLogger.tsx
+++ b/src/app/tasks/components/TaskLogger/TaskLogger.tsx
@@ -100,6 +100,7 @@ const TaskLogger = (): JSX.Element => {
return (
{
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ hasKeys: true,
+ sKey: '53616c7465645f5f2aa5386bc0b15f6f69a733acdd46a6551dc004f6c1cb6352390535de3ec17e9b96da7de984e5d27e79ad04a88a2cc8c6315f03dc0b0d174c',
+ tfa: false,
+ hasKyberKeys: true,
+ hasEccKeys: true,
+ }),
+ });
+};
+
+const mockAccessCall = async (route: Route, request: Request) => {
+ const { email } = request.postDataJSON();
+
+ if (email === INVALID_EMAIL) {
+ return route.fulfill({
+ status: 400,
+ body: JSON.stringify({ message: 'Wrong login credentials' }),
+ });
+ }
+
+ await route.fulfill({
+ status: 200,
+ body: JSON.stringify({
+ user: loggedUser.user,
+ token: loggedUser.token,
+ newToken: loggedUser.newToken,
+ userTeam: loggedUser.userTeam,
+ }),
+ });
+};
+
+const mockRefreshUserCall = async (route: Route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ user: loggedUser.user, newToken: loggedUser.newToken }),
+ });
+};
+
+/**
+ * Mocks the auth endpoints so specs can log in with the mocked user from user.json.
+ */
+export const mockAuthRoutes = async (page: Page) => {
+ await page.route(`${BASE_API_URL}/auth/login`, mockLoginCall);
+ await page.route(`${BASE_API_URL}/auth/login/access`, mockAccessCall);
+ await page.route(`${BASE_API_URL}/users/refresh`, mockRefreshUserCall);
+};
+
+export const logInThroughUI = async (page: Page) => {
+ const credentials = getUserCredentials();
+ const loginPage = new LoginPage(page);
+
+ await page.goto('/');
+ await expect(page).toHaveURL(/\/login$/);
+ await loginPage.typeEmail(credentials.email);
+ await loginPage.typePassword(credentials.password);
+ const driveTitle = await loginPage.clickLogIn();
+ expect(driveTitle).toEqual(staticData.driveTitle);
+};
diff --git a/test/e2e/tests/helper/driveRouteMocks.ts b/test/e2e/tests/helper/driveRouteMocks.ts
new file mode 100644
index 0000000000..b4c2544b11
--- /dev/null
+++ b/test/e2e/tests/helper/driveRouteMocks.ts
@@ -0,0 +1,152 @@
+import { Page, Route } from '@playwright/test';
+import { getLoggedUser } from './getUser';
+import { mockAuthRoutes } from './authRouteMocks';
+
+const BASE_API_URL = process.env.REACT_APP_DRIVE_NEW_API_URL;
+const OLD_API_URL = process.env.REACT_APP_API_URL;
+const PAYMENTS_API_URL = process.env.REACT_APP_PAYMENTS_API_URL;
+const BRIDGE_URL = process.env.REACT_APP_STORJ_BRIDGE;
+
+const loggedUser = getLoggedUser();
+
+export type TrashRequest = { items: { uuid: string; type: string }[] };
+export type ExistingFile = ReturnType;
+
+/**
+ * Every request the specs may want to assert on, recorded by the mocked routes.
+ * Bridge uploads are only observable in Chromium: Playwright cannot route their CORS
+ * preflight in Firefox, so assert on the task panel when a spec must run in both.
+ */
+export type RecordedRequests = {
+ trash: TrashRequest[];
+ bridge: string[];
+};
+
+export const buildExistingFile = (id: number, plainName: string, type: string) => ({
+ id,
+ uuid: `existing-file-uuid-${id}`,
+ fileId: `existing-bridge-file-${id}`,
+ name: plainName,
+ plainName,
+ plain_name: plainName,
+ type,
+ size: 1024,
+ bucket: loggedUser.user.bucket,
+ folderUuid: loggedUser.user.rootFolderId,
+ createdAt: '2026-08-01T10:00:00.000Z',
+ updatedAt: '2026-08-01T10:00:00.000Z',
+ status: 'EXISTS',
+ thumbnails: [],
+});
+
+/**
+ * The Drive the mocked API serves. Trashing removes items, so follow-up listings and
+ * duplicate checks see the new state.
+ */
+class InMemoryDrive {
+ private files: ExistingFile[];
+
+ constructor(files: ExistingFile[]) {
+ this.files = [...files];
+ }
+
+ filesIn(folderUuid: string) {
+ return this.files.filter((file) => file.folderUuid === folderUuid);
+ }
+
+ trash(uuids: string[]) {
+ const trashed = new Set(uuids);
+ this.files = this.files.filter((file) => !trashed.has(file.uuid));
+ }
+}
+
+const fulfillJson = (route: Route, body: unknown) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+
+const getFolderUuidFromUrl = (url: string) => /\/folders\/content\/([^/?]+)/.exec(url)?.[1] ?? '';
+
+/**
+ * Answers the calls the app makes while bootstrapping the Drive view with minimal valid
+ * 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) => {
+ for (const baseUrl of [BASE_API_URL, OLD_API_URL, PAYMENTS_API_URL]) {
+ await page.route(`${baseUrl}/**`, (route) => fulfillJson(route, {}));
+ }
+
+ const bootstrapResponses: Record = {
+ 'workspaces/': { availableWorkspaces: [], pendingWorkspaces: [] },
+ 'sharings/invites**': { invites: [] },
+ 'sharings/roles': [],
+ 'files/limits': { versioning: { enabled: false, maxVersions: 0 }, maxUploadFileSize: 21474836480 },
+ 'users/limit': { maxSpaceBytes: 10737418240 },
+ 'users/usage': { drive: 3072, backups: 0, total: 3072 },
+ 'users/me/upload-status': { hasUploadedFiles: true },
+ 'users/avatar/refresh': { avatar: null },
+ 'referral/enabled': { enabled: false },
+ };
+
+ for (const [path, body] of Object.entries(bootstrapResponses)) {
+ await page.route(`${BASE_API_URL}/${path}`, (route) => fulfillJson(route, body));
+ }
+};
+
+/**
+ * Folder listings and the file duplicate check, scoped to the folder in the URL.
+ */
+const mockFolderContentRoutes = (page: Page, drive: InMemoryDrive) =>
+ page.route(`${BASE_API_URL}/folders/content/**`, (route, request) => {
+ const url = request.url();
+ const folderUuid = getFolderUuidFromUrl(url);
+ const isExistenceCheck = request.method() === 'POST';
+
+ if (isExistenceCheck && url.endsWith('/files/existence')) {
+ const { files } = request.postDataJSON() as { files: { plainName: string; type: string }[] };
+ const existentFiles = drive
+ .filesIn(folderUuid)
+ .filter((existing) =>
+ files.some((file) => file.plainName === existing.plainName && file.type === existing.type),
+ );
+ return fulfillJson(route, { existentFiles });
+ }
+
+ if (isExistenceCheck) return fulfillJson(route, { existentFolders: [] });
+ if (url.includes('/files/')) return fulfillJson(route, { files: drive.filesIn(folderUuid) });
+ if (url.includes('/folders/')) return fulfillJson(route, { folders: [] });
+ return fulfillJson(route, {});
+ });
+
+const mockTrashRoute = (page: Page, drive: InMemoryDrive, requests: RecordedRequests) =>
+ page.route(`${BASE_API_URL}/storage/trash/add`, (route, request) => {
+ const trashRequest = request.postDataJSON() as TrashRequest;
+ drive.trash(trashRequest.items.map((item) => item.uuid));
+ requests.trash.push(trashRequest);
+ return fulfillJson(route, {});
+ });
+
+const mockBridgeRoute = (page: Page, requests: RecordedRequests) =>
+ page.route(`${BRIDGE_URL}/**buckets/**`, (route, request) => {
+ requests.bridge.push(request.url());
+ return route.abort();
+ });
+
+/**
+ * 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.
+ * 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: [] };
+
+ await mockAppBootstrapCalls(page);
+ await mockAuthRoutes(page);
+ await mockFolderContentRoutes(page, drive);
+ await mockTrashRoute(page, drive, requests);
+ await mockBridgeRoute(page, requests);
+
+ return requests;
+};
diff --git a/test/e2e/tests/helper/mockedDrive.ts b/test/e2e/tests/helper/mockedDrive.ts
new file mode 100644
index 0000000000..308fb7d19c
--- /dev/null
+++ b/test/e2e/tests/helper/mockedDrive.ts
@@ -0,0 +1,33 @@
+import { expect, Page } from '@playwright/test';
+import { logInThroughUI } from './authRouteMocks';
+import { ExistingFile, mockDriveRoutes } from './driveRouteMocks';
+import { DrivePage } from '../pages/drivePage';
+import { NameCollisionDialogPage } from '../pages/nameCollisionDialogPage';
+
+const MIME_TYPES_BY_EXTENSION: Record = {
+ txt: 'text/plain',
+ pdf: 'application/pdf',
+};
+
+export const buildUploadFile = (name: string) => ({
+ name,
+ mimeType: MIME_TYPES_BY_EXTENSION[name.split('.').pop() ?? ''] ?? 'application/octet-stream',
+ buffer: Buffer.from(`content of ${name}`),
+});
+
+/**
+ * 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);
+ await logInThroughUI(page);
+
+ const drivePage = new DrivePage(page);
+ const [firstFile] = files;
+ if (firstFile) {
+ await expect(drivePage.fileRow(`${firstFile.plainName}.${firstFile.type}`)).toBeVisible({ timeout: 10000 });
+ }
+
+ return { drivePage, collisionDialog: new NameCollisionDialogPage(page), requests };
+};
diff --git a/test/e2e/tests/helper/staticData.ts b/test/e2e/tests/helper/staticData.ts
index 7899eb237f..bdee6b37b0 100644
--- a/test/e2e/tests/helper/staticData.ts
+++ b/test/e2e/tests/helper/staticData.ts
@@ -36,4 +36,11 @@ export const staticData = {
needHelpLinkText: 'Need help?',
driveTitle: 'Drive',
itemMovedToTrash: 'moved to trash',
+
+ //NAME COLLISION DIALOG
+ collisionDialogTitle: 'Item already exists',
+ collisionReplaceOption: 'Replace current item',
+ collisionKeepBothOption: 'Keep both',
+ collisionSkipOption: 'Skip this item',
+ collisionApplyToAll: 'Apply this action to all duplicates',
};
diff --git a/test/e2e/tests/pages/drivePage.ts b/test/e2e/tests/pages/drivePage.ts
index d57f64a268..46b52a75cf 100644
--- a/test/e2e/tests/pages/drivePage.ts
+++ b/test/e2e/tests/pages/drivePage.ts
@@ -25,6 +25,7 @@ export class DrivePage {
private uploadDownloadWidget: Locator;
private uploadWidgetBorder: Locator;
private movingToTrashAndMovedSign: Locator;
+ private fileInput: Locator;
constructor(page: Page) {
this.page = page;
@@ -67,6 +68,7 @@ export class DrivePage {
'[class$="rounded-xl border border-gray-10 bg-surface dark:bg-gray-1 "]',
);
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"]');
}
async checkFolder(folderName: string) {
const folderLocator = this.allFolderNamesInDrive.filter({ hasText: folderName });
@@ -171,4 +173,14 @@ export class DrivePage {
const checkBox = item.locator('[class$="text-white border-gray-30 hover:border-gray-40"]');
await checkBox.click();
}
+ async uploadFiles(files: { name: string; mimeType: string; buffer: Buffer }[]) {
+ await this.fileInput.setInputFiles(files);
+ }
+ fileRow(fileName: string) {
+ return this.page.locator(`[title="${fileName}"]`);
+ }
+
+ taskItem(itemName: string) {
+ return this.page.locator(`[data-test="task-logger"] [title="${itemName}"]`);
+ }
}
diff --git a/test/e2e/tests/pages/nameCollisionDialogPage.ts b/test/e2e/tests/pages/nameCollisionDialogPage.ts
new file mode 100644
index 0000000000..884cc4cbaf
--- /dev/null
+++ b/test/e2e/tests/pages/nameCollisionDialogPage.ts
@@ -0,0 +1,59 @@
+import { expect, Locator, Page } from '@playwright/test';
+import { staticData } from '../helper/staticData';
+
+export class NameCollisionDialogPage {
+ private page: Page;
+ private dialog: Locator;
+ private title: Locator;
+ private options: Locator;
+ private applyToAllLabel: Locator;
+ private applyToAllCheckbox: Locator;
+ private submitButton: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.dialog = this.page.getByRole('dialog');
+ this.title = this.dialog.getByText(staticData.collisionDialogTitle);
+ this.options = this.dialog.getByRole('radio');
+ this.applyToAllLabel = this.dialog.getByText(staticData.collisionApplyToAll);
+ this.applyToAllCheckbox = this.dialog.locator('#apply-to-all');
+ this.submitButton = this.dialog.getByRole('button', { name: /^(Upload|Move)$/ });
+ }
+
+ async expectOpenFor(itemName: string) {
+ await expect(this.title).toBeVisible({ timeout: 10000 });
+ await expect(this.dialog.getByText(`${itemName} already exists in this location`)).toBeVisible();
+ }
+
+ async expectClosed() {
+ await expect(this.title).toBeHidden({ timeout: 10000 });
+ }
+
+ async expectOptions(optionNames: string[]) {
+ await expect(this.options).toHaveText(optionNames);
+ }
+
+ async expectApplyToAllVisible(isVisible: boolean) {
+ await expect(this.applyToAllLabel).toBeVisible({ visible: isVisible });
+ }
+
+ async selectOption(optionName: string) {
+ await this.dialog.getByRole('radio', { name: optionName }).click();
+ }
+
+ async checkApplyToAllByClickingLabel() {
+ await this.applyToAllLabel.click();
+ await expect(this.applyToAllCheckbox).toBeChecked();
+ }
+
+ async submit() {
+ await expect(this.submitButton).toBeVisible();
+ await this.submitButton.click();
+ }
+
+ async resolve(itemName: string, optionName: string) {
+ await this.expectOpenFor(itemName);
+ await this.selectOption(optionName);
+ await this.submit();
+ }
+}
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
new file mode 100644
index 0000000000..f93e9bbbd9
--- /dev/null
+++ b/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts
@@ -0,0 +1,109 @@
+import { expect, test } from '@playwright/test';
+import { buildExistingFile } from '../helper/driveRouteMocks';
+import { buildUploadFile, openMockedDrive } from '../helper/mockedDrive';
+import { staticData } from '../helper/staticData';
+
+const existingReport = buildExistingFile(1, 'report', 'txt');
+const existingInvoice = buildExistingFile(2, 'invoice', 'pdf');
+
+const duplicatedReport = buildUploadFile('report.txt');
+const duplicatedInvoice = buildUploadFile('invoice.pdf');
+const newFile = buildUploadFile('brand-new.txt');
+
+const allOptions = [
+ staticData.collisionReplaceOption,
+ staticData.collisionKeepBothOption,
+ staticData.collisionSkipOption,
+];
+
+test.describe('Internxt name collision skip option', () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ let drive: Awaited>;
+
+ test.beforeEach('Logging in with existing files in Drive', async ({ page }) => {
+ drive = await openMockedDrive(page, [existingReport, existingInvoice]);
+ });
+
+ test('TC1: Validate that skipping a single duplicated file keeps the existing file and uploads nothing', async () => {
+ const { drivePage, collisionDialog, requests } = drive;
+
+ await drivePage.uploadFiles([duplicatedReport]);
+
+ await collisionDialog.expectOpenFor('report.txt');
+ await collisionDialog.expectOptions(allOptions);
+ await collisionDialog.expectApplyToAllVisible(false);
+
+ await collisionDialog.selectOption(staticData.collisionSkipOption);
+ await collisionDialog.submit();
+
+ await collisionDialog.expectClosed();
+ await expect(drivePage.fileRow('report.txt')).toHaveCount(1);
+ expect(requests.trash).toHaveLength(0);
+ expect(requests.bridge).toHaveLength(0);
+ });
+
+ test('TC2: Validate that only the non-conflicting files are uploaded when the duplicated one is skipped', async () => {
+ const { drivePage, collisionDialog, requests } = drive;
+
+ await drivePage.uploadFiles([duplicatedReport, newFile]);
+
+ await collisionDialog.expectOpenFor('report.txt');
+ await expect(drivePage.taskItem('brand-new.txt')).toBeVisible({ timeout: 10000 });
+
+ await collisionDialog.selectOption(staticData.collisionSkipOption);
+ await collisionDialog.submit();
+
+ await collisionDialog.expectClosed();
+ expect(requests.trash).toHaveLength(0);
+ });
+
+ test('TC3: Validate that duplicated files are resolved one by one when "apply to all" is not checked', async () => {
+ const { drivePage, collisionDialog, requests } = drive;
+
+ await drivePage.uploadFiles([duplicatedReport, duplicatedInvoice]);
+
+ await collisionDialog.expectOpenFor('report.txt');
+ await collisionDialog.expectApplyToAllVisible(true);
+ await collisionDialog.selectOption(staticData.collisionSkipOption);
+ await collisionDialog.submit();
+
+ await collisionDialog.expectOpenFor('invoice.pdf');
+ await collisionDialog.expectApplyToAllVisible(false);
+ await collisionDialog.selectOption(staticData.collisionSkipOption);
+ await collisionDialog.submit();
+
+ await collisionDialog.expectClosed();
+ expect(requests.trash).toHaveLength(0);
+ expect(requests.bridge).toHaveLength(0);
+ });
+
+ test('TC4: Validate that "apply to all" skips every duplicated file at once and closes the dialog', async () => {
+ const { drivePage, collisionDialog, requests } = drive;
+
+ await drivePage.uploadFiles([duplicatedReport, duplicatedInvoice]);
+
+ await collisionDialog.expectOpenFor('report.txt');
+ await collisionDialog.checkApplyToAllByClickingLabel();
+ await collisionDialog.selectOption(staticData.collisionSkipOption);
+ await collisionDialog.submit();
+
+ await collisionDialog.expectClosed();
+ await expect(drivePage.fileRow('report.txt')).toHaveCount(1);
+ await expect(drivePage.fileRow('invoice.pdf')).toHaveCount(1);
+ expect(requests.trash).toHaveLength(0);
+ expect(requests.bridge).toHaveLength(0);
+ });
+
+ test('TC5: Validate that replacing a duplicated file sends its matching existing file to trash', async () => {
+ const { drivePage, collisionDialog, requests } = drive;
+
+ await drivePage.uploadFiles([duplicatedInvoice]);
+ await collisionDialog.resolve('invoice.pdf', staticData.collisionReplaceOption);
+
+ await expect
+ .poll(() => requests.trash, { timeout: 10000 })
+ .toEqual([{ items: [{ uuid: existingInvoice.uuid, type: 'file' }] }]);
+ await collisionDialog.expectClosed();
+ });
+});
diff --git a/test/e2e/tests/specs/internxt-login.spec.ts b/test/e2e/tests/specs/internxt-login.spec.ts
index 21ec5fbfa8..a8f50a8e02 100644
--- a/test/e2e/tests/specs/internxt-login.spec.ts
+++ b/test/e2e/tests/specs/internxt-login.spec.ts
@@ -1,54 +1,17 @@
-import { expect, Request, Route, test } from '@playwright/test';
-import { getLoggedUser, getUserCredentials } from '../helper/getUser';
+import { expect, test } from '@playwright/test';
+import { INVALID_EMAIL, mockAuthRoutes } from '../helper/authRouteMocks';
+import { getUserCredentials } from '../helper/getUser';
import { staticData } from '../helper/staticData';
import { LoginPage } from '../pages/loginPage';
-const BASE_API_URL = process.env.REACT_APP_DRIVE_NEW_API_URL;
const credentialsFile = getUserCredentials();
-const user = getLoggedUser();
-const invalidEmail = 'invalid@internxt.com';
-
-const mockLoginCall = async (route: Route, request: Request) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- hasKeys: true,
- sKey: '53616c7465645f5f2aa5386bc0b15f6f69a733acdd46a6551dc004f6c1cb6352390535de3ec17e9b96da7de984e5d27e79ad04a88a2cc8c6315f03dc0b0d174c',
- tfa: false,
- hasKyberKeys: true,
- hasEccKeys: true,
- }),
- });
-};
-
-const mockAccessCall = async (route: Route, request: Request) => {
- const { email } = request.postDataJSON();
-
- if (invalidEmail === email) {
- return route.fulfill({
- status: 400,
- body: JSON.stringify({ message: 'Wrong login credentials' }),
- });
- }
-
- await route.fulfill({
- status: 200,
- body: JSON.stringify({
- user: user.user,
- token: user.token,
- newToken: user.newToken,
- userTeam: user.userTeam,
- }),
- });
-};
+const invalidEmail = INVALID_EMAIL;
test.describe('internxt login', async () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach('Visiting Internxt', async ({ page }) => {
- await page.route(`${BASE_API_URL}/auth/login`, mockLoginCall);
- await page.route(`${BASE_API_URL}/auth/login/access`, mockAccessCall);
+ await mockAuthRoutes(page);
await page.goto('/');
await expect(page).toHaveURL('http://localhost:3000/login');