diff --git a/.eslintrc.js b/.eslintrc.js index 047d7c1bb..fb1ed3a73 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,12 @@ module.exports = { extends: ["plugin:jest/recommended"], plugins: ["jest"], }, + { + files: ["src/Explorer/ContainerCopy/**/*.{test,spec}.{ts,tsx}"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, ], rules: { "no-console": ["error", { allow: ["error", "warn", "dir"] }], diff --git a/src/Common/LoadingOverlay.test.tsx b/src/Common/LoadingOverlay.test.tsx new file mode 100644 index 000000000..441304b6f --- /dev/null +++ b/src/Common/LoadingOverlay.test.tsx @@ -0,0 +1,52 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import LoadingOverlay from "./LoadingOverlay"; + +describe("LoadingOverlay", () => { + const defaultProps = { + isLoading: true, + label: "Loading...", + }; + + it("should render loading overlay when isLoading is true", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render loading overlay with custom label", () => { + const customProps = { + isLoading: true, + label: "Processing your request...", + }; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render loading overlay with empty label", () => { + const emptyLabelProps = { + isLoading: true, + label: "", + }; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should return null when isLoading is false", () => { + const notLoadingProps = { + isLoading: false, + label: "Loading...", + }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should handle long labels properly", () => { + const longLabelProps = { + isLoading: true, + label: + "This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component", + }; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/Common/__snapshots__/LoadingOverlay.test.tsx.snap b/src/Common/__snapshots__/LoadingOverlay.test.tsx.snap new file mode 100644 index 000000000..6c858b187 --- /dev/null +++ b/src/Common/__snapshots__/LoadingOverlay.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoadingOverlay should handle long labels properly 1`] = ` +
+
+
+
+ This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay when isLoading is true 1`] = ` +
+
+
+
+ Loading... +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay with custom label 1`] = ` +
+
+
+
+ Processing your request... +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay with empty label 1`] = ` +
+
+
+ +
+
+`; diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx new file mode 100644 index 000000000..adc404e66 --- /dev/null +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx @@ -0,0 +1,729 @@ +import "@testing-library/jest-dom"; +import Explorer from "Explorer/Explorer"; +import * as Logger from "../../../Common/Logger"; +import { useSidePanel } from "../../../hooks/useSidePanel"; +import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; +import * as CopyJobUtils from "../CopyJobUtils"; +import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider"; +import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums"; +import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails"; +import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; +import { CopyJobContextState, CopyJobType } from "../Types/CopyJobTypes"; +import { + getCopyJobs, + openCopyJobDetailsPanel, + openCreateCopyJobPanel, + submitCreateCopyJob, + updateCopyJobStatus, +} from "./CopyJobActions"; + +jest.mock("UserContext", () => ({ + userContext: { + databaseAccount: { + id: "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + }, + }, +})); + +jest.mock("../../../hooks/useSidePanel"); +jest.mock("../../../Common/Logger"); +jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"); +jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState"); +jest.mock("../CopyJobUtils"); + +describe("CopyJobActions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("openCreateCopyJobPanel", () => { + it("should open side panel with correct parameters", () => { + const mockExplorer = {} as Explorer; + const mockSetPanelHasConsole = jest.fn(); + const mockOpenSidePanel = jest.fn(); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + setPanelHasConsole: mockSetPanelHasConsole, + openSidePanel: mockOpenSidePanel, + }); + + openCreateCopyJobPanel(mockExplorer); + + expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false); + expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.any(String), expect.any(Object), "650px"); + }); + + it("should render CreateCopyJobScreensProvider in side panel", () => { + const mockExplorer = {} as Explorer; + const mockOpenSidePanel = jest.fn(); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + setPanelHasConsole: jest.fn(), + openSidePanel: mockOpenSidePanel, + }); + + openCreateCopyJobPanel(mockExplorer); + + const sidePanelContent = mockOpenSidePanel.mock.calls[0][1]; + expect(sidePanelContent.type).toBe(CreateCopyJobScreensProvider); + expect(sidePanelContent.props.explorer).toBe(mockExplorer); + }); + }); + + describe("openCopyJobDetailsPanel", () => { + it("should open side panel with job details", () => { + const mockJob: CopyJobType = { + ID: "1", + Mode: "online", + Name: "test-job", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "01 hours, 30 minutes, 45 seconds", + LastUpdatedTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + Source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", + }, + }; + + const mockSetPanelHasConsole = jest.fn(); + const mockOpenSidePanel = jest.fn(); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + setPanelHasConsole: mockSetPanelHasConsole, + openSidePanel: mockOpenSidePanel, + }); + + openCopyJobDetailsPanel(mockJob); + + expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false); + expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.stringContaining("test-job"), expect.any(Object), "650px"); + }); + + it("should render CopyJobDetails component with correct job", () => { + const mockJob: CopyJobType = { + ID: "1", + Mode: "offline", + Name: "test-job-2", + Status: CopyJobStatusType.Completed, + CompletionPercentage: 100, + Duration: "02 hours, 15 minutes, 30 seconds", + LastUpdatedTime: "1/2/2025, 11:00:00 AM", + timestamp: 1704193200000, + Source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", + }, + }; + + const mockOpenSidePanel = jest.fn(); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + setPanelHasConsole: jest.fn(), + openSidePanel: mockOpenSidePanel, + }); + + openCopyJobDetailsPanel(mockJob); + + const sidePanelContent = mockOpenSidePanel.mock.calls[0][1]; + expect(sidePanelContent.type).toBe(CopyJobDetails); + expect(sidePanelContent.props.job).toBe(mockJob); + }); + }); + + describe("getCopyJobs", () => { + beforeEach(() => { + (CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({ + subscriptionId: "sub-123", + resourceGroup: "rg-test", + accountName: "test-account", + }); + }); + + it("should fetch and format copy jobs successfully", async () => { + const mockResponse = { + value: [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "01:30:45", + source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", + }, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours, 30 minutes, 45 seconds"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress"); + + const result = await getCopyJobs(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + ID: "1", + Name: "job-1", + Status: "InProgress", + CompletionPercentage: 50, + Mode: "online", + }); + }); + + it("should filter jobs by CosmosDBSql component", async () => { + const mockResponse = { + value: [ + { + properties: { + jobName: "sql-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "02:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + }, + }, + { + properties: { + jobName: "other-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T11:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("02 hours"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed"); + + const result = await getCopyJobs(); + + expect(result).toHaveLength(1); + expect(result[0].Name).toBe("sql-job"); + }); + + it("should sort jobs by last updated time (newest first)", async () => { + const mockResponse = { + value: [ + { + properties: { + jobName: "older-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + }, + }, + { + properties: { + jobName: "newer-job", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-02T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" }, + destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" }, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed"); + + const result = await getCopyJobs(); + + expect(result[0].Name).toBe("newer-job"); + expect(result[1].Name).toBe("older-job"); + }); + + it("should calculate completion percentage correctly", async () => { + const mockResponse = { + value: [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 75, + totalCount: 100, + mode: "online", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress"); + + const result = await getCopyJobs(); + + expect(result[0].CompletionPercentage).toBe(75); + }); + + it("should handle zero total count gracefully", async () => { + const mockResponse = { + value: [ + { + properties: { + jobName: "job-1", + status: "Pending", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 0, + totalCount: 0, + mode: "online", + duration: "00:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("0 seconds"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Pending"); + + const result = await getCopyJobs(); + + expect(result[0].CompletionPercentage).toBe(0); + }); + + it("should extract error messages if present", async () => { + const mockError = { + message: "Error message line 1\r\n\r\nError message line 2", + code: "ErrorCode123", + }; + const mockResponse = { + value: [ + { + properties: { + jobName: "failed-job", + status: "Failed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "offline", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + error: mockError, + }, + }, + ], + }; + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ + formattedDateTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + }); + (CopyJobUtils.convertTime as jest.Mock).mockReturnValue("30 minutes"); + (CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Failed"); + (CopyJobUtils.extractErrorMessage as jest.Mock).mockReturnValue({ + message: "Error message line 1", + code: "ErrorCode123", + }); + + const result = await getCopyJobs(); + + expect(result[0].Error).toEqual({ + message: "Error message line 1", + code: "ErrorCode123", + }); + expect(CopyJobUtils.extractErrorMessage).toHaveBeenCalledWith(mockError); + }); + + it("should abort previous request when new request is made", async () => { + const mockAbortController = { + abort: jest.fn(), + signal: {} as AbortSignal, + }; + (global as any).AbortController = jest.fn(() => mockAbortController); + + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] }); + + getCopyJobs(); + expect(mockAbortController.abort).not.toHaveBeenCalled(); + + getCopyJobs(); + expect(mockAbortController.abort).toHaveBeenCalledTimes(1); + }); + + it("should throw error for invalid response format", async () => { + (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ + value: "not-an-array", + }); + + await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs."); + }); + + it("should handle abort signal error", async () => { + const abortError = { + message: "Aborted", + content: JSON.stringify({ message: "signal is aborted without reason" }), + }; + (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError); + + await expect(getCopyJobs()).rejects.toMatchObject({ + message: expect.stringContaining("Please wait for the current fetch request to complete"), + }); + }); + + it("should handle generic errors", async () => { + const genericError = new Error("Network error"); + (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError); + + await expect(getCopyJobs()).rejects.toThrow("Network error"); + }); + }); + + describe("submitCreateCopyJob", () => { + let mockRefreshJobList: jest.Mock; + let mockOnSuccess: jest.Mock; + + beforeEach(() => { + mockRefreshJobList = jest.fn(); + mockOnSuccess = jest.fn(); + + (CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({ + subscriptionId: "sub-123", + resourceGroup: "rg-test", + accountName: "test-account", + }); + + (MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({ + ref: { refreshJobList: mockRefreshJobList }, + }); + }); + + it("should create intra-account copy job successfully", async () => { + const mockState: CopyJobContextState = { + jobName: "test-job", + migrationType: "online" as any, + source: { + subscription: {} as any, + account: { id: "account-1", name: "source-account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "sub-123", + account: { id: "account-1", name: "target-account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + (CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true); + (dataTransferService.create as jest.Mock).mockResolvedValue({ id: "job-id" }); + + await submitCreateCopyJob(mockState, mockOnSuccess); + + expect(dataTransferService.create).toHaveBeenCalledWith( + "sub-123", + "rg-test", + "test-account", + "test-job", + expect.objectContaining({ + properties: expect.objectContaining({ + source: expect.objectContaining({ + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }), + destination: expect.objectContaining({ + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", + }), + mode: "online", + }), + }), + ); + + const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4]; + expect(callArgs.properties.source.remoteAccountName).toBeUndefined(); + + expect(mockRefreshJobList).toHaveBeenCalled(); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + + it("should create inter-account copy job with source account name", async () => { + const mockState: CopyJobContextState = { + jobName: "cross-account-job", + migrationType: "offline" as any, + source: { + subscription: {} as any, + account: { id: "account-1", name: "source-account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "sub-456", + account: { id: "account-2", name: "target-account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + (CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(false); + (dataTransferService.create as jest.Mock).mockResolvedValue({ id: "job-id" }); + + await submitCreateCopyJob(mockState, mockOnSuccess); + + const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4]; + expect(callArgs.properties.source.remoteAccountName).toBe("source-account"); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + + it("should handle errors and log them", async () => { + const mockState: CopyJobContextState = { + jobName: "failing-job", + migrationType: "online" as any, + source: { + subscription: {} as any, + account: { id: "account-1", name: "source-account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "sub-123", + account: { id: "account-1", name: "target-account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + const mockError = new Error("API Error"); + (CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true); + (dataTransferService.create as jest.Mock).mockRejectedValue(mockError); + + await expect(submitCreateCopyJob(mockState, mockOnSuccess)).rejects.toThrow("API Error"); + + expect(Logger.logError).toHaveBeenCalledWith("API Error", "CopyJob/CopyJobActions.submitCreateCopyJob"); + expect(mockOnSuccess).not.toHaveBeenCalled(); + expect(mockRefreshJobList).not.toHaveBeenCalled(); + }); + + it("should handle errors without message", async () => { + const mockState: CopyJobContextState = { + jobName: "test-job", + migrationType: "online" as any, + source: { + subscription: {} as any, + account: { id: "account-1", name: "source-account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "sub-123", + account: { id: "account-1", name: "target-account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + (CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true); + (dataTransferService.create as jest.Mock).mockRejectedValue({}); + + await expect(submitCreateCopyJob(mockState, mockOnSuccess)).rejects.toEqual({}); + + expect(Logger.logError).toHaveBeenCalledWith( + "Error submitting create copy job. Please try again later.", + "CopyJob/CopyJobActions.submitCreateCopyJob", + ); + }); + }); + + describe("updateCopyJobStatus", () => { + const mockJob: CopyJobType = { + ID: "1", + Mode: "online", + Name: "test-job", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "01 hours, 30 minutes", + LastUpdatedTime: "1/1/2025, 10:00:00 AM", + timestamp: 1704106800000, + Source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", + }, + }; + + beforeEach(() => { + (CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({ + subscriptionId: "sub-123", + resourceGroup: "rg-test", + accountName: "test-account", + }); + }); + + it("should pause a job successfully", async () => { + const mockResponse = { id: "job-id", properties: { status: "Paused" } }; + (dataTransferService.pause as jest.Mock).mockResolvedValue(mockResponse); + + const result = await updateCopyJobStatus(mockJob, CopyJobActions.pause); + + expect(dataTransferService.pause).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job"); + expect(result).toEqual(mockResponse); + }); + + it("should resume a job successfully", async () => { + const mockResponse = { id: "job-id", properties: { status: "InProgress" } }; + (dataTransferService.resume as jest.Mock).mockResolvedValue(mockResponse); + + const result = await updateCopyJobStatus(mockJob, CopyJobActions.resume); + + expect(dataTransferService.resume).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job"); + expect(result).toEqual(mockResponse); + }); + + it("should cancel a job successfully", async () => { + const mockResponse = { id: "job-id", properties: { status: "Cancelled" } }; + (dataTransferService.cancel as jest.Mock).mockResolvedValue(mockResponse); + + const result = await updateCopyJobStatus(mockJob, CopyJobActions.cancel); + + expect(dataTransferService.cancel).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job"); + expect(result).toEqual(mockResponse); + }); + + it("should complete a job successfully", async () => { + const mockResponse = { id: "job-id", properties: { status: "Completed" } }; + (dataTransferService.complete as jest.Mock).mockResolvedValue(mockResponse); + + const result = await updateCopyJobStatus(mockJob, CopyJobActions.complete); + + expect(dataTransferService.complete).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job"); + expect(result).toEqual(mockResponse); + }); + + it("should handle case-insensitive action names", async () => { + const mockResponse = { id: "job-id", properties: { status: "Paused" } }; + (dataTransferService.pause as jest.Mock).mockResolvedValue(mockResponse); + + await updateCopyJobStatus(mockJob, "PAUSE"); + + expect(dataTransferService.pause).toHaveBeenCalled(); + }); + + it("should throw error for unsupported action", async () => { + await expect(updateCopyJobStatus(mockJob, "invalid-action")).rejects.toThrow( + "Unsupported action: invalid-action", + ); + + expect(Logger.logError).toHaveBeenCalled(); + }); + + it("should normalize error messages with status types", async () => { + const mockError = { + message: "Job must be in 'Running' or 'InProgress' state", + content: { error: "State error" }, + }; + (dataTransferService.pause as jest.Mock).mockRejectedValue(mockError); + + await expect(updateCopyJobStatus(mockJob, CopyJobActions.pause)).rejects.toEqual(mockError); + + const loggedMessage = (Logger.logError as jest.Mock).mock.calls[0][0]; + expect(loggedMessage).toContain("Error updating copy job status"); + }); + + it("should log error with correct context", async () => { + const mockError = new Error("Network failure"); + (dataTransferService.resume as jest.Mock).mockRejectedValue(mockError); + + await expect(updateCopyJobStatus(mockJob, CopyJobActions.resume)).rejects.toThrow("Network failure"); + + expect(Logger.logError).toHaveBeenCalledWith( + expect.stringContaining("Error updating copy job status"), + "CopyJob/CopyJobActions.updateCopyJobStatus", + ); + }); + + it("should handle errors with content property", async () => { + const mockError = { + content: { message: "Content error message" }, + }; + (dataTransferService.cancel as jest.Mock).mockRejectedValue(mockError); + + await expect(updateCopyJobStatus(mockJob, CopyJobActions.cancel)).rejects.toEqual(mockError); + + expect(Logger.logError).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx new file mode 100644 index 000000000..314fecc37 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx @@ -0,0 +1,185 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import Explorer from "../../Explorer"; +import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil"; +import CopyJobCommandBar from "./CopyJobCommandBar"; +import * as Utils from "./Utils"; + +jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState"); +jest.mock("../../Menus/CommandBar/CommandBarUtil"); +jest.mock("./Utils"); + +describe("CopyJobCommandBar", () => { + let mockExplorer: Explorer; + let mockConvertButton: jest.MockedFunction; + let mockGetCommandBarButtons: jest.MockedFunction; + + beforeEach(() => { + mockExplorer = {} as Explorer; + + mockConvertButton = CommandBarUtil.convertButton as jest.MockedFunction; + mockGetCommandBarButtons = Utils.getCommandBarButtons as jest.MockedFunction; + + jest.clearAllMocks(); + }); + + it("should render without crashing", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + const { container } = render(); + expect(container.querySelector(".commandBarContainer")).toBeInTheDocument(); + }); + + it("should call getCommandBarButtons with explorer", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + render(); + + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1); + }); + + it("should call convertButton with command bar items and background color", () => { + const mockCommandButtonProps: CommandButtonComponentProps[] = [ + { + iconSrc: "icon.svg", + iconAlt: "Test Icon", + onCommandClick: jest.fn(), + commandButtonLabel: "Test Button", + ariaLabel: "Test Button Aria Label", + tooltipText: "Test Tooltip", + hasPopup: false, + disabled: false, + }, + ]; + mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps); + mockConvertButton.mockReturnValue([]); + + render(); + + expect(mockConvertButton).toHaveBeenCalledTimes(1); + }); + + it("should render FluentCommandBar with correct aria label", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + const { getByRole } = render(); + + const commandBar = getByRole("menubar", { hidden: true }); + expect(commandBar).toHaveAttribute("aria-label", "Use left and right arrow keys to navigate between commands"); + }); + + it("should render FluentCommandBar with converted items", () => { + const mockCommandButtonProps: CommandButtonComponentProps[] = [ + { + iconSrc: "icon1.svg", + iconAlt: "Test Icon 1", + onCommandClick: jest.fn(), + commandButtonLabel: "Test Button 1", + ariaLabel: "Test Button 1 Aria Label", + tooltipText: "Test Tooltip 1", + hasPopup: false, + disabled: false, + }, + { + iconSrc: "icon2.svg", + iconAlt: "Test Icon 2", + onCommandClick: jest.fn(), + commandButtonLabel: "Test Button 2", + ariaLabel: "Test Button 2 Aria Label", + tooltipText: "Test Tooltip 2", + hasPopup: false, + disabled: false, + }, + ]; + + const mockFluentItems = [ + { + key: "button1", + text: "Test Button 1", + iconProps: { iconName: "Add" }, + }, + { + key: "button2", + text: "Test Button 2", + iconProps: { iconName: "Feedback" }, + }, + ]; + + mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps); + mockConvertButton.mockReturnValue(mockFluentItems); + + const { container } = render(); + + expect(mockConvertButton).toHaveBeenCalledTimes(1); + expect(container.querySelector(".commandBarContainer")).toBeInTheDocument(); + }); + + it("should handle multiple command bar buttons", () => { + const mockCommandButtonProps: CommandButtonComponentProps[] = [ + { + iconSrc: "create.svg", + iconAlt: "Create", + onCommandClick: jest.fn(), + commandButtonLabel: "Create Copy Job", + ariaLabel: "Create Copy Job", + tooltipText: "Create Copy Job", + hasPopup: false, + disabled: false, + }, + { + iconSrc: "refresh.svg", + iconAlt: "Refresh", + onCommandClick: jest.fn(), + commandButtonLabel: "Refresh", + ariaLabel: "Refresh", + tooltipText: "Refresh", + hasPopup: false, + disabled: false, + }, + { + iconSrc: "feedback.svg", + iconAlt: "Feedback", + onCommandClick: jest.fn(), + commandButtonLabel: "Feedback", + ariaLabel: "Feedback", + tooltipText: "Feedback", + hasPopup: false, + disabled: false, + }, + ]; + + mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps); + mockConvertButton.mockReturnValue([ + { key: "create", text: "Create Copy Job" }, + { key: "refresh", text: "Refresh" }, + { key: "feedback", text: "Feedback" }, + ]); + + render(); + + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps); + }); + + it("should re-render when explorer prop changes", () => { + const mockExplorer1 = { id: "explorer1" } as unknown as Explorer; + const mockExplorer2 = { id: "explorer2" } as unknown as Explorer; + + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + const { rerender } = render(); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1); + + rerender(); + + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2); + expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index 9f163613d..92b1107c9 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -28,4 +28,6 @@ const CopyJobCommandBar: React.FC = ({ explorer }) => { ); }; +CopyJobCommandBar.displayName = "CopyJobCommandBar"; + export default CopyJobCommandBar; diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts new file mode 100644 index 000000000..8ce248f41 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts @@ -0,0 +1,268 @@ +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import Explorer from "../../Explorer"; +import * as Actions from "../Actions/CopyJobActions"; +import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; +import { getCommandBarButtons } from "./Utils"; + +jest.mock("../../../ConfigContext", () => ({ + configContext: { + platform: "Portal", + }, + Platform: { + Portal: "Portal", + Emulator: "Emulator", + Hosted: "Hosted", + }, +})); + +jest.mock("../Actions/CopyJobActions", () => ({ + openCreateCopyJobPanel: jest.fn(), +})); + +jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState", () => ({ + MonitorCopyJobsRefState: jest.fn(), +})); + +describe("CommandBar Utils", () => { + let mockExplorer: Explorer; + let mockOpenContainerCopyFeedbackBlade: jest.Mock; + let mockRefreshJobList: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockOpenContainerCopyFeedbackBlade = jest.fn(); + mockRefreshJobList = jest.fn(); + + mockExplorer = { + openContainerCopyFeedbackBlade: mockOpenContainerCopyFeedbackBlade, + } as unknown as Explorer; + + (MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + ref: { + refreshJobList: mockRefreshJobList, + }, + }; + return selector(state); + }); + }); + + describe("getCommandBarButtons", () => { + it("should return an array of command button props", () => { + const buttons = getCommandBarButtons(mockExplorer); + + expect(buttons).toBeDefined(); + expect(Array.isArray(buttons)).toBe(true); + expect(buttons.length).toBeGreaterThan(0); + }); + + it("should include create copy job button", () => { + const buttons = getCommandBarButtons(mockExplorer); + const createButton = buttons[0]; + + expect(createButton).toBeDefined(); + expect(createButton.commandButtonLabel).toBeUndefined(); + expect(createButton.ariaLabel).toBe("Create a new container copy job"); + expect(createButton.tooltipText).toBe("Create Copy Job"); + expect(createButton.hasPopup).toBe(false); + expect(createButton.disabled).toBe(false); + }); + + it("should include refresh button", () => { + const buttons = getCommandBarButtons(mockExplorer); + const refreshButton = buttons[1]; + + expect(refreshButton).toBeDefined(); + expect(refreshButton.ariaLabel).toBe("Refresh copy jobs"); + expect(refreshButton.tooltipText).toBe("Refresh"); + expect(refreshButton.disabled).toBe(false); + }); + + it("should include feedback button when platform is Portal", () => { + const buttons = getCommandBarButtons(mockExplorer); + + expect(buttons.length).toBe(3); + + const feedbackButton = buttons[2]; + expect(feedbackButton).toBeDefined(); + expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs"); + expect(feedbackButton.tooltipText).toBe("Feedback"); + expect(feedbackButton.disabled).toBe(false); + }); + + it("should not include feedback button when platform is not Portal", async () => { + jest.resetModules(); + jest.doMock("../../../ConfigContext", () => ({ + configContext: { + platform: "Emulator", + }, + Platform: { + Portal: "Portal", + Emulator: "Emulator", + Hosted: "Hosted", + }, + })); + + const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils"); + const buttons = getCommandBarButtonsEmulator(mockExplorer); + + expect(buttons.length).toBe(2); + }); + + it("should call openCreateCopyJobPanel when create button is clicked", () => { + const buttons = getCommandBarButtons(mockExplorer); + const createButton = buttons[0]; + + createButton.onCommandClick({} as React.SyntheticEvent); + + expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer); + expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledTimes(1); + }); + + it("should call refreshJobList when refresh button is clicked", () => { + const buttons = getCommandBarButtons(mockExplorer); + const refreshButton = buttons[1]; + + refreshButton.onCommandClick({} as React.SyntheticEvent); + + expect(mockRefreshJobList).toHaveBeenCalledTimes(1); + }); + + it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => { + const buttons = getCommandBarButtons(mockExplorer); + const feedbackButton = buttons[2]; + + feedbackButton.onCommandClick({} as React.SyntheticEvent); + + expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalledTimes(1); + }); + + it("should return buttons with correct icon sources", () => { + const buttons = getCommandBarButtons(mockExplorer); + + expect(buttons[0].iconSrc).toBeDefined(); + expect(buttons[0].iconAlt).toBe("Create Copy Job"); + + expect(buttons[1].iconSrc).toBeDefined(); + expect(buttons[1].iconAlt).toBe("Refresh"); + + expect(buttons[2].iconSrc).toBeDefined(); + expect(buttons[2].iconAlt).toBe("Feedback"); + }); + + it("should handle null MonitorCopyJobsRefState ref gracefully", () => { + (MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementationOnce((selector) => { + const state: { ref: null } = { ref: null }; + return selector(state); + }); + + const buttons = getCommandBarButtons(mockExplorer); + const refreshButton = buttons[1]; + + expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow(); + }); + + it("should set hasPopup to false for all buttons", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.hasPopup).toBe(false); + }); + }); + + it("should set commandButtonLabel to undefined for all buttons", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.commandButtonLabel).toBeUndefined(); + }); + }); + + it("should respect disabled state when provided", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.disabled).toBe(false); + }); + }); + + it("should return CommandButtonComponentProps with all required properties", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button: CommandButtonComponentProps) => { + expect(button).toHaveProperty("iconSrc"); + expect(button).toHaveProperty("iconAlt"); + expect(button).toHaveProperty("onCommandClick"); + expect(button).toHaveProperty("commandButtonLabel"); + expect(button).toHaveProperty("ariaLabel"); + expect(button).toHaveProperty("tooltipText"); + expect(button).toHaveProperty("hasPopup"); + expect(button).toHaveProperty("disabled"); + }); + }); + + it("should maintain button order: create, refresh, feedback", () => { + const buttons = getCommandBarButtons(mockExplorer); + + expect(buttons[0].tooltipText).toBe("Create Copy Job"); + expect(buttons[1].tooltipText).toBe("Refresh"); + expect(buttons[2].tooltipText).toBe("Feedback"); + }); + }); + + describe("Button click handlers", () => { + it("should execute click handlers without errors", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow(); + }); + }); + + it("should call correct action for each button", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons[0].onCommandClick({} as React.SyntheticEvent); + expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer); + + buttons[1].onCommandClick({} as React.SyntheticEvent); + expect(mockRefreshJobList).toHaveBeenCalled(); + + buttons[2].onCommandClick({} as React.SyntheticEvent); + expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled(); + }); + }); + + describe("Accessibility", () => { + it("should have aria labels for all buttons", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.ariaLabel).toBeDefined(); + expect(typeof button.ariaLabel).toBe("string"); + expect(button.ariaLabel.length).toBeGreaterThan(0); + }); + }); + + it("should have tooltip text for all buttons", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.tooltipText).toBeDefined(); + expect(typeof button.tooltipText).toBe("string"); + expect(button.tooltipText.length).toBeGreaterThan(0); + }); + }); + + it("should have icon alt text for all buttons", () => { + const buttons = getCommandBarButtons(mockExplorer); + + buttons.forEach((button) => { + expect(button.iconAlt).toBeDefined(); + expect(typeof button.iconAlt).toBe("string"); + expect(button.iconAlt.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index a1472793b..152c2dfbd 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -17,7 +17,7 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] { iconSrc: AddIcon, label: ContainerCopyMessages.createCopyJobButtonLabel, ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel, - onClick: Actions.openCreateCopyJobPanel.bind(null, explorer), + onClick: () => Actions.openCreateCopyJobPanel(explorer), }, { key: "refresh", diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index fdc4e38c6..936c923b6 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -89,7 +89,7 @@ export default { enablementTitle: "Enable system assigned managed identity", enablementDescription: (accountName: string) => accountName - ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. ` + ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button.` : "", }, defaultManagedIdentity: { @@ -116,7 +116,7 @@ export default { }, popoverTitle: "Read permissions assigned to default identity.", popoverDescription: - "Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ", + "Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.", }, pointInTimeRestore: { title: "Point In Time Restore enabled", diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx new file mode 100644 index 000000000..0f559e026 --- /dev/null +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx @@ -0,0 +1,131 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import Explorer from "../Explorer"; +import ContainerCopyPanel from "./ContainerCopyPanel"; +import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState"; + +jest.mock("./CommandBar/CopyJobCommandBar", () => { + const MockCopyJobCommandBar = () => { + return
CopyJobCommandBar
; + }; + MockCopyJobCommandBar.displayName = "CopyJobCommandBar"; + return MockCopyJobCommandBar; +}); + +jest.mock("./MonitorCopyJobs/MonitorCopyJobs", () => { + const React = jest.requireActual("react"); + const MockMonitorCopyJobs = React.forwardRef((_props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + refreshJobList: jest.fn(), + })); + return
MonitorCopyJobs
; + }); + MockMonitorCopyJobs.displayName = "MonitorCopyJobs"; + return MockMonitorCopyJobs; +}); + +jest.mock("./MonitorCopyJobs/MonitorCopyJobRefState", () => ({ + MonitorCopyJobsRefState: { + getState: jest.fn(() => ({ + setRef: jest.fn(), + })), + }, +})); + +describe("ContainerCopyPanel", () => { + let mockExplorer: Explorer; + let mockSetRef: jest.Mock; + + beforeEach(() => { + mockExplorer = {} as Explorer; + + mockSetRef = jest.fn(); + (MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({ + setRef: mockSetRef, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders the component with correct structure", () => { + render(); + + const wrapper = document.querySelector("#containerCopyWrapper"); + expect(wrapper).toBeInTheDocument(); + expect(wrapper).toHaveClass("flexContainer", "hideOverflows"); + }); + + it("renders CopyJobCommandBar component", () => { + render(); + + const commandBar = screen.getByTestId("copy-job-command-bar"); + expect(commandBar).toBeInTheDocument(); + expect(commandBar).toHaveTextContent("CopyJobCommandBar"); + }); + + it("renders MonitorCopyJobs component", () => { + render(); + + const monitorCopyJobs = screen.getByTestId("monitor-copy-jobs"); + expect(monitorCopyJobs).toBeInTheDocument(); + expect(monitorCopyJobs).toHaveTextContent("MonitorCopyJobs"); + }); + + it("passes explorer prop to child components", () => { + render(); + + expect(screen.getByTestId("copy-job-command-bar")).toBeInTheDocument(); + expect(screen.getByTestId("monitor-copy-jobs")).toBeInTheDocument(); + }); + + it("sets the MonitorCopyJobs ref in the state on mount", async () => { + render(); + + await waitFor(() => { + expect(mockSetRef).toHaveBeenCalledTimes(1); + }); + + const refArgument = mockSetRef.mock.calls[0][0]; + expect(refArgument).toBeDefined(); + expect(refArgument).toHaveProperty("refreshJobList"); + expect(typeof refArgument.refreshJobList).toBe("function"); + }); + + it("updates the ref state when monitorCopyJobsRef changes", async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockSetRef).toHaveBeenCalledTimes(1); + }); + mockSetRef.mockClear(); + rerender(); + }); + + it("handles missing explorer prop gracefully", () => { + const { container } = render(); + expect(container.querySelector("#containerCopyWrapper")).toBeInTheDocument(); + }); + + it("applies correct CSS classes to wrapper", () => { + render(); + + const wrapper = document.querySelector("#containerCopyWrapper"); + expect(wrapper).toHaveClass("flexContainer"); + expect(wrapper).toHaveClass("hideOverflows"); + }); + + it("maintains ref across re-renders", async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockSetRef).toHaveBeenCalled(); + }); + + const firstCallRef = mockSetRef.mock.calls[0][0]; + const newExplorer = {} as Explorer; + rerender(); + expect(mockSetRef.mock.calls[0][0]).toBe(firstCallRef); + }); +}); diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx index 2d7cccb87..1c82ad4a6 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx @@ -20,4 +20,6 @@ const ContainerCopyPanel: React.FC = ({ explorer }) => { ); }; +ContainerCopyPanel.displayName = "ContainerCopyPanel"; + export default ContainerCopyPanel; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx new file mode 100644 index 000000000..d3776f07c --- /dev/null +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx @@ -0,0 +1,667 @@ +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import Explorer from "../../Explorer"; +import { CopyJobMigrationType } from "../Enums/CopyJobEnums"; +import CopyJobContextProvider, { CopyJobContext, useCopyJobContext } from "./CopyJobContext"; + +jest.mock("UserContext", () => ({ + userContext: { + subscriptionId: "test-subscription-id", + databaseAccount: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + kind: "GlobalDocumentDB", + }, + }, +})); + +describe("CopyJobContext", () => { + let mockExplorer: Explorer; + + beforeEach(() => { + mockExplorer = {} as Explorer; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("CopyJobContextProvider", () => { + it("should render children correctly", () => { + render( + +
Test Child
+
, + ); + + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child"); + }); + + it("should initialize with default state", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue).toBeDefined(); + expect(contextValue.copyJobState).toEqual({ + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { + subscriptionId: "test-subscription-id", + }, + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + kind: "GlobalDocumentDB", + }, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "test-subscription-id", + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + kind: "GlobalDocumentDB", + }, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }); + expect(contextValue.flow).toBeNull(); + expect(contextValue.contextError).toBeNull(); + expect(contextValue.explorer).toBe(mockExplorer); + }); + + it("should provide setCopyJobState function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setCopyJobState).toBeDefined(); + expect(typeof contextValue.setCopyJobState).toBe("function"); + }); + + it("should provide setFlow function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setFlow).toBeDefined(); + expect(typeof contextValue.setFlow).toBe("function"); + }); + + it("should provide setContextError function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setContextError).toBeDefined(); + expect(typeof contextValue.setContextError).toBe("function"); + }); + + it("should provide resetCopyJobState function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.resetCopyJobState).toBeDefined(); + expect(typeof contextValue.resetCopyJobState).toBe("function"); + }); + + it("should update copyJobState when setCopyJobState is called", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + return ( + + ); + }; + + render( + + + , + ); + + const button = screen.getByText("Update Job"); + act(() => { + button.click(); + }); + + expect(contextValue.copyJobState.jobName).toBe("test-job"); + expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online); + }); + + it("should update flow when setFlow is called", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + const handleSetFlow = (): void => { + context.setFlow({ currentScreen: "source-selection" }); + }; + + return ; + }; + + render( + + + , + ); + + expect(contextValue.flow).toBeNull(); + + const button = screen.getByText("Set Flow"); + act(() => { + button.click(); + }); + + expect(contextValue.flow).toEqual({ currentScreen: "source-selection" }); + }); + + it("should update contextError when setContextError is called", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + return ; + }; + + render( + + + , + ); + + expect(contextValue.contextError).toBeNull(); + + const button = screen.getByText("Set Error"); + act(() => { + button.click(); + }); + + expect(contextValue.contextError).toBe("Test error message"); + }); + + it("should reset copyJobState when resetCopyJobState is called", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + const handleUpdate = (): void => { + context.setCopyJobState({ + ...context.copyJobState, + jobName: "modified-job", + migrationType: CopyJobMigrationType.Online, + source: { + ...context.copyJobState.source, + databaseId: "test-db", + containerId: "test-container", + }, + }); + }; + + return ( + <> + + + + ); + }; + + render( + + + , + ); + + const updateButton = screen.getByText("Update"); + act(() => { + updateButton.click(); + }); + + expect(contextValue.copyJobState.jobName).toBe("modified-job"); + expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online); + expect(contextValue.copyJobState.source.databaseId).toBe("test-db"); + + const resetButton = screen.getByText("Reset"); + act(() => { + resetButton.click(); + }); + + expect(contextValue.copyJobState.jobName).toBe(""); + expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline); + expect(contextValue.copyJobState.source.databaseId).toBe(""); + expect(contextValue.copyJobState.source.containerId).toBe(""); + }); + + it("should maintain explorer reference", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.explorer).toBe(mockExplorer); + }); + + it("should handle multiple state updates correctly", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + return ( + <> + + + + + ); + }; + + render( + + + , + ); + + act(() => { + screen.getByText("Update 1").click(); + }); + expect(contextValue.copyJobState.jobName).toBe("job-1"); + + act(() => { + screen.getByText("Flow 1").click(); + }); + expect(contextValue.flow).toEqual({ currentScreen: "screen-1" }); + + act(() => { + screen.getByText("Error 1").click(); + }); + expect(contextValue.contextError).toBe("error-1"); + }); + + it("should handle partial state updates", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + const handlePartialUpdate = (): void => { + context.setCopyJobState((prev) => ({ + ...prev, + jobName: "partial-update", + })); + }; + + return ; + }; + + render( + + + , + ); + + const initialState = { ...contextValue.copyJobState }; + + act(() => { + screen.getByText("Partial Update").click(); + }); + + expect(contextValue.copyJobState.jobName).toBe("partial-update"); + expect(contextValue.copyJobState.migrationType).toBe(initialState.migrationType); + expect(contextValue.copyJobState.source).toEqual(initialState.source); + expect(contextValue.copyJobState.target).toEqual(initialState.target); + }); + }); + + describe("useCopyJobContext", () => { + it("should return context value when used within provider", () => { + let contextValue: any; + + const TestComponent = (): null => { + const context = useCopyJobContext(); + contextValue = context; + return null; + }; + + render( + + + , + ); + + expect(contextValue).toBeDefined(); + expect(contextValue.copyJobState).toBeDefined(); + expect(contextValue.setCopyJobState).toBeDefined(); + expect(contextValue.flow).toBeNull(); + expect(contextValue.setFlow).toBeDefined(); + expect(contextValue.contextError).toBeNull(); + expect(contextValue.setContextError).toBeDefined(); + expect(contextValue.resetCopyJobState).toBeDefined(); + expect(contextValue.explorer).toBe(mockExplorer); + }); + + it("should throw error when used outside provider", () => { + const originalError = console.error; + console.error = jest.fn(); + + const TestComponent = (): null => { + useCopyJobContext(); + return null; + }; + + expect(() => { + render(); + }).toThrow("useCopyJobContext must be used within a CopyJobContextProvider"); + + console.error = originalError; + }); + + it("should allow updating state through hook", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + return ( + + ); + }; + + render( + + + , + ); + + act(() => { + screen.getByText("Update").click(); + }); + + expect(contextValue.copyJobState.jobName).toBe("hook-test-job"); + }); + + it("should allow resetting state through hook", () => { + let contextValue: any; + + const TestComponent = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue = context; + + return ( + <> + + + + ); + }; + + render( + + + , + ); + + act(() => { + screen.getByText("Modify").click(); + }); + + expect(contextValue.copyJobState.jobName).toBe("modified"); + expect(contextValue.copyJobState.source.databaseId).toBe("modified-db"); + + act(() => { + screen.getByText("Reset").click(); + }); + + expect(contextValue.copyJobState.jobName).toBe(""); + expect(contextValue.copyJobState.source.databaseId).toBe(""); + }); + + it("should maintain state consistency across multiple components", () => { + let contextValue1: any; + let contextValue2: any; + + const TestComponent1 = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue1 = context; + + return ( + + ); + }; + + const TestComponent2 = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue2 = context; + return
Component 2
; + }; + + render( + + + + , + ); + + expect(contextValue1.copyJobState).toEqual(contextValue2.copyJobState); + + act(() => { + screen.getByText("Update From Component 1").click(); + }); + + expect(contextValue1.copyJobState.jobName).toBe("shared-job"); + expect(contextValue2.copyJobState.jobName).toBe("shared-job"); + }); + }); + + describe("Initial State", () => { + it("should initialize with offline migration type", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline); + }); + + it("should initialize source with userContext values", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.source.subscription.subscriptionId).toBe("test-subscription-id"); + expect(contextValue.copyJobState.source.account.name).toBe("test-account"); + }); + + it("should initialize target with userContext values", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id"); + expect(contextValue.copyJobState.target.account.name).toBe("test-account"); + }); + + it("should initialize sourceReadAccessFromTarget as false", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false); + }); + + it("should initialize with empty database and container ids", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.source.databaseId).toBe(""); + expect(contextValue.copyJobState.source.containerId).toBe(""); + expect(contextValue.copyJobState.target.databaseId).toBe(""); + expect(contextValue.copyJobState.target.containerId).toBe(""); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.test.ts b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts new file mode 100644 index 000000000..5c0a2a49d --- /dev/null +++ b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts @@ -0,0 +1,490 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import * as CopyJobUtils from "./CopyJobUtils"; +import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes"; + +describe("CopyJobUtils", () => { + describe("buildResourceLink", () => { + const mockResource: DatabaseAccount = { + id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1", + name: "account1", + location: "eastus", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: {}, + }; + + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + }); + + afterEach(() => { + (window as any).location = originalLocation; + }); + + it("should build resource link with Azure portal endpoint", () => { + delete (window as any).location; + (window as any).location = { + ...originalLocation, + origin: "https://portal.azure.com", + ancestorOrigins: ["https://portal.azure.com"] as any, + } as Location; + + const link = CopyJobUtils.buildResourceLink(mockResource); + expect(link).toBe( + "https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1", + ); + }); + + it("should replace cosmos.azure with portal.azure", () => { + delete (window as any).location; + (window as any).location = { + ...originalLocation, + origin: "https://cosmos.azure.com", + ancestorOrigins: ["https://cosmos.azure.com"] as any, + } as Location; + + const link = CopyJobUtils.buildResourceLink(mockResource); + expect(link).toContain("https://portal.azure.com"); + }); + + it("should use Azure portal endpoint for localhost", () => { + delete (window as any).location; + (window as any).location = { + ...originalLocation, + origin: "http://localhost:1234", + ancestorOrigins: ["http://localhost:1234"] as any, + } as Location; + + const link = CopyJobUtils.buildResourceLink(mockResource); + expect(link).toContain("https://ms.portal.azure.com"); + }); + + it("should remove trailing slash from origin", () => { + delete (window as any).location; + (window as any).location = { + ...originalLocation, + origin: "https://portal.azure.com/", + ancestorOrigins: ["https://portal.azure.com/"] as any, + } as Location; + + const link = CopyJobUtils.buildResourceLink(mockResource); + expect(link).toBe( + "https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1", + ); + }); + }); + + describe("buildDataTransferJobPath", () => { + it("should build basic path without jobName or action", () => { + const path = CopyJobUtils.buildDataTransferJobPath({ + subscriptionId: "sub123", + resourceGroup: "rg1", + accountName: "account1", + }); + + expect(path).toBe( + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs", + ); + }); + + it("should build path with jobName", () => { + const path = CopyJobUtils.buildDataTransferJobPath({ + subscriptionId: "sub123", + resourceGroup: "rg1", + accountName: "account1", + jobName: "job1", + }); + + expect(path).toBe( + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1", + ); + }); + + it("should build path with jobName and action", () => { + const path = CopyJobUtils.buildDataTransferJobPath({ + subscriptionId: "sub123", + resourceGroup: "rg1", + accountName: "account1", + jobName: "job1", + action: "cancel", + }); + + expect(path).toBe( + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1/cancel", + ); + }); + }); + + describe("convertTime", () => { + it("should convert time string with hours, minutes, and seconds", () => { + const result = CopyJobUtils.convertTime("02:30:45"); + expect(result).toBe("02 hours, 30 minutes, 45 seconds"); + }); + + it("should convert time string with only seconds", () => { + const result = CopyJobUtils.convertTime("00:00:30"); + expect(result).toBe("30 seconds"); + }); + + it("should convert time string with only minutes and seconds", () => { + const result = CopyJobUtils.convertTime("00:05:15"); + expect(result).toBe("05 minutes, 15 seconds"); + }); + + it("should round seconds", () => { + const result = CopyJobUtils.convertTime("00:00:45.678"); + expect(result).toBe("46 seconds"); + }); + + it("should return '0 seconds' for zero time", () => { + const result = CopyJobUtils.convertTime("00:00:00"); + expect(result).toBe("0 seconds"); + }); + + it("should return null for invalid time format", () => { + const result = CopyJobUtils.convertTime("invalid"); + expect(result).toBeNull(); + }); + + it("should return null for incomplete time string", () => { + const result = CopyJobUtils.convertTime("10:30"); + expect(result).toBeNull(); + }); + + it("should pad single digit values", () => { + const result = CopyJobUtils.convertTime("1:5:9"); + expect(result).toBe("01 hours, 05 minutes, 09 seconds"); + }); + }); + + describe("formatUTCDateTime", () => { + it("should format valid UTC date string", () => { + const result = CopyJobUtils.formatUTCDateTime("2025-11-26T10:30:00Z"); + expect(result).not.toBeNull(); + expect(result?.formattedDateTime).toContain("11/26/25, 10:30:00 AM"); + expect(result?.timestamp).toBeGreaterThan(0); + }); + + it("should return null for invalid date string", () => { + const result = CopyJobUtils.formatUTCDateTime("invalid-date"); + expect(result).toBeNull(); + }); + + it("should return timestamp for valid date", () => { + const result = CopyJobUtils.formatUTCDateTime("2025-01-01T00:00:00Z"); + expect(result).not.toBeNull(); + expect(typeof result?.timestamp).toBe("number"); + expect(result?.timestamp).toBe(new Date("2025-01-01T00:00:00Z").getTime()); + }); + }); + + describe("convertToCamelCase", () => { + it("should convert string to camel case", () => { + const result = CopyJobUtils.convertToCamelCase("hello world"); + expect(result).toBe("HelloWorld"); + }); + + it("should handle single word", () => { + const result = CopyJobUtils.convertToCamelCase("hello"); + expect(result).toBe("Hello"); + }); + + it("should handle multiple spaces", () => { + const result = CopyJobUtils.convertToCamelCase("hello world test"); + expect(result).toBe("HelloWorldTest"); + }); + + it("should handle mixed case input", () => { + const result = CopyJobUtils.convertToCamelCase("HELLO WORLD"); + expect(result).toBe("HelloWorld"); + }); + + it("should handle empty string", () => { + const result = CopyJobUtils.convertToCamelCase(""); + expect(result).toBe(""); + }); + }); + + describe("extractErrorMessage", () => { + it("should extract first part of error message before line breaks", () => { + const error: CopyJobErrorType = { + message: "Error occurred\r\n\r\nAdditional details\r\n\r\nMore info", + code: "500", + }; + + const result = CopyJobUtils.extractErrorMessage(error); + expect(result.message).toBe("Error occurred"); + expect(result.code).toBe("500"); + }); + + it("should return same message if no line breaks", () => { + const error: CopyJobErrorType = { + message: "Simple error message", + code: "404", + }; + + const result = CopyJobUtils.extractErrorMessage(error); + expect(result.message).toBe("Simple error message"); + expect(result.code).toBe("404"); + }); + }); + + describe("getAccountDetailsFromResourceId", () => { + it("should extract account details from valid resource ID", () => { + const resourceId = + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1"; + const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId); + + expect(details).toEqual({ + subscriptionId: "sub123", + resourceGroup: "rg1", + accountName: "account1", + }); + }); + + it("should be case insensitive", () => { + const resourceId = + "/subscriptions/sub123/resourceGroups/rg1/providers/microsoft.documentdb/databaseAccounts/account1"; + const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId); + + expect(details).toEqual({ + subscriptionId: "sub123", + resourceGroup: "rg1", + accountName: "account1", + }); + }); + + it("should return null for undefined resource ID", () => { + const details = CopyJobUtils.getAccountDetailsFromResourceId(undefined); + expect(details).toBeNull(); + }); + + it("should return null for invalid resource ID", () => { + const details = CopyJobUtils.getAccountDetailsFromResourceId("invalid-resource-id"); + expect(details).toEqual({ accountName: undefined, resourceGroup: undefined, subscriptionId: undefined }); + }); + }); + + describe("getContainerIdentifiers", () => { + it("should extract container identifiers", () => { + const container = { + account: { + id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1", + name: "account1", + location: "eastus", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: {}, + }, + databaseId: "db1", + containerId: "container1", + } as CopyJobContextState["source"]; + + const identifiers = CopyJobUtils.getContainerIdentifiers(container); + expect(identifiers).toEqual({ + accountId: container.account.id, + databaseId: "db1", + containerId: "container1", + }); + }); + + it("should return empty strings for undefined values", () => { + const container = { + account: undefined, + databaseId: undefined, + containerId: undefined, + } as CopyJobContextState["source"]; + + const identifiers = CopyJobUtils.getContainerIdentifiers(container); + expect(identifiers).toEqual({ + accountId: "", + databaseId: "", + containerId: "", + }); + }); + }); + + describe("isIntraAccountCopy", () => { + const sourceAccountId = + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1"; + const targetAccountId = + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1"; + const differentAccountId = + "/subscriptions/sub456/resourceGroups/rg2/providers/Microsoft.DocumentDB/databaseAccounts/account2"; + + it("should return true for same account", () => { + const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, targetAccountId); + expect(result).toBe(true); + }); + + it("should return false for different accounts", () => { + const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentAccountId); + expect(result).toBe(false); + }); + + it("should return false for different subscriptions", () => { + const differentSubId = + "/subscriptions/sub999/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1"; + const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentSubId); + expect(result).toBe(false); + }); + + it("should return false for different resource groups", () => { + const differentRgId = + "/subscriptions/sub123/resourceGroups/rg999/providers/Microsoft.DocumentDB/databaseAccounts/account1"; + const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentRgId); + expect(result).toBe(false); + }); + + it("should return false for undefined source", () => { + const result = CopyJobUtils.isIntraAccountCopy(undefined, targetAccountId); + expect(result).toBe(false); + }); + + it("should return false for undefined target", () => { + const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, undefined); + expect(result).toBe(false); + }); + }); + + describe("isEqual", () => { + const createMockJob = (name: string, status: string): CopyJobType => ({ + ID: name, + Mode: "Online", + Name: name, + Status: status as any, + CompletionPercentage: 50, + Duration: "00:05:00", + LastUpdatedTime: "2025-11-26T10:00:00Z", + timestamp: Date.now(), + Source: {} as any, + Destination: {} as any, + }); + + it("should return true for equal job arrays", () => { + const jobs1 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")]; + const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")]; + + const result = CopyJobUtils.isEqual(jobs1, jobs2); + expect(result).toBe(true); + }); + + it("should return false for different lengths", () => { + const jobs1 = [createMockJob("job1", "Running")]; + const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")]; + + const result = CopyJobUtils.isEqual(jobs1, jobs2); + expect(result).toBe(false); + }); + + it("should return false for different status", () => { + const jobs1 = [createMockJob("job1", "Running")]; + const jobs2 = [createMockJob("job1", "Completed")]; + + const result = CopyJobUtils.isEqual(jobs1, jobs2); + expect(result).toBe(false); + }); + + it("should return false for missing job in second array", () => { + const jobs1 = [createMockJob("job1", "Running")]; + const jobs2 = [createMockJob("job2", "Running")]; + + const result = CopyJobUtils.isEqual(jobs1, jobs2); + expect(result).toBe(false); + }); + + it("should return true for empty arrays", () => { + const result = CopyJobUtils.isEqual([], []); + expect(result).toBe(true); + }); + }); + + describe("getDefaultJobName", () => { + beforeEach(() => { + jest.spyOn(Date.prototype, "getTime").mockReturnValue(1234567890); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should generate default job name for single container", () => { + const containers = [ + { + sourceDatabaseName: "sourceDb", + sourceContainerName: "sourceCont", + targetDatabaseName: "targetDb", + targetContainerName: "targetCont", + }, + ]; + + const jobName = CopyJobUtils.getDefaultJobName(containers); + expect(jobName).toBe("sourc.sourc_targe.targe_1234567890"); + }); + + it("should truncate long names", () => { + const containers = [ + { + sourceDatabaseName: "veryLongSourceDatabaseName", + sourceContainerName: "veryLongSourceContainerName", + targetDatabaseName: "veryLongTargetDatabaseName", + targetContainerName: "veryLongTargetContainerName", + }, + ]; + + const jobName = CopyJobUtils.getDefaultJobName(containers); + expect(jobName).toBe("veryL.veryL_veryL.veryL_1234567890"); + }); + + it("should return empty string for multiple containers", () => { + const containers = [ + { + sourceDatabaseName: "db1", + sourceContainerName: "cont1", + targetDatabaseName: "db2", + targetContainerName: "cont2", + }, + { + sourceDatabaseName: "db3", + sourceContainerName: "cont3", + targetDatabaseName: "db4", + targetContainerName: "cont4", + }, + ]; + + const jobName = CopyJobUtils.getDefaultJobName(containers); + expect(jobName).toBe(""); + }); + + it("should return empty string for empty array", () => { + const jobName = CopyJobUtils.getDefaultJobName([]); + expect(jobName).toBe(""); + }); + + it("should handle short names without truncation", () => { + const containers = [ + { + sourceDatabaseName: "src", + sourceContainerName: "cont", + targetDatabaseName: "tgt", + targetContainerName: "dest", + }, + ]; + + const jobName = CopyJobUtils.getDefaultJobName(containers); + expect(jobName).toBe("src.cont_tgt.dest_1234567890"); + }); + }); + + describe("constants", () => { + it("should have correct COSMOS_SQL_COMPONENT value", () => { + expect(CopyJobUtils.COSMOS_SQL_COMPONENT).toBe("CosmosDBSql"); + }); + + it("should have correct COPY_JOB_API_VERSION value", () => { + expect(CopyJobUtils.COPY_JOB_API_VERSION).toBe("2025-05-01-preview"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx new file mode 100644 index 000000000..6022b98d4 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx @@ -0,0 +1,295 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DatabaseAccount } from "Contracts/DataModels"; +import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes"; +import React from "react"; +import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import AddManagedIdentity from "./AddManagedIdentity"; + +jest.mock("../../../../../Utils/arm/identityUtils", () => ({ + updateSystemIdentity: jest.fn(), +})); + +jest.mock("@fluentui/react", () => ({ + ...jest.requireActual("@fluentui/react"), + getTheme: () => ({ + semanticColors: { + bodySubtext: "#666666", + errorIcon: "#d13438", + successIcon: "#107c10", + }, + palette: { + themePrimary: "#0078d4", + }, + }), + mergeStyles: () => "mocked-styles", + mergeStyleSets: (styleSet: any) => { + const result: any = {}; + Object.keys(styleSet).forEach((key) => { + result[key] = "mocked-style-" + key; + }); + return result; + }, +})); + +jest.mock("../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(() => ({ + subscriptionId: "test-subscription-id", + resourceGroup: "test-resource-group", + accountName: "test-account-name", + })), +})); + +jest.mock("../../../../../Common/Logger", () => ({ + logError: jest.fn(), +})); + +const mockUpdateSystemIdentity = updateSystemIdentity as jest.MockedFunction; + +describe("AddManagedIdentity", () => { + const mockCopyJobState = { + jobName: "test-job", + migrationType: "Offline" as any, + source: { + subscription: { subscriptionId: "source-sub-id" }, + account: { id: "source-account-id", name: "source-account-name" }, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-target-account", + }, + databaseId: "target-db", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + }; + + const mockContextValue = { + copyJobState: mockCopyJobState, + setCopyJobState: jest.fn(), + flow: { currentScreen: "AssignPermissions" }, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, + contextError: "", + setContextError: jest.fn(), + } as unknown as CopyJobContextProviderType; + + const renderWithContext = (contextValue = mockContextValue) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdateSystemIdentity.mockResolvedValue({ + id: "updated-account-id", + name: "updated-account-name", + } as any); + }); + + describe("Snapshot Tests", () => { + it("renders initial state correctly", () => { + const { container } = renderWithContext(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("renders with toggle on and popover visible", () => { + const { container } = renderWithContext(); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("renders loading state", async () => { + mockUpdateSystemIdentity.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({} as any), 100)), + ); + + const { container } = renderWithContext(); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + const primaryButton = screen.getByText("Yes"); + fireEvent.click(primaryButton); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("Component Rendering", () => { + it("renders all required elements", () => { + renderWithContext(); + + expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.description)).toBeInTheDocument(); + expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText)).toBeInTheDocument(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("renders description link with correct href", () => { + renderWithContext(); + + const link = screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText); + expect(link.closest("a")).toHaveAttribute("href", ContainerCopyMessages.addManagedIdentity.descriptionHref); + expect(link.closest("a")).toHaveAttribute("target", "_blank"); + expect(link.closest("a")).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("toggle shows correct initial state", () => { + renderWithContext(); + + const toggle = screen.getByRole("switch"); + expect(toggle).not.toBeChecked(); + }); + }); + + describe("Toggle Functionality", () => { + it("toggles state when clicked", () => { + renderWithContext(); + + const toggle = screen.getByRole("switch"); + expect(toggle).not.toBeChecked(); + + fireEvent.click(toggle); + expect(toggle).toBeChecked(); + + fireEvent.click(toggle); + expect(toggle).not.toBeChecked(); + }); + + it("shows popover when toggle is on", () => { + renderWithContext(); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).toBeInTheDocument(); + }); + + it("hides popover when toggle is off", () => { + renderWithContext(); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + fireEvent.click(toggle); + + expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument(); + }); + }); + + describe("Popover Functionality", () => { + beforeEach(() => { + renderWithContext(); + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + }); + + it("displays correct enablement description with account name", () => { + const expectedDescription = ContainerCopyMessages.addManagedIdentity.enablementDescription( + mockCopyJobState.target.account.name, + ); + expect(screen.getByText(expectedDescription)).toBeInTheDocument(); + }); + + it("calls handleAddSystemIdentity when primary button clicked", async () => { + const primaryButton = screen.getByText("Yes"); + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockUpdateSystemIdentity).toHaveBeenCalledWith( + "test-subscription-id", + "test-resource-group", + "test-account-name", + ); + }); + }); + + it.skip("closes popover when cancel button clicked", () => { + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument(); + + const toggle = screen.getByRole("switch"); + expect(toggle).not.toBeChecked(); + }); + }); + + describe("Managed Identity Operations", () => { + it("successfully updates system identity", async () => { + const setCopyJobState = jest.fn(); + const contextWithMockSetter = { + ...mockContextValue, + setCopyJobState, + }; + + renderWithContext(contextWithMockSetter); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + const primaryButton = screen.getByText("Yes"); + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockUpdateSystemIdentity).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(setCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it("handles error during identity update", async () => { + const setContextError = jest.fn(); + const contextWithErrorHandler = { + ...mockContextValue, + setContextError, + }; + + const errorMessage = "Failed to update identity"; + mockUpdateSystemIdentity.mockRejectedValue(new Error(errorMessage)); + + renderWithContext(contextWithErrorHandler); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + const primaryButton = screen.getByText("Yes"); + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(setContextError).toHaveBeenCalledWith(errorMessage); + }); + }); + }); + + describe("Edge Cases", () => { + it("handles missing target account gracefully", () => { + const contextWithoutTargetAccount = { + ...mockContextValue, + copyJobState: { + ...mockCopyJobState, + target: { + ...mockCopyJobState.target, + account: null as DatabaseAccount | null, + }, + }, + } as unknown as CopyJobContextProviderType; + + expect(() => renderWithContext(contextWithoutTargetAccount)).not.toThrow(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx new file mode 100644 index 000000000..5ef3577b8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx @@ -0,0 +1,503 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes"; +import AddReadPermissionToDefaultIdentity from "./AddReadPermissionToDefaultIdentity"; + +jest.mock("../../../../../Common/Logger", () => ({ + logError: jest.fn(), +})); + +jest.mock("../../../../../Utils/arm/RbacUtils", () => ({ + assignRole: jest.fn(), +})); + +jest.mock("../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(), +})); + +jest.mock("../Components/InfoTooltip", () => { + const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => { + return
{content}
; + }; + MockInfoTooltip.displayName = "MockInfoTooltip"; + return MockInfoTooltip; +}); + +jest.mock("../Components/PopoverContainer", () => { + const MockPopoverContainer = ({ + isLoading, + visible, + title, + onCancel, + onPrimary, + children, + }: { + isLoading?: boolean; + visible: boolean; + title: string; + onCancel: () => void; + onPrimary: () => void; + children: React.ReactNode; + }) => { + if (!visible) { + return null; + } + return ( +
+
{title}
+
{children}
+ + +
+ ); + }; + MockPopoverContainer.displayName = "MockPopoverContainer"; + return MockPopoverContainer; +}); + +jest.mock("./hooks/useToggle", () => { + return jest.fn(); +}); + +import { Subscription } from "Contracts/DataModels"; +import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { logError } from "../../../../../Common/Logger"; +import { assignRole, RoleAssignmentType } from "../../../../../Utils/arm/RbacUtils"; +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; +import useToggle from "./hooks/useToggle"; + +describe("AddReadPermissionToDefaultIdentity Component", () => { + const mockUseToggle = useToggle as jest.MockedFunction; + const mockAssignRole = assignRole as jest.MockedFunction; + const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction< + typeof getAccountDetailsFromResourceId + >; + const mockLogError = logError as jest.MockedFunction; + + const mockContextValue: CopyJobContextProviderType = { + copyJobState: { + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "source-sub-id" } as Subscription, + account: { + id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account", + name: "source-account", + location: "East US", + kind: "GlobalDocumentDB", + type: "Microsoft.DocumentDB/databaseAccounts", + properties: { + documentEndpoint: "https://source-account.documents.azure.com:443/", + }, + }, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { + id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", + name: "target-account", + location: "West US", + kind: "GlobalDocumentDB", + type: "Microsoft.DocumentDB/databaseAccounts", + properties: { + documentEndpoint: "https://target-account.documents.azure.com:443/", + }, + identity: { + principalId: "target-principal-id", + type: "SystemAssigned", + }, + }, + databaseId: "target-db", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + }, + setCopyJobState: jest.fn(), + setContextError: jest.fn(), + contextError: null, + flow: null, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, + }; + + const renderComponent = (contextValue = mockContextValue) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseToggle.mockReturnValue([false, jest.fn()]); + }); + + describe("Rendering", () => { + it("should render correctly with default state", () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly when toggle is on", () => { + mockUseToggle.mockReturnValue([true, jest.fn()]); + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with different context states", () => { + const contextWithError = { + ...mockContextValue, + contextError: "Test error message", + }; + const { container } = renderComponent(contextWithError); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly when sourceReadAccessFromTarget is true", () => { + const contextWithAccess = { + ...mockContextValue, + copyJobState: { + ...mockContextValue.copyJobState, + sourceReadAccessFromTarget: true, + }, + }; + const { container } = renderComponent(contextWithAccess); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Component Structure", () => { + it("should display the description text", () => { + renderComponent(); + expect(screen.getByText(ContainerCopyMessages.readPermissionAssigned.description)).toBeInTheDocument(); + }); + + it("should display the info tooltip", () => { + renderComponent(); + expect(screen.getByTestId("info-tooltip")).toBeInTheDocument(); + }); + + it("should display the toggle component", () => { + renderComponent(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + }); + + describe("Toggle Interaction", () => { + it("should call onToggle when toggle is clicked", () => { + const mockOnToggle = jest.fn(); + mockUseToggle.mockReturnValue([false, mockOnToggle]); + + renderComponent(); + const toggle = screen.getByRole("switch"); + + fireEvent.click(toggle); + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("should show popover when toggle is turned on", () => { + mockUseToggle.mockReturnValue([true, jest.fn()]); + renderComponent(); + + expect(screen.getByTestId("popover-message")).toBeInTheDocument(); + expect(screen.getByTestId("popover-title")).toHaveTextContent( + ContainerCopyMessages.readPermissionAssigned.popoverTitle, + ); + expect(screen.getByTestId("popover-content")).toHaveTextContent( + ContainerCopyMessages.readPermissionAssigned.popoverDescription, + ); + }); + + it("should not show popover when toggle is turned off", () => { + mockUseToggle.mockReturnValue([false, jest.fn()]); + renderComponent(); + + expect(screen.queryByTestId("popover-message")).not.toBeInTheDocument(); + }); + }); + + describe("Popover Interactions", () => { + beforeEach(() => { + mockUseToggle.mockReturnValue([true, jest.fn()]); + }); + + it("should call onToggle with false when cancel button is clicked", () => { + const mockOnToggle = jest.fn(); + mockUseToggle.mockReturnValue([true, mockOnToggle]); + + renderComponent(); + const cancelButton = screen.getByTestId("popover-cancel"); + + fireEvent.click(cancelButton); + expect(mockOnToggle).toHaveBeenCalledWith(null, false); + }); + + it("should call handleAddReadPermission when primary button is clicked", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith( + "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account", + ); + }); + }); + }); + + describe("handleAddReadPermission Function", () => { + beforeEach(() => { + mockUseToggle.mockReturnValue([true, jest.fn()]); + }); + + it("should successfully assign role and update context", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockAssignRole).toHaveBeenCalledWith( + "source-sub-id", + "source-rg", + "source-account", + "target-principal-id", + ); + }); + + await waitFor(() => { + expect(mockContextValue.setCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it("should handle error when assignRole fails", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockRejectedValue(new Error("Permission denied")); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockLogError).toHaveBeenCalledWith( + "Permission denied", + "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission", + ); + }); + + await waitFor(() => { + expect(mockContextValue.setContextError).toHaveBeenCalledWith("Permission denied"); + }); + }); + + it("should handle error without message", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockRejectedValue({}); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockLogError).toHaveBeenCalledWith( + "Error assigning read permission to default identity. Please try again later.", + "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission", + ); + }); + + await waitFor(() => { + expect(mockContextValue.setContextError).toHaveBeenCalledWith( + "Error assigning read permission to default identity. Please try again later.", + ); + }); + }); + + it("should show loading state during role assignment", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + + mockAssignRole.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ id: "role-id" } as RoleAssignmentType), 100)), + ); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(screen.getByTestId("popover-message")).toHaveAttribute("data-loading", "true"); + }); + }); + + it.skip("should not assign role when assignRole returns falsy", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockResolvedValue(null); + + renderComponent(); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockAssignRole).toHaveBeenCalled(); + }); + + expect(mockContextValue.setCopyJobState).not.toHaveBeenCalled(); + }); + }); + + describe("Edge Cases", () => { + it("should handle missing target account identity", () => { + const contextWithoutIdentity = { + ...mockContextValue, + copyJobState: { + ...mockContextValue.copyJobState, + target: { + ...mockContextValue.copyJobState.target, + account: { + ...mockContextValue.copyJobState.target.account!, + identity: undefined as any, + }, + }, + }, + }; + + const { container } = renderComponent(contextWithoutIdentity); + expect(container).toMatchSnapshot(); + }); + + it("should handle missing source account", () => { + const contextWithoutSource = { + ...mockContextValue, + copyJobState: { + ...mockContextValue.copyJobState, + source: { + ...mockContextValue.copyJobState.source, + account: null as any, + }, + }, + }; + + const { container } = renderComponent(contextWithoutSource); + expect(container).toMatchSnapshot(); + }); + + it("should handle empty string principal ID", async () => { + const contextWithEmptyPrincipal = { + ...mockContextValue, + copyJobState: { + ...mockContextValue.copyJobState, + target: { + ...mockContextValue.copyJobState.target, + account: { + ...mockContextValue.copyJobState.target.account!, + identity: { + principalId: "", + type: "SystemAssigned", + }, + }, + }, + }, + }; + + mockUseToggle.mockReturnValue([true, jest.fn()]); + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType); + + renderComponent(contextWithEmptyPrincipal); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(mockAssignRole).toHaveBeenCalledWith("source-sub-id", "source-rg", "source-account", ""); + }); + }); + }); + + describe("Component Integration", () => { + it("should work with all context updates", async () => { + const setCopyJobStateMock = jest.fn(); + const setContextErrorMock = jest.fn(); + + const fullContextValue = { + ...mockContextValue, + setCopyJobState: setCopyJobStateMock, + setContextError: setContextErrorMock, + }; + + mockUseToggle.mockReturnValue([true, jest.fn()]); + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }); + mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType); + + renderComponent(fullContextValue); + const primaryButton = screen.getByTestId("popover-primary"); + + fireEvent.click(primaryButton); + + await waitFor(() => { + expect(setCopyJobStateMock).toHaveBeenCalledWith(expect.any(Function)); + }); + + const setCopyJobStateCall = setCopyJobStateMock.mock.calls[0][0]; + const updatedState = setCopyJobStateCall(mockContextValue.copyJobState); + + expect(updatedState).toEqual({ + ...mockContextValue.copyJobState, + sourceReadAccessFromTarget: true, + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx new file mode 100644 index 000000000..76f915c5e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx @@ -0,0 +1,379 @@ +import "@testing-library/jest-dom"; +import { render, RenderResult } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; +import AssignPermissions from "./AssignPermissions"; + +jest.mock("../../Utils/useCopyJobPrerequisitesCache", () => ({ + useCopyJobPrerequisitesCache: () => ({ + validationCache: new Map(), + setValidationCache: jest.fn(), + }), +})); + +jest.mock("../../../CopyJobUtils", () => ({ + isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId), +})); + +jest.mock("./hooks/usePermissionsSection", () => ({ + __esModule: true, + default: jest.fn((): any[] => []), +})); + +jest.mock("../../../../../Common/ShimmerTree/ShimmerTree", () => { + const MockShimmerTree = (props: any) => { + return ( +
+ Loading... +
+ ); + }; + MockShimmerTree.displayName = "MockShimmerTree"; + return MockShimmerTree; +}); + +jest.mock("./AddManagedIdentity", () => { + const MockAddManagedIdentity = () => { + return
Add Managed Identity Component
; + }; + MockAddManagedIdentity.displayName = "MockAddManagedIdentity"; + return MockAddManagedIdentity; +}); + +jest.mock("./AddReadPermissionToDefaultIdentity", () => { + const MockAddReadPermissionToDefaultIdentity = () => { + return
Add Read Permission Component
; + }; + MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity"; + return MockAddReadPermissionToDefaultIdentity; +}); + +jest.mock("./DefaultManagedIdentity", () => { + const MockDefaultManagedIdentity = () => { + return
Default Managed Identity Component
; + }; + MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity"; + return MockDefaultManagedIdentity; +}); + +jest.mock("./OnlineCopyEnabled", () => { + const MockOnlineCopyEnabled = () => { + return
Online Copy Enabled Component
; + }; + MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled"; + return MockOnlineCopyEnabled; +}); + +jest.mock("./PointInTimeRestore", () => { + const MockPointInTimeRestore = () => { + return
Point In Time Restore Component
; + }; + MockPointInTimeRestore.displayName = "MockPointInTimeRestore"; + return MockPointInTimeRestore; +}); + +jest.mock("../../../../../../images/successfulPopup.svg", () => "checkmark-icon"); +jest.mock("../../../../../../images/warning.svg", () => "warning-icon"); + +describe("AssignPermissions Component", () => { + const mockExplorer = {} as any; + + const createMockCopyJobState = (overrides: Partial = {}): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "source-sub" } as any, + account: { id: "source-account", name: "Source Account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub", + account: { id: "target-account", name: "Target Account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + ...overrides, + }); + + const createMockContextValue = (copyJobState: CopyJobContextState): CopyJobContextProviderType => ({ + contextError: null, + setContextError: jest.fn(), + copyJobState, + setCopyJobState: jest.fn(), + flow: null, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: mockExplorer, + }); + + const renderWithContext = (copyJobState: CopyJobContextState): RenderResult => { + const contextValue = createMockContextValue(copyJobState); + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("should render without crashing with offline migration", () => { + const copyJobState = createMockCopyJobState(); + const { container } = renderWithContext(copyJobState); + + expect(container.firstChild).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it("should render without crashing with online migration", () => { + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Online, + }); + const { container } = renderWithContext(copyJobState); + + expect(container.firstChild).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it("should display shimmer tree when no permission groups are available", () => { + const copyJobState = createMockCopyJobState(); + const { getByTestId } = renderWithContext(copyJobState); + + expect(getByTestId("shimmer-tree")).toBeInTheDocument(); + }); + + it("should display cross account description for different accounts", () => { + const copyJobState = createMockCopyJobState(); + const { getByText } = renderWithContext(copyJobState); + + expect(getByText(ContainerCopyMessages.assignPermissions.crossAccountDescription)).toBeInTheDocument(); + }); + + it("should display intra account description for same accounts with online migration", async () => { + const { isIntraAccountCopy } = await import("../../../CopyJobUtils"); + (isIntraAccountCopy as jest.Mock).mockReturnValue(true); + + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Online, + source: { + subscription: { subscriptionId: "same-sub" } as any, + account: { id: "same-account", name: "Same Account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "same-sub", + account: { id: "same-account", name: "Same Account" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + }); + + const { getByText } = renderWithContext(copyJobState); + expect( + getByText(ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription("Same Account")), + ).toBeInTheDocument(); + }); + }); + + describe("Permission Groups", () => { + it("should render permission groups when available", async () => { + const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock; + mockUsePermissionSections.mockReturnValue([ + { + id: "crossAccountConfigs", + title: "Cross Account Configuration", + description: "Configure permissions for cross-account copy", + sections: [ + { + id: "addManagedIdentity", + title: "Add Managed Identity", + Component: () =>
Add Managed Identity Component
, + disabled: false, + completed: true, + }, + { + id: "readPermissionAssigned", + title: "Read Permission Assigned", + Component: () =>
Add Read Permission Component
, + disabled: false, + completed: false, + }, + ], + }, + ]); + + const copyJobState = createMockCopyJobState(); + const { container } = renderWithContext(copyJobState); + + expect(container).toMatchSnapshot(); + }); + + it("should render online migration specific groups", async () => { + const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock; + mockUsePermissionSections.mockReturnValue([ + { + id: "onlineConfigs", + title: "Online Configuration", + description: "Configure settings for online migration", + sections: [ + { + id: "pointInTimeRestore", + title: "Point In Time Restore", + Component: () =>
Point In Time Restore Component
, + disabled: false, + completed: true, + }, + { + id: "onlineCopyEnabled", + title: "Online Copy Enabled", + Component: () =>
Online Copy Enabled Component
, + disabled: false, + completed: false, + }, + ], + }, + ]); + + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Online, + }); + const { container } = renderWithContext(copyJobState); + + expect(container).toMatchSnapshot(); + }); + + it("should render multiple permission groups", async () => { + const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock; + mockUsePermissionSections.mockReturnValue([ + { + id: "crossAccountConfigs", + title: "Cross Account Configuration", + description: "Configure permissions for cross-account copy", + sections: [ + { + id: "addManagedIdentity", + title: "Add Managed Identity", + Component: () =>
Add Managed Identity Component
, + disabled: false, + completed: true, + }, + ], + }, + { + id: "onlineConfigs", + title: "Online Configuration", + description: "Configure settings for online migration", + sections: [ + { + id: "onlineCopyEnabled", + title: "Online Copy Enabled", + Component: () =>
Online Copy Enabled Component
, + disabled: false, + completed: false, + }, + ], + }, + ]); + + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Online, + }); + const { container, getByText } = renderWithContext(copyJobState); + + expect(getByText("Cross Account Configuration")).toBeInTheDocument(); + expect(getByText("Online Configuration")).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Accordion Behavior", () => { + it("should render accordion sections with proper status icons", async () => { + const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock; + mockUsePermissionSections.mockReturnValue([ + { + id: "testGroup", + title: "Test Group", + description: "Test Description", + sections: [ + { + id: "completedSection", + title: "Completed Section", + Component: () =>
Completed Component
, + disabled: false, + completed: true, + }, + { + id: "incompleteSection", + title: "Incomplete Section", + Component: () =>
Incomplete Component
, + disabled: false, + completed: false, + }, + { + id: "disabledSection", + title: "Disabled Section", + Component: () =>
Disabled Component
, + disabled: true, + completed: false, + }, + ], + }, + ]); + + const copyJobState = createMockCopyJobState(); + const { container, getByText, getAllByRole } = renderWithContext(copyJobState); + + expect(getByText("Completed Section")).toBeInTheDocument(); + expect(getByText("Incomplete Section")).toBeInTheDocument(); + expect(getByText("Disabled Section")).toBeInTheDocument(); + + const images = getAllByRole("img"); + expect(images.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); + }); + + describe("Edge Cases", () => { + it("should handle missing account names", () => { + const copyJobState = createMockCopyJobState({ + source: { + subscription: { subscriptionId: "source-sub" } as any, + account: { id: "source-account" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + }); + + const { container } = renderWithContext(copyJobState); + expect(container).toMatchSnapshot(); + }); + + it("should calculate correct indent levels for offline migration", () => { + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Offline, + }); + + const { container } = renderWithContext(copyJobState); + expect(container).toMatchSnapshot(); + }); + + it("should calculate correct indent levels for online migration", () => { + const copyJobState = createMockCopyJobState({ + migrationType: CopyJobMigrationType.Online, + }); + + const { container } = renderWithContext(copyJobState); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx new file mode 100644 index 000000000..93418859f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx @@ -0,0 +1,355 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import DefaultManagedIdentity from "./DefaultManagedIdentity"; + +jest.mock("./hooks/useManagedIdentity"); +jest.mock("./hooks/useToggle"); + +jest.mock("../../../../../Utils/arm/identityUtils", () => ({ + updateDefaultIdentity: jest.fn(), +})); + +jest.mock("../Components/InfoTooltip", () => { + const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => { + return
{content}
; + }; + MockInfoTooltip.displayName = "MockInfoTooltip"; + return MockInfoTooltip; +}); + +jest.mock("../Components/PopoverContainer", () => { + const MockPopoverContainer = ({ + children, + isLoading, + visible, + title, + onCancel, + onPrimary, + }: { + children: React.ReactNode; + isLoading: boolean; + visible: boolean; + title: string; + onCancel: () => void; + onPrimary: () => void; + }) => { + if (!visible) { + return null; + } + return ( +
+
{title}
+
{children}
+
{isLoading ? "Loading" : "Not Loading"}
+ + +
+ ); + }; + MockPopoverContainer.displayName = "MockPopoverContainer"; + return MockPopoverContainer; +}); + +import { DatabaseAccount } from "Contracts/DataModels"; +import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes"; +import useManagedIdentity from "./hooks/useManagedIdentity"; +import useToggle from "./hooks/useToggle"; + +const mockUseManagedIdentity = useManagedIdentity as jest.MockedFunction; +const mockUseToggle = useToggle as jest.MockedFunction; + +describe("DefaultManagedIdentity", () => { + const mockCopyJobContextValue = { + copyJobState: { + target: { + account: { + name: "test-cosmos-account", + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos-account", + }, + }, + }, + setCopyJobState: jest.fn(), + setContextError: jest.fn(), + contextError: "", + flow: {}, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, + }; + + const mockHandleAddSystemIdentity = jest.fn(); + const mockOnToggle = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseManagedIdentity.mockReturnValue({ + loading: false, + handleAddSystemIdentity: mockHandleAddSystemIdentity, + }); + + mockUseToggle.mockReturnValue([false, mockOnToggle]); + }); + + const renderComponent = (contextValue = mockCopyJobContextValue) => { + return render( + + + , + ); + }; + + describe("Rendering", () => { + it("should render correctly with default state", () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it("should render the description with account name", () => { + renderComponent(); + + const description = screen.getByText( + /Set the system-assigned managed identity as default for "test-cosmos-account"/, + ); + expect(description).toBeInTheDocument(); + }); + + it("should render the info tooltip", () => { + renderComponent(); + + const tooltip = screen.getByTestId("info-tooltip"); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent("Learn more about"); + expect(tooltip).toHaveTextContent("Default Managed Identities."); + }); + + it("should render the toggle button with correct initial state", () => { + renderComponent(); + + const toggle = screen.getByRole("switch"); + expect(toggle).toBeInTheDocument(); + expect(toggle).not.toBeChecked(); + }); + + it("should not show popover when toggle is false", () => { + renderComponent(); + + const popover = screen.queryByTestId("popover-message"); + expect(popover).not.toBeInTheDocument(); + }); + }); + + describe("Toggle Interactions", () => { + it("should call onToggle when toggle is clicked", () => { + renderComponent(); + + const toggle = screen.getByRole("switch"); + fireEvent.click(toggle); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("should show popover when toggle is true", () => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + + renderComponent(); + + const popover = screen.getByTestId("popover-message"); + expect(popover).toBeInTheDocument(); + + const title = screen.getByTestId("popover-title"); + expect(title).toHaveTextContent(ContainerCopyMessages.defaultManagedIdentity.popoverTitle); + + const content = screen.getByTestId("popover-content"); + expect(content).toHaveTextContent( + /Assign the system-assigned managed identity as the default for "test-cosmos-account"/, + ); + }); + + it("should render toggle with checked state when toggle is true", () => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Loading States", () => { + it("should show loading state in popover when loading is true", () => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + mockUseManagedIdentity.mockReturnValue({ + loading: true, + handleAddSystemIdentity: mockHandleAddSystemIdentity, + }); + + renderComponent(); + + const loadingIndicator = screen.getByTestId("popover-loading"); + expect(loadingIndicator).toHaveTextContent("Loading"); + }); + + it("should not show loading state when loading is false", () => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + + renderComponent(); + + const loadingIndicator = screen.getByTestId("popover-loading"); + expect(loadingIndicator).toHaveTextContent("Not Loading"); + }); + + it("should render loading state snapshot", () => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + mockUseManagedIdentity.mockReturnValue({ + loading: true, + handleAddSystemIdentity: mockHandleAddSystemIdentity, + }); + + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Popover Interactions", () => { + beforeEach(() => { + mockUseToggle.mockReturnValue([true, mockOnToggle]); + }); + + it("should call onToggle with false when cancel button is clicked", () => { + renderComponent(); + + const cancelButton = screen.getByTestId("popover-cancel"); + fireEvent.click(cancelButton); + + expect(mockOnToggle).toHaveBeenCalledWith(null, false); + }); + + it("should call handleAddSystemIdentity when primary button is clicked", () => { + renderComponent(); + + const primaryButton = screen.getByTestId("popover-primary"); + fireEvent.click(primaryButton); + + expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1); + }); + + it("should handle primary button click correctly when loading", async () => { + mockUseManagedIdentity.mockReturnValue({ + loading: true, + handleAddSystemIdentity: mockHandleAddSystemIdentity, + }); + + renderComponent(); + + const primaryButton = screen.getByTestId("popover-primary"); + fireEvent.click(primaryButton); + + expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1); + }); + }); + + describe("Edge Cases", () => { + it("should handle missing account name gracefully", () => { + const contextValueWithoutAccount = { + ...mockCopyJobContextValue, + copyJobState: { + target: { + account: { + name: "", + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/", + }, + }, + }, + }; + + const { container } = renderComponent(contextValueWithoutAccount); + expect(container).toMatchSnapshot(); + }); + + it("should handle null account", () => { + const contextValueWithNullAccount = { + ...mockCopyJobContextValue, + copyJobState: { + target: { + account: null as DatabaseAccount | null, + }, + }, + }; + + const { container } = renderComponent(contextValueWithNullAccount); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Hook Integration", () => { + it("should pass updateDefaultIdentity to useManagedIdentity hook", () => { + renderComponent(); + + expect(mockUseManagedIdentity).toHaveBeenCalledWith(updateDefaultIdentity); + }); + + it("should initialize useToggle with false", () => { + renderComponent(); + + expect(mockUseToggle).toHaveBeenCalledWith(false); + }); + }); + + describe("Accessibility", () => { + it("should have proper ARIA attributes", () => { + renderComponent(); + + const toggle = screen.getByRole("switch"); + expect(toggle).toBeInTheDocument(); + }); + + it("should have proper link accessibility", () => { + renderComponent(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + describe("Component Structure", () => { + it("should have correct CSS class", () => { + const { container } = renderComponent(); + + const componentContainer = container.querySelector(".defaultManagedIdentityContainer"); + expect(componentContainer).toBeInTheDocument(); + }); + + it("should render all required FluentUI components", () => { + renderComponent(); + + expect(screen.getByRole("switch")).toBeInTheDocument(); + expect(screen.getByRole("link")).toBeInTheDocument(); + }); + }); + + describe("Messages and Text Content", () => { + it("should display correct toggle button text", () => { + renderComponent(); + + const onText = screen.queryByText(ContainerCopyMessages.toggleBtn.onText); + const offText = screen.queryByText(ContainerCopyMessages.toggleBtn.offText); + + expect(onText || offText).toBeTruthy(); + }); + + it("should display correct link text in tooltip", () => { + renderComponent(); + + const linkText = screen.getByText(ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText); + expect(linkText).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx index da6bd4815..69e12e72e 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx @@ -27,7 +27,7 @@ const DefaultManagedIdentity: React.FC = () => { return (
- {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}   + {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account?.name)}  
= () => { onCancel={() => onToggle(null, false)} onPrimary={handleAddSystemIdentity} > - {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)} + {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account?.name)}
); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx new file mode 100644 index 000000000..8d28f2482 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx @@ -0,0 +1,579 @@ +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DatabaseAccount } from "Contracts/DataModels"; +import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes"; +import React from "react"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import { CapabilityNames } from "../../../../../Common/Constants"; +import { logError } from "../../../../../Common/Logger"; +import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import OnlineCopyEnabled from "./OnlineCopyEnabled"; + +jest.mock("Utils/arm/databaseAccountUtils", () => ({ + fetchDatabaseAccount: jest.fn(), +})); + +jest.mock("../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({ + update: jest.fn(), +})); + +jest.mock("../../../../../Common/Logger", () => ({ + logError: jest.fn(), +})); + +jest.mock("../../../../../Common/LoadingOverlay", () => { + const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => { + return isLoading ?
{label}
: null; + }; + MockLoadingOverlay.displayName = "MockLoadingOverlay"; + return MockLoadingOverlay; +}); + +const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction; +const mockUpdateDatabaseAccount = updateDatabaseAccount as jest.MockedFunction; +const mockLogError = logError as jest.MockedFunction; + +describe("OnlineCopyEnabled", () => { + const mockSetContextError = jest.fn(); + const mockSetCopyJobState = jest.fn(); + + const mockSourceAccount: DatabaseAccount = { + id: "/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + capabilities: [], + enableAllVersionsAndDeletesChangeFeed: false, + locations: [], + writeLocations: [], + readLocations: [], + }, + }; + + const mockCopyJobContextValue = { + copyJobState: { + source: { + account: mockSourceAccount, + }, + }, + setCopyJobState: mockSetCopyJobState, + setContextError: mockSetContextError, + contextError: "", + flow: { currentScreen: "" }, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, + } as unknown as CopyJobContextProviderType; + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + const renderComponent = (contextValue = mockCopyJobContextValue) => { + return render( + + + , + ); + }; + + describe("Rendering", () => { + it("should render correctly with initial state", () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it("should render the description with account name", () => { + renderComponent(); + + const description = screen.getByText(ContainerCopyMessages.onlineCopyEnabled.description("test-account")); + expect(description).toBeInTheDocument(); + }); + + it("should render the learn more link", () => { + renderComponent(); + + const link = screen.getByRole("link", { + name: ContainerCopyMessages.onlineCopyEnabled.hrefText, + }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", ContainerCopyMessages.onlineCopyEnabled.href); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render the enable button with correct text when not loading", () => { + renderComponent(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it("should not show loading overlay initially", () => { + renderComponent(); + + const loadingOverlay = screen.queryByTestId("loading-overlay"); + expect(loadingOverlay).not.toBeInTheDocument(); + }); + + it("should not show refresh button initially", () => { + renderComponent(); + + const refreshButton = screen.queryByRole("button", { + name: ContainerCopyMessages.refreshButtonLabel, + }); + expect(refreshButton).not.toBeInTheDocument(); + }); + }); + + describe("Enable Online Copy Flow", () => { + it("should handle complete enable online copy flow successfully", async () => { + const accountAfterChangeFeedUpdate = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + enableAllVersionsAndDeletesChangeFeed: true, + }, + }; + + const accountWithOnlineCopyEnabled: DatabaseAccount = { + ...accountAfterChangeFeedUpdate, + properties: { + ...accountAfterChangeFeedUpdate.properties, + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }], + }, + }; + + mockFetchDatabaseAccount + .mockResolvedValueOnce(mockSourceAccount) + .mockResolvedValueOnce(accountWithOnlineCopyEnabled); + + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + expect(screen.getByTestId("loading-overlay")).toBeInTheDocument(); + await waitFor(() => { + expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account"); + }); + + await waitFor(() => { + expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", { + properties: { + enableAllVersionsAndDeletesChangeFeed: true, + }, + }); + }); + + await waitFor(() => { + expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", { + properties: { + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + }); + }); + + it("should skip change feed enablement if already enabled", async () => { + const accountWithChangeFeedEnabled = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + enableAllVersionsAndDeletesChangeFeed: true, + }, + }; + + const accountWithOnlineCopyEnabled: DatabaseAccount = { + ...accountWithChangeFeedEnabled, + properties: { + ...accountWithChangeFeedEnabled.properties, + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }], + }, + }; + + mockFetchDatabaseAccount + .mockResolvedValueOnce(accountWithChangeFeedEnabled) + .mockResolvedValueOnce(accountWithOnlineCopyEnabled); + + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await waitFor(() => { + expect(mockUpdateDatabaseAccount).toHaveBeenCalledTimes(1); + expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", { + properties: { + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + }); + }); + + it("should show correct loading messages during the process", async () => { + mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount); + mockUpdateDatabaseAccount.mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await waitFor(() => { + expect(mockFetchDatabaseAccount).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect( + screen.getByText(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel("test-account")), + ).toBeInTheDocument(); + }); + }); + + it("should handle error during update operations", async () => { + const errorMessage = "Failed to update account"; + mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount); + mockUpdateDatabaseAccount.mockRejectedValue(new Error(errorMessage)); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await waitFor(() => { + expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable"); + expect(mockSetContextError).toHaveBeenCalledWith(errorMessage); + }); + + expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument(); + }); + + it("should handle refresh button click", async () => { + const accountWithOnlineCopyEnabled: DatabaseAccount = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }], + }, + }; + + mockFetchDatabaseAccount + .mockResolvedValueOnce(mockSourceAccount) + .mockResolvedValueOnce(mockSourceAccount) + .mockResolvedValueOnce(accountWithOnlineCopyEnabled); + + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await act(async () => { + jest.advanceTimersByTime(10 * 60 * 1000); + }); + + const refreshButton = screen.getByRole("button", { + name: ContainerCopyMessages.refreshButtonLabel, + }); + + await act(async () => { + fireEvent.click(refreshButton); + }); + + expect(screen.getByTestId("loading-overlay")).toBeInTheDocument(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalled(); + }); + }); + }); + + describe("Account Validation and State Updates", () => { + it("should update state when account capabilities change", async () => { + const accountWithOnlineCopyEnabled: DatabaseAccount = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }], + }, + }; + + mockFetchDatabaseAccount.mockResolvedValue(accountWithOnlineCopyEnabled); + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await act(async () => { + jest.advanceTimersByTime(30000); + }); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; + const newState = stateUpdateFunction({ + source: { account: mockSourceAccount }, + }); + + expect(newState.source.account).toEqual(accountWithOnlineCopyEnabled); + }); + + it("should not update state when account capabilities remain unchanged", async () => { + mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount); + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await act(async () => { + jest.advanceTimersByTime(30000); + }); + + expect(mockSetCopyJobState).not.toHaveBeenCalled(); + }); + }); + + describe("Button States and Interactions", () => { + it("should disable button during loading", async () => { + mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + const loadingButton = screen.getByRole("button"); + expect(loadingButton).toBeDisabled(); + }); + + it("should show sync icon during loading", async () => { + mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + const loadingButton = screen.getByRole("button"); + expect(loadingButton.querySelector("[data-icon-name='SyncStatusSolid']")).toBeInTheDocument(); + }); + + it("should disable refresh button during loading", async () => { + mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount); + mockUpdateDatabaseAccount.mockResolvedValue({} as any); + + renderComponent(); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + + await act(async () => { + fireEvent.click(enableButton); + }); + + await act(async () => { + jest.advanceTimersByTime(10 * 60 * 1000); + }); + + mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {})); + + const refreshButton = screen.getByRole("button", { + name: ContainerCopyMessages.refreshButtonLabel, + }); + + await act(async () => { + fireEvent.click(refreshButton); + }); + + expect(refreshButton).toBeDisabled(); + }); + }); + + describe("Edge Cases", () => { + it("should handle missing account name gracefully", () => { + const contextWithoutAccountName = { + ...mockCopyJobContextValue, + copyJobState: { + source: { + account: { + ...mockSourceAccount, + name: "", + }, + }, + }, + } as CopyJobContextProviderType; + + const { container } = renderComponent(contextWithoutAccountName); + expect(container).toMatchSnapshot(); + }); + + it("should handle null account", () => { + const contextWithNullAccount = { + ...mockCopyJobContextValue, + copyJobState: { + source: { + account: null as DatabaseAccount | null, + }, + }, + } as CopyJobContextProviderType; + + const { container } = renderComponent(contextWithNullAccount); + expect(container).toMatchSnapshot(); + }); + + it("should handle account with existing online copy capability", () => { + const accountWithExistingCapability = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }, { name: "SomeOtherCapability" }], + }, + }; + + const contextWithExistingCapability = { + ...mockCopyJobContextValue, + copyJobState: { + source: { + account: accountWithExistingCapability, + }, + }, + } as CopyJobContextProviderType; + + const { container } = renderComponent(contextWithExistingCapability); + expect(container).toMatchSnapshot(); + }); + + it("should handle account with no capabilities array", () => { + const accountWithNoCapabilities = { + ...mockSourceAccount, + properties: { + ...mockSourceAccount.properties, + capabilities: undefined, + }, + } as DatabaseAccount; + + const contextWithNoCapabilities = { + ...mockCopyJobContextValue, + copyJobState: { + source: { + account: accountWithNoCapabilities, + }, + }, + } as CopyJobContextProviderType; + + renderComponent(contextWithNoCapabilities); + + const enableButton = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + expect(enableButton).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("should have proper button role and accessibility attributes", () => { + renderComponent(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.onlineCopyEnabled.buttonText, + }); + expect(button).toBeInTheDocument(); + }); + + it("should have proper link accessibility", () => { + renderComponent(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + describe("CSS Classes and Styling", () => { + it("should apply correct CSS class to container", () => { + const { container } = renderComponent(); + + const onlineCopyContainer = container.querySelector(".onlineCopyContainer"); + expect(onlineCopyContainer).toBeInTheDocument(); + }); + + it("should apply fullWidth class to buttons", () => { + renderComponent(); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fullWidth"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index ebbe77447..a6f0918c3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -32,7 +32,7 @@ const OnlineCopyEnabled: React.FC = () => { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; const handleFetchAccount = async () => { try { @@ -91,12 +91,6 @@ const OnlineCopyEnabled: React.FC = () => { }); } setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName)); - await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { - properties: { - enableAllVersionsAndDeletesChangeFeed: true, - }, - }); - await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { properties: { capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx new file mode 100644 index 000000000..de5ddcc49 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx @@ -0,0 +1,341 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { logError } from "Common/Logger"; +import { DatabaseAccount } from "Contracts/DataModels"; +import React from "react"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; +import PointInTimeRestore from "./PointInTimeRestore"; + +jest.mock("Utils/arm/databaseAccountUtils"); +jest.mock("Common/Logger"); + +const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction; +const mockLogError = logError as jest.MockedFunction; + +const mockWindowOpen = jest.fn(); +Object.defineProperty(window, "open", { + value: mockWindowOpen, + writable: true, +}); + +global.clearInterval = jest.fn(); +global.clearTimeout = jest.fn(); + +describe("PointInTimeRestore", () => { + const mockSourceAccount: DatabaseAccount = { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + type: "Microsoft.DocumentDB/databaseAccounts", + location: "East US", + properties: { + backupPolicy: { + type: "Continuous", + }, + }, + } as DatabaseAccount; + + const mockUpdatedAccount: DatabaseAccount = { + ...mockSourceAccount, + properties: { + backupPolicy: { + type: "Periodic", + }, + }, + } as DatabaseAccount; + + const defaultCopyJobState = { + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" }, + account: mockSourceAccount, + databaseId: "test-db", + containerId: "test-container", + }, + target: { + subscriptionId: "test-sub", + account: mockSourceAccount, + databaseId: "target-db", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + } as CopyJobContextState; + + const mockSetCopyJobState = jest.fn(); + + const createMockContext = (overrides?: Partial): CopyJobContextProviderType => ({ + copyJobState: defaultCopyJobState, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + resetCopyJobState: jest.fn(), + ...overrides, + }); + + const renderWithContext = (contextValue: CopyJobContextProviderType) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchDatabaseAccount.mockClear(); + mockLogError.mockClear(); + mockWindowOpen.mockClear(); + mockSetCopyJobState.mockClear(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + describe("Initial Render", () => { + it("should render correctly with default props", () => { + const mockContext = createMockContext(); + const { container } = renderWithContext(mockContext); + + expect(container).toMatchSnapshot(); + }); + + it("should display the correct description with account name", () => { + const mockContext = createMockContext(); + renderWithContext(mockContext); + + expect(screen.getByText(/test-account/)).toBeInTheDocument(); + }); + + it("should show the primary action button with correct text", () => { + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it("should render with empty account name gracefully", () => { + const contextWithoutAccount = createMockContext({ + copyJobState: { + ...defaultCopyJobState, + source: { + ...defaultCopyJobState.source, + account: { ...mockSourceAccount, name: "" }, + }, + }, + }); + + const { container } = renderWithContext(contextWithoutAccount); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Button Interactions", () => { + it("should open window and start monitoring when button is clicked", () => { + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringMatching( + /#resource\/subscriptions\/test-sub\/resourceGroups\/test-rg\/providers\/Microsoft.DocumentDB\/databaseAccounts\/test-account\/backupRestore$/, + ), + "_blank", + ); + }); + + it("should disable button and show loading state after click", () => { + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(button).toBeDisabled(); + expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument(); + }); + + it("should show refresh button when timeout occurs", async () => { + jest.useFakeTimers(); + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(10 * 60 * 1000 + 1000); + + await waitFor(() => { + expect(screen.getByText(/Refresh/)).toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + + it("should fetch account periodically after button click", async () => { + jest.useFakeTimers(); + mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount); + + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(30 * 1000); + + await waitFor(() => { + expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account"); + }); + + jest.useRealTimers(); + }); + + it("should not update context when account validation fails", async () => { + jest.useFakeTimers(); + mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount); + + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(30 * 1000); + + await waitFor(() => { + expect(mockFetchDatabaseAccount).toHaveBeenCalled(); + }); + + expect(mockSetCopyJobState).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + }); + + describe("Refresh Button Functionality", () => { + it("should handle refresh button click", async () => { + jest.useFakeTimers(); + mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount); + + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(10 * 60 * 1000 + 1000); + + await waitFor(() => { + const refreshButton = screen.getByText(/Refresh/); + expect(refreshButton).toBeInTheDocument(); + }); + + const refreshButton = screen.getByText(/Refresh/); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account"); + }); + + jest.useRealTimers(); + }); + + it("should show loading state during refresh", async () => { + jest.useFakeTimers(); + mockFetchDatabaseAccount.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockUpdatedAccount), 1000)), + ); + + const mockContext = createMockContext(); + renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(10 * 60 * 1000 + 1000); + + await waitFor(() => { + expect(screen.getByText(/Refresh/)).toBeInTheDocument(); + }); + + const refreshButton = screen.getByText(/Refresh/); + fireEvent.click(refreshButton); + + expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument(); + + jest.useRealTimers(); + }); + }); + + describe("Edge Cases", () => { + it("should handle missing source account gracefully", () => { + const contextWithoutSourceAccount = createMockContext({ + copyJobState: { + ...defaultCopyJobState, + source: { + ...defaultCopyJobState.source, + account: null as any, + }, + }, + }); + + const { container } = renderWithContext(contextWithoutSourceAccount); + expect(container).toMatchSnapshot(); + }); + + it("should handle missing account ID gracefully", () => { + const contextWithoutAccountId = createMockContext({ + copyJobState: { + ...defaultCopyJobState, + source: { + ...defaultCopyJobState.source, + account: { ...mockSourceAccount, id: undefined as any }, + }, + }, + }); + + const { container } = renderWithContext(contextWithoutAccountId); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Snapshots", () => { + it("should match snapshot in loading state", () => { + const mockContext = createMockContext(); + const { container } = renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with refresh button", async () => { + jest.useFakeTimers(); + const mockContext = createMockContext(); + const { container } = renderWithContext(mockContext); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + jest.advanceTimersByTime(10 * 60 * 1000 + 1000); + + await waitFor(() => { + expect(screen.getByText(/Refresh/)).toBeInTheDocument(); + }); + + expect(container).toMatchSnapshot(); + jest.useRealTimers(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index f62331677..95d10c49a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -31,7 +31,11 @@ const PointInTimeRestore: React.FC = () => { const [showRefreshButton, setShowRefreshButton] = useState(false); const intervalRef = useRef(null); const timeoutRef = useRef(null); - const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + const { copyJobState: { source } = {}, setCopyJobState, setContextError } = useCopyJobContext(); + if (!source?.account?.id) { + setContextError("Invalid source account. Please select a valid source account for Point-in-Time Restore."); + return null; + } const sourceAccountLink = buildResourceLink(source?.account); const featureUrl = `${sourceAccountLink}/backupRestore`; const selectedSourceAccount = source?.account; @@ -39,7 +43,7 @@ const PointInTimeRestore: React.FC = () => { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; useEffect(() => { return () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap new file mode 100644 index 000000000..a0d1c3033 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap @@ -0,0 +1,406 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = ` +
+ + A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+`; + +exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = ` +
+ + A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+
+
+
+
+ Please wait while we process your request... +
+
+
+ + Enable system assigned managed identity + + + Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. + +
+ + +
+
+
+`; + +exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover visible 1`] = ` +
+ + A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+ + Enable system assigned managed identity + + + Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. + +
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap new file mode 100644 index 000000000..e461c2058 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap @@ -0,0 +1,398 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+ Read permissions assigned to default identity. +
+
+ Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. +
+ + +
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = ` +
+
+ + To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap new file mode 100644 index 000000000..a6d76c3f3 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap @@ -0,0 +1,1301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignPermissions Component Accordion Behavior should render accordion sections with proper status icons 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for offline migration 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for online migration 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should handle missing account names 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render multiple permission groups 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Cross Account Configuration + + + Configure permissions for cross-account copy + +
+
+
+
+ +
+
+
+
+
+
+ + Online Configuration + + + Configure settings for online migration + +
+
+
+
+ +
+
+
+ Online Copy Enabled Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render online migration specific groups 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Online Configuration + + + Configure settings for online migration + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Online Copy Enabled Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render permission groups when available 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+
+
+ + Cross Account Configuration + + + Configure permissions for cross-account copy + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Add Read Permission Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Rendering should render without crashing with offline migration 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+ Loading... +
+
+
+`; + +exports[`AssignPermissions Component Rendering should render without crashing with online migration 1`] = ` +
+
+ + To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps. + +
+ Loading... +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap new file mode 100644 index 000000000..631e60100 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultManagedIdentity Edge Cases should handle missing account name gracefully 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "undefined" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Loading States should render loading state snapshot 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+ System assigned managed identity set as default +
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button. +
+
+ Loading +
+ + +
+
+
+`; + +exports[`DefaultManagedIdentity Rendering should render correctly with default state 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Toggle Interactions should render toggle with checked state when toggle is true 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+ System assigned managed identity set as default +
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button. +
+
+ Not Loading +
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap new file mode 100644 index 000000000..bc2151ac8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OnlineCopyEnabled Edge Cases should handle account with existing online copy capability 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "test-account" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Edge Cases should handle missing account name gracefully 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Edge Cases should handle null account 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Rendering should render correctly with initial state 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "test-account" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap new file mode 100644 index 000000000..83fe4df8e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap @@ -0,0 +1,333 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PointInTimeRestore Edge Cases should handle missing account ID gracefully 1`] = `
`; + +exports[`PointInTimeRestore Edge Cases should handle missing source account gracefully 1`] = `
`; + +exports[`PointInTimeRestore Initial Render should render correctly with default props 1`] = ` +
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Initial Render should render with empty account name gracefully 1`] = ` +
+
+
+ To facilitate online container copy jobs, please update your "" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`] = ` +
+
+
+
+
+
+ Please wait while we process your request... +
+
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Snapshots should match snapshot with refresh button 1`] = ` +
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx new file mode 100644 index 000000000..2a91c8c69 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx @@ -0,0 +1,255 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { logError } from "../../../../../../Common/Logger"; +import { DatabaseAccount } from "../../../../../../Contracts/DataModels"; +import Explorer from "../../../../../Explorer"; +import CopyJobContextProvider, { useCopyJobContext } from "../../../../Context/CopyJobContext"; +import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; +import useManagedIdentity from "./useManagedIdentity"; + +jest.mock("../../../../CopyJobUtils"); +jest.mock("../../../../../../Common/Logger"); + +const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction< + typeof getAccountDetailsFromResourceId +>; +const mockLogError = logError as jest.MockedFunction; + +const mockDatabaseAccount: DatabaseAccount = { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + }, +} as DatabaseAccount; + +interface TestComponentProps { + updateIdentityFn: ( + subscriptionId: string, + resourceGroup?: string, + accountName?: string, + ) => Promise; + onError?: (error: string) => void; +} + +const TestComponent: React.FC = ({ updateIdentityFn, onError }) => { + const { loading, handleAddSystemIdentity } = useManagedIdentity(updateIdentityFn); + const { contextError } = useCopyJobContext(); + + React.useEffect(() => { + if (contextError && onError) { + onError(contextError); + } + }, [contextError, onError]); + + const handleClick = async () => { + await handleAddSystemIdentity(); + }; + + return ( +
+ +
{loading ? "true" : "false"}
+ {contextError &&
{contextError}
} +
+ ); +}; + +const TestWrapper: React.FC = (props) => { + const mockExplorer = new Explorer(); + + return ( + + + + ); +}; + +describe("useManagedIdentity", () => { + const mockUpdateIdentityFn = jest.fn(); + const mockOnError = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetAccountDetailsFromResourceId.mockReturnValue({ + subscriptionId: "test-subscription", + resourceGroup: "test-resource-group", + accountName: "test-account-name", + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should initialize with loading false", () => { + render(); + + expect(screen.getByTestId("loading-status")).toHaveTextContent("false"); + expect(screen.getByTestId("add-identity-button")).toHaveTextContent("Add System Identity"); + expect(screen.getByTestId("add-identity-button")).not.toBeDisabled(); + }); + + it("should show loading state when handleAddSystemIdentity is called", async () => { + mockUpdateIdentityFn.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockDatabaseAccount), 100)), + ); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + expect(screen.getByTestId("loading-status")).toHaveTextContent("true"); + expect(button).toHaveTextContent("Loading..."); + expect(button).toBeDisabled(); + }); + + it("should call updateIdentityFn with correct parameters", async () => { + mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockUpdateIdentityFn).toHaveBeenCalledWith( + "test-subscription", + "test-resource-group", + "test-account-name", + ); + }); + }); + + it("should handle successful identity update", async () => { + const updatedAccount = { + ...mockDatabaseAccount, + properties: { + ...mockDatabaseAccount.properties, + identity: { type: "SystemAssigned" }, + }, + }; + mockUpdateIdentityFn.mockResolvedValue(updatedAccount); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockUpdateIdentityFn).toHaveBeenCalled(); + }); + + expect(screen.queryByTestId("error-message")).toBeNull(); + }); + + it("should handle error when updateIdentityFn fails", async () => { + const errorMessage = "Failed to update identity"; + mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage)); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage); + }); + + expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity"); + expect(mockOnError).toHaveBeenCalledWith(errorMessage); + }); + + it("should handle error without message", async () => { + const errorWithoutMessage = {} as Error; + mockUpdateIdentityFn.mockRejectedValue(errorWithoutMessage); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId("error-message")).toHaveTextContent( + "Error enabling system-assigned managed identity. Please try again later.", + ); + }); + + expect(mockLogError).toHaveBeenCalledWith( + "Error enabling system-assigned managed identity. Please try again later.", + "CopyJob/useManagedIdentity.handleAddSystemIdentity", + ); + }); + + it("should handle case when getAccountDetailsFromResourceId returns null", async () => { + mockGetAccountDetailsFromResourceId.mockReturnValue(null); + mockUpdateIdentityFn.mockResolvedValue(undefined); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockUpdateIdentityFn).toHaveBeenCalledWith(undefined, undefined, undefined); + }); + }); + + it("should handle case when updateIdentityFn returns undefined", async () => { + mockUpdateIdentityFn.mockResolvedValue(undefined); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockUpdateIdentityFn).toHaveBeenCalled(); + }); + + expect(screen.queryByTestId("error-message")).toBeNull(); + }); + + it("should call getAccountDetailsFromResourceId with target account id", async () => { + mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled(); + }); + + const callArgs = mockGetAccountDetailsFromResourceId.mock.calls[0]; + expect(callArgs).toBeDefined(); + }); + + it("should reset loading state on error", async () => { + const errorMessage = "Network error"; + mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage)); + + render(); + + const button = screen.getByTestId("add-identity-button"); + fireEvent.click(button); + + expect(screen.getByTestId("loading-status")).toHaveTextContent("true"); + + await waitFor(() => { + expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage); + }); + + expect(screen.getByTestId("loading-status")).toHaveTextContent("false"); + expect(button).not.toBeDisabled(); + expect(button).toHaveTextContent("Add System Identity"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx index 5d3ed0474..571b1898f 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -31,7 +31,7 @@ const useManagedIdentity = ( subscriptionId: targetSubscriptionId, resourceGroup: targetResourceGroup, accountName: targetAccountName, - } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {}; const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName); if (updatedAccount) { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx new file mode 100644 index 000000000..78935657d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx @@ -0,0 +1,691 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { noop } from "underscore"; +import { CapabilityNames } from "../../../../../../Common/Constants"; +import * as RbacUtils from "../../../../../../Utils/arm/RbacUtils"; +import { + BackupPolicyType, + CopyJobMigrationType, + DefaultIdentityType, + IdentityType, +} from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../../Types/CopyJobTypes"; +import * as CopyJobPrerequisitesCacheModule from "../../../Utils/useCopyJobPrerequisitesCache"; +import usePermissionSections, { + checkTargetHasReaderRoleOnSource, + PermissionGroupConfig, + SECTION_IDS, +} from "./usePermissionsSection"; + +jest.mock("../../../../../../Utils/arm/RbacUtils"); +jest.mock("../../../Utils/useCopyJobPrerequisitesCache"); +jest.mock("../../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(() => ({ + subscriptionId: "sub-123", + resourceGroup: "rg-test", + accountName: "account-test", + })), + getContainerIdentifiers: jest.fn((container: any) => ({ + accountId: container?.account?.id || "default-account-id", + })), + isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId), +})); + +jest.mock("../AddManagedIdentity", () => { + const MockAddManagedIdentity = () => { + return
AddManagedIdentity
; + }; + MockAddManagedIdentity.displayName = "MockAddManagedIdentity"; + return MockAddManagedIdentity; +}); + +jest.mock("../AddReadPermissionToDefaultIdentity", () => { + const MockAddReadPermissionToDefaultIdentity = () => { + return
AddReadPermissionToDefaultIdentity
; + }; + MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity"; + return MockAddReadPermissionToDefaultIdentity; +}); + +jest.mock("../DefaultManagedIdentity", () => { + const MockDefaultManagedIdentity = () => { + return
DefaultManagedIdentity
; + }; + MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity"; + return MockDefaultManagedIdentity; +}); + +jest.mock("../OnlineCopyEnabled", () => { + const MockOnlineCopyEnabled = () => { + return
OnlineCopyEnabled
; + }; + MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled"; + return MockOnlineCopyEnabled; +}); + +jest.mock("../PointInTimeRestore", () => { + const MockPointInTimeRestore = () => { + return
PointInTimeRestore
; + }; + MockPointInTimeRestore.displayName = "MockPointInTimeRestore"; + return MockPointInTimeRestore; +}); + +const mockedRbacUtils = RbacUtils as jest.Mocked; +const mockedCopyJobPrerequisitesCache = CopyJobPrerequisitesCacheModule as jest.Mocked< + typeof CopyJobPrerequisitesCacheModule +>; + +interface TestWrapperProps { + state: CopyJobContextState; + onResult?: (result: PermissionGroupConfig[]) => void; +} + +const TestWrapper: React.FC = ({ state, onResult }) => { + const result = usePermissionSections(state); + + React.useEffect(() => { + if (onResult) { + onResult(result); + } + }, [result, onResult]); + + return ( +
+
{result.length}
+ {result.map((group) => ( +
+

{group.title}

+

{group.description}

+ {group.sections.map((section) => ( +
+ + {section.completed?.toString() || "undefined"} + + {section.disabled.toString()} +
+ ))} +
+ ))} +
+ ); +}; + +describe("usePermissionsSection", () => { + let mockValidationCache: Map; + let mockSetValidationCache: jest.Mock; + + const createMockState = (overrides: Partial = {}): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Periodic, + }, + capabilities: [], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.None, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + ...overrides, + }); + + beforeEach(() => { + mockValidationCache = new Map(); + mockSetValidationCache = jest.fn(); + + mockedCopyJobPrerequisitesCache.useCopyJobPrerequisitesCache.mockReturnValue({ + validationCache: mockValidationCache, + setValidationCache: mockSetValidationCache, + }); + + mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([]); + mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Cross-account copy scenarios", () => { + it("should return cross-account configuration for different accounts", async () => { + const state = createMockState(); + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + expect(capturedResult).toHaveLength(1); + expect(capturedResult[0].id).toBe("crossAccountConfigs"); + expect(capturedResult[0].sections).toHaveLength(3); + expect(capturedResult[0].sections.map((s) => s.id)).toEqual([ + SECTION_IDS.addManagedIdentity, + SECTION_IDS.defaultManagedIdentity, + SECTION_IDS.readPermissionAssigned, + ]); + }); + + it("should not return cross-account configuration for same account (intra-account copy)", async () => { + const state = createMockState({ + source: { + account: { + id: "same-account-id", + name: "same-account", + properties: undefined, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + target: { + account: { + id: "same-account-id", + name: "same-account", + identity: { type: IdentityType.None, principalId: "principal-123" }, + properties: { defaultIdentity: DefaultIdentityType.FirstPartyIdentity }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("0"); + }); + + expect(capturedResult).toHaveLength(0); + }); + }); + + describe("Online copy scenarios", () => { + it("should return online configuration for online migration", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("2"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + expect(onlineGroup).toBeDefined(); + expect(onlineGroup?.sections).toHaveLength(2); + expect(onlineGroup?.sections.map((s) => s.id)).toEqual([ + SECTION_IDS.pointInTimeRestore, + SECTION_IDS.onlineCopyEnabled, + ]); + }); + + it("should not return online configuration for offline migration", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Offline, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + expect(onlineGroup).toBeUndefined(); + }); + }); + + describe("Section validation", () => { + it("should validate addManagedIdentity section correctly", async () => { + const stateWithSystemAssigned = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + const addManagedIdentitySection = crossAccountGroup?.sections.find( + (s) => s.id === SECTION_IDS.addManagedIdentity, + ); + expect(addManagedIdentitySection?.completed).toBe(true); + }); + + it("should validate defaultManagedIdentity section correctly", async () => { + const stateWithSystemAssignedIdentity = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.SystemAssignedIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + const defaultManagedIdentitySection = crossAccountGroup?.sections.find( + (s) => s.id === SECTION_IDS.defaultManagedIdentity, + ); + expect(defaultManagedIdentitySection?.completed).toBe(true); + }); + + it("should validate readPermissionAssigned section with reader role", async () => { + const mockRoleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Custom Role", + permissions: [ + { + dataActions: [ + "Microsoft.DocumentDB/databaseAccounts/readMetadata", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read", + ], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([{ roleDefinitionId: "role-def-1" }] as any); + mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue(mockRoleDefinitions); + + const state = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.SystemAssignedIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.readPermissionAssigned}-completed`)).toHaveTextContent("true"); + }); + + expect(mockedRbacUtils.fetchRoleAssignments).toHaveBeenCalledWith( + "sub-123", + "rg-test", + "account-test", + "principal-123", + ); + }); + + it("should validate pointInTimeRestore section for continuous backup", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Continuous, + }, + capabilities: [], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.pointInTimeRestore}-completed`)).toHaveTextContent("true"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + const pointInTimeSection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.pointInTimeRestore); + expect(pointInTimeSection?.completed).toBe(true); + }); + + it("should validate onlineCopyEnabled section with proper capability", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Continuous, + }, + capabilities: [ + { + name: CapabilityNames.EnableOnlineCopyFeature, + description: "", + }, + ], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.onlineCopyEnabled}-completed`)).toHaveTextContent("true"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + const onlineCopySection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.onlineCopyEnabled); + expect(onlineCopySection?.completed).toBe(true); + }); + }); + + describe("Validation caching", () => { + it("should use cached validation results", async () => { + mockValidationCache.set(SECTION_IDS.addManagedIdentity, true); + mockValidationCache.set(SECTION_IDS.defaultManagedIdentity, true); + + const state = createMockState(); + render(); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + it("should clear online job validation cache when migration type changes to offline", async () => { + mockValidationCache.set(SECTION_IDS.pointInTimeRestore, true); + mockValidationCache.set(SECTION_IDS.onlineCopyEnabled, true); + + const state = createMockState({ + migrationType: CopyJobMigrationType.Offline, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + expect(mockSetValidationCache).toHaveBeenCalled(); + }); + }); + + describe("Sequential validation within groups", () => { + it("should stop validation at first failure within a group", async () => { + const state = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.None, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("false"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + expect(crossAccountGroup?.sections[0].completed).toBe(false); + expect(crossAccountGroup?.sections[1].completed).toBe(false); + expect(crossAccountGroup?.sections[2].completed).toBe(false); + }); + }); +}); + +describe("checkTargetHasReaderRoleOnSource", () => { + it("should return true for built-in Reader role", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "00000000-0000-0000-0000-000000000001", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); + + it("should return true for custom role with required data actions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Custom Reader Role", + permissions: [ + { + dataActions: [ + "Microsoft.DocumentDB/databaseAccounts/readMetadata", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read", + ], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); + + it("should return false for role without required permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Insufficient Role", + permissions: [ + { + dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(false); + }); + + it("should return false for empty role definitions", () => { + const result = checkTargetHasReaderRoleOnSource([]); + expect(result).toBe(false); + }); + + it("should return false for role definitions without permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "No Permissions Role", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(false); + }); + + it("should handle multiple roles and return true if any has sufficient permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Insufficient Role", + permissions: [ + { + dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + { + id: "role-2", + name: "00000000-0000-0000-0000-000000000001", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx new file mode 100644 index 000000000..e74d334dc --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import useToggle from "./useToggle"; + +const TestToggleComponent: React.FC<{ initialState?: boolean }> = ({ initialState }) => { + const [state, onToggle] = useToggle(initialState); + + return ( +
+ {state ? "true" : "false"} + + + +
+ ); +}; + +describe("useToggle hook", () => { + it("should initialize with false as default", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + expect(stateElement.textContent).toBe("false"); + }); + + it("should initialize with provided initial state", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + expect(stateElement.textContent).toBe("true"); + }); + + it("should toggle state when onToggle is called with opposite value", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + const toggleButton = screen.getByTestId("toggle-button"); + + expect(stateElement.textContent).toBe("false"); + + fireEvent.click(toggleButton); + expect(stateElement.textContent).toBe("true"); + + fireEvent.click(toggleButton); + expect(stateElement.textContent).toBe("false"); + }); + + it("should handle undefined checked parameter gracefully", () => { + const TestUndefinedComponent: React.FC = () => { + const [state, onToggle] = useToggle(false); + + return ( +
+ {state ? "true" : "false"} + +
+ ); + }; + + render(); + + const stateElement = screen.getByTestId("toggle-state"); + const undefinedButton = screen.getByTestId("undefined-button"); + + expect(stateElement.textContent).toBe("false"); + + fireEvent.click(undefinedButton); + expect(stateElement.textContent).toBe("false"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx new file mode 100644 index 000000000..33fd7e0ce --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx @@ -0,0 +1,251 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import FieldRow from "./FieldRow"; + +describe("FieldRow", () => { + const mockChildContent = "Test Child Content"; + const testLabel = "Test Label"; + const customClassName = "custom-label-class"; + + describe("Component Rendering", () => { + it("renders the component with correct structure", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.firstChild).toHaveClass("flex-row"); + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText(mockChildContent)).toBeInTheDocument(); + }); + + it("renders children content correctly", () => { + render( + + + + , + ); + + expect(screen.getByTestId("test-input")).toBeInTheDocument(); + expect(screen.getByTestId("test-button")).toBeInTheDocument(); + }); + + it("renders complex children components correctly", () => { + const ComplexChild = () => ( +
+ Nested content + +
+ ); + + render( + + + , + ); + + expect(screen.getByText("Nested content")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter value")).toBeInTheDocument(); + }); + + it("does not render label when not provided", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.querySelector("label")).not.toBeInTheDocument(); + expect(screen.getByText(mockChildContent)).toBeInTheDocument(); + }); + + it("applies custom label className when provided", () => { + render( + +
{mockChildContent}
+
, + ); + + const label = screen.getByText(`${testLabel}:`); + expect(label).toHaveClass("field-label", customClassName); + }); + }); + + describe("CSS Classes and Styling", () => { + it("applies default CSS classes correctly", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const mainContainer = container.firstChild as Element; + expect(mainContainer).toHaveClass("flex-row"); + + const labelContainer = container.querySelector(".flex-fixed-width"); + expect(labelContainer).toBeInTheDocument(); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + + const label = screen.getByText(`${testLabel}:`); + expect(label).toHaveClass("field-label"); + }); + }); + + describe("Layout and Structure", () => { + it("uses horizontal Stack with space-between alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const mainContainer = container.firstChild as Element; + expect(mainContainer).toHaveClass("flex-row"); + }); + + it("positions label in fixed-width container with center alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const labelContainer = container.querySelector(".flex-fixed-width"); + expect(labelContainer).toBeInTheDocument(); + expect(labelContainer).toContainElement(screen.getByText(`${testLabel}:`)); + }); + + it("positions children in grow container with center alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + expect(childContainer).toContainElement(screen.getByTestId("child-content")); + }); + + it("maintains layout when no label is provided", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.firstChild).toHaveClass("flex-row"); + expect(container.querySelector(".flex-fixed-width")).not.toBeInTheDocument(); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + expect(childContainer).toContainElement(screen.getByTestId("child-content")); + }); + }); + + describe("Edge Cases and Error Handling", () => { + it("handles null children gracefully", () => { + render({null}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + }); + + it("handles zero as children", () => { + render({0}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("handles empty string as children", () => { + render({""}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + }); + + it("handles array of children", () => { + render({[First, Second]}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with minimal props", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with label only", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with custom className", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with complex children", () => { + const { container } = render( + +
+ + + +
+
, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with no label", () => { + const { container } = render( + +
+

Section Title

+

Section description goes here

+
+
, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty label", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx new file mode 100644 index 000000000..c1bb62372 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx @@ -0,0 +1,39 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import InfoTooltip from "./InfoTooltip"; + +describe("InfoTooltip", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render null when no content is provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should render null when content is undefined", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should render tooltip with image when content is provided", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render with JSX element content", () => { + const jsxContent = ( +
+ Important: This is a JSX tooltip +
+ ); + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx new file mode 100644 index 000000000..a8cd22e48 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx @@ -0,0 +1,112 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import NavigationControls from "./NavigationControls"; + +describe("NavigationControls", () => { + const defaultProps = { + primaryBtnText: "Next", + onPrimary: jest.fn(), + onPrevious: jest.fn(), + onCancel: jest.fn(), + isPrimaryDisabled: false, + isPreviousDisabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders all buttons with correct text", () => { + render(); + + expect(screen.getByText("Next")).toBeInTheDocument(); + expect(screen.getByText("Previous")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("renders primary button with custom text", () => { + const customProps = { + ...defaultProps, + primaryBtnText: "Complete", + }; + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + expect(screen.queryByText("Next")).not.toBeInTheDocument(); + }); + + it("calls onPrimary when primary button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Next")); + expect(defaultProps.onPrimary).toHaveBeenCalledTimes(1); + }); + + it("calls onPrevious when previous button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Previous")); + expect(defaultProps.onPrevious).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when cancel button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Cancel")); + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("disables primary button when isPrimaryDisabled is true", () => { + const disabledProps = { + ...defaultProps, + isPrimaryDisabled: true, + }; + render(); + const primaryButton = screen.getByText("Next").closest("button"); + expect(primaryButton).toHaveAttribute("aria-disabled", "true"); + expect(primaryButton).toHaveAttribute("data-is-focusable", "true"); + }); + + it("disables previous button when isPreviousDisabled is true", () => { + const disabledProps = { + ...defaultProps, + isPreviousDisabled: true, + }; + render(); + + const previousButton = screen.getByText("Previous").closest("button"); + expect(previousButton).toHaveAttribute("aria-disabled", "true"); + expect(previousButton).toHaveAttribute("data-is-focusable", "true"); + }); + + it("does not call onPrimary when disabled primary button is clicked", () => { + const disabledProps = { + ...defaultProps, + isPrimaryDisabled: true, + }; + render(); + + fireEvent.click(screen.getByText("Next")); + expect(defaultProps.onPrimary).not.toHaveBeenCalled(); + }); + + it("does not call onPrevious when disabled previous button is clicked", () => { + const disabledProps = { + ...defaultProps, + isPreviousDisabled: true, + }; + render(); + + fireEvent.click(screen.getByText("Previous")); + expect(defaultProps.onPrevious).not.toHaveBeenCalled(); + }); + + it("enables both buttons when neither is disabled", () => { + render(); + + expect(screen.getByText("Next").closest("button")).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Previous").closest("button")).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Cancel").closest("button")).not.toHaveAttribute("aria-disabled"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx new file mode 100644 index 000000000..597159be8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx @@ -0,0 +1,251 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import PopoverMessage from "./PopoverContainer"; + +jest.mock("../../../../../Common/LoadingOverlay", () => { + const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => { + return isLoading ?
: null; + }; + MockLoadingOverlay.displayName = "MockLoadingOverlay"; + return MockLoadingOverlay; +}); + +describe("PopoverMessage Component", () => { + const defaultProps = { + visible: true, + title: "Test Title", + onCancel: jest.fn(), + onPrimary: jest.fn(), + children:
Test content
, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("should render correctly when visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly when not visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with loading state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with different title", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with different children content", () => { + const customChildren = ( +
+

First paragraph

+

Second paragraph

+
+ ); + const { container } = render({customChildren}); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Visibility", () => { + it("should not render anything when visible is false", () => { + render(); + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + expect(screen.queryByText("Test content")).not.toBeInTheDocument(); + }); + + it("should render content when visible is true", () => { + render(); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test content")).toBeInTheDocument(); + }); + }); + + describe("Title Display", () => { + it("should display the provided title", () => { + render(); + expect(screen.getByText("Custom Popover Title")).toBeInTheDocument(); + }); + + it("should handle empty title", () => { + render(); + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + }); + }); + + describe("Children Content", () => { + it("should render children content", () => { + const customChildren = Custom child content; + render({customChildren}); + expect(screen.getByText("Custom child content")).toBeInTheDocument(); + }); + + it("should render complex children content", () => { + const complexChildren = ( +
+

Heading

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ ); + render({complexChildren}); + expect(screen.getByText("Heading")).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + }); + + describe("Button Interactions", () => { + it("should call onPrimary when Yes button is clicked", () => { + const onPrimaryMock = jest.fn(); + render(); + + const yesButton = screen.getByText("Yes"); + fireEvent.click(yesButton); + + expect(onPrimaryMock).toHaveBeenCalledTimes(1); + }); + + it("should call onCancel when No button is clicked", () => { + const onCancelMock = jest.fn(); + render(); + + const noButton = screen.getByText("No"); + fireEvent.click(noButton); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it("should not call handlers multiple times on rapid clicks", () => { + const onPrimaryMock = jest.fn(); + const onCancelMock = jest.fn(); + render(); + + const yesButton = screen.getByText("Yes"); + const noButton = screen.getByText("No"); + + fireEvent.click(yesButton); + fireEvent.click(yesButton); + fireEvent.click(noButton); + fireEvent.click(noButton); + + expect(onPrimaryMock).toHaveBeenCalledTimes(2); + expect(onCancelMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("Loading State", () => { + test("should show loading overlay when isLoading is true", () => { + render(); + expect(screen.getByTestId("loading-overlay")).toBeInTheDocument(); + }); + + it("should not show loading overlay when isLoading is false", () => { + render(); + expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument(); + }); + + it("should disable buttons when loading", () => { + render(); + + const yesButton = screen.getByText("Yes").closest("button"); + const noButton = screen.getByText("No").closest("button"); + + expect(yesButton).toHaveAttribute("aria-disabled", "true"); + expect(noButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("should enable buttons when not loading", () => { + render(); + + const yesButton = screen.getByText("Yes").closest("button"); + const noButton = screen.getByText("No").closest("button"); + + expect(yesButton).not.toHaveAttribute("aria-disabled"); + expect(noButton).not.toHaveAttribute("aria-disabled"); + }); + + it("should use correct loading overlay label", () => { + render(); + const loadingOverlay = screen.getByTestId("loading-overlay"); + expect(loadingOverlay).toHaveAttribute("aria-label", ContainerCopyMessages.popoverOverlaySpinnerLabel); + }); + }); + + describe("Default Props", () => { + it("should handle missing isLoading prop (defaults to false)", () => { + const propsWithoutLoading = { ...defaultProps }; + delete (propsWithoutLoading as any).isLoading; + + render(); + + expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument(); + expect(screen.getByText("Yes")).not.toBeDisabled(); + expect(screen.getByText("No")).not.toBeDisabled(); + }); + }); + + describe("CSS Classes and Styling", () => { + it("should apply correct CSS classes", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).toHaveClass("foreground"); + }); + + it("should apply loading class when isLoading is true", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).toHaveClass("loading"); + }); + + it("should not apply loading class when isLoading is false", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).not.toHaveClass("loading"); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined children", () => { + const propsWithUndefinedChildren = { ...defaultProps, children: undefined as React.ReactNode }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle null children", () => { + const propsWithNullChildren = { ...defaultProps, children: null as React.ReactNode }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle empty string title", () => { + const propsWithEmptyTitle = { ...defaultProps, title: "" }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle very long title", () => { + const longTitle = + "This is a very long title that might cause layout issues or text wrapping in the popover component"; + const propsWithLongTitle = { ...defaultProps, title: longTitle }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap new file mode 100644 index 000000000..697d1da47 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldRow Snapshot Testing matches snapshot with complex children 1`] = ` +
+
+ +
+
+
+ + + +
+
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with custom className 1`] = ` +
+
+ +
+
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with empty label 1`] = ` +
+ +
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with label only 1`] = ` +
+
+ +
+
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with minimal props 1`] = ` +
+ +
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with no label 1`] = ` +
+ +
+
+

+ Section Title +

+

+ Section description goes here +

+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap new file mode 100644 index 000000000..8b79c2b74 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoTooltip Component Rendering should render tooltip with image when content is provided 1`] = ` +
+
+
+ Information +
+ +
+
+`; + +exports[`InfoTooltip Component Rendering should render with JSX element content 1`] = ` +
+
+
+ Information +
+ +
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap new file mode 100644 index 000000000..978f38f5b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap @@ -0,0 +1,552 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PopoverMessage Component Edge Cases should handle empty string title 1`] = ` +
+
+ + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle null children 1`] = ` +
+
+ + Test Title + +
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle undefined children 1`] = ` +
+
+ + Test Title + +
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = ` +
+
+ + This is a very long title that might cause layout issues or text wrapping in the popover component + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly when not visible 1`] = `
`; + +exports[`PopoverMessage Component Rendering should render correctly when visible 1`] = ` +
+
+ + Test Title + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with different children content 1`] = ` +
+
+ + Test Title + + +
+

+ First paragraph +

+

+ Second paragraph +

+
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with different title 1`] = ` +
+
+ + Custom Title + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with loading state 1`] = ` +
+
+
+ + Test Title + + +
+ Test content +
+
+
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx new file mode 100644 index 000000000..226ea15af --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx @@ -0,0 +1,261 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes"; +import Explorer from "Explorer/Explorer"; +import { useSidePanel } from "hooks/useSidePanel"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import AddCollectionPanelWrapper from "./AddCollectionPanelWrapper"; + +jest.mock("hooks/useSidePanel"); +jest.mock("../../../Context/CopyJobContext"); + +jest.mock("../../../../Panes/AddCollectionPanel/AddCollectionPanel", () => ({ + AddCollectionPanel: ({ + explorer, + isCopyJobFlow, + onSubmitSuccess, + }: { + explorer?: Explorer; + isCopyJobFlow: boolean; + onSubmitSuccess: (data: { databaseId: string; collectionId: string }) => void; + }) => ( +
+
{explorer ? "explorer-present" : "no-explorer"}
+
{isCopyJobFlow ? "true" : "false"}
+ +
+ ), +})); + +jest.mock("immer", () => ({ + produce: jest.fn((updater) => (state: any) => { + const draft = { ...state }; + updater(draft); + return draft; + }), +})); + +const mockUseSidePanel = useSidePanel as jest.MockedFunction; +const mockUseCopyJobContext = useCopyJobContext as jest.MockedFunction; + +describe("AddCollectionPanelWrapper", () => { + const mockSetCopyJobState = jest.fn(); + const mockGoBack = jest.fn(); + const mockSetHeaderText = jest.fn(); + const mockExplorer = {} as Explorer; + + const mockSidePanelState = { + isOpen: false, + panelWidth: "440px", + hasConsole: true, + headerText: "", + setHeaderText: mockSetHeaderText, + openSidePanel: jest.fn(), + closeSidePanel: jest.fn(), + setPanelHasConsole: jest.fn(), + }; + + const mockCopyJobContextValue = { + contextError: null, + setContextError: jest.fn(), + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "" }, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: mockExplorer, + } as unknown as CopyJobContextProviderType; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSidePanel.mockReturnValue(mockSidePanelState); + mockUseSidePanel.getState = jest.fn().mockReturnValue(mockSidePanelState); + mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValue); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render correctly with all required elements", () => { + const { container } = render(); + + expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument(); + expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument(); + expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument(); + expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + + it("should match snapshot", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with explorer prop", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with goBack prop", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with both props", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Side Panel Header Management", () => { + it("should set header text to create container heading on mount", () => { + render(); + + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading); + }); + + it("should reset header text to create copy job panel title on unmount", () => { + const { unmount } = render(); + + unmount(); + + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle); + }); + + it("should not change header text if already set correctly", () => { + const modifiedSidePanelState = { + ...mockSidePanelState, + headerText: ContainerCopyMessages.createContainerHeading, + }; + + mockUseSidePanel.getState = jest.fn().mockReturnValue(modifiedSidePanelState); + + render(); + + expect(mockSetHeaderText).not.toHaveBeenCalled(); + }); + }); + + describe("AddCollectionPanel Integration", () => { + it("should pass explorer prop to AddCollectionPanel", () => { + render(); + + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present"); + }); + + it("should pass undefined explorer to AddCollectionPanel when not provided", () => { + render(); + + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer"); + }); + + it("should pass isCopyJobFlow as true to AddCollectionPanel", () => { + render(); + + expect(screen.getByTestId("copy-job-flow")).toHaveTextContent("true"); + }); + }); + + describe("Collection Success Handler", () => { + it("should update copy job state when handleAddCollectionSuccess is called", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledTimes(1); + }); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockState = { + target: { databaseId: "", containerId: "" }, + }; + + const updatedState = stateUpdater(mockState); + expect(updatedState.target.databaseId).toBe("test-db"); + expect(updatedState.target.containerId).toBe("test-collection"); + }); + + it("should call goBack when handleAddCollectionSuccess is called and goBack is provided", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + it("should not call goBack when handleAddCollectionSuccess is called and goBack is not provided", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledTimes(1); + }); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + it("should handle missing setCopyJobState gracefully", () => { + const mockCopyJobContextValueWithoutSetState = { + ...mockCopyJobContextValue, + setCopyJobState: undefined as any, + }; + + mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValueWithoutSetState); + + expect(() => render()).not.toThrow(); + }); + }); + + describe("Component Lifecycle", () => { + it("should properly cleanup on unmount", () => { + const { unmount } = render(); + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading); + mockSetHeaderText.mockClear(); + unmount(); + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle); + }); + + it("should re-render correctly when props change", () => { + const { rerender } = render(); + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer"); + rerender(); + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap new file mode 100644 index 000000000..6824baba9 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ no-explorer +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with both props 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ explorer-present +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with explorer prop 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ explorer-present +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with goBack prop 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ no-explorer +
+
+ true +
+ +
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx new file mode 100644 index 000000000..bbff5758e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx @@ -0,0 +1,426 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import CreateCopyJobScreens from "./CreateCopyJobScreens"; + +jest.mock("../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +jest.mock("../Utils/useCopyJobNavigation", () => ({ + useCopyJobNavigation: jest.fn(), +})); + +jest.mock("./Components/NavigationControls", () => { + const MockedNavigationControls = ({ + primaryBtnText, + onPrimary, + onPrevious, + onCancel, + isPrimaryDisabled, + isPreviousDisabled, + }: { + primaryBtnText: string; + onPrimary: () => void; + onPrevious: () => void; + onCancel: () => void; + isPrimaryDisabled: boolean; + isPreviousDisabled: boolean; + }) => ( +
+ + + +
+ ); + return MockedNavigationControls; +}); + +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; + +const createMockNavigationHook = (overrides = {}) => ({ + currentScreen: { + key: "SelectAccount", + component:
Mock Screen Component
, + }, + isPrimaryDisabled: false, + isPreviousDisabled: true, + handlePrimary: jest.fn(), + handlePrevious: jest.fn(), + handleCancel: jest.fn(), + primaryBtnText: "Next", + showAddCollectionPanel: jest.fn(), + ...overrides, +}); + +const createMockContext = (overrides = {}) => ({ + contextError: "", + setContextError: jest.fn(), + copyJobState: {}, + setCopyJobState: jest.fn(), + flow: {}, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {}, + ...overrides, +}); + +describe("CreateCopyJobScreens", () => { + const mockNavigationHook = createMockNavigationHook(); + const mockContext = createMockContext(); + + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigationHook); + (useCopyJobContext as jest.Mock).mockReturnValue(mockContext); + }); + + describe("Rendering", () => { + test("should render without error", () => { + render(); + expect(screen.getByTestId("mock-screen")).toBeInTheDocument(); + expect(screen.getByTestId("navigation-controls")).toBeInTheDocument(); + }); + + test("should render current screen component", () => { + const customScreen =
Custom Screen Content
; + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: customScreen }, + }), + ); + + render(); + expect(screen.getByTestId("custom-screen")).toBeInTheDocument(); + expect(screen.getByText("Custom Screen Content")).toBeInTheDocument(); + }); + + test("should have correct CSS classes", () => { + const { container } = render(); + const mainContainer = container.querySelector(".createCopyJobScreensContainer"); + const contentContainer = container.querySelector(".createCopyJobScreensContent"); + const footerContainer = container.querySelector(".createCopyJobScreensFooter"); + + expect(mainContainer).toBeInTheDocument(); + expect(contentContainer).toBeInTheDocument(); + expect(footerContainer).toBeInTheDocument(); + }); + }); + + describe("Error Message Bar", () => { + test("should not show error message bar when no error", () => { + render(); + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + }); + + test("should show error message bar when context error exists", () => { + const errorMessage = "Something went wrong"; + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: errorMessage, + }), + ); + + render(); + const messageBar = screen.getByRole("region"); + expect(messageBar).toBeInTheDocument(); + expect(messageBar).toHaveClass("createCopyJobErrorMessageBar"); + }); + + test("should have correct error message bar properties", () => { + const errorMessage = "Test error message"; + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: errorMessage, + }), + ); + + render(); + const messageBar = screen.getByRole("region"); + + expect(messageBar).toHaveClass("createCopyJobErrorMessageBar"); + }); + + test("should call setContextError when dismiss button is clicked", () => { + const mockSetContextError = jest.fn(); + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + setContextError: mockSetContextError, + }), + ); + + render(); + + const dismissButton = screen.getByLabelText("Close"); + fireEvent.click(dismissButton); + + expect(mockSetContextError).toHaveBeenCalledWith(null); + }); + + test("should show overflow button with correct aria label", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "A very long error message that should trigger overflow behavior", + }), + ); + + render(); + const overflowButton = screen.getByLabelText("See more"); + expect(overflowButton).toBeInTheDocument(); + }); + }); + + describe("Navigation Controls Integration", () => { + test("should pass correct props to NavigationControls", () => { + const mockHook = createMockNavigationHook({ + primaryBtnText: "Create", + isPrimaryDisabled: true, + isPreviousDisabled: false, + }); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockHook); + + render(); + + const primaryButton = screen.getByTestId("primary-button"); + const previousButton = screen.getByTestId("previous-button"); + + expect(primaryButton).toHaveTextContent("Create"); + expect(primaryButton).toBeDisabled(); + expect(previousButton).not.toBeDisabled(); + }); + + test("should call navigation handlers when buttons are clicked", () => { + const mockHandlePrimary = jest.fn(); + const mockHandlePrevious = jest.fn(); + const mockHandleCancel = jest.fn(); + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + handlePrimary: mockHandlePrimary, + handlePrevious: mockHandlePrevious, + handleCancel: mockHandleCancel, + isPrimaryDisabled: false, + isPreviousDisabled: false, + }), + ); + + render(); + + fireEvent.click(screen.getByTestId("primary-button")); + fireEvent.click(screen.getByTestId("previous-button")); + fireEvent.click(screen.getByTestId("cancel-button")); + + expect(mockHandlePrimary).toHaveBeenCalledTimes(1); + expect(mockHandlePrevious).toHaveBeenCalledTimes(1); + expect(mockHandleCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe("Screen Component Props", () => { + test("should pass showAddCollectionPanel prop to screen component", () => { + const mockShowAddCollectionPanel = jest.fn(); + const TestScreen = ({ showAddCollectionPanel }: { showAddCollectionPanel: () => void }) => ( +
+ +
+ ); + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: {}} /> }, + showAddCollectionPanel: mockShowAddCollectionPanel, + }), + ); + + render(); + + const addButton = screen.getByTestId("add-collection-btn"); + expect(addButton).toBeInTheDocument(); + }); + + test("should handle screen component without props", () => { + const SimpleScreen = () =>
Simple Screen
; + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: }, + }), + ); + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("simple-screen")).toBeInTheDocument(); + }); + }); + + describe("Layout and Structure", () => { + test("should maintain vertical layout with space-between alignment", () => { + const { container } = render(); + const stackContainer = container.querySelector(".createCopyJobScreensContainer"); + + expect(stackContainer).toBeInTheDocument(); + }); + + test("should have content area above navigation controls", () => { + const { container } = render(); + + const content = container.querySelector(".createCopyJobScreensContent"); + const footer = container.querySelector(".createCopyJobScreensFooter"); + + expect(content).toBeInTheDocument(); + expect(footer).toBeInTheDocument(); + + const contentIndex = Array.from(container.querySelectorAll("*")).indexOf(content!); + const footerIndex = Array.from(container.querySelectorAll("*")).indexOf(footer!); + expect(contentIndex).toBeLessThan(footerIndex); + }); + }); + + describe("Error Scenarios", () => { + test("should handle missing current screen gracefully", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: null, + }), + ); + expect(() => render()).toThrow(); + }); + + test("should handle missing screen component", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { key: "test", component: null }, + }), + ); + expect(() => render()).toThrow(); + }); + + test("should render with valid screen component", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { + key: "test", + component:
Valid Screen
, + }, + }), + ); + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("valid-screen")).toBeInTheDocument(); + }); + + test("should handle context hook throwing error", () => { + (useCopyJobContext as jest.Mock).mockImplementation(() => { + throw new Error("Context not available"); + }); + + expect(() => render()).toThrow("Context not available"); + }); + + test("should handle navigation hook throwing error", () => { + (useCopyJobNavigation as jest.Mock).mockImplementation(() => { + throw new Error("Navigation not available"); + }); + + expect(() => render()).toThrow("Navigation not available"); + }); + }); + + describe("Multiple Error States", () => { + test("should handle error message changes", () => { + const mockSetContextError = jest.fn(); + const { rerender } = render(); + + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "First error", + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.getByRole("region")).toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Second error", + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.getByRole("region")).toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: null, + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + test("should have proper ARIA labels for message bar", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + }), + ); + + render(); + + const dismissButton = screen.getByLabelText("Close"); + const overflowButton = screen.getByLabelText("See more"); + + expect(dismissButton).toBeInTheDocument(); + expect(overflowButton).toBeInTheDocument(); + }); + + test("should have proper region role for message bar", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + }), + ); + + render(); + const messageRegion = screen.getByRole("region"); + expect(messageRegion).toBeInTheDocument(); + + const alert = screen.getByRole("alert"); + expect(alert).toBeInTheDocument(); + }); + }); + + describe("Component Integration", () => { + test("should integrate with both context and navigation hooks", () => { + const mockContext = createMockContext({ + contextError: "Integration test error", + }); + const mockNavigation = createMockNavigationHook({ + primaryBtnText: "Integration Test", + isPrimaryDisabled: true, + }); + + (useCopyJobContext as jest.Mock).mockReturnValue(mockContext); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigation); + + render(); + + expect(screen.getByRole("region")).toBeInTheDocument(); + expect(screen.getByText("Integration Test")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx new file mode 100644 index 000000000..51bd5636e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx @@ -0,0 +1,95 @@ +import { shallow } from "enzyme"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import CreateCopyJobScreensProvider from "./CreateCopyJobScreensProvider"; + +jest.mock("../../Context/CopyJobContext", () => ({ + __esModule: true, + default: ({ children, explorer }: { children: React.ReactNode; explorer: Explorer }) => ( +
+ {children} +
+ ), +})); + +jest.mock("./CreateCopyJobScreens", () => ({ + __esModule: true, + default: () =>
CreateCopyJobScreens
, +})); + +const mockExplorer = { + databaseAccount: { + id: "test-account", + name: "test-account-name", + location: "East US", + type: "DocumentDB", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/", + }, + }, + subscriptionId: "test-subscription-id", + resourceGroup: "test-resource-group", +} as unknown as Explorer; + +describe("CreateCopyJobScreensProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with explorer prop", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render with null explorer", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render with undefined explorer", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should not crash with minimal explorer object", () => { + const minimalExplorer = {} as Explorer; + + expect(() => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }).not.toThrow(); + }); + + it("should match snapshot for default render", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot("default-render"); + }); + + it("should match snapshot for edge cases", () => { + const emptyExplorer = {} as Explorer; + const wrapperEmpty = shallow(); + expect(wrapperEmpty).toMatchSnapshot("empty-explorer"); + + const partialExplorer = { + databaseAccount: { id: "partial-account" }, + } as unknown as Explorer; + const wrapperPartial = shallow(); + expect(wrapperPartial).toMatchSnapshot("partial-explorer"); + }); + + describe("Error Boundaries and Edge Cases", () => { + it("should handle React rendering errors gracefully", () => { + const edgeCases = [null, undefined, {}, { invalidProperty: "test" }]; + + edgeCases.forEach((explorerCase) => { + expect(() => { + shallow(); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx new file mode 100644 index 000000000..adaea1915 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx @@ -0,0 +1,366 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { Subscription } from "Contracts/DataModels"; +import React from "react"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; +import PreviewCopyJob from "./PreviewCopyJob"; + +jest.mock("./Utils/PreviewCopyJobUtils", () => ({ + getPreviewCopyJobDetailsListColumns: () => [ + { + key: "sourcedbname", + name: "Source Database", + fieldName: "sourceDatabaseName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "sourcecolname", + name: "Source Container", + fieldName: "sourceContainerName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "targetdbname", + name: "Destination Database", + fieldName: "targetDatabaseName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "targetcolname", + name: "Destination Container", + fieldName: "targetContainerName", + minWidth: 130, + maxWidth: 140, + }, + ], +})); + +jest.mock("../../../CopyJobUtils", () => ({ + getDefaultJobName: jest.fn((selectedDatabaseAndContainers) => { + if (selectedDatabaseAndContainers.length === 1) { + const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } = + selectedDatabaseAndContainers[0]; + return `${sourceDatabaseName}.${sourceContainerName}_${targetDatabaseName}.${targetContainerName}_123456789`; + } + return ""; + }), +})); + +describe("PreviewCopyJob", () => { + const mockSetCopyJobState = jest.fn(); + const mockSetContextError = jest.fn(); + const mockSetFlow = jest.fn(); + const mockResetCopyJobState = jest.fn(); + + const mockSubscription: Subscription = { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + }, + authorizationSource: "test", + }; + + const mockDatabaseAccount = { + id: "/subscriptions/test-subscription-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/", + }, + }; + + const createMockContext = (overrides: Partial = {}): CopyJobContextProviderType => { + const defaultState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "source-database", + containerId: "source-container", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "target-database", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + ...overrides, + }; + + return { + contextError: null, + setContextError: mockSetContextError, + copyJobState: defaultState, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: mockSetFlow, + resetCopyJobState: mockResetCopyJobState, + explorer: {} as any, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with default state and empty job name", () => { + const mockContext = createMockContext(); + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with pre-filled job name", () => { + const mockContext = createMockContext({ + jobName: "custom-job-name-123", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with missing source subscription information", () => { + const mockContext = createMockContext({ + source: { + subscription: undefined, + account: mockDatabaseAccount, + databaseId: "source-database", + containerId: "source-container", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with missing source account information", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: null, + databaseId: "source-database", + containerId: "source-container", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with undefined database and container names", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "", + containerId: "", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with long subscription and account names", () => { + const longNameSubscription: Subscription = { + ...mockSubscription, + displayName: "This is a very long subscription name that might cause display issues if not handled properly", + }; + + const longNameAccount = { + ...mockDatabaseAccount, + name: "this-is-a-very-long-database-account-name-that-might-cause-display-issues", + }; + + const mockContext = createMockContext({ + source: { + subscription: longNameSubscription, + account: longNameAccount, + databaseId: "long-database-name-for-testing-purposes", + containerId: "long-container-name-for-testing-purposes", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with online migration type", () => { + const mockContext = createMockContext({ + migrationType: CopyJobMigrationType.Online, + jobName: "online-migration-job", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should handle special characters in database and container names", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "test-db_with@special#chars", + containerId: "test-container_with@special#chars", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "target-db_with@special#chars", + containerId: "target-container_with@special#chars", + }, + jobName: "job-with@special#chars_123", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render component with cross-subscription setup", () => { + const targetAccount = { + ...mockDatabaseAccount, + id: "/subscriptions/target-subscription-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", + name: "target-account", + }; + + const mockContext = createMockContext({ + target: { + subscriptionId: "target-subscription-id", + account: targetAccount, + databaseId: "target-database", + containerId: "target-container", + }, + sourceReadAccessFromTarget: true, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should call setCopyJobState with default job name on mount", async () => { + const mockContext = createMockContext(); + + render( + + + , + ); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it("should update job name when text field is changed", async () => { + const mockContext = createMockContext({ + jobName: "initial-job-name", + }); + + const { getByDisplayValue } = render( + + + , + ); + + const jobNameInput = getByDisplayValue("initial-job-name"); + fireEvent.change(jobNameInput, { target: { value: "updated-job-name" } }); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should handle empty job name input", () => { + const mockContext = createMockContext({ + jobName: "existing-name", + }); + + const { getByDisplayValue } = render( + + + , + ); + + const jobNameInput = getByDisplayValue("existing-name"); + fireEvent.change(jobNameInput, { target: { value: "" } }); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should display proper field labels from ContainerCopyMessages", () => { + const mockContext = createMockContext(); + + const { getByText } = render( + + + , + ); + + expect(getByText(/Job name/i)).toBeInTheDocument(); + expect(getByText(/Source subscription/i)).toBeInTheDocument(); + expect(getByText(/Source account/i)).toBeInTheDocument(); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts new file mode 100644 index 000000000..ba0219a8f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts @@ -0,0 +1,8 @@ +import { getPreviewCopyJobDetailsListColumns } from "./PreviewCopyJobUtils"; + +describe("PreviewCopyJobUtils", () => { + it("should return correctly formatted columns for preview copy job details list", () => { + const columns = getPreviewCopyJobDetailsListColumns(); + expect(columns).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap new file mode 100644 index 000000000..49c73a93d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewCopyJobUtils should return correctly formatted columns for preview copy job details list 1`] = ` +[ + { + "fieldName": "sourceDatabaseName", + "key": "sourcedbname", + "maxWidth": 140, + "minWidth": 130, + "name": "Source database", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "sourceContainerName", + "key": "sourcecolname", + "maxWidth": 140, + "minWidth": 130, + "name": "Source container", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "targetDatabaseName", + "key": "targetdbname", + "maxWidth": 140, + "minWidth": 130, + "name": "Destination database", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "targetContainerName", + "key": "targetcolname", + "maxWidth": 140, + "minWidth": 130, + "name": "Destination container", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, +] +`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap new file mode 100644 index 000000000..df45dbab7 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap @@ -0,0 +1,2845 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewCopyJob should handle special characters in database and container names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render component with cross-subscription setup 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with default state and empty job name 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with long subscription and account names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + This is a very long subscription name that might cause display issues if not handled properly + +
+
+ + Source account + + + this-is-a-very-long-database-account-name-that-might-cause-display-issues + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with missing source account information 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with missing source subscription information 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with online migration type 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with undefined database and container names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx new file mode 100644 index 000000000..806f80d5b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -0,0 +1,219 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { AccountDropdown } from "./AccountDropdown"; + +describe("AccountDropdown", () => { + const mockOnChange = jest.fn(); + + const mockAccountOptions: DropdownOptionType[] = [ + { + key: "account-1", + text: "Development Account", + data: { + id: "account-1", + name: "Development Account", + location: "East US", + resourceGroup: "dev-rg", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://dev-account.documents.azure.com:443/", + provisioningState: "Succeeded", + consistencyPolicy: { + defaultConsistencyLevel: "Session", + }, + }, + }, + }, + { + key: "account-2", + text: "Production Account", + data: { + id: "account-2", + name: "Production Account", + location: "West US 2", + resourceGroup: "prod-rg", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://prod-account.documents.azure.com:443/", + provisioningState: "Succeeded", + consistencyPolicy: { + defaultConsistencyLevel: "Strong", + }, + }, + }, + }, + { + key: "account-3", + text: "Testing Account", + data: { + id: "account-3", + name: "Testing Account", + location: "Central US", + resourceGroup: "test-rg", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + provisioningState: "Succeeded", + consistencyPolicy: { + defaultConsistencyLevel: "Eventual", + }, + }, + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with all account options", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with selected account", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with disabled dropdown", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty options", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with single option", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with special characters in options", () => { + const specialOptions = [ + { + key: "special", + text: 'Account with & "characters"', + data: { + id: "special", + name: 'Account with & "characters"', + location: "East US", + }, + }, + ]; + + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with long account name", () => { + const longNameOption = [ + { + key: "long", + text: "This is an extremely long account name that tests how the component handles text overflow and layout constraints in the dropdown", + data: { + id: "long", + name: "This is an extremely long account name that tests how the component handles text overflow and layout constraints in the dropdown", + location: "North Central US", + }, + }, + ]; + + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with disabled state and no selection", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with multiple account types", () => { + const mixedAccountOptions = [ + { + key: "sql-account", + text: "SQL API Account", + data: { + id: "sql-account", + name: "SQL API Account", + kind: "GlobalDocumentDB", + location: "East US", + }, + }, + { + key: "mongo-account", + text: "MongoDB Account", + data: { + id: "mongo-account", + name: "MongoDB Account", + kind: "MongoDB", + location: "West US", + }, + }, + { + key: "cassandra-account", + text: "Cassandra Account", + data: { + id: "cassandra-account", + name: "Cassandra Account", + kind: "Cassandra", + location: "Central US", + }, + }, + ]; + + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx new file mode 100644 index 000000000..67289fe39 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx @@ -0,0 +1,72 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox"; + +describe("MigrationTypeCheckbox", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render with default props (unchecked state)", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render in checked state", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should display the correct label text", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + + const label = screen.getByText("Copy container in offline mode"); + expect(label).toBeInTheDocument(); + }); + + it("should have correct accessibility attributes when checked", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeChecked(); + expect(checkbox).toHaveAttribute("checked"); + }); + }); + + describe("FluentUI Integration", () => { + it("should render FluentUI Checkbox component correctly", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toHaveAttribute("type", "checkbox"); + }); + + it("should render FluentUI Stack component correctly", () => { + render(); + + const stackContainer = document.querySelector(".migrationTypeRow"); + expect(stackContainer).toBeInTheDocument(); + }); + + it("should apply FluentUI Stack horizontal alignment correctly", () => { + const { container } = render(); + + const stackContainer = container.querySelector(".migrationTypeRow"); + expect(stackContainer).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx new file mode 100644 index 000000000..4d905a5d2 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx @@ -0,0 +1,118 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { SubscriptionDropdown } from "./SubscriptionDropdown"; + +describe("SubscriptionDropdown", () => { + const mockOnChange = jest.fn(); + + const mockSubscriptionOptions: DropdownOptionType[] = [ + { + key: "sub-1", + text: "Development Subscription", + data: { + subscriptionId: "sub-1", + displayName: "Development Subscription", + authorizationSource: "RoleBased", + subscriptionPolicies: { + quotaId: "quota-1", + spendingLimit: "Off", + locationPlacementId: "loc-1", + }, + }, + }, + { + key: "sub-2", + text: "Production Subscription", + data: { + subscriptionId: "sub-2", + displayName: "Production Subscription", + authorizationSource: "RoleBased", + subscriptionPolicies: { + quotaId: "quota-2", + spendingLimit: "On", + locationPlacementId: "loc-2", + }, + }, + }, + { + key: "sub-3", + text: "Testing Subscription", + data: { + subscriptionId: "sub-3", + displayName: "Testing Subscription", + authorizationSource: "Legacy", + subscriptionPolicies: { + quotaId: "quota-3", + spendingLimit: "Off", + locationPlacementId: "loc-3", + }, + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with all subscription options", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with selected subscription", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty options", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with single option", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with special characters in options", () => { + const specialOptions = [ + { + key: "special", + text: 'Subscription with & "characters"', + data: { subscriptionId: "special" }, + }, + ]; + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with long subscription name", () => { + const longNameOption = [ + { + key: "long", + text: "This is an extremely long subscription name that tests how the component handles text overflow and layout constraints", + data: { subscriptionId: "long" }, + }, + ]; + + const { container } = render( + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap new file mode 100644 index 000000000..379e4e0ab --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap @@ -0,0 +1,514 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountDropdown Snapshot Testing matches snapshot with all account options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with disabled dropdown 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with disabled state and no selection 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with empty options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with long account name 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with multiple account types 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with selected account 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with single option 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`AccountDropdown Snapshot Testing matches snapshot with special characters in options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap new file mode 100644 index 000000000..4e1c653ef --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = ` +
+
+ + +
+
+`; + +exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = ` +
+
+ + +
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/SubscriptionDropdown.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/SubscriptionDropdown.test.tsx.snap new file mode 100644 index 000000000..7a9f657be --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/SubscriptionDropdown.test.tsx.snap @@ -0,0 +1,337 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with all subscription options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with empty options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with long subscription name 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with selected subscription 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with single option 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`SubscriptionDropdown Snapshot Testing matches snapshot with special characters in options 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx new file mode 100644 index 000000000..2fdd0529c --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx @@ -0,0 +1,480 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { apiType } from "UserContext"; +import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; +import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts"; +import { useSubscriptions } from "../../../../../hooks/useSubscriptions"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; +import SelectAccount from "./SelectAccount"; + +jest.mock("UserContext", () => ({ + apiType: jest.fn(), +})); + +jest.mock("../../../../../hooks/useDatabaseAccounts"); +jest.mock("../../../../../hooks/useSubscriptions"); +jest.mock("../../../Context/CopyJobContext", () => ({ + useCopyJobContext: () => mockContextValue, +})); + +jest.mock("./Utils/selectAccountUtils", () => ({ + useDropdownOptions: jest.fn(), + useEventHandlers: jest.fn(), +})); + +jest.mock("./Components/SubscriptionDropdown", () => ({ + SubscriptionDropdown: jest.fn(({ options, selectedKey, onChange, ...props }) => ( +
+ {options?.map((option: any) => ( +
onChange?.(undefined, option)} + > + {option.text} +
+ ))} +
+ )), +})); + +jest.mock("./Components/AccountDropdown", () => ({ + AccountDropdown: jest.fn(({ options, selectedKey, disabled, onChange, ...props }) => ( +
+ {options?.map((option: any) => ( +
onChange?.(undefined, option)} + > + {option.text} +
+ ))} +
+ )), +})); + +jest.mock("./Components/MigrationTypeCheckbox", () => ({ + MigrationTypeCheckbox: jest.fn(({ checked, onChange, ...props }) => ( +
+ onChange?.(e, e.target.checked)} + data-testid="migration-checkbox-input" + /> +
+ )), +})); + +jest.mock("../../../ContainerCopyMessages", () => ({ + selectAccountDescription: "Select your source account and subscription", +})); + +const mockUseDatabaseAccounts = useDatabaseAccounts as jest.MockedFunction; +const mockUseSubscriptions = useSubscriptions as jest.MockedFunction; +const mockApiType = apiType as jest.MockedFunction; + +import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils"; +const mockUseDropdownOptions = useDropdownOptions as jest.MockedFunction; +const mockUseEventHandlers = useEventHandlers as jest.MockedFunction; + +const mockSubscriptions = [ + { + subscriptionId: "sub-1", + displayName: "Test Subscription 1", + authorizationSource: "RoleBased", + subscriptionPolicies: { + quotaId: "quota-1", + spendingLimit: "Off", + locationPlacementId: "loc-1", + }, + }, + { + subscriptionId: "sub-2", + displayName: "Test Subscription 2", + authorizationSource: "RoleBased", + subscriptionPolicies: { + quotaId: "quota-2", + spendingLimit: "On", + locationPlacementId: "loc-2", + }, + }, +] as Subscription[]; + +const mockAccounts = [ + { + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.DocumentDB/databaseAccounts/account-1", + name: "test-cosmos-account-1", + location: "East US", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://account-1.documents.azure.com/", + capabilities: [], + enableFreeTier: false, + }, + }, + { + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.DocumentDB/databaseAccounts/account-2", + name: "test-cosmos-account-2", + location: "West US", + kind: "MongoDB", + properties: { + documentEndpoint: "https://account-2.documents.azure.com/", + capabilities: [], + }, + }, +] as DatabaseAccount[]; + +const mockDropdownOptions = { + subscriptionOptions: [ + { key: "sub-1", text: "Test Subscription 1", data: mockSubscriptions[0] }, + { key: "sub-2", text: "Test Subscription 2", data: mockSubscriptions[1] }, + ], + accountOptions: [{ key: mockAccounts[0].id, text: mockAccounts[0].name, data: mockAccounts[0] }], +}; + +const mockEventHandlers = { + handleSelectSourceAccount: jest.fn(), + handleMigrationTypeChange: jest.fn(), +}; + +let mockContextValue = { + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + } as CopyJobContextState, + setCopyJobState: jest.fn(), + flow: null, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, +} as CopyJobContextProviderType; + +describe("SelectAccount Component", () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockContextValue = { + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + } as CopyJobContextState, + setCopyJobState: jest.fn(), + flow: null, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {} as any, + }; + + mockUseSubscriptions.mockReturnValue(mockSubscriptions); + mockUseDatabaseAccounts.mockReturnValue(mockAccounts); + mockApiType.mockReturnValue("SQL"); + mockUseDropdownOptions.mockReturnValue(mockDropdownOptions); + mockUseEventHandlers.mockReturnValue(mockEventHandlers); + }); + + describe("Rendering", () => { + it("should render component with default state", () => { + const { container } = render(); + + expect(screen.getByText("Select your source account and subscription")).toBeInTheDocument(); + expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("account-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it("should render with selected subscription", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + + const { container } = render(); + + expect(screen.getByTestId("subscription-dropdown")).toHaveAttribute("data-selected", "sub-1"); + expect(container).toMatchSnapshot(); + }); + + it("should render with selected account", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + mockContextValue.copyJobState.source.account = mockAccounts[0]; + + const { container } = render(); + + expect(screen.getByTestId("account-dropdown")).toHaveAttribute("data-selected", mockAccounts[0].id); + expect(container).toMatchSnapshot(); + }); + + it("should render with offline migration type checked", () => { + mockContextValue.copyJobState.migrationType = CopyJobMigrationType.Offline; + + const { container } = render(); + + expect(screen.getByTestId("migration-type-checkbox")).toHaveAttribute("data-checked", "true"); + expect(container).toMatchSnapshot(); + }); + + it("should render with online migration type unchecked", () => { + mockContextValue.copyJobState.migrationType = CopyJobMigrationType.Online; + + const { container } = render(); + + expect(screen.getByTestId("migration-type-checkbox")).toHaveAttribute("data-checked", "false"); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Hook Integration", () => { + it("should call useSubscriptions hook", () => { + render(); + expect(mockUseSubscriptions).toHaveBeenCalledTimes(1); + }); + + it("should call useDatabaseAccounts with selected subscription ID", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + render(); + + expect(mockUseDatabaseAccounts).toHaveBeenCalledWith("sub-1"); + }); + + it("should call useDatabaseAccounts with undefined when no subscription selected", () => { + render(); + expect(mockUseDatabaseAccounts).toHaveBeenCalledWith(undefined); + }); + + it("should filter accounts to SQL API only", () => { + mockApiType.mockReturnValueOnce("SQL").mockReturnValueOnce("Mongo"); + render(); + + expect(mockApiType).toHaveBeenCalledTimes(2); + expect(mockApiType).toHaveBeenCalledWith(mockAccounts[0]); + expect(mockApiType).toHaveBeenCalledWith(mockAccounts[1]); + }); + + it("should call useDropdownOptions with correct parameters", () => { + const sqlOnlyAccounts = [mockAccounts[0]]; // Only SQL account + mockApiType.mockImplementation((account) => (account === mockAccounts[0] ? "SQL" : "Mongo")); + + render(); + + expect(mockUseDropdownOptions).toHaveBeenCalledWith(mockSubscriptions, sqlOnlyAccounts); + }); + + it("should call useEventHandlers with setCopyJobState", () => { + render(); + expect(mockUseEventHandlers).toHaveBeenCalledWith(mockContextValue.setCopyJobState); + }); + }); + + describe("Event Handling", () => { + it("should handle subscription selection", () => { + render(); + + const subscriptionOption = screen.getByTestId("subscription-option-sub-1"); + fireEvent.click(subscriptionOption); + + expect(mockEventHandlers.handleSelectSourceAccount).toHaveBeenCalledWith("subscription", mockSubscriptions[0]); + }); + + it("should handle account selection", () => { + render(); + + const accountOption = screen.getByTestId(`account-option-${mockAccounts[0].id}`); + fireEvent.click(accountOption); + + expect(mockEventHandlers.handleSelectSourceAccount).toHaveBeenCalledWith("account", mockAccounts[0]); + }); + + it("should handle migration type change", () => { + render(); + const checkbox = screen.getByTestId("migration-checkbox-input"); + fireEvent.click(checkbox); + + expect(mockEventHandlers.handleMigrationTypeChange).toHaveBeenCalledWith(expect.any(Object), false); + }); + }); + + describe("Dropdown States", () => { + it("should disable account dropdown when no subscription is selected", () => { + render(); + + expect(screen.getByTestId("account-dropdown")).toHaveAttribute("data-disabled", "true"); + }); + + it("should enable account dropdown when subscription is selected", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + + render(); + + expect(screen.getByTestId("account-dropdown")).toHaveAttribute("data-disabled", "false"); + }); + }); + + describe("Component Props", () => { + it("should pass correct props to SubscriptionDropdown", () => { + render(); + + const dropdown = screen.getByTestId("subscription-dropdown"); + expect(dropdown).not.toHaveAttribute("data-selected"); + }); + + it("should pass selected subscription ID to SubscriptionDropdown", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + + render(); + + const dropdown = screen.getByTestId("subscription-dropdown"); + expect(dropdown).toHaveAttribute("data-selected", "sub-1"); + }); + + it("should pass correct props to AccountDropdown", () => { + render(); + + const dropdown = screen.getByTestId("account-dropdown"); + expect(dropdown).not.toHaveAttribute("data-selected"); + expect(dropdown).toHaveAttribute("data-disabled", "true"); + }); + + it("should pass selected account ID to AccountDropdown", () => { + mockContextValue.copyJobState.source.account = mockAccounts[0]; + + render(); + + const dropdown = screen.getByTestId("account-dropdown"); + expect(dropdown).toHaveAttribute("data-selected", mockAccounts[0].id); + }); + + it("should pass correct checked state to MigrationTypeCheckbox", () => { + mockContextValue.copyJobState.migrationType = CopyJobMigrationType.Offline; + + render(); + + const checkbox = screen.getByTestId("migration-type-checkbox"); + expect(checkbox).toHaveAttribute("data-checked", "true"); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty subscriptions array", () => { + mockUseSubscriptions.mockReturnValue([]); + mockUseDropdownOptions.mockReturnValue({ + subscriptionOptions: [], + accountOptions: [], + }); + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle empty accounts array", () => { + mockUseDatabaseAccounts.mockReturnValue([]); + mockUseDropdownOptions.mockReturnValue({ + subscriptionOptions: mockDropdownOptions.subscriptionOptions, + accountOptions: [], + }); + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle null subscription in context", () => { + mockContextValue.copyJobState.source.subscription = null; + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle null account in context", () => { + mockContextValue.copyJobState.source.account = null; + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle undefined subscriptions from hook", () => { + mockUseSubscriptions.mockReturnValue(undefined as any); + mockUseDropdownOptions.mockReturnValue({ + subscriptionOptions: [], + accountOptions: [], + }); + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle undefined accounts from hook", () => { + mockUseDatabaseAccounts.mockReturnValue(undefined as any); + mockUseDropdownOptions.mockReturnValue({ + subscriptionOptions: mockDropdownOptions.subscriptionOptions, + accountOptions: [], + }); + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should filter out non-SQL accounts correctly", () => { + const mixedAccounts = [ + { ...mockAccounts[0], kind: "GlobalDocumentDB" }, + { ...mockAccounts[1], kind: "MongoDB" }, + ]; + + mockUseDatabaseAccounts.mockReturnValue(mixedAccounts); + mockApiType.mockImplementation((account) => (account.kind === "GlobalDocumentDB" ? "SQL" : "Mongo")); + + render(); + expect(mockApiType).toHaveBeenCalledTimes(2); + + const sqlOnlyAccounts = mixedAccounts.filter((account) => apiType(account) === "SQL"); + expect(mockUseDropdownOptions).toHaveBeenCalledWith(mockSubscriptions, sqlOnlyAccounts); + }); + }); + + describe("Complete Workflow", () => { + it("should render complete workflow with all selections", () => { + mockContextValue.copyJobState.source.subscription = mockSubscriptions[0]; + mockContextValue.copyJobState.source.account = mockAccounts[0]; + mockContextValue.copyJobState.migrationType = CopyJobMigrationType.Online; + + const { container } = render(); + + expect(screen.getByTestId("subscription-dropdown")).toHaveAttribute("data-selected", "sub-1"); + expect(screen.getByTestId("account-dropdown")).toHaveAttribute("data-selected", mockAccounts[0].id); + expect(screen.getByTestId("account-dropdown")).toHaveAttribute("data-disabled", "false"); + expect(screen.getByTestId("migration-type-checkbox")).toHaveAttribute("data-checked", "false"); + + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.test.tsx new file mode 100644 index 000000000..5b1ef6028 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.test.tsx @@ -0,0 +1,526 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render } from "@testing-library/react"; +import React from "react"; +import { noop } from "underscore"; +import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../../Types/CopyJobTypes"; +import { useDropdownOptions, useEventHandlers } from "./selectAccountUtils"; + +jest.mock("../../../Utils/useCopyJobPrerequisitesCache", () => ({ + useCopyJobPrerequisitesCache: jest.fn(() => ({ + setValidationCache: jest.fn(), + })), +})); + +const mockSubscriptions: Subscription[] = [ + { + subscriptionId: "sub-1", + displayName: "Test Subscription 1", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + spendingLimit: "Off", + }, + }, + { + subscriptionId: "sub-2", + displayName: "Test Subscription 2", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + spendingLimit: "Off", + }, + }, +]; + +const mockAccounts: DatabaseAccount[] = [ + { + id: "account-1", + name: "Test Account 1", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test1.documents.azure.com:443/", + gremlinEndpoint: "https://test1.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test1.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test1.cassandra.cosmosdb.azure.com:443/", + capabilities: [], + writeLocations: [], + readLocations: [], + locations: [], + ipRules: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + publicNetworkAccess: "Enabled", + defaultIdentity: "", + disableLocalAuth: false, + }, + }, + { + id: "account-2", + name: "Test Account 2", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test2.documents.azure.com:443/", + gremlinEndpoint: "https://test2.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test2.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test2.cassandra.cosmosdb.azure.com:443/", + capabilities: [], + writeLocations: [], + readLocations: [], + locations: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + publicNetworkAccess: "Enabled", + defaultIdentity: "", + disableLocalAuth: false, + }, + }, +]; + +const DropdownOptionsTestComponent: React.FC<{ + subscriptions: Subscription[]; + accounts: DatabaseAccount[]; + onResult?: (result: { subscriptionOptions: any[]; accountOptions: any[] }) => void; +}> = ({ subscriptions, accounts, onResult }) => { + const result = useDropdownOptions(subscriptions, accounts); + + React.useEffect(() => { + if (onResult) { + onResult(result); + } + }, [result, onResult]); + + return ( +
+
{result.subscriptionOptions.length}
+
{result.accountOptions.length}
+
+ ); +}; + +const EventHandlersTestComponent: React.FC<{ + setCopyJobState: jest.Mock; + onResult?: (result: any) => void; +}> = ({ setCopyJobState, onResult }) => { + const result = useEventHandlers(setCopyJobState); + + React.useEffect(() => { + if (onResult) { + onResult(result); + } + }, [result, onResult]); + + return ( +
+ + + +
+ ); +}; + +describe("selectAccountUtils", () => { + describe("useDropdownOptions", () => { + it("should return empty arrays when subscriptions and accounts are undefined", () => { + let capturedResult: any; + + render( + { + capturedResult = result; + }} + />, + ); + + expect(capturedResult).toEqual({ + subscriptionOptions: [], + accountOptions: [], + }); + }); + + it("should return empty arrays when subscriptions and accounts are empty arrays", () => { + let capturedResult: any; + + render( + { + capturedResult = result; + }} + />, + ); + + expect(capturedResult).toEqual({ + subscriptionOptions: [], + accountOptions: [], + }); + }); + + it("should transform subscriptions into dropdown options correctly", () => { + let capturedResult: any; + + render( + { + capturedResult = result; + }} + />, + ); + + expect(capturedResult.subscriptionOptions).toHaveLength(2); + expect(capturedResult.subscriptionOptions[0]).toEqual({ + key: "sub-1", + text: "Test Subscription 1", + data: mockSubscriptions[0], + }); + expect(capturedResult.subscriptionOptions[1]).toEqual({ + key: "sub-2", + text: "Test Subscription 2", + data: mockSubscriptions[1], + }); + }); + + it("should transform accounts into dropdown options correctly", () => { + let capturedResult: any; + + render( + { + capturedResult = result; + }} + />, + ); + + expect(capturedResult.accountOptions).toHaveLength(2); + expect(capturedResult.accountOptions[0]).toEqual({ + key: "account-1", + text: "Test Account 1", + data: mockAccounts[0], + }); + expect(capturedResult.accountOptions[1]).toEqual({ + key: "account-2", + text: "Test Account 2", + data: mockAccounts[1], + }); + }); + + it("should handle both subscriptions and accounts correctly", () => { + let capturedResult: any; + + render( + { + capturedResult = result; + }} + />, + ); + + expect(capturedResult.subscriptionOptions).toHaveLength(2); + expect(capturedResult.accountOptions).toHaveLength(2); + }); + }); + + describe("useEventHandlers", () => { + let mockSetCopyJobState: jest.Mock; + let mockSetValidationCache: jest.Mock; + + beforeEach(async () => { + mockSetCopyJobState = jest.fn(); + mockSetValidationCache = jest.fn(); + + const { useCopyJobPrerequisitesCache } = await import("../../../Utils/useCopyJobPrerequisitesCache"); + (useCopyJobPrerequisitesCache as unknown as jest.Mock).mockReturnValue({ + setValidationCache: mockSetValidationCache, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should handle subscription selection correctly", () => { + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId("select-subscription-button")); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + expect(mockSetValidationCache).toHaveBeenCalledWith(new Map()); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: null, + account: { id: "existing-account" } as any, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: mockSubscriptions[0], + account: null, + }, + migrationType: CopyJobMigrationType.Online, + }); + }); + + it("should handle account selection correctly", () => { + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId("select-account-button")); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + expect(mockSetValidationCache).toHaveBeenCalledWith(new Map()); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: { subscriptionId: "existing-sub" } as any, + account: null, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: { subscriptionId: "existing-sub" }, + account: mockAccounts[0], + }, + migrationType: CopyJobMigrationType.Online, + }); + }); + + it("should handle subscription selection with undefined data", () => { + let capturedHandlers: any; + + render( + { + capturedHandlers = result; + }} + />, + ); + + capturedHandlers.handleSelectSourceAccount("subscription", undefined); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: { subscriptionId: "existing-sub" } as any, + account: { id: "existing-account" } as any, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: null, + account: null, + }, + migrationType: CopyJobMigrationType.Online, + }); + }); + + it("should handle account selection with undefined data", () => { + let capturedHandlers: any; + + render( + { + capturedHandlers = result; + }} + />, + ); + + capturedHandlers.handleSelectSourceAccount("account", undefined); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: { subscriptionId: "existing-sub" } as any, + account: { id: "existing-account" } as any, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: { subscriptionId: "existing-sub" }, + account: null, + }, + migrationType: CopyJobMigrationType.Online, + }); + }); + + it("should handle migration type change to offline", () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId("migration-type-button")); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + expect(mockSetValidationCache).toHaveBeenCalledWith(new Map()); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: null, + account: null, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: null, + account: null, + }, + migrationType: CopyJobMigrationType.Offline, + }); + }); + + it("should handle migration type change to online when checked is false", () => { + let capturedHandlers: any; + + render( + { + capturedHandlers = result; + }} + />, + ); + + capturedHandlers.handleMigrationTypeChange(undefined, false); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: null, + account: null, + }, + migrationType: CopyJobMigrationType.Offline, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual({ + source: { + subscription: null, + account: null, + }, + migrationType: CopyJobMigrationType.Online, + }); + }); + + it("should preserve other state properties when updating", () => { + let capturedHandlers: any; + + render( + { + capturedHandlers = result; + }} + />, + ); + + capturedHandlers.handleSelectSourceAccount("subscription", mockSubscriptions[0] as Subscription); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState = { + jobName: "Test Job", + source: { + subscription: null, + account: null, + databaseId: "test-database-id", + containerId: "test-container-id", + }, + migrationType: CopyJobMigrationType.Online, + target: { + account: { id: "dest-account" } as DatabaseAccount, + databaseId: "test-database-id", + containerId: "test-container-id", + subscriptionId: "dest-sub-id", + }, + } as CopyJobContextState; + + const newState = stateUpdater(mockPrevState); + expect(newState.target).toEqual(mockPrevState.target); + }); + + it("should return the same state for unknown selection type", () => { + let capturedHandlers: any; + + render( + { + capturedHandlers = result; + }} + />, + ); + + capturedHandlers.handleSelectSourceAccount("unknown" as any, mockSubscriptions[0] as any); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockPrevState: CopyJobContextState = { + source: { + subscription: { subscriptionId: "existing-sub" } as any, + account: { id: "existing-account" } as any, + }, + migrationType: CopyJobMigrationType.Online, + } as any; + + const newState = stateUpdater(mockPrevState); + expect(newState).toEqual(mockPrevState); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx index de9b0c976..b054e63d3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx @@ -18,9 +18,16 @@ export function useDropdownOptions( data: sub, })) || []; + const normalizeAccountId = (id: string) => { + if (!id) { + return id; + } + return id.replace(/\/Microsoft\.DocumentDb\//i, "/Microsoft.DocumentDB/"); + }; + const accountOptions = accounts?.map((account) => ({ - key: account.id, + key: normalizeAccountId(account.id), text: account.name, data: account, })) || []; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap new file mode 100644 index 000000000..b40198330 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -0,0 +1,510 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectAccount Component Complete Workflow should render complete workflow with all selections 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle empty accounts array 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle empty subscriptions array 1`] = ` +
+
+ + Select your source account and subscription + +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle null account in context 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle null subscription in context 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle undefined accounts from hook 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Edge Cases should handle undefined subscriptions from hook 1`] = ` +
+
+ + Select your source account and subscription + +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Rendering should render component with default state 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Rendering should render with offline migration type checked 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Rendering should render with online migration type unchecked 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Rendering should render with selected account 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; + +exports[`SelectAccount Component Rendering should render with selected subscription 1`] = ` +
+
+ + Select your source account and subscription + +
+
+ Test Subscription 1 +
+
+ Test Subscription 2 +
+
+
+
+ test-cosmos-account-1 +
+
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx new file mode 100644 index 000000000..4cf137ea4 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, render } from "@testing-library/react"; +import React from "react"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { dropDownChangeHandler } from "./DropDownChangeHandler"; + +const createMockInitialState = (): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + sourceReadAccessFromTarget: false, + source: { + subscription: { + subscriptionId: "source-sub-id", + displayName: "Source Subscription", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + spendingLimit: "Off", + }, + authorizationSource: "test", + }, + account: { + id: "source-account-id", + name: "source-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "DocumentDB", + properties: { + documentEndpoint: "https://source.documents.azure.com:443/", + cassandraEndpoint: undefined, + gremlinEndpoint: undefined, + tableEndpoint: undefined, + writeLocations: [], + readLocations: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + backupPolicy: undefined, + disableLocalAuth: false, + capacity: undefined, + enablePriorityBasedExecution: false, + publicNetworkAccess: "Enabled", + enableMaterializedViews: false, + }, + systemData: undefined, + }, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { + id: "target-account-id", + name: "target-account", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "DocumentDB", + properties: { + documentEndpoint: "https://target.documents.azure.com:443/", + cassandraEndpoint: undefined, + gremlinEndpoint: undefined, + tableEndpoint: undefined, + writeLocations: [], + readLocations: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + backupPolicy: undefined, + disableLocalAuth: false, + capacity: undefined, + enablePriorityBasedExecution: false, + publicNetworkAccess: "Enabled", + enableMaterializedViews: false, + }, + systemData: undefined, + }, + databaseId: "target-db", + containerId: "target-container", + }, +}); + +interface TestComponentProps { + initialState: CopyJobContextState; + onStateChange: (state: CopyJobContextState) => void; +} + +const TestComponent: React.FC = ({ initialState, onStateChange }) => { + const [state, setState] = React.useState(initialState); + const handler = dropDownChangeHandler(setState); + + React.useEffect(() => { + onStateChange(state); + }, [state, onStateChange]); + + return ( +
+ + + + +
+ ); +}; + +describe("dropDownChangeHandler", () => { + let capturedState: CopyJobContextState; + let initialState: CopyJobContextState; + + beforeEach(() => { + initialState = createMockInitialState(); + capturedState = initialState; + }); + + const renderTestComponent = () => { + return render( + { + capturedState = state; + }} + />, + ); + }; + + describe("sourceDatabase dropdown change", () => { + it("should update source database and reset source container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-database-btn")); + + expect(capturedState.source.databaseId).toBe("new-source-db"); + expect(capturedState.source.containerId).toBeUndefined(); + expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.account).toEqual(initialState.source.account); + expect(capturedState.target).toEqual(initialState.target); + }); + + it("should maintain other state properties when updating source database", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-database-btn")); + + expect(capturedState.jobName).toBe(initialState.jobName); + expect(capturedState.migrationType).toBe(initialState.migrationType); + expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget); + }); + }); + + describe("sourceContainer dropdown change", () => { + it("should update source container only", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-container-btn")); + + expect(capturedState.source.containerId).toBe("new-source-container"); + expect(capturedState.source.databaseId).toBe(initialState.source.databaseId); + expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.account).toEqual(initialState.source.account); + expect(capturedState.target).toEqual(initialState.target); + }); + + it("should not affect database selection when updating container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-container-btn")); + + expect(capturedState.source.databaseId).toBe("source-db"); + }); + }); + + describe("targetDatabase dropdown change", () => { + it("should update target database and reset target container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-database-btn")); + + expect(capturedState.target.databaseId).toBe("new-target-db"); + expect(capturedState.target.containerId).toBeUndefined(); + expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.account).toEqual(initialState.target.account); + expect(capturedState.source).toEqual(initialState.source); + }); + + it("should maintain other state properties when updating target database", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-database-btn")); + + expect(capturedState.jobName).toBe(initialState.jobName); + expect(capturedState.migrationType).toBe(initialState.migrationType); + expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget); + }); + }); + + describe("targetContainer dropdown change", () => { + it("should update target container only", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-container-btn")); + + expect(capturedState.target.containerId).toBe("new-target-container"); + expect(capturedState.target.databaseId).toBe(initialState.target.databaseId); + expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.account).toEqual(initialState.target.account); + expect(capturedState.source).toEqual(initialState.source); + }); + + it("should not affect database selection when updating container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-container-btn")); + + expect(capturedState.target.databaseId).toBe("target-db"); + }); + }); + + describe("edge cases and error scenarios", () => { + it("should handle empty string keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "", text: "Empty Option", data: {} }; + + handler("sourceDatabase")(mockEvent, mockOption); + + expect(capturedState.source.databaseId).toBe(""); + expect(capturedState.source.containerId).toBeUndefined(); + }); + + it("should handle special characters in keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { + key: "test-db-with-special-chars-@#$%", + text: "Special DB", + data: {}, + }; + + handler("sourceDatabase")(mockEvent, mockOption); + + expect(capturedState.source.databaseId).toBe("test-db-with-special-chars-@#$%"); + expect(capturedState.source.containerId).toBeUndefined(); + }); + + it("should handle numeric keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "12345", text: "Numeric Option", data: {} }; + + handler("targetContainer")(mockEvent, mockOption); + + expect(capturedState.target.containerId).toBe("12345"); + }); + + it.skip("should handle invalid dropdown type gracefully", () => { + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "test-value", text: "Test Option", data: {} }; + + const invalidHandler = handler as any; + invalidHandler("invalidType")(mockEvent, mockOption); + + expect(capturedState).toEqual(initialState); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx new file mode 100644 index 000000000..5b7a6b13f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx @@ -0,0 +1,484 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { DatabaseModel } from "Contracts/DataModels"; +import React from "react"; +import Explorer from "../../../../../Explorer/Explorer"; +import CopyJobContextProvider from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import SelectSourceAndTargetContainers from "./SelectSourceAndTargetContainers"; + +jest.mock("../../../../../hooks/useDatabases", () => ({ + useDatabases: jest.fn(), +})); + +jest.mock("../../../../../hooks/useDataContainers", () => ({ + useDataContainers: jest.fn(), +})); + +jest.mock("../../../ContainerCopyMessages", () => ({ + __esModule: true, + default: { + selectSourceAndTargetContainersDescription: "Select source and target containers for migration", + sourceContainerSubHeading: "Source Container", + targetContainerSubHeading: "Target Container", + }, +})); + +jest.mock("./Events/DropDownChangeHandler", () => ({ + dropDownChangeHandler: jest.fn(() => () => jest.fn()), +})); + +jest.mock("./memoizedData", () => ({ + useSourceAndTargetData: jest.fn(), +})); + +jest.mock("UserContext", () => ({ + userContext: { + subscriptionId: "test-subscription-id", + databaseAccount: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + kind: "GlobalDocumentDB", + }, + }, +})); + +import { useDatabases } from "../../../../../hooks/useDatabases"; +import { useDataContainers } from "../../../../../hooks/useDataContainers"; +import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; +import { useSourceAndTargetData } from "./memoizedData"; + +const mockUseDatabases = useDatabases as jest.MockedFunction; +const mockUseDataContainers = useDataContainers as jest.MockedFunction; +const mockDropDownChangeHandler = dropDownChangeHandler as jest.MockedFunction; +const mockUseSourceAndTargetData = useSourceAndTargetData as jest.MockedFunction; + +describe("SelectSourceAndTargetContainers", () => { + let mockExplorer: Explorer; + let mockShowAddCollectionPanel: jest.Mock; + let mockOnDropdownChange: jest.Mock; + + const mockDatabases: DatabaseModel[] = [ + { id: "db1", name: "Database1" } as DatabaseModel, + { id: "db2", name: "Database2" } as DatabaseModel, + ]; + + const mockContainers: DatabaseModel[] = [ + { id: "container1", name: "Container1" } as DatabaseModel, + { id: "container2", name: "Container2" } as DatabaseModel, + ]; + + const mockCopyJobState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "test-subscription-id" }, + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + }, + databaseId: "db1", + containerId: "container1", + }, + target: { + subscriptionId: "test-subscription-id", + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + }, + databaseId: "db2", + containerId: "container2", + }, + sourceReadAccessFromTarget: false, + }; + + const mockMemoizedData = { + source: mockCopyJobState.source, + target: mockCopyJobState.target, + sourceDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const, + sourceContainerParams: ["test-sub", "test-rg", "test-account", "db1", "SQL"] as const, + targetDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const, + targetContainerParams: ["test-sub", "test-rg", "test-account", "db2", "SQL"] as const, + }; + + beforeEach(() => { + mockExplorer = {} as Explorer; + mockShowAddCollectionPanel = jest.fn(); + mockOnDropdownChange = jest.fn(); + + mockUseDatabases.mockReturnValue(mockDatabases); + mockUseDataContainers.mockReturnValue(mockContainers); + mockUseSourceAndTargetData.mockReturnValue(mockMemoizedData as ReturnType); + mockDropDownChangeHandler.mockReturnValue(() => mockOnDropdownChange); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderWithContext = (component: React.ReactElement) => { + return render({component}); + }; + + describe("Component Rendering", () => { + it("should render without crashing", () => { + renderWithContext(); + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should render description text", () => { + renderWithContext(); + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should render source container section", () => { + renderWithContext(); + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should render target container section", () => { + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should return null when source is not available", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: null, + } as ReturnType); + + const { container } = renderWithContext(); + expect(container.firstChild).toBeNull(); + }); + + it("should call useDatabases hooks with correct parameters", () => { + renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.sourceDbParams); + expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.targetDbParams); + }); + + it("should call useDataContainers hooks with correct parameters", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.sourceContainerParams); + expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.targetContainerParams); + }); + }); + + describe("Database Options", () => { + it("should create source database options from useDatabases data", () => { + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should create target database options from useDatabases data", () => { + renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should handle empty database list", () => { + mockUseDatabases.mockReturnValue([]); + + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should handle undefined database list", () => { + mockUseDatabases.mockReturnValue(undefined); + + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + }); + + describe("Container Options", () => { + it("should create source container options from useDataContainers data", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should create target container options from useDataContainers data", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should handle empty container list", () => { + mockUseDataContainers.mockReturnValue([]); + + renderWithContext(); + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should handle undefined container list", () => { + mockUseDataContainers.mockReturnValue(undefined); + + renderWithContext(); + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + }); + + describe("Event Handlers", () => { + it("should call dropDownChangeHandler with setCopyJobState", () => { + renderWithContext(); + + expect(mockDropDownChangeHandler).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should create dropdown change handlers for different types", () => { + renderWithContext(); + expect(mockDropDownChangeHandler).toHaveBeenCalled(); + }); + }); + + describe("Component Props", () => { + it("should pass showAddCollectionPanel to DatabaseContainerSection", () => { + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should render without showAddCollectionPanel prop", () => { + renderWithContext(); + + expect(screen.getByText("Source Container")).toBeInTheDocument(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + }); + + describe("Memoization", () => { + it("should memoize source database options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + rerender( + + + , + ); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should memoize target database options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should memoize source container options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should memoize target container options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + }); + + describe("Database Container Section Props", () => { + it("should pass correct props to source DatabaseContainerSection", () => { + renderWithContext(); + + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should pass correct props to target DatabaseContainerSection", () => { + renderWithContext(); + + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should disable source container dropdown when no database is selected", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: "", + }, + } as ReturnType); + + renderWithContext(); + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should disable target container dropdown when no database is selected", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + target: { + ...mockMemoizedData.target, + databaseId: "", + }, + } as ReturnType); + + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + }); + + describe("Error Handling", () => { + it("should handle hooks returning null gracefully", () => { + mockUseDatabases.mockReturnValue(null); + mockUseDataContainers.mockReturnValue(null); + + renderWithContext(); + + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should handle hooks throwing errors gracefully", () => { + const originalError = console.error; + console.error = jest.fn(); + + mockUseDatabases.mockImplementation(() => { + throw new Error("Database fetch error"); + }); + + expect(() => { + renderWithContext(); + }).toThrow(); + + console.error = originalError; + }); + + it("should handle missing source data gracefully", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: undefined, + } as ReturnType); + + const { container } = renderWithContext(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("Integration with CopyJobContext", () => { + it("should use CopyJobContext for state management", () => { + renderWithContext(); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + + it("should respond to context state changes", () => { + const { rerender } = renderWithContext(); + + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: "different-db", + }, + } as ReturnType); + + rerender( + + + , + ); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + }); + + describe("Stack Layout", () => { + it("should render with correct Stack className", () => { + const { container } = renderWithContext(); + + const stackElement = container.querySelector(".selectSourceAndTargetContainers"); + expect(stackElement).toBeInTheDocument(); + }); + + it("should apply correct spacing tokens", () => { + renderWithContext(); + + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + }); + + describe("Component Structure", () => { + it("should render description, source section, and target section in correct order", () => { + renderWithContext(); + + const description = screen.getByText("Select source and target containers for migration"); + const sourceSection = screen.getByText("Source Container"); + const targetSection = screen.getByText("Target Container"); + + expect(description).toBeInTheDocument(); + expect(sourceSection).toBeInTheDocument(); + expect(targetSection).toBeInTheDocument(); + }); + + it("should maintain component hierarchy", () => { + const { container } = renderWithContext(); + + const mainContainer = container.querySelector(".selectSourceAndTargetContainers"); + expect(mainContainer).toBeInTheDocument(); + }); + }); + + describe("Performance", () => { + it("should not cause unnecessary re-renders when props don't change", () => { + const { rerender } = renderWithContext(); + + rerender( + + + , + ); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + + it("should handle rapid state changes efficiently", () => { + const { rerender } = renderWithContext(); + + for (let i = 0; i < 5; i++) { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: `db-${i}`, + }, + } as ReturnType); + + rerender( + + + , + ); + } + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx new file mode 100644 index 000000000..95cffb6e0 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx @@ -0,0 +1,452 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { DatabaseContainerSection } from "./DatabaseContainerSection"; + +describe("DatabaseContainerSection", () => { + const mockDatabaseOnChange = jest.fn(); + const mockContainerOnChange = jest.fn(); + const mockHandleOnDemandCreateContainer = jest.fn(); + + const mockDatabaseOptions: DropdownOptionType[] = [ + { key: "db1", text: "Database 1", data: { id: "db1" } }, + { key: "db2", text: "Database 2", data: { id: "db2" } }, + { key: "db3", text: "Database 3", data: { id: "db3" } }, + ]; + + const mockContainerOptions: DropdownOptionType[] = [ + { key: "container1", text: "Container 1", data: { id: "container1" } }, + { key: "container2", text: "Container 2", data: { id: "container2" } }, + { key: "container3", text: "Container 3", data: { id: "container3" } }, + ]; + + const defaultProps: DatabaseContainerSectionProps = { + heading: "Source container", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseDisabled: false, + databaseOnChange: mockDatabaseOnChange, + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerDisabled: false, + containerOnChange: mockContainerOnChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("renders the component with correct structure", () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass("databaseContainerSection"); + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders heading correctly", () => { + render(); + + const heading = screen.getByText("Source container"); + expect(heading).toBeInTheDocument(); + expect(heading.tagName).toBe("LABEL"); + expect(heading).toHaveClass("subHeading"); + }); + + it("renders database dropdown with correct properties", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + expect(databaseDropdown).toBeInTheDocument(); + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + expect(databaseDropdown).not.toBeDisabled(); + }); + + it("renders container dropdown with correct properties", () => { + render(); + + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(containerDropdown).toBeInTheDocument(); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + expect(containerDropdown).not.toBeDisabled(); + }); + + it("renders database label correctly", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument(); + }); + + it("renders container label correctly", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument(); + }); + + it("does not render create container button when handleOnDemandCreateContainer is not provided", () => { + render(); + + expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument(); + }); + + it("renders create container button when handleOnDemandCreateContainer is provided", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + const { container } = render(); + const createButton = container.querySelector(".create-container-link-btn"); + + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveTextContent(ContainerCopyMessages.createContainerButtonLabel); + }); + }); + + describe("Dropdown States", () => { + it("renders database dropdown as disabled when databaseDisabled is true", () => { + const propsWithDisabledDatabase = { + ...defaultProps, + databaseDisabled: true, + }; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("renders container dropdown as disabled when containerDisabled is true", () => { + const propsWithDisabledContainer = { + ...defaultProps, + containerDisabled: true, + }; + + render(); + + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(containerDropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("handles falsy values for disabled props correctly", () => { + const propsWithFalsyDisabled = { + ...defaultProps, + databaseDisabled: undefined, + containerDisabled: null, + } as DatabaseContainerSectionProps; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).not.toHaveAttribute("aria-disabled", "true"); + expect(containerDropdown).not.toHaveAttribute("aria-disabled", "true"); + }); + }); + + describe("User Interactions", () => { + it("calls databaseOnChange when database dropdown selection changes", () => { + render(); + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + fireEvent.click(databaseDropdown); + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + }); + + it("calls containerOnChange when container dropdown selection changes", () => { + render(); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + fireEvent.click(containerDropdown); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + }); + + it("calls handleOnDemandCreateContainer when create container button is clicked", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + render(); + + const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel); + fireEvent.click(createButton); + + expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledTimes(1); + expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledWith(); + }); + }); + + describe("Props Validation", () => { + it("renders with different heading text", () => { + const propsWithDifferentHeading = { + ...defaultProps, + heading: "Target container", + }; + + render(); + + expect(screen.getByText("Target container")).toBeInTheDocument(); + expect(screen.queryByText("Source container")).not.toBeInTheDocument(); + }); + + it("renders with different selected values", () => { + const propsWithDifferentSelections = { + ...defaultProps, + selectedDatabase: "db2", + selectedContainer: "container3", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders with empty options arrays", () => { + const propsWithEmptyOptions = { + ...defaultProps, + databaseOptions: [], + containerOptions: [], + } as DatabaseContainerSectionProps; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toBeInTheDocument(); + expect(containerDropdown).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has proper ARIA labels for dropdowns", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + }); + + it("has proper required attributes for dropdowns", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-required", "true"); + expect(containerDropdown).toHaveAttribute("aria-required", "true"); + }); + + it("maintains proper label associations", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument(); + expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("handles undefined optional props gracefully", () => { + const minimalProps: DatabaseContainerSectionProps = { + heading: "Test Heading", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseOnChange: mockDatabaseOnChange, + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerOnChange: mockContainerOnChange, + }; + + render(); + + expect(screen.getByText("Test Heading")).toBeInTheDocument(); + expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument(); + }); + + it("handles empty string selections", () => { + const propsWithEmptySelections = { + ...defaultProps, + selectedDatabase: "", + selectedContainer: "", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders correctly with long option texts", () => { + const longOptions = [ + { + key: "long1", + text: "This is a very long database name that might wrap to multiple lines in the dropdown", + data: { id: "long1" }, + }, + ]; + + const propsWithLongOptions = { + ...defaultProps, + databaseOptions: longOptions, + containerOptions: longOptions, + selectedDatabase: "long1", + selectedContainer: "long1", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + }); + + describe("Component Structure", () => { + it("has correct CSS classes applied", () => { + const { container } = render(); + + const mainContainer = container.querySelector(".databaseContainerSection"); + expect(mainContainer).toBeInTheDocument(); + + const subHeading = screen.getByText("Source container"); + expect(subHeading).toHaveClass("subHeading"); + }); + + it("maintains proper component hierarchy", () => { + const { container } = render(); + + const mainStack = container.querySelector(".databaseContainerSection"); + expect(mainStack).toBeInTheDocument(); + + const fieldRows = container.querySelectorAll(".flex-row"); + expect(fieldRows.length).toBe(2); + }); + + it("renders create button in correct position when provided", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + const { container } = render(); + + const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel); + expect(createButton).toBeInTheDocument(); + + const containerSection = container.querySelector(".databaseContainerSection"); + expect(containerSection).toContainElement(createButton); + }); + + it("displays correct create container button label", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + render(); + + expect(screen.getByText(ContainerCopyMessages.createContainerButtonLabel)).toBeInTheDocument(); + }); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with minimal props", () => { + const minimalProps: DatabaseContainerSectionProps = { + heading: "Source Container", + databaseOptions: [{ key: "db1", text: "Database 1", data: { id: "db1" } }], + selectedDatabase: "db1", + databaseOnChange: jest.fn(), + containerOptions: [{ key: "c1", text: "Container 1", data: { id: "c1" } }], + selectedContainer: "c1", + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with all props including create container handler", () => { + const fullProps: DatabaseContainerSectionProps = { + heading: "Target Container", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db2", + databaseDisabled: false, + databaseOnChange: jest.fn(), + containerOptions: mockContainerOptions, + selectedContainer: "container2", + containerDisabled: false, + containerOnChange: jest.fn(), + handleOnDemandCreateContainer: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with disabled states", () => { + const disabledProps: DatabaseContainerSectionProps = { + heading: "Disabled Section", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseDisabled: true, + databaseOnChange: jest.fn(), + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerDisabled: true, + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty options", () => { + const emptyOptionsProps: DatabaseContainerSectionProps = { + heading: "Empty Options", + databaseOptions: [], + selectedDatabase: "", + databaseOnChange: jest.fn(), + containerOptions: [], + selectedContainer: "", + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap new file mode 100644 index 000000000..09582b12b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap @@ -0,0 +1,518 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with all props including create container handler 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with disabled states 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with empty options 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with minimal props 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx new file mode 100644 index 000000000..85de409cb --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx @@ -0,0 +1,387 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../Types/CopyJobTypes"; +import { useSourceAndTargetData } from "./memoizedData"; + +jest.mock("../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(), +})); + +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; + +const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction< + typeof getAccountDetailsFromResourceId +>; + +interface TestComponentProps { + copyJobState: CopyJobContextState | null; + onResult?: (result: any) => void; +} + +const TestComponent: React.FC = ({ copyJobState, onResult }) => { + const result = useSourceAndTargetData(copyJobState); + + React.useEffect(() => { + onResult?.(result); + }, [result, onResult]); + + return
Test Component
; +}; + +describe("useSourceAndTargetData", () => { + const mockSubscription: Subscription = { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + state: "Enabled", + subscriptionPolicies: null, + authorizationSource: "RoleBased", + }; + + const mockSourceAccount: DatabaseAccount = { + id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account", + name: "source-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://source-account.documents.azure.com:443/", + capabilities: [], + locations: [], + }, + }; + + const mockTargetAccount: DatabaseAccount = { + id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", + name: "target-account", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://target-account.documents.azure.com:443/", + capabilities: [], + locations: [], + }, + }; + + const mockCopyJobState: CopyJobContextState = { + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + sourceReadAccessFromTarget: false, + source: { + subscription: mockSubscription, + account: mockSourceAccount, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-subscription-id", + account: mockTargetAccount, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + beforeEach(() => { + mockGetAccountDetailsFromResourceId.mockImplementation((accountId) => { + if (accountId === mockSourceAccount.id) { + return { + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }; + } else if (accountId === mockTargetAccount.id) { + return { + subscriptionId: "target-sub-id", + resourceGroup: "target-rg", + accountName: "target-account", + }; + } + return null; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Hook Execution", () => { + it("should return correct data structure when copyJobState is provided", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(onResult).toHaveBeenCalled(); + expect(hookResult).toBeDefined(); + expect(hookResult).toHaveProperty("source"); + expect(hookResult).toHaveProperty("target"); + expect(hookResult).toHaveProperty("sourceDbParams"); + expect(hookResult).toHaveProperty("sourceContainerParams"); + expect(hookResult).toHaveProperty("targetDbParams"); + expect(hookResult).toHaveProperty("targetContainerParams"); + }); + + it("should call getAccountDetailsFromResourceId with correct parameters", () => { + render(); + + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockSourceAccount.id); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockTargetAccount.id); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledTimes(2); + }); + + it("should return source and target objects from copyJobState", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toEqual(mockCopyJobState.source); + expect(hookResult.target).toEqual(mockCopyJobState.target); + }); + + it("should construct sourceDbParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]); + }); + + it("should construct sourceContainerParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceContainerParams).toEqual([ + "source-sub-id", + "source-rg", + "source-account", + "source-db", + "SQL", + ]); + }); + + it("should construct targetDbParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]); + }); + + it("should construct targetContainerParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.targetContainerParams).toEqual([ + "target-sub-id", + "target-rg", + "target-account", + "target-db", + "SQL", + ]); + }); + }); + + describe("Memoization and Performance", () => { + it("should work with React strict mode (double invocation)", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + const { rerender } = render(); + const firstResult = { ...hookResult }; + + rerender(); + const secondResult = { ...hookResult }; + + expect(firstResult).toEqual(secondResult); + }); + + it("should handle component re-renders gracefully", () => { + let renderCount = 0; + const onResult = jest.fn(() => { + renderCount++; + }); + + const { rerender } = render(); + + for (let i = 0; i < 5; i++) { + rerender(); + } + + expect(renderCount).toBeGreaterThan(0); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled(); + }); + + it("should recalculate when copyJobState changes", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + const { rerender } = render(); + const firstResult = { ...hookResult }; + + const updatedState = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + databaseId: "updated-source-db", + }, + }; + + rerender(); + const secondResult = { ...hookResult }; + + expect(firstResult.sourceContainerParams[3]).toBe("source-db"); + expect(secondResult.sourceContainerParams[3]).toBe("updated-source-db"); + }); + }); + + describe("Complex State Scenarios", () => { + it("should handle state with only source defined", () => { + const sourceOnlyState = { + ...mockCopyJobState, + target: undefined as any, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeDefined(); + expect(hookResult.target).toBeUndefined(); + expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]); + expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + }); + + it("should handle state with only target defined", () => { + const targetOnlyState = { + ...mockCopyJobState, + source: undefined as any, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeUndefined(); + expect(hookResult.target).toBeDefined(); + expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]); + }); + + it("should handle state with missing database IDs", () => { + const stateWithoutDbIds = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + databaseId: undefined as any, + }, + target: { + ...mockCopyJobState.target, + databaseId: undefined as any, + }, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceContainerParams[3]).toBeUndefined(); + expect(hookResult.targetContainerParams[3]).toBeUndefined(); + }); + + it("should handle state with missing accounts", () => { + const stateWithoutAccounts = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: undefined as any, + }, + target: { + ...mockCopyJobState.target, + account: undefined as any, + }, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(undefined); + expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + }); + }); + + describe("Hook Return Value Structure", () => { + it("should return an object with exactly 6 properties", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + const keys = Object.keys(hookResult); + expect(keys).toHaveLength(6); + expect(keys).toContain("source"); + expect(keys).toContain("target"); + expect(keys).toContain("sourceDbParams"); + expect(keys).toContain("sourceContainerParams"); + expect(keys).toContain("targetDbParams"); + expect(keys).toContain("targetContainerParams"); + }); + + it("should not return undefined properties when state is valid", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeDefined(); + expect(hookResult.target).toBeDefined(); + expect(hookResult.sourceDbParams).toBeDefined(); + expect(hookResult.sourceContainerParams).toBeDefined(); + expect(hookResult.targetDbParams).toBeDefined(); + expect(hookResult.targetContainerParams).toBeDefined(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx index 99977ed3a..76fa3dc7a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx @@ -9,12 +9,12 @@ export function useSourceAndTargetData(copyJobState: CopyJobContextState) { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; const { subscriptionId: targetSubscriptionId, resourceGroup: targetResourceGroup, accountName: targetAccountName, - } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {}; const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams; const sourceContainerParams = [ diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap new file mode 100644 index 000000000..31aab621f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateCopyJobScreensProvider should match snapshot for default render: default-render 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: empty-explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: partial-explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with explorer prop 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with null explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with undefined explorer 1`] = ` + + + +`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx new file mode 100644 index 000000000..737d9c78f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx @@ -0,0 +1,324 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../Types/CopyJobTypes"; +import { useCopyJobNavigation } from "./useCopyJobNavigation"; + +jest.mock("../../../../hooks/useSidePanel", () => ({ + useSidePanel: { + getState: jest.fn(() => ({ + closeSidePanel: jest.fn(), + })), + }, +})); + +jest.mock("../../Actions/CopyJobActions", () => ({ + submitCreateCopyJob: jest.fn(), +})); + +jest.mock("../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +jest.mock("./useCopyJobPrerequisitesCache", () => ({ + useCopyJobPrerequisitesCache: jest.fn(), +})); + +jest.mock("./useCreateCopyJobScreensList", () => ({ + SCREEN_KEYS: { + SelectAccount: "SelectAccount", + AssignPermissions: "AssignPermissions", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + CreateCollection: "CreateCollection", + PreviewCopyJob: "PreviewCopyJob", + }, + useCreateCopyJobScreensList: jest.fn(), +})); + +jest.mock("../../CopyJobUtils", () => ({ + getContainerIdentifiers: jest.fn(), + isIntraAccountCopy: jest.fn(), +})); + +import { useSidePanel } from "../../../../hooks/useSidePanel"; +import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +const TestComponent: React.FC<{ + onHookResult?: (result: ReturnType) => void; +}> = ({ onHookResult }) => { + const hookResult = useCopyJobNavigation(); + + React.useEffect(() => { + onHookResult?.(hookResult); + }, [hookResult, onHookResult]); + + return ( +
+
{hookResult.currentScreen?.key}
+
{hookResult.isPrimaryDisabled.toString()}
+
{hookResult.isPreviousDisabled.toString()}
+
{hookResult.primaryBtnText}
+ + + + {hookResult.currentScreen?.key === SCREEN_KEYS.SelectSourceAndTargetContainers && ( + + )} +
+ ); +}; + +describe("useCopyJobNavigation", () => { + const createMockCopyJobState = (overrides?: Partial): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "source-sub-id" } as any, + account: { id: "source-account-id", name: "Account-1" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { id: "target-account-id", name: "Account-2" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + ...overrides, + }); + + const createMockScreen = (key: string, validations: any[] = []) => ({ + key, + component:
{key} Screen
, + validations, + }); + + const mockResetCopyJobState = jest.fn(); + const mockSetContextError = jest.fn(); + const mockCloseSidePanel = jest.fn(); + const mockCopyJobState = createMockCopyJobState(); + const mockValidationCache = new Map([ + ["validation1", true], + ["validation2", true], + ]); + + const setupMocks = (screensList: any[] = [], isIntraAccount = false) => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + copyJobState: mockCopyJobState, + resetCopyJobState: mockResetCopyJobState, + setContextError: mockSetContextError, + }); + + (useCopyJobPrerequisitesCache as unknown as jest.Mock).mockReturnValue({ + validationCache: mockValidationCache, + }); + + (useCreateCopyJobScreensList as jest.Mock).mockReturnValue( + screensList.length > 0 ? screensList : [createMockScreen(SCREEN_KEYS.SelectAccount)], + ); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + closeSidePanel: mockCloseSidePanel, + }); + + (getContainerIdentifiers as jest.Mock).mockImplementation((container) => ({ + accountId: container.account?.id, + databaseId: container.databaseId, + containerId: container.containerId, + })); + + (isIntraAccountCopy as jest.Mock).mockReturnValue(isIntraAccount); + }; + + const clickPrimaryButton = () => fireEvent.click(screen.getByTestId("primary-btn")); + const clickPreviousButton = () => fireEvent.click(screen.getByTestId("previous-btn")); + + const expectScreen = (screenKey: string) => { + expect(screen.getByTestId("current-screen")).toHaveTextContent(screenKey); + }; + + const expectPrimaryButtonText = (text: string) => { + expect(screen.getByTestId("primary-btn-text")).toHaveTextContent(text); + }; + + const expectPrimaryDisabled = (disabled: boolean) => { + expect(screen.getByTestId("primary-disabled")).toHaveTextContent(disabled.toString()); + }; + + const navigateToScreen = (screenKey: string, clicks: number) => { + for (let i = 0; i < clicks; i++) { + clickPrimaryButton(); + } + expectScreen(screenKey); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setupMocks(); + }); + + describe("Initial state and navigation", () => { + test("should start with SelectAccount screen and disable previous button", () => { + render(); + + expectScreen(SCREEN_KEYS.SelectAccount); + expect(screen.getByTestId("previous-disabled")).toHaveTextContent("true"); + }); + + test("should show Next button text by default", () => { + render(); + expectPrimaryButtonText("Next"); + }); + + test("should navigate through screens and show Create button for CreateCollection", () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers), + createMockScreen(SCREEN_KEYS.CreateCollection), + ]; + setupMocks(screens, true); + + render(); + + expectScreen(SCREEN_KEYS.SelectAccount); + clickPrimaryButton(); + + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expectPrimaryButtonText("Next"); + + fireEvent.click(screen.getByTestId("add-collection-btn")); + expectScreen(SCREEN_KEYS.CreateCollection); + expectPrimaryButtonText("Create"); + + clickPreviousButton(); + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expectPrimaryButtonText("Next"); + }); + }); + + describe("Validation logic", () => { + test("should disable primary button when validations fail", () => { + const invalidScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [ + { validate: () => false, message: "Invalid state" }, + ]); + setupMocks([invalidScreen]); + + render(); + expectPrimaryDisabled(true); + }); + + test("should enable primary button when all validations pass", () => { + const validScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [ + { validate: () => true, message: "Valid state" }, + ]); + setupMocks([validScreen]); + + render(); + expectPrimaryDisabled(false); + }); + + test("should prevent navigation when source and target containers are identical", () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, [ + { validate: () => true, message: "Valid containers" }, + ]), + ]; + setupMocks(screens, true); + + (getContainerIdentifiers as jest.Mock).mockImplementation(() => ({ + accountId: "same-account", + databaseId: "same-db", + containerId: "same-container", + })); + + render(); + + navigateToScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, 1); + clickPrimaryButton(); + + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expect(mockSetContextError).toHaveBeenCalledWith( + "Source and destination containers cannot be the same. Please select different containers to proceed.", + ); + }); + }); + + describe("Copy job submission", () => { + const setupToPreviewScreen = () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers), + createMockScreen(SCREEN_KEYS.PreviewCopyJob), + ]; + setupMocks(screens, true); + + render(); + navigateToScreen(SCREEN_KEYS.PreviewCopyJob, 2); + clickPrimaryButton(); + }; + + test("should handle successful copy job submission", async () => { + (submitCreateCopyJob as jest.Mock).mockResolvedValue(undefined); + + setupToPreviewScreen(); + + await waitFor(() => { + expect(submitCreateCopyJob).toHaveBeenCalledWith(mockCopyJobState, expect.any(Function)); + }); + }); + + test("should handle copy job submission error", async () => { + const error = new Error("Submission failed"); + (submitCreateCopyJob as jest.Mock).mockRejectedValue(error); + setupToPreviewScreen(); + + await waitFor(() => { + expect(mockSetContextError).toHaveBeenCalledWith("Submission failed"); + }); + }); + + test("should handle unknown error during submission", async () => { + (submitCreateCopyJob as jest.Mock).mockRejectedValue("Unknown error"); + + setupToPreviewScreen(); + + await waitFor(() => { + expect(mockSetContextError).toHaveBeenCalledWith("Failed to create copy job. Please try again later."); + }); + }); + + test("should disable buttons during loading", async () => { + let resolveSubmission: () => void; + const submissionPromise = new Promise((resolve) => { + resolveSubmission = resolve; + }); + (submitCreateCopyJob as jest.Mock).mockReturnValue(submissionPromise); + + setupToPreviewScreen(); + + await waitFor(() => { + expectPrimaryDisabled(true); + }); + + resolveSubmission!(); + + await waitFor(() => { + expectPrimaryDisabled(false); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx new file mode 100644 index 000000000..5b17d1c3b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx @@ -0,0 +1,334 @@ +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; + +describe("useCopyJobPrerequisitesCache", () => { + let hookResult: any; + + const TestComponent = ({ onHookUpdate }: { onHookUpdate?: () => void }): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + React.useEffect(() => { + if (onHookUpdate) { + onHookUpdate(); + } + }, [onHookUpdate]); + + return ( +
+ {hookResult.validationCache.size} + + +
+ ); + }; + + afterEach(() => { + if (hookResult) { + act(() => { + hookResult.setValidationCache(new Map()); + }); + } + }); + + it("should initialize with an empty validation cache", () => { + render(); + + expect(hookResult.validationCache).toBeInstanceOf(Map); + expect(hookResult.validationCache.size).toBe(0); + expect(screen.getByTestId("cache-size")).toHaveTextContent("0"); + }); + + it("should provide a setValidationCache function", () => { + render(); + + expect(typeof hookResult.setValidationCache).toBe("function"); + }); + + it("should update validation cache when setValidationCache is called", () => { + render(); + + const testCache = new Map(); + testCache.set("test-key", true); + testCache.set("another-key", false); + + act(() => { + hookResult.setValidationCache(testCache); + }); + + expect(hookResult.validationCache).toBe(testCache); + expect(hookResult.validationCache.size).toBe(2); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(hookResult.validationCache.get("another-key")).toBe(false); + expect(screen.getByTestId("cache-size")).toHaveTextContent("2"); + }); + + it("should replace the entire validation cache when setValidationCache is called", () => { + render(); + + const initialCache = new Map(); + initialCache.set("initial-key", true); + + act(() => { + hookResult.setValidationCache(initialCache); + }); + + expect(hookResult.validationCache.get("initial-key")).toBe(true); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + + const newCache = new Map(); + newCache.set("new-key", false); + + act(() => { + hookResult.setValidationCache(newCache); + }); + + expect(hookResult.validationCache.get("initial-key")).toBeUndefined(); + expect(hookResult.validationCache.get("new-key")).toBe(false); + expect(hookResult.validationCache.size).toBe(1); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + }); + + it("should handle empty Map updates", () => { + render(); + + const initialCache = new Map(); + initialCache.set("test-key", true); + + act(() => { + hookResult.setValidationCache(initialCache); + }); + + expect(hookResult.validationCache.size).toBe(1); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + + act(() => { + screen.getByTestId("clear-cache-button").click(); + }); + + expect(hookResult.validationCache.size).toBe(0); + expect(screen.getByTestId("cache-size")).toHaveTextContent("0"); + }); + + it("should maintain state across multiple hook instances (global store behavior)", () => { + let firstHookResult: any; + let secondHookResult: any; + + const FirstComponent = (): JSX.Element => { + firstHookResult = useCopyJobPrerequisitesCache(); + return
First
; + }; + + const SecondComponent = (): JSX.Element => { + secondHookResult = useCopyJobPrerequisitesCache(); + return
Second
; + }; + + render( +
+ + +
, + ); + + const testCache = new Map(); + testCache.set("shared-key", true); + + act(() => { + firstHookResult.setValidationCache(testCache); + }); + + expect(secondHookResult.validationCache.get("shared-key")).toBe(true); + expect(secondHookResult.validationCache.size).toBe(1); + expect(firstHookResult.validationCache.get("shared-key")).toBe(true); + expect(firstHookResult.validationCache.size).toBe(1); + }); + + it("should allow updates from different hook instances", () => { + let firstHookResult: any; + let secondHookResult: any; + + const FirstComponent = (): JSX.Element => { + firstHookResult = useCopyJobPrerequisitesCache(); + return ( + + ); + }; + + const SecondComponent = (): JSX.Element => { + secondHookResult = useCopyJobPrerequisitesCache(); + return ( + + ); + }; + + render( +
+ + +
, + ); + + act(() => { + screen.getByTestId("first-update").click(); + }); + + expect(secondHookResult.validationCache.get("key-from-first")).toBe(true); + + act(() => { + screen.getByTestId("second-update").click(); + }); + + expect(firstHookResult.validationCache.get("key-from-second")).toBe(false); + expect(firstHookResult.validationCache.get("key-from-first")).toBeUndefined(); + }); + + it("should handle complex validation scenarios", () => { + const ComplexTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleComplexUpdate = () => { + const complexCache = new Map(); + complexCache.set("database-validation", true); + complexCache.set("container-validation", true); + complexCache.set("network-validation", false); + complexCache.set("authentication-validation", true); + complexCache.set("permission-validation", false); + hookResult.setValidationCache(complexCache); + }; + + return ( + + ); + }; + + render(); + + act(() => { + screen.getByTestId("complex-update").click(); + }); + + expect(hookResult.validationCache.size).toBe(5); + expect(hookResult.validationCache.get("database-validation")).toBe(true); + expect(hookResult.validationCache.get("container-validation")).toBe(true); + expect(hookResult.validationCache.get("network-validation")).toBe(false); + expect(hookResult.validationCache.get("authentication-validation")).toBe(true); + expect(hookResult.validationCache.get("permission-validation")).toBe(false); + }); + + it("should handle edge case keys", () => { + const EdgeCaseTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleEdgeCaseUpdate = () => { + const edgeCaseCache = new Map(); + edgeCaseCache.set("", true); + edgeCaseCache.set(" ", false); + edgeCaseCache.set("special-chars!@#$%^&*()", true); + edgeCaseCache.set("very-long-key-".repeat(10), false); + edgeCaseCache.set("unicode-key-🔑", true); + hookResult.setValidationCache(edgeCaseCache); + }; + + return ( + + ); + }; + + render(); + + act(() => { + screen.getByTestId("edge-case-update").click(); + }); + + expect(hookResult.validationCache.size).toBe(5); + expect(hookResult.validationCache.get("")).toBe(true); + expect(hookResult.validationCache.get(" ")).toBe(false); + expect(hookResult.validationCache.get("special-chars!@#$%^&*()")).toBe(true); + expect(hookResult.validationCache.get("very-long-key-".repeat(10))).toBe(false); + expect(hookResult.validationCache.get("unicode-key-🔑")).toBe(true); + }); + + it("should handle setting the same cache reference without errors", () => { + let testCache: Map; + + const SameReferenceTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleFirstUpdate = () => { + testCache = new Map(); + testCache.set("test-key", true); + hookResult.setValidationCache(testCache); + }; + + const handleSecondUpdate = () => { + hookResult.setValidationCache(testCache); + }; + + return ( +
+ + + {hookResult.validationCache.get("test-key")?.toString()} +
+ ); + }; + + render(); + act(() => { + screen.getByTestId("first-update").click(); + }); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(screen.getByTestId("cache-content")).toHaveTextContent("true"); + + act(() => { + screen.getByTestId("second-update").click(); + }); + expect(hookResult.validationCache).toBe(testCache); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(screen.getByTestId("cache-content")).toHaveTextContent("true"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx new file mode 100644 index 000000000..3768ef5f1 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx @@ -0,0 +1,477 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import Explorer from "../../../Explorer"; +import CopyJobContextProvider, { useCopyJobContext } from "../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../Types/CopyJobTypes"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +jest.mock("../../Context/CopyJobContext", () => { + const actual = jest.requireActual("../../Context/CopyJobContext"); + return { + __esModule: true, + ...actual, + default: actual.default, + useCopyJobContext: jest.fn(), + }; +}); + +jest.mock("../Screens/AssignPermissions/AssignPermissions", () => { + const MockAssignPermissions = () => { + return
AssignPermissions
; + }; + MockAssignPermissions.displayName = "MockAssignPermissions"; + return MockAssignPermissions; +}); + +jest.mock("../Screens/CreateContainer/AddCollectionPanelWrapper", () => { + const MockAddCollectionPanelWrapper = () => { + return
AddCollectionPanelWrapper
; + }; + MockAddCollectionPanelWrapper.displayName = "MockAddCollectionPanelWrapper"; + return MockAddCollectionPanelWrapper; +}); + +jest.mock("../Screens/PreviewCopyJob/PreviewCopyJob", () => { + const MockPreviewCopyJob = () => { + return
PreviewCopyJob
; + }; + MockPreviewCopyJob.displayName = "MockPreviewCopyJob"; + return MockPreviewCopyJob; +}); + +jest.mock("../Screens/SelectAccount/SelectAccount", () => { + const MockSelectAccount = () => { + return
SelectAccount
; + }; + MockSelectAccount.displayName = "MockSelectAccount"; + return MockSelectAccount; +}); + +jest.mock("../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers", () => { + const MockSelectSourceAndTargetContainers = () => { + return
SelectSourceAndTargetContainers
; + }; + MockSelectSourceAndTargetContainers.displayName = "MockSelectSourceAndTargetContainers"; + return MockSelectSourceAndTargetContainers; +}); + +const TestHookComponent: React.FC<{ goBack: () => void }> = ({ goBack }) => { + const screens = useCreateCopyJobScreensList(goBack); + + return ( +
+ {screens.map((screen, index) => ( +
+
{screen.key}
+
{screen.component}
+
+ {JSON.stringify(screen.validations.map((v) => v.message))} +
+
+ ))} +
+ ); +}; + +describe("useCreateCopyJobScreensList", () => { + const mockExplorer = {} as Explorer; + const mockGoBack = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobContext as jest.Mock).mockReturnValue({ + explorer: mockExplorer, + }); + }); + + const renderWithContext = (component: React.ReactElement) => { + return render({component}); + }; + + describe("Hook behavior", () => { + it("should return screens list with correct keys and components", () => { + renderWithContext(); + + expect(screen.getByTestId("test-hook-component")).toBeInTheDocument(); + expect(screen.getByTestId("screen-key-0")).toHaveTextContent(SCREEN_KEYS.SelectAccount); + expect(screen.getByTestId("screen-key-1")).toHaveTextContent(SCREEN_KEYS.SelectSourceAndTargetContainers); + expect(screen.getByTestId("screen-key-2")).toHaveTextContent(SCREEN_KEYS.CreateCollection); + expect(screen.getByTestId("screen-key-3")).toHaveTextContent(SCREEN_KEYS.PreviewCopyJob); + expect(screen.getByTestId("screen-key-4")).toHaveTextContent(SCREEN_KEYS.AssignPermissions); + + expect(screen.getByTestId("select-account")).toBeInTheDocument(); + expect(screen.getByTestId("select-source-target")).toBeInTheDocument(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + expect(screen.getByTestId("preview-copy-job")).toBeInTheDocument(); + expect(screen.getByTestId("assign-permissions")).toBeInTheDocument(); + }); + + it("should return exactly 5 screens in the correct order", () => { + renderWithContext(); + + const screens = screen.getAllByTestId(/screen-\d+/); + expect(screens).toHaveLength(5); + }); + + it("should memoize results based on explorer dependency", () => { + const { rerender } = renderWithContext(); + const initialScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent); + rerender( + + + , + ); + + const rerenderScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent); + expect(rerenderScreens).toEqual(initialScreens); + }); + }); + + describe("Screen validations", () => { + describe("SelectAccount screen validation", () => { + it("should validate subscription and account presence", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-0").textContent || "[]"); + expect(validationMessages).toContain("Please select a subscription and account to proceed"); + }); + + it("should pass validation when subscription and account are present", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "test-sub" } as any, + account: { name: "test-account" } as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount); + const isValid = selectAccountScreen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when subscription is missing", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: { name: "test-account" } as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount); + const isValid = selectAccountScreen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("SelectSourceAndTargetContainers screen validation", () => { + it("should validate source and target containers", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-1").textContent || "[]"); + expect(validationMessages).toContain("Please select source and target containers to proceed"); + }); + + it("should pass validation when all required fields are present", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when source database is missing", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "source-container", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("CreateCollection screen", () => { + it("should have no validations", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-2").textContent || "[]"); + expect(validationMessages).toEqual([]); + }); + }); + + describe("PreviewCopyJob screen validation", () => { + it("should validate job name format", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-3").textContent || "[]"); + expect(validationMessages).toContain("Please enter a job name to proceed"); + }); + + it("should pass validation with valid job name", () => { + const mockState: CopyJobContextState = { + jobName: "valid-job-name_123", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation with invalid job name characters", () => { + const mockState: CopyJobContextState = { + jobName: "invalid job name with spaces!", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + + it("should fail validation with empty job name", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("AssignPermissions screen validation", () => { + it("should validate cache values", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-4").textContent || "[]"); + expect(validationMessages).toContain("Please ensure all previous steps are valid to proceed"); + }); + + it("should pass validation when all cache values are true", () => { + const mockCache = new Map([ + ["step1", true], + ["step2", true], + ["step3", true], + ]); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when cache is empty", () => { + const mockCache = new Map(); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + + it("should fail validation when any cache value is false", () => { + const mockCache = new Map([ + ["step1", true], + ["step2", false], + ["step3", true], + ]); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + }); + + describe("SCREEN_KEYS constant", () => { + it("should export correct screen keys", () => { + expect(SCREEN_KEYS.CreateCollection).toBe("CreateCollection"); + expect(SCREEN_KEYS.SelectAccount).toBe("SelectAccount"); + expect(SCREEN_KEYS.SelectSourceAndTargetContainers).toBe("SelectSourceAndTargetContainers"); + expect(SCREEN_KEYS.PreviewCopyJob).toBe("PreviewCopyJob"); + expect(SCREEN_KEYS.AssignPermissions).toBe("AssignPermissions"); + }); + }); + + describe("Component props", () => { + it("should pass explorer to AddCollectionPanelWrapper", () => { + renderWithContext(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + + it("should pass goBack function to AddCollectionPanelWrapper", () => { + renderWithContext(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + }); + + describe("Error handling", () => { + it("should handle context provider error gracefully", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + (useCopyJobContext as jest.Mock).mockImplementation(() => { + throw new Error("Context not found"); + }); + + expect(() => { + render(); + }).toThrow("Context not found"); + + consoleError.mockRestore(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index acb17f602..0b5283558 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -8,11 +8,11 @@ import SelectAccount from "../Screens/SelectAccount/SelectAccount"; import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers"; const SCREEN_KEYS = { - CreateCollection: "CreateCollection", SelectAccount: "SelectAccount", - SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", - PreviewCopyJob: "PreviewCopyJob", AssignPermissions: "AssignPermissions", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + CreateCollection: "CreateCollection", + PreviewCopyJob: "PreviewCopyJob", }; type Validation = { diff --git a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts index 9be43bcc8..10548f05f 100644 --- a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts +++ b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts @@ -11,6 +11,7 @@ export enum IdentityType { export enum DefaultIdentityType { SystemAssignedIdentity = "systemassignedidentity", + FirstPartyIdentity = "FirstPartyIdentity", } export enum BackupPolicyType { diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx new file mode 100644 index 000000000..f8cad1cd5 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx @@ -0,0 +1,611 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import CopyJobActionMenu from "./CopyJobActionMenu"; + +jest.mock("../../ContainerCopyMessages", () => ({ + __esModule: true, + default: { + MonitorJobs: { + Columns: { + actions: "Actions", + }, + Actions: { + pause: "Pause", + resume: "Resume", + cancel: "Cancel", + complete: "Complete", + }, + }, + }, +})); + +describe("CopyJobActionMenu", () => { + const createMockJob = (overrides: Partial = {}): CopyJobType => + ({ + ID: "test-job-id", + Mode: CopyJobMigrationType.Offline, + Name: "Test Job", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "00:10:30", + LastUpdatedTime: "2025-01-01T10:00:00Z", + timestamp: Date.now(), + Source: { + databaseName: "sourceDb", + collectionName: "sourceContainer", + component: "source", + }, + Destination: { + databaseName: "targetDb", + collectionName: "targetContainer", + component: "destination", + }, + ...overrides, + }) as CopyJobType; + + const mockHandleClick: HandleJobActionClickType = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render the action menu button for active jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + expect(actionButton).toHaveAttribute("aria-label", "Actions"); + expect(actionButton).toHaveAttribute("title", "Actions"); + }); + + it("should not render anything for completed jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Completed }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for cancelled jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Cancelled }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for failed jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Failed }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for faulted jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Faulted }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe("Menu Items for Different Job Statuses", () => { + it("should show pause and cancel actions for InProgress jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show resume and cancel actions for Paused jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Paused }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Pending jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Pending }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show only resume action for Skipped jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Skipped }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Running jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Running }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Partitioning jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Partitioning }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + }); + + describe("Online Mode Complete Action", () => { + it("should show complete action for online InProgress jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should show complete action for online Running jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.Running, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + it("should show complete action for online Partitioning jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.Partitioning, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + it("should not show complete action for offline jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Offline, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should handle case-insensitive online mode detection", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: "ONLINE", + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Action Click Handling", () => { + it("should call handleClick when pause action is clicked", () => { + 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(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); + }); + + it("should call handleClick when cancel action is clicked", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); + }); + + it("should call handleClick when resume action is clicked", () => { + const job = createMockJob({ Status: CopyJobStatusType.Paused }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const resumeButton = screen.getByText("Resume"); + fireEvent.click(resumeButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function)); + }); + + it("should call handleClick when complete action is clicked", () => { + const job = createMockJob({ + 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(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); + }); + }); + + describe("Disabled States During Updates", () => { + const TestComponentWrapper: React.FC<{ + job: CopyJobType; + initialUpdatingState?: { jobName: string; action: string } | null; + }> = ({ job, initialUpdatingState = null }) => { + const stateUpdater = React.useState(initialUpdatingState); + const setUpdatingJobAction = stateUpdater[1]; + + const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobActionCallback) => { + setUpdatingJobActionCallback({ jobName: job.Name, action }); + setUpdatingJobAction({ jobName: job.Name, action }); + }; + + return ; + }; + + it("should disable pause action when job is being paused", async () => { + 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); + fireEvent.click(actionButton); + const pauseButtonAfterClick = screen.getByText("Pause"); + expect(pauseButtonAfterClick).toBeInTheDocument(); + }); + + it("should not disable actions for different jobs when one is updating", () => { + const job1 = createMockJob({ Name: "Job1", Status: CopyJobStatusType.InProgress }); + const job2 = createMockJob({ Name: "Job2", Status: CopyJobStatusType.InProgress }); + + const { rerender } = render(); + let actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + fireEvent.click(screen.getByText("Pause")); + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + 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, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + fireEvent.click(actionButton); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: undefined as any, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should handle null mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: null as any, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should handle empty string mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: "", + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should return all base items for unknown status", () => { + const job = createMockJob({ Status: "UnknownStatus" as any }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Resume")).toBeInTheDocument(); + }); + }); + + describe("Icon and Accessibility", () => { + it("should have correct icon and accessibility attributes", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + render(); + const actionButton = screen.getByRole("button", { name: "Actions" }); + + expect(actionButton).toHaveAttribute("aria-label", "Actions"); + expect(actionButton).toHaveAttribute("title", "Actions"); + + const moreIcon = actionButton.querySelector('[data-icon-name="More"]'); + expect(moreIcon || actionButton).toBeInTheDocument(); + }); + + it("should have correct menu item icons", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + render(); + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + }); + + describe("Component State Management", () => { + it("should manage updating job action state correctly", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const mockHandleClickWithState: HandleJobActionClickType = jest.fn((job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockHandleClickWithState).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); + }); + + it("should handle rapid successive clicks properly", () => { + 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); + + fireEvent.click(actionButton); + const pauseButton2 = screen.getByText("Pause"); + fireEvent.click(pauseButton2); + + fireEvent.click(actionButton); + const pauseButton3 = screen.getByText("Pause"); + fireEvent.click(pauseButton3); + + expect(mockHandleClick).toHaveBeenCalledTimes(3); + }); + }); + + describe("Integration Tests", () => { + it("should work correctly with different job names", () => { + const jobWithLongName = createMockJob({ + Name: "Very Long Job Name That Might Cause UI Issues", + Status: CopyJobStatusType.InProgress, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockHandleClick).toHaveBeenCalledWith(jobWithLongName, CopyJobActions.pause, expect.any(Function)); + }); + + it("should handle special characters in job names", () => { + const jobWithSpecialChars = createMockJob({ + Name: "Job-Name_With$pecial#Characters!@", + Status: CopyJobStatusType.Paused, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const resumeButton = screen.getByText("Resume"); + fireEvent.click(resumeButton); + + expect(mockHandleClick).toHaveBeenCalledWith(jobWithSpecialChars, CopyJobActions.resume, expect.any(Function)); + }); + + it("should maintain consistent behavior across re-renders", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { rerender } = render(); + + let actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + fireEvent.click(actionButton); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should handle prop changes correctly", () => { + const job1 = createMockJob({ Status: CopyJobStatusType.InProgress }); + const job2 = createMockJob({ Status: CopyJobStatusType.Paused }); + + const { rerender } = render(); + + let actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + }); + }); + + describe("Performance and Memory", () => { + it("should not create memory leaks with multiple renders", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { unmount } = render(); + expect(() => unmount()).not.toThrow(); + }); + + it("should handle null/undefined props gracefully", () => { + const incompleteJob = { + ...createMockJob({ Status: CopyJobStatusType.InProgress }), + Name: undefined as any, + }; + + expect(() => { + render(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx new file mode 100644 index 000000000..0dc436e15 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx @@ -0,0 +1,449 @@ +import { IColumn } from "@fluentui/react"; +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import { getColumns } from "./CopyJobColumns"; + +jest.mock("./CopyJobActionMenu", () => { + const MockCopyJobActionMenu = ({ job }: { job: CopyJobType }) => { + return
Action Menu
; + }; + MockCopyJobActionMenu.displayName = "MockCopyJobActionMenu"; + return MockCopyJobActionMenu; +}); + +jest.mock("./CopyJobStatusWithIcon", () => { + const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => { + return
Status: {status}
; + }; + MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon"; + return MockCopyJobStatusWithIcon; +}); + +describe("CopyJobColumns", () => { + type OnColumnClickType = IColumn & { onColumnClick: () => void }; + const mockHandleSort = jest.fn(); + const mockHandleActionClick: HandleJobActionClickType = jest.fn(); + + const mockJob = { + ID: "test-job-id", + Mode: "Online", + Name: "Test Job Name", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 75, + Duration: "00:05:30", + LastUpdatedTime: "2024-12-01T10:30:00Z", + timestamp: 1701426600000, + Source: { + databaseName: "test-source-db", + containerName: "test-source-container", + component: "CosmosDBSql", + }, + Destination: { + databaseName: "test-dest-db", + containerName: "test-dest-container", + component: "CosmosDBSql", + }, + } as CopyJobType; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getColumns", () => { + it("should return an array of IColumn objects", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns).toBeDefined(); + expect(Array.isArray(columns)).toBe(true); + expect(columns.length).toBe(6); + + columns.forEach((column: IColumn) => { + expect(column).toHaveProperty("key"); + expect(column).toHaveProperty("name"); + expect(column).toHaveProperty("minWidth"); + expect(column).toHaveProperty("maxWidth"); + expect(column).toHaveProperty("isResizable"); + }); + }); + + it("should have correct column keys", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const expectedKeys = ["LastUpdatedTime", "Name", "Mode", "CompletionPercentage", "CopyJobStatus", "Actions"]; + const actualKeys = columns.map((column) => column.key); + + expect(actualKeys).toEqual(expectedKeys); + }); + + it("should have correct column names from ContainerCopyMessages", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime); + expect(columns[1].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.name); + expect(columns[2].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.mode); + expect(columns[3].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.completionPercentage); + expect(columns[4].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.status); + expect(columns[5].name).toBe(""); + }); + + it("should configure sortable columns correctly when no sort is applied", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].isSorted).toBe(false); // LastUpdatedTime + expect(columns[1].isSorted).toBe(false); // Name + expect(columns[2].isSorted).toBe(false); // Mode + expect(columns[3].isSorted).toBe(false); // CompletionPercentage + expect(columns[4].isSorted).toBe(false); // CopyJobStatus + }); + + it("should configure sorted column correctly when sort is applied", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", true); + + expect(columns[1].isSorted).toBe(true); + expect(columns[1].isSortedDescending).toBe(true); + + expect(columns[0].isSorted).toBe(false); + expect(columns[2].isSorted).toBe(false); + expect(columns[3].isSorted).toBe(false); + expect(columns[4].isSorted).toBe(false); + }); + + it("should handle timestamp sorting for LastUpdatedTime column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "timestamp", false); + + expect(columns[0].isSorted).toBe(true); + expect(columns[0].isSortedDescending).toBe(false); + }); + + it("should call handleSort with correct column keys when column headers are clicked", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + (columns[0] as OnColumnClickType).onColumnClick?.(); + expect(mockHandleSort).toHaveBeenCalledWith("timestamp"); + + (columns[1] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Name"); + + (columns[2] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Mode"); + + (columns[3] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("CompletionPercentage"); + + (columns[4] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Status"); + + expect(mockHandleSort).toHaveBeenCalledTimes(5); + }); + + it("should have correct column widths and resizability", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].minWidth).toBe(140); // LastUpdatedTime + expect(columns[0].maxWidth).toBe(300); + expect(columns[0].isResizable).toBe(true); + + expect(columns[1].minWidth).toBe(140); // Name + expect(columns[1].maxWidth).toBe(300); + expect(columns[1].isResizable).toBe(true); + + expect(columns[2].minWidth).toBe(90); // Mode + expect(columns[2].maxWidth).toBe(200); + expect(columns[2].isResizable).toBe(true); + + expect(columns[3].minWidth).toBe(110); // CompletionPercentage + expect(columns[3].maxWidth).toBe(200); + expect(columns[3].isResizable).toBe(true); + + expect(columns[4].minWidth).toBe(130); // CopyJobStatus + expect(columns[4].maxWidth).toBe(200); + expect(columns[4].isResizable).toBe(true); + + expect(columns[5].minWidth).toBe(80); // Actions + expect(columns[5].maxWidth).toBe(200); + expect(columns[5].isResizable).toBe(true); + }); + }); + + describe("Column Render Functions", () => { + let columns: IColumn[]; + + beforeEach(() => { + columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + }); + + describe("Name column render function", () => { + it("should render job name with correct styling", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.onRender).toBeDefined(); + + const rendered = nameColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent("Test Job Name"); + }); + + it("should handle empty job name", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + const jobWithEmptyName = { ...mockJob, Name: "" }; + + const rendered = nameColumn?.onRender?.(jobWithEmptyName); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent(""); + }); + + it("should handle special characters in job name", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + const jobWithSpecialName = { ...mockJob, Name: "Test & 'Name' \"With\" Special Characters" }; + + const rendered = nameColumn?.onRender?.(jobWithSpecialName); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent("Test & 'Name' \"With\" Special Characters"); + }); + }); + + describe("CompletionPercentage column render function", () => { + it("should render completion percentage with % symbol", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(completionColumn?.onRender).toBeDefined(); + + const result = completionColumn?.onRender?.(mockJob); + expect(result).toBe("75%"); + }); + + it("should handle 0% completion", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithZeroCompletion = { ...mockJob, CompletionPercentage: 0 }; + + const result = completionColumn?.onRender?.(jobWithZeroCompletion); + expect(result).toBe("0%"); + }); + + it("should handle 100% completion", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithFullCompletion = { ...mockJob, CompletionPercentage: 100 }; + + const result = completionColumn?.onRender?.(jobWithFullCompletion); + expect(result).toBe("100%"); + }); + + it("should handle decimal completion percentages", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithDecimalCompletion = { ...mockJob, CompletionPercentage: 75.5 }; + + const result = completionColumn?.onRender?.(jobWithDecimalCompletion); + expect(result).toBe("75.5%"); + }); + + it("should handle negative completion percentages", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithNegativeCompletion = { ...mockJob, CompletionPercentage: -5 }; + + const result = completionColumn?.onRender?.(jobWithNegativeCompletion); + expect(result).toBe("-5%"); + }); + }); + + describe("CopyJobStatus column render function", () => { + it("should render CopyJobStatusWithIcon component", () => { + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + expect(statusColumn?.onRender).toBeDefined(); + + const rendered = statusColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const statusIcon = container.querySelector(`[data-testid="status-icon-${mockJob.Status}"]`); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon).toHaveTextContent(`Status: ${mockJob.Status}`); + }); + + it("should handle different job statuses", () => { + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + + Object.values(CopyJobStatusType).forEach((status) => { + const jobWithStatus = { ...mockJob, Status: status }; + const rendered = statusColumn?.onRender?.(jobWithStatus); + const { container } = render(
{rendered}
); + + const statusIcon = container.querySelector(`[data-testid="status-icon-${status}"]`); + expect(statusIcon).toBeInTheDocument(); + }); + }); + }); + + describe("Actions column render function", () => { + it("should render CopyJobActionMenu component", () => { + const actionsColumn = columns.find((col) => col.key === "Actions"); + expect(actionsColumn?.onRender).toBeDefined(); + + const rendered = actionsColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const actionMenu = container.querySelector(`[data-testid="action-menu-${mockJob.Name}"]`); + expect(actionMenu).toBeInTheDocument(); + expect(actionMenu).toHaveTextContent("Action Menu"); + }); + + it("should pass correct props to CopyJobActionMenu", () => { + const actionsColumn = columns.find((col) => col.key === "Actions"); + const rendered = actionsColumn?.onRender?.(mockJob); + + expect(rendered).toBeDefined(); + expect(React.isValidElement(rendered)).toBe(true); + }); + }); + }); + + describe("Column Field Names", () => { + it("should have correct fieldName properties", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].fieldName).toBe("LastUpdatedTime"); + expect(columns[1].fieldName).toBe("Name"); + expect(columns[2].fieldName).toBe("Mode"); + expect(columns[3].fieldName).toBe("CompletionPercentage"); + expect(columns[4].fieldName).toBe("Status"); + expect(columns[5].fieldName).toBeUndefined(); // Actions column doesn't have fieldName + }); + }); + + describe("Different Sort Configurations", () => { + it("should handle ascending sort", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.isSorted).toBe(true); + expect(nameColumn?.isSortedDescending).toBe(false); + }); + + it("should handle descending sort", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Mode", true); + + const modeColumn = columns.find((col) => col.key === "Mode"); + expect(modeColumn?.isSorted).toBe(true); + expect(modeColumn?.isSortedDescending).toBe(true); + }); + + it("should handle sort on CompletionPercentage column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "CompletionPercentage", false); + + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(completionColumn?.isSorted).toBe(true); + expect(completionColumn?.isSortedDescending).toBe(false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.isSorted).toBe(false); + }); + + it("should handle sort on Status column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Status", true); + + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + expect(statusColumn?.isSorted).toBe(true); + expect(statusColumn?.isSortedDescending).toBe(true); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined sortedColumnKey", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const sortableColumns = columns.filter((col) => col.key !== "Actions"); + sortableColumns.forEach((column) => { + expect(column.isSorted).toBe(false); + }); + }); + + it("should handle null job object in render functions gracefully", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(() => { + nameColumn?.onRender?.(null as any); + }).toThrow(); + + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(() => { + completionColumn?.onRender?.(null as any); + }).toThrow(); + }); + + it("should handle job object with missing properties", () => { + const incompleteJob = { + Name: "Incomplete Job", + } as CopyJobType; + + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const nameColumn = columns.find((col) => col.key === "Name"); + const rendered = nameColumn?.onRender?.(incompleteJob); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toHaveTextContent("Incomplete Job"); + }); + + it("should handle unknown sortedColumnKey", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "UnknownColumn", false); + + const sortableColumns = columns.filter((col) => col.key !== "Actions"); + sortableColumns.forEach((column) => { + expect(column.isSorted).toBe(false); + }); + }); + }); + + describe("Accessibility", () => { + it("should have Actions column without name for accessibility", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const actionsColumn = columns.find((col) => col.key === "Actions"); + expect(actionsColumn?.name).toBe(""); + }); + + it("should maintain column structure for screen readers", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const columnsWithNames = columns.filter((col) => col.key !== "Actions"); + columnsWithNames.forEach((column) => { + expect(column.name).toBeTruthy(); + expect(typeof column.name).toBe("string"); + expect(column.name.length).toBeGreaterThan(0); + }); + }); + }); + + describe("Function References", () => { + it("should maintain function reference stability", () => { + const columns1 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + const columns2 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + (columns1[0] as OnColumnClickType).onColumnClick?.(); + (columns2[0] as OnColumnClickType).onColumnClick?.(); + + expect(mockHandleSort).toHaveBeenCalledTimes(2); + expect(mockHandleSort).toHaveBeenNthCalledWith(1, "timestamp"); + expect(mockHandleSort).toHaveBeenNthCalledWith(2, "timestamp"); + }); + + it("should call handleActionClick when action menu is rendered", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + const actionsColumn = columns.find((col) => col.key === "Actions"); + + const rendered = actionsColumn?.onRender?.(mockJob); + expect(React.isValidElement(rendered)).toBe(true); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx new file mode 100644 index 000000000..1aa57ebca --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx @@ -0,0 +1,383 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType } from "../../Types/CopyJobTypes"; +import CopyJobDetails from "./CopyJobDetails"; + +jest.mock("./CopyJobStatusWithIcon", () => { + const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => { + return {status}; + }; + MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon"; + return MockCopyJobStatusWithIcon; +}); + +jest.mock("../../ContainerCopyMessages", () => ({ + errorTitle: "Error Details", + sourceDatabaseLabel: "Source Database", + sourceContainerLabel: "Source Container", + targetDatabaseLabel: "Destination Database", + targetContainerLabel: "Destination Container", + sourceAccountLabel: "Source Account", + MonitorJobs: { + Columns: { + lastUpdatedTime: "Date & time", + status: "Status", + mode: "Mode", + }, + }, +})); + +describe("CopyJobDetails", () => { + const mockBasicJob: CopyJobType = { + ID: "test-job-1", + Mode: "Offline", + Name: "test-job-1", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "10 minutes", + LastUpdatedTime: "2024-01-01T10:00:00Z", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + databaseName: "sourceDb", + containerName: "sourceContainer", + remoteAccountName: "sourceAccount", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + remoteAccountName: "targetAccount", + }, + }; + + const mockJobWithError: CopyJobType = { + ...mockBasicJob, + ID: "test-job-error", + Status: CopyJobStatusType.Failed, + Error: { + message: "Failed to connect to source database", + code: "CONNECTION_ERROR", + }, + }; + + const mockJobWithNullValues: CopyJobType = { + ...mockBasicJob, + ID: "test-job-null", + Source: { + component: "CosmosDBSql", + databaseName: undefined, + containerName: undefined, + remoteAccountName: undefined, + }, + Destination: { + component: "CosmosDBSql", + databaseName: undefined, + containerName: undefined, + remoteAccountName: undefined, + }, + }; + + describe("Basic Rendering", () => { + it("renders the component with correct structure", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass("copyJobDetailsContainer"); + }); + + it("displays job details without error when no error exists", () => { + render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument(); + }); + + it("renders all required job information fields", () => { + render(); + + expect(screen.getByText("Date & time")).toBeInTheDocument(); + expect(screen.getByText("2024-01-01T10:00:00Z")).toBeInTheDocument(); + + expect(screen.getByText("Source Account")).toBeInTheDocument(); + expect(screen.getByText("sourceAccount")).toBeInTheDocument(); + + expect(screen.getByText("Mode")).toBeInTheDocument(); + expect(screen.getByText("Offline")).toBeInTheDocument(); + }); + + it("renders the DetailsList with correct job data", () => { + render(); + + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + + expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress"); + }); + }); + + describe("Error Handling", () => { + it("displays error section when job has error", () => { + render(); + + const errorStack = screen.getByTestId("error-stack"); + expect(errorStack).toBeInTheDocument(); + + expect(screen.getByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Failed to connect to source database")).toBeInTheDocument(); + }); + + it("does not display error section when job has no error", () => { + render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + expect(screen.queryByText("Error Details")).not.toBeInTheDocument(); + }); + }); + + describe("Null/Undefined Value Handling", () => { + it("displays 'N/A' for null or undefined source values", () => { + render(); + + const nATexts = screen.getAllByText("N/A"); + expect(nATexts).toHaveLength(4); + }); + + it("handles null remote account name gracefully", () => { + render(); + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + }); + + it("handles empty status gracefully", () => { + const jobWithEmptyStatus: CopyJobType = { + ...mockBasicJob, + Status: "" as CopyJobStatusType, + }; + + render(); + + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent(""); + }); + }); + + describe("Different Job Statuses", () => { + const statusTestCases = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Running, + CopyJobStatusType.Paused, + CopyJobStatusType.Completed, + CopyJobStatusType.Failed, + CopyJobStatusType.Cancelled, + ]; + + statusTestCases.forEach((status) => { + it(`renders correctly for ${status} status`, () => { + const jobWithStatus: CopyJobType = { + ...mockBasicJob, + Status: status, + }; + + render(); + + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent(status); + }); + }); + }); + + describe("Component Memoization", () => { + it("re-renders when job ID changes", () => { + render(); + + expect(screen.getByText(CopyJobStatusType.InProgress)).toBeInTheDocument(); + + const updatedJob: CopyJobType = { + ...mockBasicJob, + Status: CopyJobStatusType.Completed, + }; + + render(); + + expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument(); + }); + + it("re-renders when error changes", () => { + const { rerender } = render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId("error-stack")).toBeInTheDocument(); + }); + + it("does not re-render when other props change but ID and Error stay same", () => { + const jobWithSameIdAndError = { + ...mockBasicJob, + Mode: "Online", + CompletionPercentage: 75, + }; + + const { rerender } = render(); + rerender(); + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + }); + }); + + describe("Data Transformation", () => { + it("correctly transforms job data for DetailsList items", () => { + render(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress"); + }); + + it("handles complex job data structure", () => { + const complexJob: CopyJobType = { + ...mockBasicJob, + Source: { + component: "CosmosDBSql", + databaseName: "complex-source-db-with-hyphens", + containerName: "complex_source_container_with_underscores", + remoteAccountName: "complex.source.account", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "complex-target-db-with-hyphens", + containerName: "complex_target_container_with_underscores", + remoteAccountName: "complex.target.account", + }, + }; + + render(); + + expect(screen.getByText("complex-source-db-with-hyphens")).toBeInTheDocument(); + expect(screen.getByText("complex_source_container_with_underscores")).toBeInTheDocument(); + expect(screen.getByText("complex-target-db-with-hyphens")).toBeInTheDocument(); + expect(screen.getByText("complex_target_container_with_underscores")).toBeInTheDocument(); + expect(screen.getByText("complex.source.account")).toBeInTheDocument(); + }); + }); + + describe("DetailsList Configuration", () => { + it("configures DetailsList with correct layout mode", () => { + render(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + }); + + it("renders all expected column data", () => { + render(); + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has proper data-testid attributes", () => { + render(); + + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + expect(screen.getByTestId("error-stack")).toBeInTheDocument(); + expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument(); + }); + + it("renders semantic HTML structure", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toBeInTheDocument(); + + const nestedStack = screen.getByTestId("selectedcollection-stack"); + expect(nestedStack).toBeInTheDocument(); + }); + }); + + describe("CSS and Styling", () => { + it("applies correct CSS classes", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toHaveClass("copyJobDetailsContainer"); + }); + + it("applies correct styling to error text", () => { + render(); + + const errorText = screen.getByText("Failed to connect to source database"); + expect(errorText).toHaveStyle({ whiteSpace: "pre-wrap" }); + }); + + it("applies bold styling to heading texts", () => { + render(); + + const dateTimeHeading = screen.getByText("Date & time"); + const sourceAccountHeading = screen.getByText("Source Account"); + const modeHeading = screen.getByText("Mode"); + + expect(dateTimeHeading).toHaveClass("bold"); + expect(sourceAccountHeading).toHaveClass("bold"); + expect(modeHeading).toHaveClass("bold"); + }); + }); + + describe("Edge Cases", () => { + it("handles job with minimal required data", () => { + const minimalJob = { + ID: "minimal", + Mode: "", + Name: "", + Status: CopyJobStatusType.Pending, + CompletionPercentage: 0, + Duration: "", + LastUpdatedTime: "", + timestamp: 0, + Source: { + component: "CosmosDBSql", + }, + Destination: { + component: "CosmosDBSql", + }, + } as CopyJobType; + + render(); + + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + expect(screen.getAllByText("N/A")).toHaveLength(4); + }); + + it("handles very long text values", () => { + const longTextJob: CopyJobType = { + ...mockBasicJob, + Source: { + ...mockBasicJob.Source, + databaseName: "very-long-database-name-that-might-cause-layout-issues-in-the-ui-component", + containerName: "very-long-container-name-that-might-cause-layout-issues-in-the-ui-component", + remoteAccountName: "very-long-account-name-that-might-cause-layout-issues-in-the-ui-component", + }, + Error: { + message: + "This is a very long error message that contains multiple sentences and might span several lines when displayed in the user interface. It should handle line breaks and maintain readability even with extensive content.", + code: "LONG_ERROR", + }, + }; + + render(); + + expect( + screen.getByText("very-long-database-name-that-might-cause-layout-issues-in-the-ui-component"), + ).toBeInTheDocument(); + expect(screen.getByText(/This is a very long error message/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx new file mode 100644 index 000000000..1c4528e68 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx @@ -0,0 +1,162 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon"; + +jest.mock("@fluentui/react", () => ({ + ...jest.requireActual("@fluentui/react"), + getTheme: () => ({ + semanticColors: { + bodySubtext: "#666666", + errorIcon: "#d13438", + successIcon: "#107c10", + }, + palette: { + themePrimary: "#0078d4", + }, + }), + mergeStyles: () => "mocked-styles", + mergeStyleSets: (styleSet: any) => { + const result: any = {}; + Object.keys(styleSet).forEach((key) => { + result[key] = "mocked-style-" + key; + }); + return result; + }, +})); + +describe("CopyJobStatusWithIcon", () => { + describe("Static Icon Status Types - Snapshot Tests", () => { + const staticIconStatuses = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Paused, + CopyJobStatusType.Skipped, + CopyJobStatusType.Cancelled, + CopyJobStatusType.Failed, + CopyJobStatusType.Faulted, + CopyJobStatusType.Completed, + ]; + + test.each(staticIconStatuses)("renders %s status correctly", (status) => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("Spinner Status Types", () => { + const spinnerStatuses = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning]; + + test.each(spinnerStatuses)("renders %s with spinner and expected text", (status) => { + const { container } = render(); + + const spinner = container.querySelector('[class*="ms-Spinner"]'); + expect(spinner).toBeInTheDocument(); + expect(container).toHaveTextContent("In Progress"); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("PropTypes Validation", () => { + it("has correct display name", () => { + expect(CopyJobStatusWithIcon.displayName).toBe("CopyJobStatusWithIcon"); + }); + it("accepts all valid CopyJobStatusType values", () => { + const allStatuses = Object.values(CopyJobStatusType); + + allStatuses.forEach((status) => { + expect(() => { + render(); + }).not.toThrow(); + }); + }); + }); + + describe("Accessibility", () => { + it("provides proper aria-label for icon elements", () => { + const { container } = render(); + + const icon = container.querySelector('[class*="ms-Icon"]'); + expect(icon).toHaveAttribute("aria-label", CopyJobStatusType.Failed); + }); + + it("provides meaningful text content for screen readers", () => { + const { container } = render(); + + expect(container).toHaveTextContent("In Progress"); + }); + }); + + describe("Icon and Status Mapping", () => { + it("renders correct status text based on mapping", () => { + const statusMappings = [ + { status: CopyJobStatusType.Pending, expectedText: "Pending" }, + { status: CopyJobStatusType.Paused, expectedText: "Paused" }, + { status: CopyJobStatusType.Failed, expectedText: "Failed" }, + { status: CopyJobStatusType.Completed, expectedText: "Completed" }, + { status: CopyJobStatusType.Running, expectedText: "In Progress" }, + ]; + + statusMappings.forEach(({ status, expectedText }) => { + const { container, unmount } = render(); + expect(container).toHaveTextContent(expectedText); + unmount(); + }); + }); + + it("renders icons for static status types", () => { + const staticStatuses = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Paused, + CopyJobStatusType.Failed, + CopyJobStatusType.Completed, + ]; + + staticStatuses.forEach((status) => { + const { container, unmount } = render(); + const icon = container.querySelector('[class*="ms-Icon"]'); + const spinner = container.querySelector('[class*="ms-Spinner"]'); + + expect(icon).toBeInTheDocument(); + expect(spinner).not.toBeInTheDocument(); + + unmount(); + }); + }); + + it("renders spinners for progress status types", () => { + const progressStatuses = [ + CopyJobStatusType.Running, + CopyJobStatusType.InProgress, + CopyJobStatusType.Partitioning, + ]; + + progressStatuses.forEach((status) => { + const { container, unmount } = render(); + const icon = container.querySelector('[class*="ms-Icon"]'); + const spinner = container.querySelector('[class*="ms-Spinner"]'); + + expect(spinner).toBeInTheDocument(); + expect(icon).not.toBeInTheDocument(); + + unmount(); + }); + }); + }); + + describe("Performance", () => { + it("does not cause unnecessary re-renders with same props", () => { + const renderSpy = jest.fn(); + const TestWrapper = ({ status }: { status: CopyJobStatusType }) => { + renderSpy(); + return ; + }; + + const { rerender } = render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + + rerender(); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx new file mode 100644 index 000000000..d81f64289 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx @@ -0,0 +1,73 @@ +jest.mock("../../Actions/CopyJobActions"); + +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import * as Actions from "../../Actions/CopyJobActions"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import CopyJobsNotFound from "./CopyJobs.NotFound"; + +describe("CopyJobsNotFound", () => { + let mockExplorer: Explorer; + + beforeEach(() => { + mockExplorer = {} as Explorer; + jest.clearAllMocks(); + }); + + it("should render the component with correct elements", () => { + const { container, getByText } = render(); + + const image = container.querySelector(".notFoundContainer .ms-Image"); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute("style", "width: 100px; height: 100px;"); + expect(getByText(ContainerCopyMessages.noCopyJobsTitle)).toBeInTheDocument(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("createCopyJobButton"); + }); + + it("should render with correct container classes", () => { + const { container } = render(); + + const notFoundContainer = container.querySelector(".notFoundContainer"); + expect(notFoundContainer).toBeInTheDocument(); + expect(notFoundContainer).toHaveClass("flexContainer", "centerContent"); + }); + + it("should call openCreateCopyJobPanel when button is clicked", () => { + const openCreateCopyJobPanelSpy = jest.spyOn(Actions, "openCreateCopyJobPanel"); + + render(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + + fireEvent.click(button); + + expect(openCreateCopyJobPanelSpy).toHaveBeenCalledTimes(1); + expect(openCreateCopyJobPanelSpy).toHaveBeenCalledWith(mockExplorer); + }); + + it("should render ActionButton with correct props", () => { + render(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe(ContainerCopyMessages.createCopyJobButtonText); + }); + + it("should use memo to prevent unnecessary re-renders", () => { + const { rerender } = render(); + rerender(); + expect(screen.getByRole("heading", { level: 4 })).toBeInTheDocument(); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx index d9ad5bfa0..3c9658d62 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -17,7 +17,7 @@ const CopyJobsNotFound: React.FC = ({ explorer }) => { Actions.openCreateCopyJobPanel(explorer)} > {ContainerCopyMessages.createCopyJobButtonText} diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx new file mode 100644 index 000000000..c3b723265 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx @@ -0,0 +1,446 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import CopyJobsList from "./CopyJobsList"; + +jest.mock("../../Actions/CopyJobActions", () => ({ + openCopyJobDetailsPanel: jest.fn(), +})); + +jest.mock("./CopyJobColumns", () => ({ + getColumns: jest.fn(() => [ + { + key: "Name", + name: "Name", + fieldName: "Name", + minWidth: 140, + maxWidth: 300, + isResizable: true, + onRender: (job: CopyJobType) => {job.Name}, + }, + { + key: "Status", + name: "Status", + fieldName: "Status", + minWidth: 130, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => {job.Status}, + }, + { + key: "CompletionPercentage", + name: "Progress", + fieldName: "CompletionPercentage", + minWidth: 110, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => {job.CompletionPercentage}%, + }, + { + key: "Actions", + name: "Actions", + minWidth: 80, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => , + }, + ]), +})); + +// Sample test data +const mockJobs: CopyJobType[] = [ + { + ID: "job-1", + Mode: "Live", + Name: "Test Job 1", + Status: CopyJobStatusType.Running, + CompletionPercentage: 45, + Duration: "00:05:30", + LastUpdatedTime: "2025-01-01 10:00:00", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account", + databaseName: "sourceDb", + containerName: "sourceContainer", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + }, + }, + { + ID: "job-2", + Mode: "Offline", + Name: "Test Job 2", + Status: CopyJobStatusType.Completed, + CompletionPercentage: 100, + Duration: "00:15:45", + LastUpdatedTime: "2025-01-01 11:00:00", + timestamp: 1704114000000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account-2", + databaseName: "sourceDb2", + containerName: "sourceContainer2", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb2", + containerName: "targetContainer2", + }, + }, + { + ID: "job-3", + Mode: "Live", + Name: "Test Job 3", + Status: CopyJobStatusType.Failed, + CompletionPercentage: 25, + Duration: "00:02:15", + LastUpdatedTime: "2025-01-01 09:30:00", + timestamp: 1704108600000, + Error: { + message: "Connection timeout", + code: "TIMEOUT_ERROR", + }, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account-3", + databaseName: "sourceDb3", + containerName: "sourceContainer3", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb3", + containerName: "targetContainer3", + }, + }, +]; + +const mockHandleActionClick: HandleJobActionClickType = jest.fn(); + +describe("CopyJobsList", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("renders empty list when no jobs provided", () => { + render(); + + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + }); + + it("renders jobs list with provided jobs", () => { + render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + + it("renders job statuses correctly", () => { + render(); + + expect(screen.getByText(CopyJobStatusType.Running)).toBeInTheDocument(); + expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument(); + expect(screen.getByText(CopyJobStatusType.Failed)).toBeInTheDocument(); + }); + + it("renders completion percentages correctly", () => { + render(); + + expect(screen.getByText("45%")).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); + }); + + it("renders action menus for each job", () => { + render(); + + expect(screen.getByTestId("action-menu-job-1")).toBeInTheDocument(); + expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument(); + expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument(); + }); + }); + + describe("Pagination", () => { + it("shows pager when jobs exceed page size", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to first page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to last page")).toBeInTheDocument(); + }); + + it("does not show pager when jobs are within page size", () => { + render(); + + expect(screen.queryByLabelText("Go to first page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to previous page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to last page")).not.toBeInTheDocument(); + }); + + it("displays correct page information", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + + it("navigates to next page correctly", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 10")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 11")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.getByText("Test Job 11")).toBeInTheDocument(); + expect(screen.getByText("Test Job 15")).toBeInTheDocument(); + }); + }); + + it("uses custom page size when provided", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 8 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 5 of 8 items")).toBeInTheDocument(); + }); + }); + + describe("Sorting", () => { + it("sorts jobs by name in ascending order", async () => { + const unsortedJobs = [ + { ...mockJobs[0], Name: "Z Job" }, + { ...mockJobs[1], Name: "A Job" }, + { ...mockJobs[2], Name: "M Job" }, + ]; + + render(); + + const rows = screen.getAllByText(/Job$/); + expect(rows[0]).toHaveTextContent("Z Job"); + expect(rows[1]).toHaveTextContent("A Job"); + expect(rows[2]).toHaveTextContent("M Job"); + }); + + it("resets pagination to first page after sorting", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Job ${String.fromCharCode(90 - i)}`, + })); + + render(); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument(); + }); + }); + + it("updates jobs list when jobs prop changes", async () => { + const { rerender } = render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + + it("resets start index when jobs change", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument(); + }); + + const newJobs = [mockJobs[0], mockJobs[1]]; + rerender(); + + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + }); + }); + + describe("Row Interactions", () => { + it("calls openCopyJobDetailsPanel when row is clicked", async () => { + const { openCopyJobDetailsPanel } = await import("../../Actions/CopyJobActions"); + + render(); + + const jobNameElement = screen.getByText("Test Job 1"); + const rowElement = jobNameElement.closest('[role="row"]') || jobNameElement.closest("div"); + + if (rowElement) { + fireEvent.click(rowElement); + } else { + fireEvent.click(jobNameElement); + } + + await waitFor(() => { + expect(openCopyJobDetailsPanel).toHaveBeenCalledWith(mockJobs[0]); + }); + }); + + it("applies cursor pointer style to rows", () => { + render(); + + const jobNameElement = screen.getByText("Test Job 1"); + const rowElement = jobNameElement.closest("div"); + + expect(rowElement).toBeInTheDocument(); + }); + }); + + describe("Component Props", () => { + it("uses default page size when not provided", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument(); + }); + + it("passes correct props to getColumns function", async () => { + const { getColumns } = await import("./CopyJobColumns"); + + render(); + + expect(getColumns).toHaveBeenCalledWith( + expect.any(Function), // handleSort + mockHandleActionClick, // handleActionClick + undefined, // sortedColumnKey + false, // isSortedDescending + ); + }); + }); + + describe("Accessibility", () => { + it("renders with proper ARIA attributes", () => { + render(); + + const detailsList = screen.getByRole("grid"); + expect(detailsList).toBeInTheDocument(); + }); + + it("has accessible pager controls", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to first page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to last page")).toBeInTheDocument(); + }); + }); + + describe("Error Handling", () => { + it("handles empty jobs array gracefully", () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it("handles jobs with missing optional properties", () => { + const incompleteJob: CopyJobType = { + ID: "incomplete-job", + Mode: "Live", + Name: "Incomplete Job", + Status: CopyJobStatusType.Running, + CompletionPercentage: 0, + Duration: "00:00:00", + LastUpdatedTime: "2025-01-01 12:00:00", + timestamp: 1704117600000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account", + databaseName: "sourceDb", + containerName: "sourceContainer", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + }, + }; + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText("Incomplete Job")).toBeInTheDocument(); + }); + + it("handles very large job lists", () => { + const largeJobsList: CopyJobType[] = Array.from({ length: 1000 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Job ${i + 1}`, + })); + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap new file mode 100644 index 000000000..003766a26 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spinner and expected text 1`] = ` +
+
+
+
+ + In Progress + +
+`; + +exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with spinner and expected text 1`] = ` +
+
+
+
+ + In Progress + +
+`; + +exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner and expected text 1`] = ` +
+
+
+
+ + In Progress + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Cancelled status correctly 1`] = ` +
+ +  + + + Cancelled + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Completed status correctly 1`] = ` +
+ +  + + + Completed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Failed status correctly 1`] = ` +
+ +  + + + Failed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Faulted status correctly 1`] = ` +
+ +  + + + Failed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Paused status correctly 1`] = ` +
+ +  + + + Paused + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Pending status correctly 1`] = ` +
+ +  + + + Pending + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Skipped status correctly 1`] = ` +
+ +  + + + Cancelled + +
+`; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx new file mode 100644 index 000000000..afdb419df --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx @@ -0,0 +1,122 @@ +import { MonitorCopyJobsRefState } from "./MonitorCopyJobRefState"; +import { MonitorCopyJobsRef } from "./MonitorCopyJobs"; + +describe("MonitorCopyJobsRefState", () => { + beforeEach(() => { + MonitorCopyJobsRefState.setState({ ref: null }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize with null ref", () => { + const state = MonitorCopyJobsRefState.getState(); + expect(state.ref).toBeNull(); + }); + + it("should set ref using setRef", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + const state = MonitorCopyJobsRefState.getState(); + state.setRef(mockRef); + + const updatedState = MonitorCopyJobsRefState.getState(); + expect(updatedState.ref).toBe(mockRef); + expect(updatedState.ref).toEqual(mockRef); + }); + + it("should allow setting ref to null", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + expect(MonitorCopyJobsRefState.getState().ref).toBe(mockRef); + + MonitorCopyJobsRefState.getState().setRef(null); + expect(MonitorCopyJobsRefState.getState().ref).toBeNull(); + }); + + it("should call refreshJobList method on the stored ref", () => { + const mockRefreshJobList = jest.fn(); + const mockRef: MonitorCopyJobsRef = { + refreshJobList: mockRefreshJobList, + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + + const state = MonitorCopyJobsRefState.getState(); + state.ref?.refreshJobList(); + + expect(mockRefreshJobList).toHaveBeenCalledTimes(1); + }); + + it("should handle calling refreshJobList when ref is null", () => { + MonitorCopyJobsRefState.setState({ ref: null }); + + const state = MonitorCopyJobsRefState.getState(); + expect(state.ref).toBeNull(); + + expect(() => { + state.ref?.refreshJobList(); + }).not.toThrow(); + }); + + it("should allow partial state updates", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.setState({ ref: mockRef }); + const state1 = MonitorCopyJobsRefState.getState(); + expect(state1.ref).toBe(mockRef); + expect(state1.setRef).toBeDefined(); + + const newMockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + MonitorCopyJobsRefState.setState({ ref: newMockRef }); + const state2 = MonitorCopyJobsRefState.getState(); + expect(state2.ref).toBe(newMockRef); + expect(state2.setRef).toBeDefined(); + }); + + it("should handle multiple subscribers", () => { + const mockSubscriber1 = jest.fn(); + const mockSubscriber2 = jest.fn(); + + const unsubscribe1 = MonitorCopyJobsRefState.subscribe(mockSubscriber1); + const unsubscribe2 = MonitorCopyJobsRefState.subscribe(mockSubscriber2); + + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + + expect(mockSubscriber1).toHaveBeenCalled(); + expect(mockSubscriber2).toHaveBeenCalled(); + + unsubscribe1(); + unsubscribe2(); + }); + + it("should not notify unsubscribed listeners", () => { + const mockSubscriber = jest.fn(); + + const unsubscribe = MonitorCopyJobsRefState.subscribe(mockSubscriber); + unsubscribe(); + + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + mockSubscriber.mockClear(); + MonitorCopyJobsRefState.getState().setRef(mockRef); + + expect(mockSubscriber).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx new file mode 100644 index 000000000..a59ebf687 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx @@ -0,0 +1,435 @@ +import "@testing-library/jest-dom"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types"; +import Explorer from "../../Explorer"; +import * as CopyJobActions from "../Actions/CopyJobActions"; +import { CopyJobStatusType } from "../Enums/CopyJobEnums"; +import { CopyJobType } from "../Types/CopyJobTypes"; +import MonitorCopyJobs from "./MonitorCopyJobs"; + +jest.mock("Common/ShimmerTree/ShimmerTree", () => { + const MockShimmerTree = () => { + return
Loading...
; + }; + MockShimmerTree.displayName = "MockShimmerTree"; + return MockShimmerTree; +}); + +jest.mock("./Components/CopyJobsList", () => { + const MockCopyJobsList = ({ jobs }: any) => { + return
Jobs: {jobs.length}
; + }; + MockCopyJobsList.displayName = "MockCopyJobsList"; + return MockCopyJobsList; +}); + +jest.mock("./Components/CopyJobs.NotFound", () => { + const MockCopyJobsNotFound = () => { + return
No jobs found
; + }; + MockCopyJobsNotFound.displayName = "MockCopyJobsNotFound"; + return MockCopyJobsNotFound; +}); + +jest.mock("../Actions/CopyJobActions", () => ({ + getCopyJobs: jest.fn(), + updateCopyJobStatus: jest.fn(), +})); + +describe("MonitorCopyJobs", () => { + let mockExplorer: Explorer; + const mockGetCopyJobs = CopyJobActions.getCopyJobs as jest.MockedFunction; + const mockUpdateCopyJobStatus = CopyJobActions.updateCopyJobStatus as jest.MockedFunction< + typeof CopyJobActions.updateCopyJobStatus + >; + + const mockJobs: CopyJobType[] = [ + { + ID: "1", + Mode: "Offline", + Name: "test-job-1", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "10 minutes", + LastUpdatedTime: "1/1/2024, 10:00:00 AM", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + databaseName: "db1", + containerName: "container1", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "db2", + containerName: "container2", + }, + }, + { + ID: "2", + Mode: "Online", + Name: "test-job-2", + Status: CopyJobStatusType.Completed, + CompletionPercentage: 100, + Duration: "20 minutes", + LastUpdatedTime: "1/1/2024, 11:00:00 AM", + timestamp: 1704114000000, + Source: { + component: "CosmosDBSql", + databaseName: "db3", + containerName: "container3", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "db4", + containerName: "container4", + }, + }, + ]; + + beforeEach(() => { + mockExplorer = {} as Explorer; + mockGetCopyJobs.mockResolvedValue(mockJobs); + mockUpdateCopyJobStatus.mockResolvedValue({ + id: "test-id", + type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs", + properties: { + jobName: "test-job-1", + status: "Paused", + lastUpdatedUtcTime: "2024-01-01T10:00:00Z", + processedCount: 500, + totalCount: 1000, + mode: "Offline", + duration: "00:10:00", + source: { + databaseName: "db1", + containerName: "container1", + component: "CosmosDBSql", + }, + destination: { + databaseName: "db2", + containerName: "container2", + component: "CosmosDBSql", + }, + error: { + message: "", + code: "", + }, + }, + } as DataTransferJobGetResults); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Initial Rendering", () => { + it("renders the component with correct structure", async () => { + render(); + + const container = document.querySelector(".monitorCopyJobs"); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass("flexContainer"); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + }); + + it("displays shimmer while loading initially", () => { + render(); + + expect(screen.getByTestId("shimmer-tree")).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("fetches jobs on mount", async () => { + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("Job List Display", () => { + it("displays job list when jobs are loaded", async () => { + render(); + + await waitFor( + () => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + expect(screen.getByText("Jobs: 2")).toBeInTheDocument(); + expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument(); + }); + + it("displays not found component when no jobs exist", async () => { + mockGetCopyJobs.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-jobs-list")).not.toBeInTheDocument(); + }); + + it("passes correct jobs to CopyJobsList component", async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + expect(screen.getByText("Jobs: 2")).toBeInTheDocument(); + }); + + it("updates job status when action is triggered", async () => { + const ref = React.createRef(); + render(); + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + expect(mockJobs[0].Status).toBe(CopyJobStatusType.InProgress); + }); + }); + + describe("Error Handling", () => { + it("displays error message when fetch fails", async () => { + const errorMessage = "Failed to load copy jobs. Please try again later."; + mockGetCopyJobs.mockRejectedValue(new Error(errorMessage)); + + render(); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument(); + }); + + it("allows dismissing error message", async () => { + mockGetCopyJobs.mockRejectedValue(new Error("Failed to load copy jobs")); + const { container } = render(); + await waitFor(() => { + expect(screen.getByText(/Failed to load copy jobs/)).toBeInTheDocument(); + }); + + const dismissButton = container.querySelector('[aria-label="Close"]'); + if (dismissButton) { + dismissButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + await waitFor(() => { + expect(screen.queryByText(/Failed to load copy jobs/)).not.toBeInTheDocument(); + }); + }); + + it("displays custom error message from getCopyJobs", async () => { + const customError = { message: "Custom error occurred" }; + mockGetCopyJobs.mockRejectedValue(customError); + + render(); + + await waitFor(() => { + expect(screen.getByText("Custom error occurred")).toBeInTheDocument(); + }); + }); + + it("displays error when job action update fails", async () => { + mockUpdateCopyJobStatus.mockRejectedValue(new Error("Update failed")); + + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + + const mockHandleActionClick = jest.fn(async (job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + await mockUpdateCopyJobStatus(job, action); + }); + + await expect(mockHandleActionClick(mockJobs[0], "pause", jest.fn())).rejects.toThrow("Update failed"); + }); + }); + + describe("Polling and Refresh", () => { + it.skip("polls for jobs at regular intervals", async () => { + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + act(() => { + jest.advanceTimersByTime(30000); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + + act(() => { + jest.advanceTimersByTime(30000); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(3); + }); + }); + + it("stops polling when component unmounts", async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + unmount(); + + act(() => { + jest.advanceTimersByTime(60000); + }); + + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + it("refreshes job list via ref", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + act(() => { + ref.current?.refreshJobList(); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + }); + + it("prevents refresh when update is in progress", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + + mockUpdateCopyJobStatus.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + id: "test-id", + type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs", + properties: { + jobName: "test-job-1", + status: "Paused", + lastUpdatedUtcTime: "2024-01-01T10:00:00Z", + processedCount: 500, + totalCount: 1000, + mode: "Offline", + duration: "00:10:00", + source: { + databaseName: "db1", + collectionName: "container1", + component: "CosmosDBSql", + }, + destination: { + databaseName: "db2", + collectionName: "container2", + component: "CosmosDBSql", + }, + error: { + message: "", + code: "", + }, + }, + } as DataTransferJobGetResults), + 5000, + ), + ), + ); + + expect(ref.current).toHaveProperty("refreshJobList"); + expect(typeof ref.current.refreshJobList).toBe("function"); + }); + }); + + describe("Edge Cases", () => { + it("handles empty job array", async () => { + mockGetCopyJobs.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + }); + + it("handles null response from getCopyJobs gracefully", async () => { + mockGetCopyJobs.mockResolvedValue(null as any); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + }); + + it("handles explorer prop correctly", () => { + const { rerender } = render(); + + const newExplorer = {} as Explorer; + rerender(); + + expect(document.querySelector(".monitorCopyJobs")).toBeInTheDocument(); + }); + }); + + describe("Ref Handle", () => { + it("exposes refreshJobList method through ref", () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeDefined(); + expect(ref.current).toHaveProperty("refreshJobList"); + expect(typeof ref.current.refreshJobList).toBe("function"); + }); + + it("refreshJobList triggers getCopyJobs", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + ref.current?.refreshJobList(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe("Action Callback", () => { + it("provides handleActionClick callback to CopyJobsList", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index 1e38dc18a..fc4085189 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -39,8 +39,9 @@ const MonitorCopyJobs = forwardRef(({ setError(null); const response = await getCopyJobs(); + const normalizedResponse = response || []; setJobs((prevJobs) => { - return isEqual(prevJobs, response) ? prevJobs : response; + return isEqual(prevJobs, normalizedResponse) ? prevJobs : normalizedResponse; }); } catch (error) { setError(error.message || "Failed to load copy jobs. Please try again later."); @@ -97,29 +98,27 @@ const MonitorCopyJobs = forwardRef(({ [], ); - const renderJobsList = () => { - if (loading) { - return null; - } - if (jobs.length > 0) { - return ; - } - return ; - }; - return ( {loading && ( )} {error && ( - setError(null)}> + setError(null)} + dismissButtonAriaLabel="Close" + > {error} )} - {renderJobsList()} + {!loading && jobs.length > 0 && } + {!loading && jobs.length === 0 && } ); }); +MonitorCopyJobs.displayName = "MonitorCopyJobs"; + export default MonitorCopyJobs;