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("Previous copy job request was cancelled."), }); }); 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(); }); }); });