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');