diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 27175de68..7f5537392 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -173,5 +173,10 @@ export default { Skipped: "Cancelled", Cancelled: "Cancelled", }, + dialog: { + heading: "", + confirmButtonText: "Confirm", + cancelButtonText: "Cancel", + }, }, }; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx index f8cad1cd5..abb614042 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-conditional-expect */ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; @@ -5,6 +6,20 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; import CopyJobActionMenu from "./CopyJobActionMenu"; +const mockShowOkCancelModalDialog = jest.fn(); +const mockCloseDialog = jest.fn(); +const mockOpenDialog = jest.fn(); + +jest.mock("../../../Controls/Dialog", () => ({ + useDialog: { + getState: () => ({ + showOkCancelModalDialog: mockShowOkCancelModalDialog, + closeDialog: mockCloseDialog, + openDialog: mockOpenDialog, + }), + }, +})); + jest.mock("../../ContainerCopyMessages", () => ({ __esModule: true, default: { @@ -18,6 +33,11 @@ jest.mock("../../ContainerCopyMessages", () => ({ cancel: "Cancel", complete: "Complete", }, + dialog: { + heading: "Confirm Action", + confirmButtonText: "Confirm", + cancelButtonText: "Cancel", + }, }, }, })); @@ -50,6 +70,9 @@ describe("CopyJobActionMenu", () => { beforeEach(() => { jest.clearAllMocks(); + mockShowOkCancelModalDialog.mockClear(); + mockCloseDialog.mockClear(); + mockOpenDialog.mockClear(); }); describe("Component Rendering", () => { @@ -266,7 +289,29 @@ describe("CopyJobActionMenu", () => { expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); }); - it("should call handleClick when cancel action is clicked", () => { + it("should show confirmation dialog when cancel action is clicked", () => { + const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.any(Object), // dialogBody content + ); + }); + + it("should call handleClick when dialog is confirmed for cancel action", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress }); render(); @@ -277,6 +322,9 @@ describe("CopyJobActionMenu", () => { const cancelButton = screen.getByText("Cancel"); fireEvent.click(cancelButton); + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); }); @@ -294,7 +342,33 @@ describe("CopyJobActionMenu", () => { expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function)); }); - it("should call handleClick when complete action is clicked", () => { + it("should show confirmation dialog when complete action is clicked", () => { + const job = createMockJob({ + Name: "Test Online Job", + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.any(Object), // dialogBody content + ); + }); + + it("should call handleClick when dialog is confirmed for complete action", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress, Mode: CopyJobMigrationType.Online, @@ -308,10 +382,87 @@ describe("CopyJobActionMenu", () => { const completeButton = screen.getByText("Complete"); fireEvent.click(completeButton); + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); }); }); + describe("Dialog Body Content", () => { + it("should pass correct dialog body content for cancel action", () => { + const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.objectContaining({ + props: expect.objectContaining({ + tokens: expect.any(Object), + children: expect.any(Array), + }), + }), + ); + }); + + it("should pass correct dialog body content for complete action", () => { + const job = createMockJob({ + Name: "OnlineTestJob", + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.objectContaining({ + props: expect.objectContaining({ + tokens: expect.any(Object), + children: expect.any(Array), + }), + }), + ); + }); + + it("should not show dialog body for actions without confirmation", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled(); + }); + }); + describe("Disabled States During Updates", () => { const TestComponentWrapper: React.FC<{ job: CopyJobType; @@ -339,8 +490,13 @@ describe("CopyJobActionMenu", () => { const pauseButton = screen.getByText("Pause"); fireEvent.click(pauseButton); fireEvent.click(actionButton); - const pauseButtonAfterClick = screen.getByText("Pause"); + + const pauseButtonAfterClick = screen.getByText("Pause").closest("button"); expect(pauseButtonAfterClick).toBeInTheDocument(); + expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true"); + + const cancelButtonAfterClick = screen.getByText("Cancel").closest("button"); + expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true"); }); it("should not disable actions for different jobs when one is updating", () => { @@ -360,22 +516,6 @@ describe("CopyJobActionMenu", () => { expect(screen.getByText("Cancel")).toBeInTheDocument(); }); - it("should properly handle multiple action types being disabled for the same job", () => { - const job = createMockJob({ Status: CopyJobStatusType.InProgress }); - render(); - const actionButton = screen.getByRole("button", { name: "Actions" }); - - fireEvent.click(actionButton); - fireEvent.click(screen.getByText("Pause")); - - fireEvent.click(actionButton); - fireEvent.click(screen.getByText("Cancel")); - - fireEvent.click(actionButton); - expect(screen.getByText("Pause")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); - }); - it("should handle complete action disabled state for online jobs", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress, @@ -462,6 +602,7 @@ describe("CopyJobActionMenu", () => { expect(actionButton).toHaveAttribute("aria-label", "Actions"); expect(actionButton).toHaveAttribute("title", "Actions"); + expect(actionButton).toHaveAttribute("role", "button"); const moreIcon = actionButton.querySelector('[data-icon-name="More"]'); expect(moreIcon || actionButton).toBeInTheDocument(); @@ -608,4 +749,129 @@ describe("CopyJobActionMenu", () => { }).not.toThrow(); }); }); + + describe("Complete Coverage Tests", () => { + it("should handle all possible dialog scenarios", () => { + const dialogTests = [ + { action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true }, + { + action: CopyJobActions.complete, + status: CopyJobStatusType.InProgress, + mode: CopyJobMigrationType.Online, + shouldShowDialog: true, + }, + { action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false }, + { action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false }, + ]; + + dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => { + jest.clearAllMocks(); + + const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` }); + const { unmount } = render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const actionText = action.charAt(0).toUpperCase() + action.slice(1); + if (screen.queryByText(actionText)) { + fireEvent.click(screen.getByText(actionText)); + + if (shouldShowDialog) { + expect(mockShowOkCancelModalDialog).toHaveBeenCalled(); + } else { + expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled(); + expect(mockHandleClick).toHaveBeenCalled(); + } + } + + unmount(); + }); + }); + + it("should verify component handles state updates correctly", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const stateUpdater = jest.fn(); + + const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + stateUpdater(job.Name, action); + }; + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause); + }); + }); + + describe("Full Integration Coverage", () => { + it("should test complete workflow for cancel action with dialog", () => { + const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress }); + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job"); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", // title + null, // subText + "Confirm", // confirmLabel + expect.any(Function), // onOk + "Cancel", // cancelLabel + null, // onCancel + expect.any(Object), // contentHtml (dialogBody) + ); + + const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3]; + onOkCallback(); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); + }); + + it("should test complete workflow for complete action with dialog", () => { + const job = createMockJob({ + Name: "Online Integration Job", + Status: CopyJobStatusType.Running, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalled(); + + const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6]; + expect(dialogContent).toBeTruthy(); + + const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3]; + onOkCallback(); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); + }); + + it("should maintain proper component lifecycle", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { rerender, unmount } = render(); + + rerender(); + expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument(); + + expect(() => unmount()).not.toThrow(); + }); + }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx index 5d41b8595..058a717bb 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -1,5 +1,6 @@ -import { IconButton, IContextualMenuProps } from "@fluentui/react"; +import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react"; import React from "react"; +import { useDialog } from "../../../Controls/Dialog"; import ContainerCopyMessages from "../../ContainerCopyMessages"; import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums"; import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; @@ -9,6 +10,28 @@ interface CopyJobActionMenuProps { handleClick: HandleJobActionClickType; } +const dialogBody = { + [CopyJobActions.cancel]: (jobName: string) => ( + + + You are about to cancel {jobName} copy job. + + Cancelling will stop the job immediately. + + ), + [CopyJobActions.complete]: (jobName: string) => ( + + + You are about to complete {jobName} copy job. + + + Once completed, continuous data copy will stop after any pending documents are processed. To maintain data + integrity, we recommend stopping updates to the source container before completing the job. + + + ), +}; + const CopyJobActionMenu: React.FC = ({ job, handleClick }) => { const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null); if ( @@ -22,6 +45,20 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick return null; } + const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => { + useDialog + .getState() + .showOkCancelModalDialog( + ContainerCopyMessages.MonitorJobs.dialog.heading, + null, + ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText, + () => handleClick(job, action, setUpdatingJobAction), + ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText, + null, + action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null, + ); + }; + const getMenuItems = (): IContextualMenuProps["items"] => { const isThisJobUpdating = updatingJobAction?.jobName === job.Name; const updatingAction = updatingJobAction?.action; @@ -32,21 +69,21 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick text: ContainerCopyMessages.MonitorJobs.Actions.pause, iconProps: { iconName: "Pause" }, onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause, + disabled: isThisJobUpdating, }, { key: CopyJobActions.cancel, text: ContainerCopyMessages.MonitorJobs.Actions.cancel, iconProps: { iconName: "Cancel" }, - onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel, + onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel), + disabled: isThisJobUpdating, }, { key: CopyJobActions.resume, text: ContainerCopyMessages.MonitorJobs.Actions.resume, iconProps: { iconName: "Play" }, onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume, + disabled: isThisJobUpdating, }, ]; @@ -67,7 +104,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick key: CopyJobActions.complete, text: ContainerCopyMessages.MonitorJobs.Actions.complete, iconProps: { iconName: "CheckMark" }, - onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction), + onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete), disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete, }); } @@ -86,8 +123,8 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick data-test={`CopyJobActionMenu/Button:${job.Name}`} role="button" iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }} - menuProps={{ items: getMenuItems() }} - menuIconProps={{ iconName: "" }} + menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }} + menuIconProps={{ iconName: "", className: "hidden" }} ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions} title={ContainerCopyMessages.MonitorJobs.Columns.actions} /> diff --git a/src/Main.tsx b/src/Main.tsx index f30ff9902..af3d462a7 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -128,6 +128,7 @@ const App = (): JSX.Element => { <> + ) : ( diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts deleted file mode 100644 index c019b99b7..000000000 --- a/test/sql/containercopy.spec.ts +++ /dev/null @@ -1,493 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { expect, Frame, Locator, Page, test } from "@playwright/test"; -import { set } from "lodash"; -import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils"; -import { - ContainerCopy, - getAccountName, - getDropdownItemByNameOrPosition, - interceptAndInspectApiRequest, - TestAccount, - waitForApiResponse, -} from "../fx"; -import { createMultipleTestContainers } from "../testData"; - -let page: Page; -let wrapper: Locator = null!; -let panel: Locator = null!; -let frame: Frame = null!; -let expectedCopyJobNameInitial: string = null!; -let expectedJobName: string = ""; -let targetAccountName: string = ""; -let expectedSourceAccountName: string = ""; -let expectedSubscriptionName: string = ""; -const VISIBLE_TIMEOUT_MS = 30 * 1000; - -test.describe.configure({ mode: "serial" }); - -test.describe("Container Copy", () => { - test.beforeAll("Container Copy - Before All", async ({ browser }) => { - await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 }); - - page = await browser.newPage(); - ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); - expectedJobName = `test_job_${Date.now()}`; - targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); - }); - - test.afterEach("Container Copy - After Each", async () => { - await page.unroute(/.*/, (route) => route.continue()); - }); - - test("Loading and verifying the content of the page", async () => { - expect(wrapper).not.toBeNull(); - await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - }); - - test("Successfully create a copy job for offline migration", async () => { - expect(wrapper).not.toBeNull(); - // Loading and verifying subscription & account dropdown - - const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); - await createCopyJobButton.click(); - panel = frame.getByTestId("Panel:Create copy job"); - await expect(panel).toBeVisible(); - - await page.waitForTimeout(10 * 1000); - - const subscriptionDropdown = panel.getByTestId("subscription-dropdown"); - - const expectedAccountName = targetAccountName; - expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText(); - - await subscriptionDropdown.click(); - const subscriptionItem = await getDropdownItemByNameOrPosition( - frame, - { name: expectedSubscriptionName }, - { ariaLabel: "Subscription" }, - ); - await subscriptionItem.click(); - - // Load account dropdown based on selected subscription - - const accountDropdown = panel.getByTestId("account-dropdown"); - await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName)); - await accountDropdown.click(); - - const accountItem = await getDropdownItemByNameOrPosition( - frame, - { name: expectedAccountName }, - { ariaLabel: "Account" }, - ); - await accountItem.click(); - - // Verifying online or offline checkbox functionality - /** - * This test verifies the functionality of the migration type checkbox that toggles between - * online and offline container copy modes. It ensures that: - * 1. When online mode is selected, the user is directed to a permissions screen - * 2. When offline mode is selected, the user bypasses the permissions screen - * 3. The UI correctly reflects the selected migration type throughout the workflow - */ - const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); - await fluentUiCheckboxContainer.click(); - await panel.getByRole("button", { name: "Next" }).click(); - await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); - await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); - await panel.getByRole("button", { name: "Previous" }).click(); - await fluentUiCheckboxContainer.click(); - await panel.getByRole("button", { name: "Next" }).click(); - await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); - await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); - - // Verifying source and target container selection - - const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); - expect(sourceContainerDropdown).toBeVisible(); - await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); - await sourceDatabaseDropdown.click(); - - const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Database" }, - ); - await sourceDbDropdownItem.click(); - - await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - await sourceContainerDropdown.click(); - const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Container" }, - ); - await sourceContainerDropdownItem.click(); - - const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); - expect(targetContainerDropdown).toBeVisible(); - await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); - await targetDatabaseDropdown.click(); - const targetDbDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Database" }, - ); - await targetDbDropdownItem.click(); - - await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - await targetContainerDropdown.click(); - const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Container" }, - ); - await targetContainerDropdownItem1.click(); - - await panel.getByRole("button", { name: "Next" }).click(); - - const errorContainer = panel.getByTestId("Panel:ErrorContainer"); - await expect(errorContainer).toBeVisible(); - await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i); - - // Reselect target container to be different from source container - await targetContainerDropdown.click(); - const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition( - frame, - { position: 1 }, - { ariaLabel: "Container" }, - ); - await targetContainerDropdownItem2.click(); - - const selectedSourceDatabase = await sourceDatabaseDropdown.innerText(); - const selectedSourceContainer = await sourceContainerDropdown.innerText(); - const selectedTargetDatabase = await targetDatabaseDropdown.innerText(); - const selectedTargetContainer = await targetContainerDropdown.innerText(); - expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName( - selectedSourceContainer, - )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`; - - await panel.getByRole("button", { name: "Next" }).click(); - - await expect(errorContainer).not.toBeVisible(); - await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible(); - - // Verifying the preview of the copy job - const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); - await expect(previewContainer).toBeVisible(); - await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName); - await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName); - const jobNameInput = previewContainer.getByTestId("job-name-textfield"); - await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial)); - const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true }); - await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - - await jobNameInput.fill("test job name"); - await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - // Testing API request interception with duplicate job name - const duplicateJobName = "test-job-name-1"; - await jobNameInput.fill(duplicateJobName); - - const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); - const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`; - await interceptAndInspectApiRequest( - page, - `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`, - "PUT", - new Error(expectedErrorMessage), - (url?: string) => url?.includes(duplicateJobName) ?? false, - ); - - let errorThrown = false; - try { - await copyButton.click(); - await page.waitForTimeout(2000); - } catch (error: any) { - errorThrown = true; - expect(error.message).toContain("not allowed"); - } - if (!errorThrown) { - const errorContainer = panel.getByTestId("Panel:ErrorContainer"); - await expect(errorContainer).toBeVisible(); - await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i")); - } - - await expect(panel).toBeVisible(); - - // Testing API request success with valid job name and verifying copy job creation - - const validJobName = expectedJobName; - - const copyJobCreationPromise = waitForApiResponse( - page, - `${expectedAccountName}/dataTransferJobs/${validJobName}`, - "PUT", - ); - - await jobNameInput.fill(validJobName); - await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - - await copyButton.click(); - - const response = await copyJobCreationPromise; - expect(response.ok()).toBe(true); - - await expect(panel).not.toBeVisible({ timeout: 10000 }); - - const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible" }); - - const jobItem = jobsListContainer.getByText(validJobName); - await jobItem.waitFor({ state: "visible" }); - await expect(jobItem).toBeVisible(); - }); - - test("Verify Online or Offline Container Copy Permissions Panel", async () => { - expect(wrapper).not.toBeNull(); - - // Opening the Create Copy Job panel again to verify initial state - const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); - await createCopyJobButton.click(); - panel = frame.getByTestId("Panel:Create copy job"); - await expect(panel).toBeVisible(); - await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible(); - - // select different account dropdown - - const accountDropdown = panel.getByTestId("account-dropdown"); - await accountDropdown.click(); - - const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); - expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account"); - - const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all(); - - const filteredItems = []; - for (const item of allDropdownItems) { - const testContent = (await item.textContent()) ?? ""; - if (testContent.trim() !== targetAccountName.trim()) { - filteredItems.push(item); - } - } - - if (filteredItems.length > 0) { - const firstDropdownItem = filteredItems[0]; - expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? ""; - await firstDropdownItem.click(); - } else { - throw new Error("No dropdown items available after filtering"); - } - - const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); - await fluentUiCheckboxContainer.click(); - - await panel.getByRole("button", { name: "Next" }).click(); - - // Verifying Assign Permissions panel for online copy - - const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); - await expect(permissionScreen).toBeVisible(); - - await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); - await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible(); - - // Verify Point-in-Time Restore timer and refresh button workflow - - await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => { - const mockData = { - identity: { - type: "SystemAssigned", - principalId: "00-11-22-33", - }, - properties: { - defaultIdentity: "SystemAssignedIdentity", - backupPolicy: { - type: "Continuous", - }, - capabilities: [{ name: "EnableOnlineContainerCopy" }], - }, - }; - if (route.request().method() === "GET") { - const response = await route.fetch(); - const actualData = await response.json(); - const mergedData = { ...actualData }; - - set(mergedData, "identity", mockData.identity); - set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); - set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); - set(mergedData, "properties.capabilities", mockData.properties.capabilities); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(mergedData), - }); - } else { - await route.continue(); - } - }); - - await expect(permissionScreen).toBeVisible(); - - const expandedOnlineAccordionHeader = permissionScreen - .getByTestId("permission-group-container-onlineConfigs") - .locator("button[aria-expanded='true']"); - await expect(expandedOnlineAccordionHeader).toBeVisible(); - - const accordionItem = expandedOnlineAccordionHeader - .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") - .first(); - - const accordionPanel = accordionItem - .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") - .first(); - - await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") }); - - const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); - await expect(pitrBtn).toBeVisible(); - await pitrBtn.click(); - - page.context().on("page", async (newPage) => { - const expectedUrlEndPattern = new RegExp( - `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`, - ); - expect(newPage.url()).toMatch(expectedUrlEndPattern); - await newPage.close(); - }); - - const loadingOverlay = frame.locator("[data-test='loading-overlay']"); - await expect(loadingOverlay).toBeVisible(); - - const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn"); - await expect(refreshBtn).not.toBeVisible(); - - // Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms) - await page.clock.fastForward(11 * 60 * 1000); - - await expect(refreshBtn).toBeVisible(); - await expect(pitrBtn).not.toBeVisible(); - - // Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions - - await page.route( - `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`, - async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - value: [ - { - principalId: "00-11-22-33", - roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`, - }, - ], - }), - }); - }, - ); - - await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - value: [ - { - name: "00000000-0000-0000-0000-000000000001", - }, - ], - }), - }); - }); - - await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => { - const mockData = { - identity: { - type: "SystemAssigned", - principalId: "00-11-22-33", - }, - properties: { - defaultIdentity: "SystemAssignedIdentity", - backupPolicy: { - type: "Continuous", - }, - capabilities: [{ name: "EnableOnlineContainerCopy" }], - }, - }; - - if (route.request().method() === "PATCH") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ status: "Succeeded" }), - }); - } else if (route.request().method() === "GET") { - // Get the actual response and merge with mock data - const response = await route.fetch(); - const actualData = await response.json(); - const mergedData = { ...actualData }; - set(mergedData, "identity", mockData.identity); - set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); - set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); - set(mergedData, "properties.capabilities", mockData.properties.capabilities); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(mergedData), - }); - } else { - await route.continue(); - } - }); - - await expect(permissionScreen).toBeVisible(); - - const expandedCrossAccordionHeader = permissionScreen - .getByTestId("permission-group-container-crossAccountConfigs") - .locator("button[aria-expanded='true']"); - await expect(expandedCrossAccordionHeader).toBeVisible(); - - const crossAccordionItem = expandedCrossAccordionHeader - .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") - .first(); - - const crossAccordionPanel = crossAccordionItem - .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") - .first(); - - const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); - await expect(toggleButton).toBeVisible(); - await toggleButton.click(); - - const popover = frame.locator("[data-test='popover-container']"); - await expect(popover).toBeVisible(); - - const yesButton = popover.getByRole("button", { name: /Yes/i }); - const noButton = popover.getByRole("button", { name: /No/i }); - await expect(yesButton).toBeVisible(); - await expect(noButton).toBeVisible(); - - await yesButton.click(); - - await expect(loadingOverlay).toBeVisible(); - - await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 }); - await expect(popover).toBeHidden({ timeout: 10 * 1000 }); - - await panel.getByRole("button", { name: "Cancel" }).click(); - }); - - test.afterAll("Container Copy - After All", async () => { - await page.unroute(/.*/, (route) => route.continue()); - await page.close(); - }); -}); diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts new file mode 100644 index 000000000..390eee68f --- /dev/null +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -0,0 +1,251 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils"; +import { + ContainerCopy, + getAccountName, + getDropdownItemByNameOrPosition, + interceptAndInspectApiRequest, + TestAccount, + waitForApiResponse, +} from "../../fx"; +import { createMultipleTestContainers } from "../../testData"; + +test.describe("Container Copy - Offline Migration", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let expectedJobName: string; + let targetAccountName: string; + let expectedSubscriptionName: string; + let expectedCopyJobNameInitial: string; + + test.beforeEach("Setup for offline migration test", async ({ browser }) => { + await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 }); + + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + expectedJobName = `offline_test_job_${Date.now()}`; + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after offline migration test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Successfully create and manage offline migration copy job", async () => { + expect(wrapper).not.toBeNull(); + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" }); + + // Open Create Copy Job panel + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + + // Reduced wait time for better performance + await page.waitForTimeout(2000); + + // Setup subscription and account + const subscriptionDropdown = panel.getByTestId("subscription-dropdown"); + const expectedAccountName = targetAccountName; + expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText(); + + await subscriptionDropdown.click(); + const subscriptionItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedSubscriptionName }, + { ariaLabel: "Subscription" }, + ); + await subscriptionItem.click(); + + // Select account + const accountDropdown = panel.getByTestId("account-dropdown"); + await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName)); + await accountDropdown.click(); + + const accountItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedAccountName }, + { ariaLabel: "Account" }, + ); + await accountItem.click(); + + // Test offline migration mode toggle functionality + const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); + + // First test online mode (should show permissions screen) + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); + await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); + + // Go back and switch to offline mode + await panel.getByRole("button", { name: "Previous" }).click(); + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify we skip permissions screen in offline mode + await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); + + // Test source and target container selection with validation + const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); + expect(sourceContainerDropdown).toBeVisible(); + await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Select source database first (containers are disabled until database is selected) + const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); + await sourceDatabaseDropdown.click(); + const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await sourceDbDropdownItem.click(); + + // Now container dropdown should be enabled + await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await sourceContainerDropdown.click(); + const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await sourceContainerDropdownItem.click(); + + // Test target container selection + const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); + expect(targetContainerDropdown).toBeVisible(); + await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); + await targetDatabaseDropdown.click(); + const targetDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await targetDbDropdownItem.click(); + + await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await targetContainerDropdown.click(); + + // First try selecting the same container (should show error) + const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem1.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify validation error for same source and target containers + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i); + + // Select different target container + await targetContainerDropdown.click(); + const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition( + frame, + { position: 1 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem2.click(); + + // Generate expected job name based on selections + const selectedSourceDatabase = await sourceDatabaseDropdown.innerText(); + const selectedSourceContainer = await sourceContainerDropdown.innerText(); + const selectedTargetDatabase = await targetDatabaseDropdown.innerText(); + const selectedTargetContainer = await targetContainerDropdown.innerText(); + expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName( + selectedSourceContainer, + )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`; + + await panel.getByRole("button", { name: "Next" }).click(); + + // Error should disappear and preview should be visible + await expect(errorContainer).not.toBeVisible(); + await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible(); + + // Verify job preview details + const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); + await expect(previewContainer).toBeVisible(); + await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName); + await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName); + + const jobNameInput = previewContainer.getByTestId("job-name-textfield"); + await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial)); + + const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true }); + await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Test invalid job name validation (spaces not allowed) + await jobNameInput.fill("test job name"); + await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Test duplicate job name error handling + const duplicateJobName = "test-job-name-1"; + await jobNameInput.fill(duplicateJobName); + + const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); + const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`; + + await interceptAndInspectApiRequest( + page, + `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`, + "PUT", + new Error(expectedErrorMessage), + (url?: string) => url?.includes(duplicateJobName) ?? false, + ); + + let errorThrown = false; + try { + await copyButton.click(); + await page.waitForTimeout(2000); + } catch (error: any) { + errorThrown = true; + expect(error.message).toContain("not allowed"); + } + + if (!errorThrown) { + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i")); + } + + await expect(panel).toBeVisible(); + + // Test successful job creation with valid job name + const validJobName = expectedJobName; + + const copyJobCreationPromise = waitForApiResponse( + page, + `${expectedAccountName}/dataTransferJobs/${validJobName}`, + "PUT", + ); + + await jobNameInput.fill(validJobName); + await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + await copyButton.click(); + + const response = await copyJobCreationPromise; + expect(response.ok()).toBe(true); + + // Verify panel closes and job appears in the list + await expect(panel).not.toBeVisible({ timeout: 5000 }); + + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); + await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + + const jobItem = jobsListContainer.getByText(validJobName); + await jobItem.waitFor({ state: "visible", timeout: 5000 }); + await expect(jobItem).toBeVisible(); + }); +}); diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts new file mode 100644 index 000000000..3e61a7849 --- /dev/null +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { + ContainerCopy, + getAccountName, + getDropdownItemByNameOrPosition, + TestAccount, + waitForApiResponse, +} from "../../fx"; +import { createMultipleTestContainers } from "../../testData"; + +test.describe("Container Copy - Online Migration", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let targetAccountName: string; + + test.beforeEach("Setup for online migration test", async ({ browser }) => { + await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 }); + + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after online migration test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Successfully create and manage online migration copy job", async () => { + expect(wrapper).not.toBeNull(); + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" }); + + // Open Create Copy Job panel + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + + // Reduced wait time for better performance + await page.waitForTimeout(1000); + + // Enable online migration mode + const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify permissions screen is shown for online migration + const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); + await expect(permissionScreen).toBeVisible(); + await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); + + // Skip permissions setup and proceed to container selection + await panel.getByRole("button", { name: "Next" }).click(); + + // Configure source and target containers for online migration + const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); + await sourceDatabaseDropdown.click(); + const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await sourceDbDropdownItem.click(); + + const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); + await sourceContainerDropdown.click(); + const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await sourceContainerDropdownItem.click(); + + const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); + await targetDatabaseDropdown.click(); + const targetDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await targetDbDropdownItem.click(); + + const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); + await targetContainerDropdown.click(); + const targetContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 1 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify job preview and create the online migration job + const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); + await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName); + + const jobNameInput = previewContainer.getByTestId("job-name-textfield"); + const onlineMigrationJobName = await jobNameInput.inputValue(); + + const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); + + const copyJobCreationPromise = waitForApiResponse( + page, + `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`, + "PUT", + ); + await copyButton.click(); + await page.waitForTimeout(1000); // Reduced wait time + + const response = await copyJobCreationPromise; + expect(response.ok()).toBe(true); + + // Verify panel closes and job appears in the list + await expect(panel).not.toBeVisible({ timeout: 5000 }); + + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); + await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + + let jobRow, statusCell, actionMenuButton; + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await jobRow.waitFor({ state: "visible", timeout: 5000 }); + + // Verify job status changes to queued state + await expect(statusCell).toContainText(/running|queued|pending/i, { timeout: 5000 }); + + // Test job lifecycle management through action menu + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + + // Test pause functionality + const pauseAction = frame.locator(".ms-ContextualMenu-list button:has-text('Pause')"); + await pauseAction.click(); + + const pauseResponse = await waitForApiResponse( + page, + `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`, + "POST", + ); + expect(pauseResponse.ok()).toBe(true); + + // Verify job status changes to paused + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + await jobRow.waitFor({ state: "visible", timeout: 5000 }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await expect(statusCell).toContainText(/paused/i, { timeout: 5000 }); + await page.waitForTimeout(1000); + + // Test cancel job functionality + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click(); + + // Verify cancellation confirmation dialog + await expect(frame.locator(".ms-Dialog-main")).toBeVisible({ timeout: 2000 }); + await expect(frame.locator(".ms-Dialog-main")).toContainText(onlineMigrationJobName); + + const cancelDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Cancel"); + await expect(cancelDialogButton).toBeVisible(); + await cancelDialogButton.click(); + await expect(frame.locator(".ms-Dialog-main")).not.toBeVisible(); + + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click(); + + const confirmDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Confirm"); + await expect(confirmDialogButton).toBeVisible(); + await confirmDialogButton.click(); + + // Verify final job status is cancelled + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await expect(statusCell).toContainText(/cancelled/i, { timeout: 5000 }); + }); +}); diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts new file mode 100644 index 000000000..519d8ec9e --- /dev/null +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -0,0 +1,267 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { set } from "lodash"; +import { ContainerCopy, getAccountName, TestAccount } from "../../fx"; + +const VISIBLE_TIMEOUT_MS = 30 * 1000; + +test.describe("Container Copy - Permission Screen Verification", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let targetAccountName: string; + let expectedSourceAccountName: string; + + test.beforeEach("Setup for each test", async ({ browser }) => { + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after each test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Verify online container copy permissions panel functionality", async () => { + expect(wrapper).not.toBeNull(); + + // Verify all command bar buttons are visible + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS }); + + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible(); + await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible(); + + // Open the Create Copy Job panel + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible(); + + // Select a different account for cross-account testing + const accountDropdown = panel.getByTestId("account-dropdown"); + await accountDropdown.click(); + + const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); + expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account"); + + const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all(); + + const filteredItems = []; + for (const item of allDropdownItems) { + const testContent = (await item.textContent()) ?? ""; + if (testContent.trim() !== targetAccountName.trim()) { + filteredItems.push(item); + } + } + + if (filteredItems.length > 0) { + const firstDropdownItem = filteredItems[0]; + expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? ""; + await firstDropdownItem.click(); + } else { + throw new Error("No dropdown items available after filtering"); + } + + // Enable online migration mode + const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify Assign Permissions panel for online copy + const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); + await expect(permissionScreen).toBeVisible(); + await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); + await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible(); + + // Setup API mocking for the source account + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + if (route.request().method() === "GET") { + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + // Verify Point-in-Time Restore functionality + const expandedOnlineAccordionHeader = permissionScreen + .getByTestId("permission-group-container-onlineConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedOnlineAccordionHeader).toBeVisible(); + + const accordionItem = expandedOnlineAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const accordionPanel = accordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + // Install clock mock and test PITR functionality + await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") }); + + const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); + await expect(pitrBtn).toBeVisible(); + await pitrBtn.click(); + + // Verify new page opens with correct URL pattern + page.context().on("page", async (newPage) => { + const expectedUrlEndPattern = new RegExp( + `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`, + ); + expect(newPage.url()).toMatch(expectedUrlEndPattern); + await newPage.close(); + }); + + const loadingOverlay = frame.locator("[data-test='loading-overlay']"); + await expect(loadingOverlay).toBeVisible(); + + const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn"); + await expect(refreshBtn).not.toBeVisible(); + + // Fast forward time by 11 minutes + await page.clock.fastForward(11 * 60 * 1000); + + await expect(refreshBtn).toBeVisible({ timeout: 5000 }); + await expect(pitrBtn).not.toBeVisible(); + + // Setup additional API mocks for role assignments and permissions + await page.route( + `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + principalId: "00-11-22-33", + roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`, + }, + ], + }), + }); + }, + ); + + await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + name: "00000000-0000-0000-0000-000000000001", + }, + ], + }), + }); + }); + + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + + if (route.request().method() === "PATCH") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "Succeeded" }), + }); + } else if (route.request().method() === "GET") { + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + // Verify cross-account permissions functionality + const expandedCrossAccordionHeader = permissionScreen + .getByTestId("permission-group-container-crossAccountConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedCrossAccordionHeader).toBeVisible(); + + const crossAccordionItem = expandedCrossAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const crossAccordionPanel = crossAccordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); + await expect(toggleButton).toBeVisible(); + await toggleButton.click(); + + // Verify popover functionality + const popover = frame.locator("[data-test='popover-container']"); + await expect(popover).toBeVisible(); + + const yesButton = popover.getByRole("button", { name: /Yes/i }); + const noButton = popover.getByRole("button", { name: /No/i }); + await expect(yesButton).toBeVisible(); + await expect(noButton).toBeVisible(); + + await yesButton.click(); + + // Verify loading states + await expect(loadingOverlay).toBeVisible(); + await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 }); + await expect(popover).toBeHidden({ timeout: 10 * 1000 }); + + // Cancel the panel to clean up + await panel.getByRole("button", { name: "Cancel" }).click(); + }); +});