瀏覽代碼

Add unit tests and test coverage report

The test coverage is automatically generated when running `npm run
test`. A summary of the report is displayed in the console, and a more
detailed report is generated in the `coverage` folder.
Sergiu Miclea 2 年之前
父節點
當前提交
e610d98acf
共有 100 個文件被更改,包括 6246 次插入1823 次删除
  1. 4 0
      .gitignore
  2. 23 9
      jest.config.ts
  3. 2 2
      package.json
  4. 140 3
      src/components/modules/DashboardModule/DashboardBarChart/DashboardBarChart.spec.tsx
  5. 131 0
      src/components/modules/DashboardModule/DashboardContent/DashboardContent.spec.tsx
  6. 252 0
      src/components/modules/DashboardModule/DashboardExecutions/DashboardExecutions.spec.tsx
  7. 76 0
      src/components/modules/DashboardModule/DashboardInfoCount/DashboardInfoCount.spec.tsx
  8. 181 0
      src/components/modules/DashboardModule/DashboardLicence/DashboardLicence.spec.tsx
  9. 1 1
      src/components/modules/DashboardModule/DashboardLicence/DashboardLicence.tsx
  10. 295 0
      src/components/modules/DashboardModule/DashboardPieChart/DashboardPieChart.spec.tsx
  11. 1 1
      src/components/modules/DashboardModule/DashboardPieChart/DashboardPieChart.tsx
  12. 227 0
      src/components/modules/DashboardModule/DashboardTopEndpoints/DashboardTopEndpoints.spec.tsx
  13. 1 1
      src/components/modules/DashboardModule/DashboardTopEndpoints/DashboardTopEndpoints.tsx
  14. 122 0
      src/components/modules/DetailsModule/DetailsContentHeader/DetailsContentHeader.spec.tsx
  15. 0 82
      src/components/modules/DetailsModule/DetailsContentHeader/test.tsx
  16. 151 0
      src/components/modules/DetailsModule/DetailsPageHeader/DetailsPageHeader.spec.tsx
  17. 0 4
      src/components/modules/DetailsModule/DetailsPageHeader/DetailsPageHeader.tsx
  18. 0 56
      src/components/modules/DetailsModule/DetailsPageHeader/test.tsx
  19. 383 0
      src/components/modules/EndpointModule/ChooseProvider/ChooseProvider.spec.tsx
  20. 288 0
      src/components/modules/EndpointModule/ChooseProvider/MultipleUploadedEndpoints.spec.tsx
  21. 1 1
      src/components/modules/EndpointModule/ChooseProvider/MultipleUploadedEndpoints.tsx
  22. 0 55
      src/components/modules/EndpointModule/ChooseProvider/test.tsx
  23. 334 0
      src/components/modules/EndpointModule/EndpointDetailsContent/EndpointDetailsContent.spec.tsx
  24. 0 120
      src/components/modules/EndpointModule/EndpointDetailsContent/test.tsx
  25. 93 0
      src/components/modules/EndpointModule/EndpointDuplicateOptions/EndpointDuplicateOptions.spec.tsx
  26. 0 75
      src/components/modules/EndpointModule/EndpointDuplicateOptions/test.tsx
  27. 91 0
      src/components/modules/EndpointModule/EndpointListItem/EndpointListItem.spec.tsx
  28. 0 58
      src/components/modules/EndpointModule/EndpointListItem/test.tsx
  29. 58 0
      src/components/modules/EndpointModule/EndpointLogos/EndpointLogos.spec.tsx
  30. 89 0
      src/components/modules/EndpointModule/EndpointLogos/resources/Generic.spec.tsx
  31. 0 42
      src/components/modules/EndpointModule/EndpointLogos/test.tsx
  32. 117 0
      src/components/modules/EndpointModule/EndpointValidation/EndpointValidation.spec.tsx
  33. 0 53
      src/components/modules/EndpointModule/EndpointValidation/test.tsx
  34. 167 0
      src/components/modules/LicenceModule/LicenceModule.spec.tsx
  35. 93 0
      src/components/modules/LoginModule/LoginForm/LoginForm.spec.tsx
  36. 1 1
      src/components/modules/LoginModule/LoginForm/LoginForm.tsx
  37. 0 54
      src/components/modules/LoginModule/LoginForm/test.tsx
  38. 0 36
      src/components/modules/LoginModule/LoginFormField/test.tsx
  39. 59 0
      src/components/modules/LoginModule/LoginOptions/LoginOptions.spec.tsx
  40. 1 1
      src/components/modules/LoginModule/LoginOptions/LoginOptions.tsx
  41. 0 57
      src/components/modules/LoginModule/LoginOptions/test.tsx
  42. 39 0
      src/components/modules/MetalHubModule/MetalHubListHeader/MetalHubListHeader.spec.tsx
  43. 65 0
      src/components/modules/MetalHubModule/MetalHubListItem/MetalHubListItem.spec.tsx
  44. 126 0
      src/components/modules/MetalHubModule/MetalHubModal/MetalHubModal.spec.tsx
  45. 11 10
      src/components/modules/MetalHubModule/MetalHubModal/MetalHubModal.tsx
  46. 101 0
      src/components/modules/MetalHubModule/MetalHubServerDetailsContent/MetalHubServerDetailsContent.spec.tsx
  47. 92 0
      src/components/modules/MinionModule/MinionEndpointModal/MinionEndpointModal.spec.tsx
  48. 1 1
      src/components/modules/MinionModule/MinionEndpointModal/MinionEndpointModal.tsx
  49. 75 0
      src/components/modules/MinionModule/MinionPoolConfirmationModal/MinionPoolConfirmationModal.spec.tsx
  50. 102 0
      src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolDetailsContent.spec.tsx
  51. 137 0
      src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolEvents.spec.tsx
  52. 0 11
      src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolEvents.tsx
  53. 106 0
      src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolMachines.spec.tsx
  54. 74 0
      src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolMainDetails.spec.tsx
  55. 39 0
      src/components/modules/MinionModule/MinionPoolListItem/MinionPoolListItem.spec.tsx
  56. 113 0
      src/components/modules/MinionModule/MinionPoolModal/MinionPoolModalContent.spec.tsx
  57. 0 55
      src/components/modules/NavigationModule/DetailsNavigation/test.tsx
  58. 1 1
      src/components/modules/NavigationModule/Navigation/Navigation.tsx
  59. 42 0
      src/components/modules/NavigationModule/NavigationMini/NavigationMini.spec.tsx
  60. 0 2
      src/components/modules/NavigationModule/NavigationMini/NavigationMini.tsx
  61. 197 0
      src/components/modules/ProjectModule/ProjectDetailsContent/ProjectDetailsContent.spec.tsx
  62. 7 13
      src/components/modules/ProjectModule/ProjectDetailsContent/ProjectDetailsContent.tsx
  63. 0 83
      src/components/modules/ProjectModule/ProjectDetailsContent/test.tsx
  64. 55 0
      src/components/modules/ProjectModule/ProjectListItem/ProjectListItem.spec.tsx
  65. 0 81
      src/components/modules/ProjectModule/ProjectListItem/test.tsx
  66. 0 151
      src/components/modules/ProjectModule/ProjectMemberModal/test.tsx
  67. 0 75
      src/components/modules/ProjectModule/ProjectModal/test.tsx
  68. 83 0
      src/components/modules/SetupModule/SetupPageEmailBody/SetupPageEmailBody.spec.tsx
  69. 12 21
      src/components/modules/SetupModule/SetupPageEmailBody/SetupPageEmailBody.tsx
  70. 8 11
      src/components/modules/SetupModule/SetupPageHelp/SetupPageHelp.spec.tsx
  71. 2 2
      src/components/modules/SetupModule/SetupPageHelp/SetupPageHelp.tsx
  72. 162 0
      src/components/modules/SetupModule/SetupPageLegal/SetupPageLegal.spec.tsx
  73. 99 0
      src/components/modules/SetupModule/SetupPageLicence/SetupPageLicence.spec.tsx
  74. 18 19
      src/components/modules/SetupModule/SetupPageModuleWrapper/SetupPageModuleWrapper.spec.tsx
  75. 14 12
      src/components/modules/SetupModule/SetupPageWelcome/SetupPageWelcome.spec.tsx
  76. 37 0
      src/components/modules/SetupModule/ui/SetupPageBackButton/SetupPageBackButton.spec.tsx
  77. 50 0
      src/components/modules/SetupModule/ui/SetupPagePasswordStrength/SetupPagePasswordStrength.spec.tsx
  78. 38 0
      src/components/modules/TemplateModule/DetailsTemplate/DetailsTemplate.spec.tsx
  79. 1 1
      src/components/modules/TemplateModule/DetailsTemplate/DetailsTemplate.tsx
  80. 30 0
      src/components/modules/TemplateModule/EmptyTemplate/EmptyTemplate.spec.tsx
  81. 34 0
      src/components/modules/TemplateModule/MainTemplate/MainTemplate.spec.tsx
  82. 32 0
      src/components/modules/TemplateModule/WizardTemplate/WizardTemplate.spec.tsx
  83. 59 0
      src/components/modules/TransferModule/DeleteReplicaModal/DeleteReplicaModal.spec.tsx
  84. 3 4
      src/components/modules/TransferModule/DeleteReplicaModal/DeleteReplicaModal.tsx
  85. 230 0
      src/components/modules/TransferModule/Executions/Executions.spec.tsx
  86. 2 6
      src/components/modules/TransferModule/Executions/Executions.tsx
  87. 0 111
      src/components/modules/TransferModule/Executions/test.tsx
  88. 105 0
      src/components/modules/TransferModule/MainDetails/MainDetails.spec.tsx
  89. 0 80
      src/components/modules/TransferModule/MainDetails/test.tsx
  90. 74 0
      src/components/modules/TransferModule/MigrationDetailsContent/MigrationDetailsContent.spec.tsx
  91. 6 7
      src/components/modules/TransferModule/MigrationDetailsContent/MigrationDetailsContent.tsx
  92. 0 77
      src/components/modules/TransferModule/MigrationDetailsContent/test.tsx
  93. 131 0
      src/components/modules/TransferModule/ReplicaDetailsContent/ReplicaDetailsContent.spec.tsx
  94. 9 9
      src/components/modules/TransferModule/ReplicaDetailsContent/ReplicaDetailsContent.tsx
  95. 0 134
      src/components/modules/TransferModule/ReplicaDetailsContent/test.tsx
  96. 75 0
      src/components/modules/TransferModule/ReplicaExecutionOptions/ReplicaExecutionOptions.spec.tsx
  97. 0 70
      src/components/modules/TransferModule/ReplicaExecutionOptions/test.tsx
  98. 154 0
      src/components/modules/TransferModule/ReplicaMigrationOptions/ReplicaMigrationOptions.spec.tsx
  99. 13 14
      src/components/modules/TransferModule/ReplicaMigrationOptions/ReplicaMigrationOptions.tsx
  100. 0 50
      src/components/modules/TransferModule/ReplicaMigrationOptions/test.tsx

+ 4 - 0
.gitignore

@@ -20,3 +20,7 @@ cypress/videos
 !.yarn/releases
 !.yarn/sdks
 !.yarn/versions
+
+
+# testing
+coverage

+ 23 - 9
jest.config.ts

@@ -17,10 +17,29 @@ export default {
   clearMocks: true,
 
   // Indicates whether the coverage information should be collected while executing the test
-  // collectCoverage: false,
+  collectCoverage: true,
 
   // An array of glob patterns indicating a set of files for which coverage information should be collected
-  // collectCoverageFrom: undefined,
+  collectCoverageFrom: [
+    "**/*.tsx", // Include all .tsx files
+    "!**/AssessmentModule/**", // Exclude files within the AssessmentModule directory (this is a module that is not used in the app)
+    "!**/story.tsx", // Exclude all storybook files
+    "!**/test.tsx", // Exclude old test files
+    "!**/plugins/**", // Exclude files within the plugins directory
+    "!src/index.tsx", // Exclude the index.tsx file
+    "!**/App.tsx", // Exclude the App.tsx file
+    "!**/smart/**", // Exclude files within the smart directory (this is a directory that contains containers)
+    // other smart components
+    "!**/EndpointModal.tsx",
+    "!**/MinionPoolModal.tsx",
+    "!**/TransferItemModal.tsx",
+    "!**/Navigation.tsx",
+    "!**/NotificationsModule.tsx",
+    "!**/ProjectModal.tsx",
+    "!**/ProjectMemberModal.tsx",
+    "!**/UserModal.tsx",
+    "!**/WizardPageContent.tsx",
+  ],
 
   // The directory where Jest should output its coverage files
   // coverageDirectory: undefined,
@@ -31,15 +50,10 @@ export default {
   // ],
 
   // Indicates which provider should be used to instrument code for coverage
-  coverageProvider: "v8",
+  // coverageProvider: "babel",
 
   // A list of reporter names that Jest uses when writing coverage reports
-  // coverageReporters: [
-  //   "json",
-  //   "text",
-  //   "lcov",
-  //   "clover"
-  // ],
+  coverageReporters: ["html", "text-summary"],
 
   // An object that configures minimum threshold enforcement for coverage results
   // coverageThreshold: undefined,

+ 2 - 2
package.json

@@ -18,7 +18,6 @@
     "test": "jest",
     "e2e": "cypress run",
     "test-release": "node ./tests/testRelease",
-    "test-coverage": "node ./tests/testCoverage",
     "storybook": "start-storybook"
   },
   "devDependencies": {
@@ -31,7 +30,7 @@
     "@types/file-saver": "^2.0.1",
     "@types/jest": "^27.0.2",
     "@types/js-cookie": "^2.2.6",
-    "@types/luxon": "^3.3.2",
+    "@types/luxon": "^3.3.3",
     "@types/moment-timezone": "^0.5.13",
     "@types/react": "^16.13.1",
     "@types/react-collapse": "^5.0.0",
@@ -51,6 +50,7 @@
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-react": "^7.31.7",
     "jest": "^27.3.1",
+    "jest-canvas-mock": "^2.5.2",
     "nodemon": "^2.0.4",
     "prettier": "^2.7.1"
   },

+ 140 - 3
src/components/modules/DashboardModule/DashboardBarChart/DashboardBarChart.spec.tsx

@@ -13,11 +13,13 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import React from "react";
-import { render } from "@testing-library/react";
-import TestUtils from "@tests/TestUtils";
+
 import { ThemePalette } from "@src/components/Theme";
+import { render } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
-import DashboardBarChart from ".";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardBarChart from "./";
 
 const DATA: DashboardBarChart["props"]["data"] = [
   {
@@ -109,4 +111,139 @@ describe("DashboardBarChart", () => {
       );
     }
   );
+
+  it("does not render bars with height of 0%", () => {
+    const ZERO_DATA = [
+      {
+        label: "label 1",
+        values: [0, 0],
+      },
+      {
+        label: "label 2",
+        values: [20, 25],
+      },
+    ];
+
+    render(<DashboardBarChart data={ZERO_DATA} yNumTicks={3} />);
+
+    const firstStackedBars = TestUtils.selectAll(
+      "DashboardBarChart__StackedBar-",
+      TestUtils.selectAll("DashboardBarChart__Bar-")[0]
+    );
+    const secondStackedBars = TestUtils.selectAll(
+      "DashboardBarChart__StackedBar-",
+      TestUtils.selectAll("DashboardBarChart__Bar-")[1]
+    );
+
+    expect(firstStackedBars.length).toBe(0);
+    expect(secondStackedBars.length).toBe(ZERO_DATA[1].values.length);
+  });
+
+  it("renders half the bars if available width is less than 30 times the number of items", () => {
+    const originalInnerWidth = window.innerWidth;
+    Object.defineProperty(window, "innerWidth", {
+      writable: true,
+      configurable: true,
+      value: 29 * DATA.length,
+    });
+
+    render(<DashboardBarChart data={DATA} yNumTicks={3} />);
+
+    const bars = TestUtils.selectAll("DashboardBarChart__Bar-");
+
+    expect(bars.length).toBe(DATA.length / 2);
+
+    Object.defineProperty(window, "innerWidth", {
+      writable: true,
+      configurable: true,
+      value: originalInnerWidth,
+    });
+  });
+
+  it("fires the onBarMouseLeave callback on bar mouse leave", () => {
+    const onBarMouseLeave = jest.fn();
+
+    render(
+      <DashboardBarChart
+        data={DATA}
+        yNumTicks={3}
+        onBarMouseLeave={onBarMouseLeave}
+      />
+    );
+
+    const bar = TestUtils.selectAll("DashboardBarChart__StackedBar-")[0];
+    userEvent.unhover(bar);
+
+    expect(onBarMouseLeave).toHaveBeenCalled();
+  });
+
+  it("calculates the correct position for bars", () => {
+    const onBarMouseEnter = jest.fn();
+    render(
+      <DashboardBarChart
+        data={DATA}
+        yNumTicks={3}
+        onBarMouseEnter={onBarMouseEnter}
+      />
+    );
+
+    const firstBar = TestUtils.selectAll("DashboardBarChart__StackedBar-")[0];
+    userEvent.hover(firstBar);
+
+    expect(onBarMouseEnter).toHaveBeenCalledWith({ x: 48, y: 65 }, DATA[0]);
+  });
+
+  it("recalculates ticks when new data is received", () => {
+    const { rerender } = render(
+      <DashboardBarChart data={DATA} yNumTicks={3} />
+    );
+
+    const bars = TestUtils.selectAll("DashboardBarChart__Bar-");
+    expect(bars.length).toBe(DATA.length);
+    expect(bars[0].textContent).toBe("label 1");
+    expect(bars[1].textContent).toBe("label 2");
+
+    const NEW_DATA = [
+      {
+        label: "label 3",
+        values: [10, 30],
+        data: "data 3",
+      },
+      {
+        label: "label 4",
+        values: [5, 20],
+        data: "data 4",
+      },
+    ];
+
+    // Mocking the offset width is necessary due to how the rendered
+    // output behaves within the @testing-library/react environment
+    Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+      configurable: true,
+      value: 500,
+    });
+    rerender(<DashboardBarChart data={NEW_DATA} yNumTicks={3} />);
+
+    const newBars = TestUtils.selectAll("DashboardBarChart__Bar-");
+    expect(newBars.length).toBe(NEW_DATA.length);
+    expect(newBars[0].textContent).toBe("label 3");
+    expect(newBars[1].textContent).toBe("label 4");
+  });
+
+  it("does not fire any function when onBarMouseEnter is not provided", () => {
+    render(<DashboardBarChart data={DATA} yNumTicks={3} />);
+
+    const firstStackedBar = TestUtils.selectAll(
+      "DashboardBarChart__StackedBar-"
+    )[0];
+    const spy = jest.spyOn(console, "error").mockImplementation(() => {});
+
+    // Hover over the stacked bar
+    userEvent.hover(firstStackedBar);
+
+    // Assert that there were no console errors
+    expect(spy).not.toHaveBeenCalled();
+
+    spy.mockRestore();
+  });
 });

+ 131 - 0
src/components/modules/DashboardModule/DashboardContent/DashboardContent.spec.tsx

@@ -0,0 +1,131 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { act, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardContent from "./DashboardContent";
+
+jest.mock("react-router-dom", () => ({ Link: "div" }));
+
+describe("DashboardContent", () => {
+  let resizeWindow: (x: number, y: number) => void;
+  let defaultProps: DashboardContent["props"];
+
+  beforeAll(() => {
+    resizeWindow = (x, y) => {
+      window.innerWidth = x;
+      window.innerHeight = y;
+      window.dispatchEvent(new Event("resize"));
+    };
+  });
+
+  beforeEach(() => {
+    defaultProps = {
+      replicas: [],
+      migrations: [],
+      endpoints: [],
+      projects: [],
+      replicasLoading: false,
+      migrationsLoading: false,
+      endpointsLoading: false,
+      usersLoading: false,
+      projectsLoading: false,
+      licenceLoading: false,
+      notificationItemsLoading: false,
+      users: [],
+      licence: null,
+      licenceServerStatus: null,
+      licenceError: null,
+      notificationItems: [],
+      isAdmin: false,
+      onNewReplicaClick: jest.fn(),
+      onNewEndpointClick: jest.fn(),
+      onAddLicenceClick: jest.fn(),
+    };
+  });
+
+  it("renders modules for non-admin users", () => {
+    render(<DashboardContent {...defaultProps} />);
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")
+    ).toHaveLength(3);
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[0].textContent
+    ).toBe("Replicas");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[1].textContent
+    ).toBe("Migrations");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[2].textContent
+    ).toBe("Endpoints");
+  });
+
+  it("renders additional modules for admin users", () => {
+    render(<DashboardContent {...defaultProps} isAdmin />);
+
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")
+    ).toHaveLength(5);
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[0].textContent
+    ).toBe("Replicas");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[1].textContent
+    ).toBe("Migrations");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[2].textContent
+    ).toBe("Endpoints");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[3].textContent
+    ).toBe("Users");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel")[4].textContent
+    ).toBe("Projects");
+  });
+
+  it("switches to mobile layout when window width is less than 1120", () => {
+    resizeWindow(1100, 800);
+    render(<DashboardContent {...defaultProps} />);
+
+    expect(
+      TestUtils.select("DashboardContent__MiddleMobileLayout")
+    ).toBeTruthy();
+  });
+
+  it("handleResize updates state correctly based on window size", async () => {
+    resizeWindow(2400, 800);
+
+    let instance: DashboardContent | null = null;
+
+    const setRef = (componentInstance: DashboardContent) => {
+      instance = componentInstance;
+    };
+
+    render(<DashboardContent ref={setRef} {...defaultProps} />);
+
+    const setStateMock = jest.spyOn(instance!, "setState");
+
+    act(() => {
+      resizeWindow(1000, 800);
+    });
+
+    expect(setStateMock).toHaveBeenCalledWith({ useMobileLayout: true });
+
+    setStateMock.mockRestore();
+    resizeWindow(2400, 800);
+  });
+});

+ 252 - 0
src/components/modules/DashboardModule/DashboardExecutions/DashboardExecutions.spec.tsx

@@ -0,0 +1,252 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import { DateTime } from "luxon";
+import React from "react";
+
+import { MigrationItem, ReplicaItem } from "@src/@types/MainItem";
+import { render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardExecutions from "./DashboardExecutions";
+
+type BuildType<T extends "replica" | "migration"> = T extends "replica"
+  ? ReplicaItem
+  : MigrationItem;
+
+const buildItem = <T extends "replica" | "migration">(
+  type: T,
+  date: string
+): BuildType<T> => {
+  const item = {
+    id: "",
+    type,
+    name: "",
+    created_at: date,
+    updated_at: date,
+    origin_endpoint_id: "",
+    destination_endpoint_id: "",
+    notes: "",
+    origin_minion_pool_id: null,
+    destination_minion_pool_id: null,
+    instances: [""],
+    info: {},
+    destination_environment: {},
+    source_environment: {},
+    transfer_result: null,
+    last_execution_status: "",
+    user_id: "",
+  };
+  return item as BuildType<T>;
+};
+const now = DateTime.utc();
+const TWENTIETH = DateTime.utc(now.year, now.month, 20, 10, 0);
+const replicas: DashboardExecutions["props"]["replicas"] = [
+  buildItem("replica", TWENTIETH.minus({ days: 5 }).toISO()!),
+  buildItem("replica", TWENTIETH.toISO()!),
+];
+
+const migrations: DashboardExecutions["props"]["migrations"] = [
+  buildItem("migration", TWENTIETH.toISO()!),
+  buildItem("migration", TWENTIETH.minus({ months: 2 }).toISO()!),
+];
+
+describe("DashboardExecutions", () => {
+  let defaultProps: DashboardExecutions["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      replicas,
+      migrations,
+      loading: false,
+    };
+  });
+
+  it("shows no recent activity message", () => {
+    const newProps = {
+      ...defaultProps,
+      replicas: [],
+      migrations: [],
+    };
+    render(<DashboardExecutions {...newProps} />);
+
+    expect(TestUtils.select("DashboardExecutions__Title")?.textContent).toBe(
+      "Items Created"
+    );
+    expect(
+      TestUtils.select("DashboardExecutions__NoDataMessage")?.textContent
+    ).toBe("No recent activity in this project");
+  });
+
+  it("groups data correctly", () => {
+    render(<DashboardExecutions {...defaultProps} />);
+    expect(
+      TestUtils.select("DashboardExecutions__BarChartWrapper")
+    ).toBeTruthy();
+    expect(TestUtils.selectAll("DashboardBarChart__Bar-")).toHaveLength(2);
+    expect(
+      TestUtils.selectAll(
+        "DashboardBarChart__StackedBar-",
+        TestUtils.selectAll("DashboardBarChart__Bar-")[0]
+      )
+    ).toHaveLength(1);
+    expect(
+      TestUtils.selectAll(
+        "DashboardBarChart__StackedBar-",
+        TestUtils.selectAll("DashboardBarChart__Bar-")[1]
+      )
+    ).toHaveLength(2);
+    expect(TestUtils.select("DropdownLink__Label")?.textContent).toBe(
+      "Last 30 days"
+    );
+    expect(
+      TestUtils.selectAll("DashboardBarChart__BarLabel")[0].textContent
+    ).toBe(TWENTIETH.minus({ days: 5 }).toFormat("dd LLL"));
+    expect(
+      TestUtils.selectAll("DashboardBarChart__BarLabel")[1].textContent
+    ).toBe(TWENTIETH.toFormat("dd LLL"));
+  });
+
+  it("updates period and regroups data when dropdown is changed", async () => {
+    // Mocking the offset width is necessary due to how the rendered
+    // output behaves within the @testing-library/react environment
+    Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+      configurable: true,
+      value: 500,
+    });
+
+    render(<DashboardExecutions {...defaultProps} />);
+
+    userEvent.click(TestUtils.select("DropdownLink__LinkButton")!);
+    expect(TestUtils.selectAll("DropdownLink__ListItem-")[1].textContent).toBe(
+      "Last 12 months"
+    );
+    userEvent.click(TestUtils.selectAll("DropdownLink__ListItem-")[1]!);
+    expect(TestUtils.select("DropdownLink__Label")?.textContent).toBe(
+      "Last 12 months"
+    );
+    expect(TestUtils.selectAll("DashboardBarChart__Bar-")).toHaveLength(2);
+    expect(
+      TestUtils.selectAll(
+        "DashboardBarChart__StackedBar-",
+        TestUtils.selectAll("DashboardBarChart__Bar-")[0]
+      )
+    ).toHaveLength(1);
+    expect(
+      TestUtils.selectAll(
+        "DashboardBarChart__StackedBar-",
+        TestUtils.selectAll("DashboardBarChart__Bar-")[1]
+      )
+    ).toHaveLength(2);
+    expect(
+      TestUtils.selectAll("DashboardBarChart__BarLabel")[0].textContent
+    ).toBe(TWENTIETH.minus({ months: 2 }).toFormat("LLL"));
+    expect(
+      TestUtils.selectAll("DashboardBarChart__BarLabel")[1].textContent
+    ).toBe(TWENTIETH.toFormat("LLL"));
+  });
+
+  it("shows tooltip correctly on bar hover", () => {
+    // Mocking the offset width is necessary due to how the rendered
+    // output behaves within the @testing-library/react environment
+    Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+      configurable: true,
+      value: 500,
+    });
+    render(<DashboardExecutions {...defaultProps} />);
+
+    userEvent.hover(TestUtils.select("DashboardBarChart__StackedBar-")!);
+
+    expect(TestUtils.select("DashboardExecutions__Tooltip")).toBeTruthy();
+    expect(
+      TestUtils.select("DashboardExecutions__TooltipHeader")?.textContent
+    ).toBe(TWENTIETH.minus({ days: 5 }).toFormat("dd LLLL"));
+    expect(
+      TestUtils.selectAll("DashboardExecutions__TooltipRow-")[0].textContent
+    ).toBe("Created1");
+    expect(
+      TestUtils.selectAll("DashboardExecutions__TooltipRow-")[1].textContent
+    ).toBe("Replicas1");
+    expect(
+      TestUtils.selectAll("DashboardExecutions__TooltipRow-")[2].textContent
+    ).toBe("Migrations0");
+
+    userEvent.unhover(TestUtils.select("DashboardBarChart__StackedBar-")!);
+    expect(TestUtils.select("DashboardExecutions__Tooltip")).toBeFalsy();
+  });
+
+  it("renders correct child based on state and props", () => {
+    // Mocking the offset width is necessary due to how the rendered
+    // output behaves within the @testing-library/react environment
+    Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+      configurable: true,
+      value: 500,
+    });
+    const { rerender } = render(<DashboardExecutions {...defaultProps} />);
+    expect(TestUtils.selectAll("DashboardBarChart__Bar-")).toHaveLength(2);
+
+    const newProps = {
+      ...defaultProps,
+      replicas: [],
+    };
+    rerender(<DashboardExecutions {...newProps} />);
+    expect(TestUtils.selectAll("DashboardBarChart__Bar-")).toHaveLength(1);
+    expect(TestUtils.select("DashboardExecutions__NoDataMessage")).toBeFalsy();
+
+    const newProps2 = {
+      ...defaultProps,
+      migrations: [],
+      replicas: [],
+    };
+    rerender(<DashboardExecutions {...newProps2} />);
+    expect(TestUtils.select("DashboardExecutions__NoDataMessage")).toBeTruthy();
+  });
+
+  it("shows loading state when loading prop is true and there are no replicas", () => {
+    const newProps = {
+      ...defaultProps,
+      loading: true,
+    };
+    const { rerender } = render(<DashboardExecutions {...newProps} />);
+
+    expect(TestUtils.select("DashboardExecutions__LoadingWrapper")).toBeFalsy();
+
+    const newProps2 = {
+      ...defaultProps,
+      loading: true,
+      replicas: [],
+    };
+    rerender(<DashboardExecutions {...newProps2} />);
+
+    expect(
+      TestUtils.select("DashboardExecutions__LoadingWrapper")
+    ).toBeTruthy();
+  });
+
+  it("shows no bar if item is not of type replica or migration", () => {
+    const newProps: DashboardExecutions["props"] = {
+      ...defaultProps,
+      replicas: [
+        {
+          ...replicas[0],
+          // @ts-expect-error
+          type: "invalid",
+        },
+      ],
+    };
+    render(<DashboardExecutions {...newProps} />);
+    expect(TestUtils.selectAll("DashboardBarChart__Bar-")).toHaveLength(1);
+  });
+});

+ 76 - 0
src/components/modules/DashboardModule/DashboardInfoCount/DashboardInfoCount.spec.tsx

@@ -0,0 +1,76 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardInfoCount from "./DashboardInfoCount";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+describe("DashboardInfoCount", () => {
+  const mockData = [
+    {
+      label: "Label1",
+      value: 1,
+      color: "red",
+      link: "/link1",
+      loading: false,
+    },
+    {
+      label: "Label2",
+      value: 0,
+      color: "blue",
+      link: "/link2",
+      loading: true,
+    },
+  ];
+
+  it("renders CountBlock for each data item", () => {
+    render(<DashboardInfoCount data={mockData} />);
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockValue-")[0].textContent
+    ).toBe("1");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel-")[0].textContent
+    ).toBe("Label1");
+    expect(
+      TestUtils.selectAll("DashboardInfoCount__CountBlockLabel-")[1].textContent
+    ).toBe("Label2");
+    // In this case, the value "0" will not be rendered because of the loading state.
+  });
+
+  it("renders loading state when item.loading is true and item.value is falsy", () => {
+    render(<DashboardInfoCount data={mockData} />);
+
+    expect(
+      TestUtils.select(
+        "DashboardInfoCount__LoadingWrapper-",
+        TestUtils.selectAll("DashboardInfoCount__CountBlock-")[1]
+      )
+    ).toBeTruthy();
+  });
+
+  it("renders CountBlockLabel with the correct color and link", () => {
+    render(<DashboardInfoCount data={mockData} />);
+    const labels = TestUtils.selectAll("DashboardInfoCount__CountBlockLabel-");
+
+    expect(window.getComputedStyle(labels[0]).color).toBe("red");
+    expect(window.getComputedStyle(labels[1]).color).toBe("blue");
+    expect(labels[0].attributes.getNamedItem("to")!.value).toBe("/link1");
+    expect(labels[1].attributes.getNamedItem("to")!.value).toBe("/link2");
+  });
+});

+ 181 - 0
src/components/modules/DashboardModule/DashboardLicence/DashboardLicence.spec.tsx

@@ -0,0 +1,181 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import { DateTime } from "luxon";
+import React from "react";
+
+import { Licence, LicenceServerStatus } from "@src/@types/Licence";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardLicence from "./DashboardLicence";
+
+describe("DashboardLicence", () => {
+  const futureLicence: Licence = {
+    applianceId: "test-id",
+    earliestLicenceExpiryDate: DateTime.now().plus({ years: 1 }).toJSDate(),
+    latestLicenceExpiryDate: DateTime.now().plus({ years: 1 }).toJSDate(),
+    currentPerformedReplicas: 5,
+    currentPerformedMigrations: 3,
+    lifetimePerformedMigrations: 4,
+    lifetimePerformedReplicas: 6,
+    currentAvailableReplicas: 10,
+    currentAvailableMigrations: 5,
+    lifetimeAvailableReplicas: 15,
+    lifetimeAvailableMigrations: 10,
+  };
+
+  const status: LicenceServerStatus = {
+    hostname: "test-hostname",
+    multi_appliance: false,
+    supported_licence_versions: ["v2"],
+    server_local_time: DateTime.now().toISO()!,
+  };
+
+  let defaultProps: DashboardLicence["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      licence: futureLicence,
+      licenceServerStatus: status,
+      licenceError: null,
+      loading: false,
+      onAddClick: jest.fn(),
+    };
+  });
+
+  it("renders licence status text when licence and licenceServerStatus are provided and the licence has not expired", () => {
+    render(<DashboardLicence {...defaultProps} />);
+    const futureDate = DateTime.now().plus({ years: 1 });
+    expect(
+      TestUtils.select("DashboardLicence__TopInfoDateTop-")?.textContent
+    ).toBe(`${futureDate.toFormat("LLL")} '${futureDate.toFormat("yy")}`);
+    expect(
+      TestUtils.select("DashboardLicence__TopInfoDateBottom-")?.textContent
+    ).toBe(futureDate.toFormat("dd"));
+
+    expect(
+      TestUtils.selectAll("DashboardLicence__ChartHeaderCurrent-")[0]
+        .textContent
+    ).toBe("5 Used Replicas ");
+    expect(
+      TestUtils.selectAll("DashboardLicence__ChartHeaderTotal-")[0].textContent
+    ).toBe("Total 10");
+    expect(
+      TestUtils.selectAll("DashboardLicence__ChartHeaderCurrent-")[1]
+        .textContent
+    ).toBe("3 Used Migrations ");
+    expect(
+      TestUtils.selectAll("DashboardLicence__ChartHeaderTotal-")[1].textContent
+    ).toBe("Total 5");
+  });
+
+  it("renders licence error when licenceError prop is provided and there's no licence", () => {
+    const newProps = {
+      ...defaultProps,
+      licence: null,
+      licenceError: "An error occurred.",
+    };
+    render(<DashboardLicence {...newProps} />);
+
+    expect(TestUtils.select("DashboardLicence__LicenceError-")).toBeTruthy();
+    expect(
+      TestUtils.select("DashboardLicence__LicenceError-")?.textContent
+    ).toBe("An error occurred.");
+  });
+
+  it("renders expired licence details when licence has expired", () => {
+    const newProps = {
+      ...defaultProps,
+      licence: {
+        ...futureLicence,
+        earliestLicenceExpiryDate: DateTime.now().minus({ days: 2 }).toJSDate(),
+      },
+    };
+
+    render(<DashboardLicence {...newProps} />);
+
+    expect(TestUtils.select("DashboardLicence__LicenceError-")).toBeTruthy();
+    expect(
+      TestUtils.select("DashboardLicence__LicenceError-")?.textContent
+    ).toContain("Please contact Cloudbase Solutions with your Appliance ID");
+    expect(
+      TestUtils.select("DashboardLicence__ApplianceId-")?.textContent
+    ).toBe("Appliance ID:test-id-licencev2");
+    expect(TestUtils.select("Button__")?.textContent).toBe("Add Licence");
+  });
+
+  it("renders loading status when loading prop is true and there's no licence", () => {
+    const newProps = {
+      ...defaultProps,
+      licence: null,
+      loading: true,
+    };
+    render(<DashboardLicence {...newProps} />);
+    expect(TestUtils.select("StatusImage__Wrapper-")).toBeTruthy();
+  });
+
+  it("does not render anything when no props are provided", () => {
+    const newProps = {
+      ...defaultProps,
+      licence: null,
+    };
+    render(<DashboardLicence {...newProps} />);
+    expect(document.body.innerHTML).toBe("<div></div>");
+  });
+
+  it("hides licenceLogoRef if buttonWrapperRef width is less than 370", async () => {
+    const newProps = {
+      ...defaultProps,
+      licence: {
+        ...futureLicence,
+        earliestLicenceExpiryDate: DateTime.now().minus({ days: 2 }).toJSDate(),
+      },
+    };
+    render(<DashboardLicence {...newProps} />);
+
+    const button = TestUtils.select(
+      "DashboardLicence__AddLicenceButtonWrapper"
+    );
+    const logo = TestUtils.select("DashboardLicence__Logo");
+
+    button!.getBoundingClientRect = jest.fn().mockReturnValue({ width: 400 });
+    window.dispatchEvent(new Event("resize"));
+
+    expect(button).toBeTruthy();
+    expect(logo).toBeTruthy();
+    expect(logo!.style.display).toBe("block");
+
+    button!.getBoundingClientRect = jest.fn().mockReturnValue({ width: 360 });
+    window.dispatchEvent(new Event("resize"));
+
+    expect(logo!.style.display).toBe("none");
+  });
+
+  it("renders singular label when current is 1", () => {
+    const newProps = {
+      ...defaultProps,
+      licence: {
+        ...futureLicence,
+        currentPerformedReplicas: 1,
+      },
+    };
+
+    render(<DashboardLicence {...newProps} />);
+    expect(
+      TestUtils.selectAll("DashboardLicence__ChartHeaderCurrent-")[0]
+        .textContent
+    ).toBe("1 Used Replica ");
+  });
+});

+ 1 - 1
src/components/modules/DashboardModule/DashboardLicence/DashboardLicence.tsx

@@ -156,7 +156,7 @@ type Props = {
   licence: Licence | null;
   licenceServerStatus: LicenceServerStatus | null;
   loading: boolean;
-  style: any;
+  style?: React.CSSProperties;
   licenceError: string | null;
   onAddClick: () => void;
 };

+ 295 - 0
src/components/modules/DashboardModule/DashboardPieChart/DashboardPieChart.spec.tsx

@@ -0,0 +1,295 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardPieChart from "./DashboardPieChart";
+
+describe("DashboardPieChart", () => {
+  const fireMouseMove = (
+    element: HTMLElement,
+    options: MouseEventInit & {
+      offsetX?: number;
+      offsetY?: number;
+    } = {}
+  ) => {
+    const mouseMoveEvent = new MouseEvent("mousemove", {
+      bubbles: true,
+      cancelable: true,
+      ...options,
+    });
+    Object.assign(mouseMoveEvent, {
+      offsetX: options.offsetX,
+      offsetY: options.offsetY,
+    });
+    element.dispatchEvent(mouseMoveEvent);
+  };
+
+  it("calls drawChart on mount", () => {
+    const spy = jest.spyOn(DashboardPieChart.prototype, "drawChart");
+    render(<DashboardPieChart size={100} data={[]} colors={[]} />);
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it("adds and removes event listeners on mount/unmount", () => {
+    const spyAdd = jest.spyOn(HTMLCanvasElement.prototype, "addEventListener");
+    const spyRemove = jest.spyOn(
+      HTMLCanvasElement.prototype,
+      "removeEventListener"
+    );
+    const { unmount } = render(
+      <DashboardPieChart size={100} data={[]} colors={[]} />
+    );
+    expect(spyAdd).toHaveBeenCalledWith("mousemove", expect.any(Function));
+    expect(spyAdd).toHaveBeenCalledWith("mouseleave", expect.any(Function));
+    unmount();
+    expect(spyRemove).toHaveBeenCalledWith("mousemove", expect.any(Function));
+    expect(spyRemove).toHaveBeenCalledWith("mouseleave", expect.any(Function));
+  });
+
+  it("handleMouseMove triggers onMouseOver if item is detected", () => {
+    const onMouseOverMock = jest.fn();
+    jest
+      .spyOn(CanvasRenderingContext2D.prototype, "isPointInPath")
+      .mockReturnValue(true);
+
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[{ value: 50 }]}
+        colors={["#FFF"]}
+        onMouseOver={onMouseOverMock}
+      />
+    );
+    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+    fireMouseMove(canvas, { offsetX: 50, offsetY: 50 });
+    expect(onMouseOverMock).toHaveBeenCalled();
+  });
+
+  it("handleMouseMove triggers onMouseLeave if no item is detected and has mouseOver", () => {
+    const onMouseLeaveMock = jest.fn();
+    jest
+      .spyOn(CanvasRenderingContext2D.prototype, "isPointInPath")
+      .mockReturnValue(false);
+
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[{ value: 50 }]}
+        colors={["#FFF"]}
+        onMouseLeave={onMouseLeaveMock}
+        onMouseOver={() => {}}
+      />
+    );
+    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+    fireMouseMove(canvas, { offsetX: 50, offsetY: 50 });
+    expect(onMouseLeaveMock).toHaveBeenCalled();
+  });
+
+  it("doesn't throw if onMouseLeave is not provided", () => {
+    jest
+      .spyOn(CanvasRenderingContext2D.prototype, "isPointInPath")
+      .mockReturnValue(false);
+
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[{ value: 50 }]}
+        colors={["#FFF"]}
+        onMouseOver={() => {}}
+      />
+    );
+    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+    fireEvent.mouseLeave(canvas);
+  });
+
+  it("handleMouseLeave triggers onMouseLeave", () => {
+    const onMouseLeaveMock = jest.fn();
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[]}
+        colors={[]}
+        onMouseLeave={onMouseLeaveMock}
+      />
+    );
+    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+    fireEvent.mouseLeave(canvas);
+    expect(onMouseLeaveMock).toHaveBeenCalled();
+  });
+
+  it("drawChart is called when props are updated", () => {
+    const { rerender } = render(
+      <DashboardPieChart size={100} data={[]} colors={["#FFF"]} />
+    );
+    const spy = jest.spyOn(DashboardPieChart.prototype, "drawChart");
+    rerender(
+      <DashboardPieChart
+        size={200}
+        data={[{ value: 50 }, { value: 100 }]}
+        colors={["#FFF", "#000"]}
+      />
+    );
+    expect(spy).toHaveBeenCalled();
+  });
+
+  it("renders Canvas, OuterShadow, and InnerShadow if holeStyle is provided", () => {
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[]}
+        colors={[]}
+        holeStyle={{ radius: 10, color: "#fff" }}
+      />
+    );
+    expect(document.querySelector("canvas")).toBeTruthy();
+    expect(TestUtils.select("DashboardPieChart__OuterShadow")).toBeTruthy();
+    expect(TestUtils.select("DashboardPieChart__InnerShadow")).toBeTruthy();
+  });
+
+  it("renders Canvas and OuterShadow without InnerShadow if holeStyle is not provided", () => {
+    render(<DashboardPieChart size={100} data={[]} colors={[]} />);
+
+    expect(document.querySelector("canvas")).toBeTruthy();
+    expect(TestUtils.select("DashboardPieChart__OuterShadow")).toBeTruthy();
+    expect(TestUtils.select("DashboardPieChart__InnerShadow")).toBeFalsy();
+  });
+
+  it("does not add event listeners when canvas is null", () => {
+    const spyAdd = jest.spyOn(HTMLCanvasElement.prototype, "addEventListener");
+
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = null;
+    instance.componentDidMount();
+
+    expect(spyAdd).not.toHaveBeenCalled();
+  });
+
+  it("does not remove event listeners when canvas is null", () => {
+    const spyRemove = jest.spyOn(
+      HTMLCanvasElement.prototype,
+      "removeEventListener"
+    );
+
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = null;
+    instance.componentWillUnmount();
+
+    expect(spyRemove).not.toHaveBeenCalled();
+  });
+
+  it("does not attempt to draw on the canvas when canvas is null", () => {
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = null;
+    const ctxSpy = jest.spyOn(HTMLCanvasElement.prototype, "getContext");
+
+    instance.drawChart();
+
+    expect(ctxSpy).not.toHaveBeenCalled();
+  });
+
+  it("does not detect hits when canvas is null", () => {
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = null;
+    const result = instance.detectHit(50, 50);
+    expect(result).toBeNull();
+  });
+
+  it("does not handle mouse move if there's no mouse over", () => {
+    const detectHit = jest.spyOn(DashboardPieChart.prototype, "detectHit");
+
+    render(<DashboardPieChart size={100} data={[]} colors={[]} />);
+    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
+    fireMouseMove(canvas, { offsetX: 50, offsetY: 50 });
+    expect(detectHit).not.toHaveBeenCalled();
+  });
+
+  it("does not evenly divide angles when sum is not 0", () => {
+    const data = [{ value: 50 }, { value: 30 }, { value: 20 }];
+    const component = new DashboardPieChart({
+      size: 100,
+      data,
+      colors: ["#FFF", "#000", "#AAA"],
+    });
+    component.canvas = document.createElement("canvas");
+    component.drawChart();
+    const expectedAngles = [Math.PI, Math.PI * 0.6, Math.PI * 0.4];
+    expect(component.angles).toEqual(expectedAngles);
+  });
+
+  it("evenly divides angles when sum is 0", () => {
+    const data = [{ value: 0 }, { value: 0 }, { value: 0 }];
+    const component = new DashboardPieChart({
+      size: 100,
+      data,
+      colors: ["#FFF", "#000", "#AAA"],
+    });
+    component.canvas = document.createElement("canvas");
+    component.drawChart();
+    const expectedAngles = Array(3).fill(Math.PI * (1 / 3) * 2); // Three items, so each gets 1/3 of the circle.
+    expect(component.angles).toEqual(expectedAngles);
+  });
+
+  it("returns null from detectHit when canvas context is not available", () => {
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = document.createElement("canvas");
+    instance.canvas.getContext = () => null;
+    const result = instance.detectHit(50, 50);
+    expect(result).toBeNull();
+  });
+
+  it("returns from drawChart when canvas context is not available", () => {
+    const beginPatchSpy = jest.spyOn(
+      CanvasRenderingContext2D.prototype,
+      "beginPath"
+    );
+    const instance = new DashboardPieChart({ size: 100, data: [], colors: [] });
+    instance.canvas = document.createElement("canvas");
+    instance.canvas.getContext = () => null;
+    instance.drawChart();
+    expect(beginPatchSpy).not.toHaveBeenCalled();
+  });
+
+  it("checks the hole in detectHit if holeStyle is provided, returning null if point is in path", () => {
+    jest
+      .spyOn(CanvasRenderingContext2D.prototype, "isPointInPath")
+      .mockReturnValue(true);
+    const instance = new DashboardPieChart({
+      size: 100,
+      data: [],
+      colors: [],
+      holeStyle: { radius: 10, color: "#fff" },
+    });
+    instance.canvas = document.createElement("canvas");
+    const result = instance.detectHit(50, 50);
+    expect(result).toBeNull();
+  });
+
+  it("calls customRef when provided", () => {
+    const customRefMock = jest.fn();
+    render(
+      <DashboardPieChart
+        size={100}
+        data={[]}
+        colors={[]}
+        customRef={customRefMock}
+      />
+    );
+    expect(customRefMock).toHaveBeenCalledTimes(1);
+    expect(customRefMock.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
+  });
+});

+ 1 - 1
src/components/modules/DashboardModule/DashboardPieChart/DashboardPieChart.tsx

@@ -58,7 +58,7 @@ type Props = {
 
 @observer
 class DashboardPieChart extends React.Component<Props> {
-  canvas: HTMLCanvasElement | null | undefined;
+  canvas: HTMLCanvasElement | null = null;
 
   angles: number[] = [];
 

+ 227 - 0
src/components/modules/DashboardModule/DashboardTopEndpoints/DashboardTopEndpoints.spec.tsx

@@ -0,0 +1,227 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Endpoint } from "@src/@types/Endpoint";
+import { MigrationItem, ReplicaItem } from "@src/@types/MainItem";
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardTopEndpoints from "./DashboardTopEndpoints";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+type BuildType<T extends "replica" | "migration"> = T extends "replica"
+  ? ReplicaItem
+  : MigrationItem;
+
+const buildItem = <T extends "replica" | "migration">(
+  type: T,
+  origin_endpoint_id: string,
+  destination_endpoint_id: string
+): BuildType<T> => {
+  const item = {
+    id: "",
+    type,
+    name: "",
+    created_at: new Date().toISOString(),
+    updated_at: new Date().toISOString(),
+    origin_endpoint_id,
+    destination_endpoint_id,
+    notes: "",
+    origin_minion_pool_id: null,
+    destination_minion_pool_id: null,
+    instances: [""],
+    info: {},
+    destination_environment: {},
+    source_environment: {},
+    transfer_result: null,
+    last_execution_status: "",
+    user_id: "",
+  };
+  return item as BuildType<T>;
+};
+
+const buildEndpoint = (id: string): Endpoint => ({
+  id,
+  name: `${id}-name`,
+  description: "",
+  type: "openstack",
+  created_at: new Date().toISOString(),
+  mapped_regions: [],
+  connection_info: {},
+});
+
+const replicas: DashboardTopEndpoints["props"]["replicas"] = [
+  buildItem("replica", "a", "b"),
+  buildItem("replica", "a", "b"),
+  buildItem("replica", "c", "d"),
+];
+
+const migrations: DashboardTopEndpoints["props"]["migrations"] = [
+  buildItem("migration", "e", "f"),
+  buildItem("migration", "e", "f"),
+  buildItem("migration", "e", "f"),
+];
+const endpoints: DashboardTopEndpoints["props"]["endpoints"] = [
+  buildEndpoint("a"),
+  buildEndpoint("b"),
+  buildEndpoint("c"),
+  buildEndpoint("d"),
+  buildEndpoint("e"),
+  buildEndpoint("f"),
+];
+
+describe("DashboardTopEndpoints", () => {
+  const defaultProps: DashboardTopEndpoints["props"] = {
+    replicas,
+    migrations,
+    endpoints,
+    style: {},
+    loading: false,
+    onNewClick: jest.fn(),
+  };
+
+  it("should display a loading state", () => {
+    render(
+      <DashboardTopEndpoints
+        {...defaultProps}
+        replicas={[]}
+        migrations={[]}
+        endpoints={[]}
+        loading={true}
+      />
+    );
+    expect(TestUtils.select("StatusImage__Image")).toBeTruthy();
+  });
+
+  it("should display no data message", () => {
+    render(
+      <DashboardTopEndpoints
+        {...defaultProps}
+        replicas={[]}
+        migrations={[]}
+        endpoints={[]}
+      />
+    );
+    expect(TestUtils.select("DashboardTopEndpoints__NoItems")).toBeTruthy();
+  });
+
+  it("should trigger onNewClick when New Endpoint button is clicked", () => {
+    const onNewClickMock = jest.fn();
+    render(
+      <DashboardTopEndpoints
+        {...defaultProps}
+        onNewClick={onNewClickMock}
+        replicas={[]}
+        migrations={[]}
+        endpoints={[]}
+      />
+    );
+
+    fireEvent.click(
+      TestUtils.select("DashboardTopEndpoints__NoItems")?.querySelector(
+        "button"
+      )!
+    );
+    expect(onNewClickMock).toHaveBeenCalledTimes(1);
+  });
+
+  it("should display the chart with data", () => {
+    render(<DashboardTopEndpoints {...defaultProps} />);
+
+    expect(
+      TestUtils.select("DashboardTopEndpoints__ChartWrapper")
+    ).toBeTruthy();
+    expect(
+      TestUtils.selectAll(
+        "DashboardTopEndpoints__LegendLabel-"
+      )[0].attributes.getNamedItem("to")?.value
+    ).toBe("/endpoints/e");
+
+    expect(
+      TestUtils.selectAll("DashboardTopEndpoints__LegendLabel-")[1].textContent
+    ).toBe("f-name");
+  });
+
+  it("should call calculateGroupedEndpoints when component receives new props", () => {
+    const calculateGroupedEndpointsSpy = jest.spyOn(
+      DashboardTopEndpoints.prototype,
+      "calculateGroupedEndpoints"
+    );
+
+    const { rerender } = render(<DashboardTopEndpoints {...defaultProps} />);
+
+    const newProps = {
+      ...defaultProps,
+      replicas: [],
+      migrations: [],
+      endpoints: [],
+    };
+    rerender(<DashboardTopEndpoints {...newProps} />);
+
+    expect(calculateGroupedEndpointsSpy).toHaveBeenCalledWith(newProps);
+    expect(calculateGroupedEndpointsSpy).toHaveBeenCalledTimes(2);
+  });
+
+  it("should handle mouse over and update state", () => {
+    const setStateSpy = jest
+      .spyOn(DashboardTopEndpoints.prototype, "setState")
+      .mockImplementationOnce(() => {});
+
+    const instance = new DashboardTopEndpoints(defaultProps);
+    instance.chartRef = document.createElement("div");
+    const groupedEndpoint = {
+      endpoint: endpoints[0],
+      replicasCount: 1,
+      migrationsCount: 2,
+      value: 3,
+    };
+    instance.handleMouseOver(groupedEndpoint, 10, 10);
+    expect(setStateSpy).toHaveBeenCalledWith({
+      groupedEndpoint,
+      tooltipPosition: { x: 26, y: -22 },
+    });
+  });
+
+  it("should handle mouse over and not update state if there's no chartRef", () => {
+    const setStateSpy = jest
+      .spyOn(DashboardTopEndpoints.prototype, "setState")
+      .mockImplementationOnce(() => {});
+
+    const instance = new DashboardTopEndpoints(defaultProps);
+    instance.chartRef = null;
+    const groupedEndpoint = {
+      endpoint: endpoints[0],
+      replicasCount: 1,
+      migrationsCount: 2,
+      value: 3,
+    };
+    instance.handleMouseOver(groupedEndpoint, 10, 10);
+    expect(setStateSpy).not.toHaveBeenCalled();
+  });
+
+  it("should handle mouse leave and update state", () => {
+    const setStateSpy = jest
+      .spyOn(DashboardTopEndpoints.prototype, "setState")
+      .mockImplementationOnce(() => {});
+
+    const instance = new DashboardTopEndpoints(defaultProps);
+    instance.handleMouseLeave();
+    expect(setStateSpy).toHaveBeenCalledWith({
+      groupedEndpoint: null,
+    });
+  });
+});

+ 1 - 1
src/components/modules/DashboardModule/DashboardTopEndpoints/DashboardTopEndpoints.tsx

@@ -141,7 +141,7 @@ type Props = {
   migrations: MigrationItem[];
   // eslint-disable-next-line react/no-unused-prop-types
   endpoints: Endpoint[];
-  style: any;
+  style: React.CSSProperties;
   loading: boolean;
   onNewClick: () => void;
 };

+ 122 - 0
src/components/modules/DetailsModule/DetailsContentHeader/DetailsContentHeader.spec.tsx

@@ -0,0 +1,122 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import TestUtils from "@tests/TestUtils";
+
+import DetailsContentHeader from "./DetailsContentHeader";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+describe("DetailsContentHeader", () => {
+  let defaultProps: DetailsContentHeader["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      dropdownActions: [{ label: "Test Action", action: () => {} }],
+      backLink: "/list",
+      typeImage: "type-image.jpeg",
+      primaryInfoPill: true,
+      statusPill: "COMPLETED",
+      statusLabel: "status-label",
+      itemTitle: "item-title",
+      itemType: "item-type",
+      itemDescription: "item-description",
+    };
+  });
+
+  it("renders back button correctly", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(
+      TestUtils.select(
+        "DetailsContentHeader__BackButton"
+      )?.attributes.getNamedItem("to")?.value
+    ).toBe("/list");
+  });
+
+  it("renders type image when prop is provided", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(
+      window.getComputedStyle(
+        TestUtils.select("DetailsContentHeader__TypeImage")!
+      ).background
+    ).toBe(`url(${defaultProps.typeImage}) no-repeat center`);
+  });
+
+  it("renders item title correctly", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(TestUtils.select("DetailsContentHeader__Text")?.textContent).toBe(
+      defaultProps.itemTitle
+    );
+  });
+
+  it("does not render status pill when statusPill prop is not provided", () => {
+    const newProps = {
+      ...defaultProps,
+      statusPill: undefined,
+    };
+    render(<DetailsContentHeader {...newProps} />);
+    expect(TestUtils.select("StatusPill__Wrapper")).toBeNull();
+  });
+
+  it("renders status pill when statusPill prop is provided", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(TestUtils.selectAll("StatusPill__Wrapper")).toHaveLength(2);
+    expect(TestUtils.selectAll("StatusPill__Wrapper")[0].textContent).toBe(
+      defaultProps.itemType
+    );
+    expect(TestUtils.selectAll("StatusPill__Wrapper")[1].textContent).toBe(
+      defaultProps.statusLabel
+    );
+  });
+
+  it("renders mock button when dropdownActions is not provided", () => {
+    const newProps = {
+      ...defaultProps,
+      dropdownActions: undefined,
+    };
+    render(<DetailsContentHeader {...newProps} />);
+    expect(TestUtils.select("DetailsContentHeader__MockButton")).toBeTruthy();
+  });
+
+  it("renders ActionDropdown when dropdownActions is provided", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(TestUtils.select("DetailsContentHeader__MockButton")).toBeNull();
+    expect(TestUtils.select("DropdownButton__Wrapper")).toBeTruthy();
+    userEvent.click(TestUtils.select("DropdownButton__Wrapper")!);
+    expect(TestUtils.selectAll("ActionDropdown__ListItem")[0].textContent).toBe(
+      "Test Action"
+    );
+  });
+
+  it("does not render description when itemDescription is not provided", () => {
+    const newProps = {
+      ...defaultProps,
+      itemDescription: undefined,
+    };
+    render(<DetailsContentHeader {...newProps} />);
+    expect(TestUtils.select("DetailsContentHeader__Description")).toBeNull();
+  });
+
+  it("renders description when itemDescription is provided", () => {
+    render(<DetailsContentHeader {...defaultProps} />);
+    expect(TestUtils.select("DetailsContentHeader__Description")).toBeTruthy();
+    expect(
+      TestUtils.select("DetailsContentHeader__Description")?.textContent
+    ).toBe(defaultProps.itemDescription);
+  });
+});

+ 0 - 82
src/components/modules/DetailsModule/DetailsContentHeader/test.tsx

@@ -1,82 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TW from "@src/utils/TestWrapper";
-import DetailsContentHeader from ".";
-
-const wrap = props =>
-  new TW(shallow(<DetailsContentHeader {...props} />), "dcHeader");
-
-const item = {
-  origin_endpoint_id: "openstack",
-  destination_endpoint_id: "azure",
-  instances: ["The instance title"],
-  type: "item type",
-  executions: [{ status: "COMPLETED", created_at: new Date() }],
-};
-
-describe("DetailsContentHeader Component", () => {
-  it("renders title", () => {
-    const wrapper = wrap({ item });
-    expect(wrapper.findText("title")).toBe(item.instances[0]);
-  });
-
-  it("renders with no action button", () => {
-    const wrapper = wrap({ item });
-    expect(wrapper.find("actionButton").length).toBe(0);
-    expect(wrapper.find("cancelButton").length).toBe(0);
-  });
-
-  it("renders with action button, if there are dropdown actions", () => {
-    const wrapper = wrap({ item, dropdownActions: [] });
-    expect(wrapper.find("actionButton").length).toBe(1);
-  });
-
-  // it('dispatches back button click', () => {
-  //   let onBackButonClick = sinon.spy()
-  //   let wrapper = wrap({ item, onBackButonClick })
-  //   wrapper.find('backButton').click()
-  //   expect(onBackButonClick.called).toBe(true)
-  // })
-
-  it("renders correct INFO pill", () => {
-    let wrapper = wrap({ item, primaryInfoPill: true });
-    expect(wrapper.find("infoPill").prop("primary")).toBe(true);
-    expect(wrapper.find("infoPill").prop("label")).toBe("ITEM TYPE");
-    expect(wrapper.find("infoPill").prop("alert")).toBe(undefined);
-
-    wrapper = wrap({ item, alertInfoPill: true });
-    expect(wrapper.find("infoPill").prop("alert")).toBe(true);
-  });
-
-  it("renders correct STATUS pill", () => {
-    let wrapper = wrap({ item });
-    expect(wrapper.findPartialId("statusPill-").prop("status")).toBe(
-      "COMPLETED"
-    );
-    const newItem = { ...item, executions: [...item.executions] };
-    newItem.executions.push({ status: "RUNNING", created_at: new Date() });
-    wrapper = wrap({ item: newItem });
-    expect(wrapper.findPartialId("statusPill-").prop("status")).toBe("RUNNING");
-  });
-
-  it("renders item description", () => {
-    const wrapper = wrap({
-      item: { ...item, description: "item description" },
-    });
-    expect(wrapper.findText("description")).toBe("item description");
-  });
-});

+ 151 - 0
src/components/modules/DetailsModule/DetailsPageHeader/DetailsPageHeader.spec.tsx

@@ -0,0 +1,151 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+import { act } from "react-dom/test-utils";
+
+import { User } from "@src/@types/User";
+import notificationStore from "@src/stores/NotificationStore";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DetailsPageHeader from "./DetailsPageHeader";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+jest.mock("@src/stores/NotificationStore", () => ({
+  notificationItems: [],
+  loadData: jest.fn().mockResolvedValue([]),
+  saveSeen: jest.fn(),
+}));
+
+const user: User = {
+  id: "1",
+  name: "Test User",
+  email: "email",
+  project: { id: "1", name: "Test Project" },
+};
+
+describe("DetailsPageHeader", () => {
+  let defaultProps: DetailsPageHeader["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      user,
+      onUserItemClick: jest.fn(),
+    };
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+  });
+
+  it("renders without crashing", () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+  });
+
+  it("starts polling on mount", async () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+    expect(notificationStore.loadData).toHaveBeenCalled();
+
+    await act(async () => {
+      await Promise.resolve();
+    });
+    act(() => {
+      jest.advanceTimersByTime(15000);
+    });
+    expect(notificationStore.loadData).toHaveBeenCalledTimes(2);
+
+    await act(async () => {
+      await Promise.resolve();
+    });
+    act(() => {
+      jest.advanceTimersByTime(15000);
+    });
+    expect(notificationStore.loadData).toHaveBeenCalledTimes(3);
+  });
+
+  it("stops polling on unmount", async () => {
+    const { unmount } = render(<DetailsPageHeader {...defaultProps} />);
+    unmount();
+
+    await act(async () => {
+      await Promise.resolve();
+    });
+    act(() => {
+      jest.advanceTimersByTime(15000);
+    });
+    expect(notificationStore.loadData).toHaveBeenCalledTimes(1);
+  });
+
+  it("handles notification close by saving seen notifications", () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+    TestUtils.select("NotificationDropdown__Icon")?.click();
+    expect(TestUtils.select("NotificationDropdown__List")).toBeTruthy();
+    TestUtils.select("NotificationDropdown__Icon")?.click();
+    expect(TestUtils.select("NotificationDropdown__List")).toBeFalsy();
+    expect(notificationStore.saveSeen).toHaveBeenCalled();
+  });
+
+  it("handles user item click for 'about'", () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+    TestUtils.select("UserDropdown__Icon")?.click();
+    expect(TestUtils.select("UserDropdown__List")).toBeTruthy();
+    expect(TestUtils.select("UserDropdown__Username")?.textContent).toBe(
+      user.name
+    );
+    expect(TestUtils.select("UserDropdown__Email")?.textContent).toBe(
+      user.email
+    );
+    TestUtils.selectAll("UserDropdown__Label").forEach(item => {
+      if (item.textContent === "About Coriolis") {
+        item.click();
+      }
+    });
+    expect(defaultProps.onUserItemClick).not.toHaveBeenCalled();
+    expect(TestUtils.select("AboutModal__Wrapper")).toBeTruthy();
+  });
+
+  it("handles user item click for other values", () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+    TestUtils.select("UserDropdown__Icon")?.click();
+    expect(TestUtils.select("UserDropdown__List")).toBeTruthy();
+    TestUtils.selectAll("UserDropdown__Label").forEach(item => {
+      if (item.textContent === "Sign Out") {
+        item.click();
+      }
+    });
+    expect(defaultProps.onUserItemClick).toHaveBeenCalledWith({
+      label: "Sign Out",
+      value: "signout",
+    });
+  });
+
+  it("closes the about modal", () => {
+    render(<DetailsPageHeader {...defaultProps} />);
+    TestUtils.select("UserDropdown__Icon")?.click();
+    TestUtils.selectAll("UserDropdown__Label").forEach(item => {
+      if (item.textContent === "About Coriolis") {
+        item.click();
+      }
+    });
+    expect(TestUtils.select("AboutModal__Wrapper")).toBeTruthy();
+    document.querySelectorAll("button").forEach(item => {
+      if (item.textContent === "Close") {
+        item.click();
+      }
+    });
+    expect(TestUtils.select("AboutModal__Wrapper")).toBeFalsy();
+  });
+});

+ 0 - 4
src/components/modules/DetailsModule/DetailsPageHeader/DetailsPageHeader.tsx

@@ -63,7 +63,6 @@ type Props = {
     label: React.ReactNode;
     value: string;
   }) => void;
-  testMode?: boolean;
 };
 
 @observer
@@ -77,9 +76,6 @@ class DetailsPageHeader extends React.Component<Props, State> {
   stopPolling!: boolean;
 
   UNSAFE_componentWillMount() {
-    if (this.props.testMode) {
-      return;
-    }
     this.stopPolling = false;
     this.pollData(true);
   }

+ 0 - 56
src/components/modules/DetailsModule/DetailsPageHeader/test.tsx

@@ -1,56 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import type { User } from "@src/@types/User";
-import DetailsPageHeader from ".";
-
-type Props = {
-  user?: User | null;
-};
-
-const wrap = (props: Props) =>
-  new TW(
-    shallow(
-      <DetailsPageHeader onUserItemClick={() => {}} testMode {...props} />
-    ),
-    "dpHeader"
-  );
-
-const user = {
-  name: "User name",
-  email: "email@email.com",
-  id: "user",
-  project: { id: "", name: "" },
-};
-
-describe("DetailsPageHeader Component", () => {
-  it("renders with given user", () => {
-    const wrapper = wrap({ user });
-    expect(wrapper.find("userDropdown").prop("user").name).toBe(user.name);
-    expect(wrapper.find("userDropdown").prop("user").email).toBe(user.email);
-  });
-
-  it("dispatches user item click", () => {
-    const onUserItemClick = sinon.spy();
-    const wrapper = wrap({ user, onUserItemClick });
-    wrapper
-      .find("userDropdown")
-      .simulate("itemClick", { value: "", label: "" });
-    expect(onUserItemClick.called).toBe(true);
-  });
-});

+ 383 - 0
src/components/modules/EndpointModule/ChooseProvider/ChooseProvider.spec.tsx

@@ -0,0 +1,383 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Endpoint } from "@src/@types/Endpoint";
+import { ThemePalette } from "@src/components/Theme";
+import notificationStore from "@src/stores/NotificationStore";
+import FileUtils from "@src/utils/FileUtils";
+import { fireEvent, render, waitFor } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import ChooseProvider from "./ChooseProvider";
+
+const OPENSTACK_ENDPOINT: Endpoint = {
+  name: "Openstack",
+  type: "openstack",
+  id: "1",
+  description: "",
+  created_at: new Date().toISOString(),
+  mapped_regions: ["region_1"],
+  connection_info: {},
+};
+
+const mockDataTransfer = (files: any[]) => ({
+  dataTransfer: {
+    dropEffect: "none",
+    files,
+    items: files.map(file => ({
+      kind: "file",
+      type: file.type,
+      getAsFile: () => file,
+    })),
+    types: ["Files"],
+  },
+});
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+jest.mock("@src/stores/NotificationStore", () => ({
+  alert: jest.fn(),
+}));
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {},
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+  },
+}));
+
+describe("ChooseProvider", () => {
+  let defaultProps: ChooseProvider["props"];
+  let readContentFromFileListMock: jest.SpyInstance;
+
+  beforeEach(() => {
+    jest.useFakeTimers();
+
+    readContentFromFileListMock = jest.spyOn(
+      FileUtils,
+      "readContentFromFileList"
+    );
+    readContentFromFileListMock.mockResolvedValue([
+      {
+        name: "openstack.endpoint",
+        content: JSON.stringify(OPENSTACK_ENDPOINT),
+      },
+    ]);
+
+    defaultProps = {
+      providers: ["openstack", "vmware_vsphere"],
+      regions: [
+        {
+          id: "region_1",
+          name: "Region 1",
+          description: "",
+          enabled: true,
+          mapped_endpoints: [],
+        },
+      ],
+      onCancelClick: jest.fn(),
+      onProviderClick: jest.fn(),
+      onUploadEndpoint: jest.fn(),
+      loading: false,
+      onValidateMultipleEndpoints: jest.fn(),
+      multiValidating: false,
+      multiValidation: [],
+      onRemoveEndpoint: jest.fn(),
+      onResetValidation: jest.fn(),
+    };
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+  });
+
+  it("renders without crashing", () => {
+    render(<ChooseProvider {...defaultProps} />);
+    expect(TestUtils.select("Button__")?.textContent).toBe("Cancel");
+  });
+
+  it('calls "onProviderClick" when a provider logo is clicked', () => {
+    render(<ChooseProvider {...defaultProps} />);
+
+    const providerButton = TestUtils.select("EndpointLogos__Wrapper")!;
+    fireEvent.click(providerButton);
+    expect(defaultProps.onProviderClick).toHaveBeenCalledWith("openstack");
+  });
+
+  it("shows loading state when loading is true", () => {
+    render(<ChooseProvider {...defaultProps} loading />);
+    expect(TestUtils.select("ChooseProvider__LoadingWrapper")).toBeTruthy();
+  });
+
+  it("handles file uploads and parses single files", async () => {
+    const onUploadEndpointMock = jest.fn();
+    render(
+      <ChooseProvider
+        {...defaultProps}
+        onUploadEndpoint={onUploadEndpointMock}
+      />
+    );
+    const fileInput = document.querySelector('input[type="file"]')!;
+
+    const file = new File(
+      [JSON.stringify(OPENSTACK_ENDPOINT)],
+      "openstack.endpoint",
+      {
+        type: "application/json",
+      }
+    );
+
+    expect(fileInput).toBeTruthy();
+    expect(defaultProps.onUploadEndpoint).not.toHaveBeenCalled();
+
+    fireEvent.change(fileInput, { target: { files: [file] } });
+
+    await waitFor(() => expect(onUploadEndpointMock).toHaveBeenCalled());
+  });
+
+  it("highlights dropzone on drag enter", () => {
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const uploadArea = TestUtils.select("ChooseProvider__Upload")!;
+    const style = () => window.getComputedStyle(uploadArea);
+
+    expect(style().border).toBe(`1px dashed white`);
+    fireEvent.dragEnter(window, mockDataTransfer([]));
+    expect(style().border).toBe(
+      `1px dashed ${ThemePalette.primary.toLowerCase()}`
+    );
+  });
+
+  it("removes highlight from dropzone on drag leave", () => {
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const uploadArea = TestUtils.select("ChooseProvider__Upload")!;
+    const style = () => window.getComputedStyle(uploadArea);
+
+    fireEvent.dragLeave(window, mockDataTransfer([]));
+    expect(style().border).toBe(`1px dashed white`);
+  });
+
+  it("processes file on drop", async () => {
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const file = new File(["endpoint content"], "openstack.endpoint", {
+      type: "application/json",
+    });
+    fireEvent.drop(window, mockDataTransfer([file]));
+
+    await waitFor(() =>
+      expect(FileUtils.readContentFromFileList).toHaveBeenCalled()
+    );
+  });
+
+  it("displays error notification for invalid file content", async () => {
+    readContentFromFileListMock.mockResolvedValue([
+      {
+        name: "invalid.endpoint",
+        content: "invalid content",
+      },
+    ]);
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const file = new File(["invalid content"], "invalid.endpoint", {
+      type: "application/json",
+    });
+    fireEvent.drop(window, mockDataTransfer([file]));
+
+    await waitFor(() =>
+      expect(notificationStore.alert).toHaveBeenCalledWith(
+        "Invalid .endpoint file",
+        "error"
+      )
+    );
+  });
+
+  it("displays error notification if endpoint has no name", async () => {
+    readContentFromFileListMock.mockResolvedValue([
+      {
+        name: "invalid.endpoint",
+        content: JSON.stringify({
+          OPENSTACK_ENDPOINT,
+          name: "",
+        }),
+      },
+    ]);
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const file = new File(["invalid content"], "invalid.endpoint", {
+      type: "application/json",
+    });
+    fireEvent.drop(window, mockDataTransfer([file]));
+
+    await waitFor(() =>
+      expect(notificationStore.alert).toHaveBeenCalledWith(
+        "Invalid .endpoint file",
+        "error"
+      )
+    );
+  });
+
+  it("processes multiple files and handles unique names", async () => {
+    const multipleFilesMeta = [
+      {
+        name: "file1.endpoint",
+        content: JSON.stringify(OPENSTACK_ENDPOINT),
+      },
+      {
+        name: "file2.endpoint",
+        content: JSON.stringify(OPENSTACK_ENDPOINT),
+      },
+    ];
+    const multipleFiles = multipleFilesMeta.map(
+      ({ name, content }) =>
+        new File([content], name, {
+          type: "application/json",
+        })
+    );
+
+    readContentFromFileListMock.mockResolvedValue(multipleFilesMeta);
+
+    let setStateObj: any = {};
+    jest
+      .spyOn(ChooseProvider.prototype, "setState")
+      .mockImplementationOnce((obj: any) => {
+        setStateObj = obj;
+      });
+
+    render(<ChooseProvider {...defaultProps} />);
+    const fileInput = document.querySelector('input[type="file"]')!;
+    fireEvent.change(fileInput, { target: { files: multipleFiles } });
+
+    await waitFor(() => {
+      expect(defaultProps.onResetValidation).toHaveBeenCalledTimes(1);
+      expect(setStateObj.multipleUploadedEndpoints[0].name).toBe(
+        OPENSTACK_ENDPOINT.name
+      );
+      expect(setStateObj.multipleUploadedEndpoints[1].name).toBe(
+        `${OPENSTACK_ENDPOINT.name} (1)`
+      );
+    });
+  });
+
+  it("fires onresizeupdate when multipleUploadedEndpoints changes", async () => {
+    const onResizeUpdateMock = jest.fn();
+    const multipleFilesMeta = [
+      {
+        name: "file1.endpoint",
+        content: JSON.stringify(OPENSTACK_ENDPOINT),
+      },
+      {
+        name: "file2.endpoint",
+        content: JSON.stringify(OPENSTACK_ENDPOINT),
+      },
+    ];
+    const multipleFiles = multipleFilesMeta.map(
+      ({ name, content }) =>
+        new File([content], name, {
+          type: "application/json",
+        })
+    );
+    readContentFromFileListMock.mockResolvedValue(multipleFilesMeta);
+
+    render(
+      <ChooseProvider {...defaultProps} onResizeUpdate={onResizeUpdateMock} />
+    );
+    const fileInput = document.querySelector('input[type="file"]')!;
+    fireEvent.change(fileInput, { target: { files: multipleFiles } });
+
+    await waitFor(() => {
+      expect(onResizeUpdateMock).toHaveBeenCalled();
+    });
+  });
+
+  it("adds drop effect on drag over", async () => {
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+
+    const transfer = mockDataTransfer([]);
+    fireEvent.dragOver(window, transfer);
+
+    await waitFor(() => {
+      expect(transfer.dataTransfer.dropEffect).toBe("copy");
+    });
+  });
+
+  it("shows warning for unindetified regions", async () => {
+    readContentFromFileListMock.mockResolvedValue([
+      {
+        name: "openstack.endpoint",
+        content: JSON.stringify({
+          ...OPENSTACK_ENDPOINT,
+          mapped_regions: ["region_2"],
+        }),
+      },
+    ]);
+    render(<ChooseProvider {...defaultProps} />);
+    jest.advanceTimersByTime(1000);
+    const file = new File(["invalid content"], "openstack.endpoint", {
+      type: "application/json",
+    });
+    fireEvent.drop(window, mockDataTransfer([file]));
+
+    await waitFor(() =>
+      expect(notificationStore.alert).toHaveBeenCalledWith(
+        "1 Coriolis Region couldn't be mapped",
+        "warning"
+      )
+    );
+  });
+
+  it("processes remove endpoint", () => {
+    const chooseProviderInstance = new ChooseProvider(defaultProps);
+    jest
+      .spyOn(chooseProviderInstance, "setState")
+      .mockImplementation((callback: any) => {
+        callback({ multipleUploadedEndpoints: [OPENSTACK_ENDPOINT] });
+      });
+
+    chooseProviderInstance.handleRemoveUploadedEndpoint(
+      OPENSTACK_ENDPOINT,
+      true
+    );
+
+    expect(defaultProps.onRemoveEndpoint).toHaveBeenCalledWith(
+      OPENSTACK_ENDPOINT
+    );
+  });
+
+  it("handles regions change", () => {
+    const chooseProviderInstance = new ChooseProvider(defaultProps);
+    let nextState: any = {};
+    jest
+      .spyOn(chooseProviderInstance, "setState")
+      .mockImplementation((callback: any) => {
+        nextState = callback({
+          multipleUploadedEndpoints: [OPENSTACK_ENDPOINT],
+        });
+      });
+
+    chooseProviderInstance.handleRegionsChange(OPENSTACK_ENDPOINT, [
+      "region_2",
+    ]);
+    expect(nextState.multipleUploadedEndpoints[0].mapped_regions).toEqual([
+      "region_2",
+    ]);
+  });
+});

+ 288 - 0
src/components/modules/EndpointModule/ChooseProvider/MultipleUploadedEndpoints.spec.tsx

@@ -0,0 +1,288 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Endpoint } from "@src/@types/Endpoint";
+import EndpointLogos from "@src/components/modules/EndpointModule/EndpointLogos";
+import DropdownLink from "@src/components/ui/Dropdowns/DropdownLink";
+import DomUtils from "@src/utils/DomUtils";
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import MultipleUploadedEndpoints from "./MultipleUploadedEndpoints";
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: EndpointLogos["props"]) => <div>{props.endpoint}</div>,
+}));
+
+jest.mock("@src/components/ui/Dropdowns/DropdownLink", () => ({
+  __esModule: true,
+  default: (props: DropdownLink["props"]) => (
+    <div>
+      {props.items.map(item => (
+        <div
+          data-testid="DropdownLink__Item"
+          onClick={() => {
+            props.onChange && props.onChange(item);
+          }}
+          key={item.value}
+        >
+          {item.label} - {item.value}
+          {props.getLabel && props.getLabel()}
+        </div>
+      ))}
+    </div>
+  ),
+}));
+
+jest.mock("@src/utils/DomUtils", () => ({
+  copyTextToClipboard: jest.fn(),
+}));
+
+const OPENSTACK_ENDPOINT: Endpoint = {
+  name: "Openstack",
+  type: "openstack",
+  id: "1",
+  description: "",
+  created_at: new Date().toISOString(),
+  mapped_regions: [],
+  connection_info: {},
+};
+
+const AWS_ENDPOINT: Endpoint = {
+  name: "AWS",
+  type: "aws",
+  id: "2",
+  description: "",
+  created_at: new Date().toISOString(),
+  mapped_regions: [],
+  connection_info: {},
+};
+
+describe("MultipleUploadedEndpoints", () => {
+  let defaultProps: MultipleUploadedEndpoints["props"];
+  let copyTextToClipboard: jest.SpyInstance;
+
+  beforeEach(() => {
+    copyTextToClipboard = jest.spyOn(DomUtils, "copyTextToClipboard");
+
+    defaultProps = {
+      endpoints: [OPENSTACK_ENDPOINT, AWS_ENDPOINT],
+      regions: [
+        {
+          id: "default",
+          name: "Default",
+          description: "",
+          enabled: true,
+          mapped_endpoints: [],
+        },
+      ],
+      invalidRegionsEndpointIds: [],
+      multiValidation: [
+        {
+          endpoint: OPENSTACK_ENDPOINT,
+          validating: false,
+          validation: { valid: true, message: "" },
+        },
+        {
+          endpoint: AWS_ENDPOINT,
+          validating: false,
+          validation: { valid: false, message: "Invalid" },
+        },
+      ],
+      validating: false,
+      onRegionsChange: jest.fn(),
+      onBackClick: jest.fn(),
+      onRemove: jest.fn(),
+      onValidateClick: jest.fn(),
+      onDone: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<MultipleUploadedEndpoints {...defaultProps} />);
+
+    expect(
+      TestUtils.selectAll("MultipleUploadedEndpoints__EndpointName")[0]
+        .textContent
+    ).toBe("Openstack");
+    expect(
+      TestUtils.selectAll("MultipleUploadedEndpoints__EndpointName")[1]
+        .textContent
+    ).toBe("AWS");
+  });
+
+  it('handles the "Back" button click', () => {
+    render(<MultipleUploadedEndpoints {...defaultProps} />);
+    document.querySelectorAll("button").forEach(button => {
+      if (button.textContent === "Back") {
+        fireEvent.click(button);
+      }
+    });
+    expect(defaultProps.onBackClick).toHaveBeenCalled();
+  });
+
+  it('handles the "Validate and Save" button click', () => {
+    render(<MultipleUploadedEndpoints {...defaultProps} />);
+    document.querySelectorAll("button").forEach(button => {
+      if (button.textContent === "Validate and save") {
+        fireEvent.click(button);
+      }
+    });
+    expect(defaultProps.onValidateClick).toHaveBeenCalled();
+  });
+
+  it('changes to "Done" button after validation', async () => {
+    const { rerender, getByText } = render(
+      <MultipleUploadedEndpoints {...defaultProps} validating={true} />
+    );
+    expect(TestUtils.select("LoadingButton__Loading")).toBeTruthy();
+    rerender(
+      <MultipleUploadedEndpoints {...defaultProps} validating={false} />
+    );
+    expect(TestUtils.select("LoadingButton__Loading")).toBeFalsy();
+    expect(getByText("Done")).toBeTruthy();
+  });
+
+  it('handles the "Done" button click', () => {
+    const { rerender, getByText } = render(
+      <MultipleUploadedEndpoints {...defaultProps} validating={true} />
+    );
+    rerender(
+      <MultipleUploadedEndpoints {...defaultProps} validating={false} />
+    );
+    fireEvent.click(getByText("Done"));
+    expect(defaultProps.onDone).toHaveBeenCalled();
+  });
+
+  it("removes an endpoint", () => {
+    render(<MultipleUploadedEndpoints {...defaultProps} />);
+    const deleteButtons = TestUtils.selectAll(
+      "MultipleUploadedEndpoints__DeleteButton"
+    );
+    fireEvent.click(deleteButtons[0]);
+    expect(defaultProps.onRemove).toHaveBeenCalledWith(
+      defaultProps.endpoints[0],
+      true
+    );
+  });
+
+  it("copies an error message to the clipboard", () => {
+    copyTextToClipboard.mockImplementation(() => Promise.resolve(true));
+    render(<MultipleUploadedEndpoints {...defaultProps} />);
+    fireEvent.click(TestUtils.selectAll("StatusIcon__Wrapper")[1]);
+    expect(DomUtils.copyTextToClipboard).toHaveBeenCalled();
+  });
+
+  it("removes an uploaded non multi endpoint", () => {
+    const newProps = {
+      ...defaultProps,
+      multiValidation: [],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    const deleteButtons = TestUtils.selectAll(
+      "MultipleUploadedEndpoints__DeleteButton"
+    );
+    fireEvent.click(deleteButtons[0]);
+    expect(defaultProps.onRemove).toHaveBeenCalledWith(
+      defaultProps.endpoints[0],
+      false
+    );
+  });
+
+  it("selects valid region when there's invalid regions", () => {
+    const newProps = {
+      ...defaultProps,
+      multiValidation: [],
+      invalidRegionsEndpointIds: [
+        { id: "openstackOpenstack", regions: ["default"] },
+      ],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    fireEvent.click(
+      document.querySelector("[data-testid='DropdownLink__Item']")!
+    );
+    expect(defaultProps.onRegionsChange).toHaveBeenCalledWith(
+      OPENSTACK_ENDPOINT,
+      ["default"]
+    );
+  });
+
+  it("selects valid region when there's invalid regions with endpoints mapped regions", () => {
+    const newOpenstackEndpoint = {
+      ...OPENSTACK_ENDPOINT,
+      mapped_regions: ["default"],
+    };
+    const newProps = {
+      ...defaultProps,
+      endpoints: [newOpenstackEndpoint],
+      multiValidation: [],
+      invalidRegionsEndpointIds: [
+        { id: "openstackOpenstack", regions: ["default"] },
+      ],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    fireEvent.click(
+      document.querySelector("[data-testid='DropdownLink__Item']")!
+    );
+    expect(defaultProps.onRegionsChange).toHaveBeenCalledWith(
+      newOpenstackEndpoint,
+      []
+    );
+  });
+
+  it("shows validating status when validating an endpoint", () => {
+    const newProps = {
+      ...defaultProps,
+      multiValidation: [
+        {
+          endpoint: OPENSTACK_ENDPOINT,
+          validating: true,
+          validation: { valid: true, message: "" },
+        },
+      ],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    expect(TestUtils.selectAll("StatusIcon__Wrapper")).toHaveLength(1);
+  });
+
+  it("shows no status if no validation", () => {
+    const newProps = {
+      ...defaultProps,
+      multiValidation: [{ endpoint: OPENSTACK_ENDPOINT, validating: false }],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    expect(TestUtils.selectAll("StatusIcon__Wrapper")).toHaveLength(0);
+  });
+
+  it("shows invalid endpoint for unsupported endpoint type", () => {
+    const newProps = {
+      ...defaultProps,
+      endpoints: ["invalid"],
+    };
+
+    render(<MultipleUploadedEndpoints {...newProps} />);
+    expect(
+      TestUtils.select("MultipleUploadedEndpoints__InvalidEndpoint")
+        ?.textContent
+    ).toContain("unsupported provider type: invalid");
+  });
+});

+ 1 - 1
src/components/modules/EndpointModule/ChooseProvider/MultipleUploadedEndpoints.tsx

@@ -113,7 +113,7 @@ class MultipleUploadedEndpoints extends React.Component<Props, State> {
     validationDone: false,
   };
 
-  UNSAFE_componentWillReceiveProps(prevProps: Props) {
+  componentDidUpdate(prevProps: Props) {
     if (prevProps.validating && !this.props.validating) {
       this.setState({ validationDone: true });
     }

+ 0 - 55
src/components/modules/EndpointModule/ChooseProvider/test.tsx

@@ -1,55 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import ChooseProvider from ".";
-
-const wrap = props =>
-  new TW(shallow(<ChooseProvider {...props} />), "cProvider");
-
-const providers = [
-  "azure",
-  "openstack",
-  "opc",
-  "oracle_vm",
-  "vmware_vsphere",
-  "aws",
-];
-
-describe("ChooseProvider Component", () => {
-  it("renders all given providers", () => {
-    const wrapper = wrap({ providers });
-    providers.forEach(key => {
-      expect(wrapper.find(`endpointLogo-${key}`).prop("endpoint")).toBe(key);
-    });
-  });
-
-  it("dispatches provider click", () => {
-    const onProviderClick = sinon.spy();
-    const wrapper = wrap({ providers, onProviderClick });
-    wrapper.find("endpointLogo-opc").click();
-    expect(onProviderClick.calledOnce).toBe(true);
-    expect(onProviderClick.args[0][0]).toBe("opc");
-  });
-
-  it("dispatches cancel click", () => {
-    const onCancelClick = sinon.spy();
-    const wrapper = wrap({ providers, onCancelClick });
-    wrapper.find("cancelButton").click();
-    expect(onCancelClick.calledOnce).toBe(true);
-  });
-});

+ 334 - 0
src/components/modules/EndpointModule/EndpointDetailsContent/EndpointDetailsContent.spec.tsx

@@ -0,0 +1,334 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Endpoint } from "@src/@types/Endpoint";
+import DomUtils from "@src/utils/DomUtils";
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import EndpointDetailsContent from "./EndpointDetailsContent";
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {},
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+    passwordFields: ["secret_key"],
+  },
+}));
+
+jest.mock("react-router-dom", () => ({
+  Link: "div",
+}));
+
+const OPENSTACK_ENDPOINT: Endpoint = {
+  name: "Openstack",
+  type: "openstack",
+  id: "1",
+  description: "openstack description",
+  created_at: new Date().toISOString(),
+  mapped_regions: [],
+  connection_info: {},
+};
+
+const USAGE = {
+  migrations: [
+    {
+      type: "migration",
+      id: "mig-1",
+      instances: [],
+      notes: "Migration 1",
+    },
+    {
+      type: "migration",
+      id: "mig-2",
+      instances: ["mig-vm1"],
+    },
+  ],
+  replicas: [
+    {
+      type: "replica",
+      id: "rep-1",
+      instances: [],
+      notes: "Replica 1",
+    },
+  ],
+};
+
+describe("EndpointDetailsContent", () => {
+  let defaultProps: EndpointDetailsContent["props"];
+  let domDownload: jest.SpyInstance;
+
+  beforeEach(() => {
+    domDownload = jest.spyOn(DomUtils, "download");
+
+    defaultProps = {
+      item: OPENSTACK_ENDPOINT,
+      regions: [
+        {
+          id: "1",
+          name: "Region 1",
+          description: "region description",
+          enabled: true,
+          mapped_endpoints: [],
+        },
+      ],
+      connectionInfo: null,
+      usage: USAGE as any,
+      loading: false,
+      connectionInfoSchema: [],
+      onDeleteClick: jest.fn(),
+      onValidateClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<EndpointDetailsContent {...defaultProps} />);
+    expect(getByText("openstack description")).toBeTruthy();
+  });
+
+  it("renders loading state correctly", () => {
+    render(<EndpointDetailsContent {...defaultProps} loading />);
+    expect(
+      TestUtils.select("EndpointDetailsContent__LoadingWrapper")
+    ).toBeTruthy();
+  });
+
+  it("handles the delete button click", () => {
+    render(<EndpointDetailsContent {...defaultProps} />);
+    document.querySelectorAll("button").forEach(button => {
+      if (button.textContent === "Delete Endpoint") {
+        fireEvent.click(button);
+      }
+    });
+    expect(defaultProps.onDeleteClick).toBeCalled();
+  });
+
+  it("downloads file field", () => {
+    const { getByText } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "file",
+            type: "string",
+            useFile: true,
+          },
+        ]}
+        connectionInfo={{
+          file: "file content",
+        }}
+      />
+    );
+    fireEvent.click(getByText("Download"));
+
+    expect(domDownload).toBeCalledWith("file content", "file");
+  });
+
+  it("fails to download if no endpoint", () => {
+    const instance = new EndpointDetailsContent({
+      ...defaultProps,
+      item: null,
+    });
+    const result = instance.renderDownloadValue("file", "content");
+    expect(result).toBe(null);
+  });
+
+  it("doesn't render secret_ref", () => {
+    render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "secret_ref",
+            type: "string",
+          },
+        ]}
+        connectionInfo={{
+          secret_ref: "secret_ref",
+        }}
+      />
+    );
+    let secretRef;
+    TestUtils.selectAll("CopyValue__Value").forEach(element => {
+      if (element.textContent === "secret_ref") {
+        secretRef = element;
+      }
+    });
+    expect(secretRef).toBe(undefined);
+  });
+
+  it("renders objects in connection info", () => {
+    const { getByText } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "object_field",
+            type: "object",
+            fields: [
+              {
+                name: "nested_prop",
+                type: "string",
+              },
+            ],
+          },
+        ]}
+        connectionInfo={{
+          object_field: {
+            nested_prop: "nested prop's value",
+          },
+        }}
+      />
+    );
+    expect(getByText("Nested Prop")).toBeTruthy();
+    expect(getByText("nested prop's value")).toBeTruthy();
+  });
+
+  it("doesn't render the same key twice", () => {
+    const connInfo = {
+      field1: "value1",
+    };
+    const instance = new EndpointDetailsContent({
+      ...defaultProps,
+      connectionInfoSchema: [
+        {
+          name: "field1",
+          type: "string",
+        },
+      ],
+    });
+    instance.renderedKeys = {};
+
+    let result: any = instance.renderConnectionInfo(connInfo);
+    expect(result[0]).not.toBeNull();
+
+    result = instance.renderConnectionInfo(connInfo);
+    expect(result[0]).toBeNull();
+  });
+
+  it("renders booleans correctly", () => {
+    const { getByText, rerender } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "bool_field",
+            type: "boolean",
+          },
+        ]}
+        connectionInfo={{
+          bool_field: true,
+        }}
+      />
+    );
+    expect(getByText("Bool Field")).toBeTruthy();
+    expect(getByText("Yes")).toBeTruthy();
+
+    rerender(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "bool_field",
+            type: "boolean",
+          },
+        ]}
+        connectionInfo={{
+          bool_field: false,
+        }}
+      />
+    );
+    expect(getByText("Bool Field")).toBeTruthy();
+    expect(getByText("No")).toBeTruthy();
+
+    rerender(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "bool_field",
+            type: "boolean",
+          },
+        ]}
+        connectionInfo={{
+          bool_field: "",
+        }}
+      />
+    );
+    let boolValue;
+    TestUtils.selectAll("CopyValue__Value").forEach(element => {
+      if (element.textContent === "-") {
+        boolValue = element;
+      }
+    });
+    expect(boolValue).toBeTruthy();
+    expect(getByText("Bool Field")).toBeTruthy();
+  });
+
+  it("renders password fields correctly", () => {
+    const { getByText } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "password_field",
+            type: "string",
+          },
+        ]}
+        connectionInfo={{
+          password_field: "password",
+        }}
+      />
+    );
+    expect(getByText("Password Field")).toBeTruthy();
+    expect(getByText("•••••••••")).toBeTruthy();
+  });
+
+  it("renders passwords from config correctly", () => {
+    const { getByText } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        connectionInfoSchema={[
+          {
+            name: "secret_key",
+            type: "string",
+          },
+        ]}
+        connectionInfo={{
+          secret_key: "password",
+        }}
+      />
+    );
+    expect(getByText("Secret Key")).toBeTruthy();
+    expect(getByText("•••••••••")).toBeTruthy();
+  });
+
+  it("renders regions correctly", () => {
+    const { getByText } = render(
+      <EndpointDetailsContent
+        {...defaultProps}
+        item={{
+          ...OPENSTACK_ENDPOINT,
+          mapped_regions: ["1"],
+        }}
+      />
+    );
+    expect(getByText("Region 1")).toBeTruthy();
+  });
+});

+ 0 - 120
src/components/modules/EndpointModule/EndpointDetailsContent/test.tsx

@@ -1,120 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import moment from "moment";
-import TW from "@src/utils/TestWrapper";
-import EndpointDetailsContent from ".";
-
-import configLoader from "@src/utils/Config";
-
-const wrap = props =>
-  new TW(
-    shallow(
-      <EndpointDetailsContent
-        usage={{ replicas: [], migrations: [] }}
-        {...props}
-      />
-    ),
-    "edContent"
-  );
-
-const item = {
-  name: "endpoint_name",
-  type: "openstack",
-  description: "endpoint_description",
-  created_at: new Date(2017, 10, 24, 13, 56),
-};
-
-const connectionInfo = {
-  username: "username",
-  password: "password123",
-  details: "other details",
-  boolean_true: true,
-  boolean_false: false,
-  nested: {
-    nested_1: "nested_first",
-    nested_2: "nested_second",
-  },
-};
-
-describe("EndpointDetailsContent Component", () => {
-  beforeAll(() => {
-    configLoader.config = { passwordFields: [] };
-  });
-
-  it("renders endpoint details", () => {
-    const wrapper = wrap({ item });
-    expect(wrapper.find("name").prop("value")).toBe(item.name);
-    expect(wrapper.find("description").prop("value")).toBe(item.description);
-    expect(wrapper.find("created").prop("value")).toBe(
-      moment(item.created_at)
-        .add(-new Date().getTimezoneOffset(), "minutes")
-        .format("DD/MM/YYYY HH:mm")
-    );
-    expect(wrapper.find("connLoading").length).toBe(0);
-  });
-
-  it("renders connection info loading", () => {
-    const wrapper = wrap({ item, loading: true });
-    expect(wrapper.find("name").prop("value")).toBe(item.name);
-    expect(wrapper.find("connLoading").length).toBe(1);
-  });
-
-  it("renders simple connection info", () => {
-    const wrapper = wrap({
-      item,
-      connectionInfo,
-      passwordFields: ["password"],
-    });
-    expect(wrapper.find("connValue-username").prop("value")).toBe(
-      connectionInfo.username
-    );
-    expect(wrapper.find("connPassword").prop("value")).toBe(
-      connectionInfo.password
-    );
-    expect(wrapper.find("connValue-details").prop("value")).toBe(
-      connectionInfo.details
-    );
-  });
-
-  it("renders boolean as Yes and No", () => {
-    const wrapper = wrap({ item, connectionInfo });
-    expect(wrapper.find("connValue-boolean_true").prop("value")).toBe("Yes");
-    expect(wrapper.find("connValue-boolean_false").prop("value")).toBe("No");
-  });
-
-  it("renders nested connection info", () => {
-    const wrapper = wrap({ item, connectionInfo });
-    expect(wrapper.find("connValue-nested_1").prop("value")).toBe(
-      connectionInfo.nested.nested_1
-    );
-    expect(wrapper.find("connValue-nested_2").prop("value")).toBe(
-      connectionInfo.nested.nested_2
-    );
-  });
-
-  it("dispatches buttons clicks", () => {
-    const onDeleteClick = sinon.spy();
-    const onValidateClick = sinon.spy();
-
-    const wrapper = wrap({ item, onDeleteClick, onValidateClick });
-    wrapper.find("validateButton").click();
-    wrapper.find("deleteButton").click();
-    expect(onValidateClick.calledOnce).toBe(true);
-    expect(onDeleteClick.calledOnce).toBe(true);
-  });
-});

+ 93 - 0
src/components/modules/EndpointModule/EndpointDuplicateOptions/EndpointDuplicateOptions.spec.tsx

@@ -0,0 +1,93 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+
+import EndpointDuplicateOptions from "./EndpointDuplicateOptions";
+
+jest.mock("@src/components/ui/FieldInput", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div
+      data-testid="FieldInput__Wrapper"
+      onClick={() => {
+        props.onChange && props.onChange("1");
+      }}
+    >
+      {props.label} - {props.value}
+    </div>
+  ),
+}));
+
+describe("EndpointDuplicateOptions", () => {
+  let defaultProps: EndpointDuplicateOptions["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      projects: [
+        { id: "1", name: "admin" },
+        { id: "2", name: "admin2" },
+      ],
+      selectedProjectId: "2",
+      duplicating: false,
+      onCancelClick: jest.fn(),
+      onDuplicateClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(
+      <EndpointDuplicateOptions {...defaultProps} />
+    );
+    expect(getByText("Duplicate To Project - 2")).toBeTruthy();
+  });
+
+  it("handles submit on Enter key press", () => {
+    render(<EndpointDuplicateOptions {...defaultProps} />);
+
+    fireEvent.keyDown(document, { key: "Enter" });
+
+    expect(defaultProps.onDuplicateClick).toHaveBeenCalledWith("2");
+  });
+
+  it("shows duplicating status", () => {
+    const { getByText } = render(
+      <EndpointDuplicateOptions {...defaultProps} duplicating />
+    );
+
+    expect(getByText("Duplicating Endpoint")).toBeTruthy();
+  });
+
+  it("changes project", () => {
+    const { getByTestId } = render(
+      <EndpointDuplicateOptions {...defaultProps} />
+    );
+
+    fireEvent.click(getByTestId("FieldInput__Wrapper"));
+    expect(getByTestId("FieldInput__Wrapper").textContent).toBe(
+      "Duplicate To Project - 1"
+    );
+  });
+
+  it("handles duplicate click", () => {
+    const { getByText } = render(
+      <EndpointDuplicateOptions {...defaultProps} />
+    );
+
+    fireEvent.click(getByText("Duplicate"));
+    expect(defaultProps.onDuplicateClick).toHaveBeenCalledWith("2");
+  });
+});

+ 0 - 75
src/components/modules/EndpointModule/EndpointDuplicateOptions/test.tsx

@@ -1,75 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import type { Project } from "@src/@types/Project";
-import EndpointDuplicateOptions from ".";
-
-type Props = {
-  projects: Project[];
-  selectedProjectId: string;
-  duplicating: boolean;
-  onCancelClick: () => void;
-  onDuplicateClick: (projectId: string) => void;
-};
-
-const wrap = (props: Props) =>
-  new TW(shallow(<EndpointDuplicateOptions {...props} />), "edOptions");
-const projects: Project[] = [
-  { id: "project-1", name: "Project 1" },
-  { id: "project-2", name: "Project 2" },
-];
-describe("EndpointDuplicateOptions Component", () => {
-  it("renders projects", () => {
-    const wrapper = wrap({
-      projects,
-      selectedProjectId: "project-2",
-      duplicating: false,
-      onCancelClick: () => {},
-      onDuplicateClick: () => {},
-    });
-    expect(wrapper.find("field-project").prop("enum")[1].name).toBe(
-      projects[1].name
-    );
-    expect(wrapper.find("field-project").prop("value")).toBe("project-2");
-    expect(wrapper.find("loading").length).toBe(0);
-  });
-
-  it("dispatches duplicate", () => {
-    const onDuplicateClick = sinon.spy();
-    const wrapper = wrap({
-      projects,
-      selectedProjectId: "project-2",
-      duplicating: false,
-      onCancelClick: () => {},
-      onDuplicateClick,
-    });
-    wrapper.find("duplicateButton").click();
-    expect(onDuplicateClick.args[0][0]).toBe("project-2");
-  });
-
-  it("renders loading", () => {
-    const wrapper = wrap({
-      projects,
-      selectedProjectId: "project-2",
-      duplicating: true,
-      onCancelClick: () => {},
-      onDuplicateClick: () => {},
-    });
-    expect(wrapper.find("loading").length).toBe(1);
-  });
-});

+ 91 - 0
src/components/modules/EndpointModule/EndpointListItem/EndpointListItem.spec.tsx

@@ -0,0 +1,91 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Endpoint } from "@src/@types/Endpoint";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import EndpointListItem from "./EndpointListItem";
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="EndpointLogos">{props.endpoint}</div>
+  ),
+}));
+
+const OPENSTACK_ENDPOINT: Endpoint = {
+  name: "Openstack",
+  type: "openstack",
+  id: "1",
+  description: "openstack description",
+  created_at: new Date().toISOString(),
+  mapped_regions: [],
+  connection_info: {},
+};
+
+describe("EndpointListItem", () => {
+  let defaultProps: EndpointListItem["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: OPENSTACK_ENDPOINT,
+      onClick: jest.fn(),
+      selected: false,
+      onSelectedChange: jest.fn(),
+      getUsage: jest.fn().mockImplementation(() => ({
+        replicasCount: 3,
+        migrationsCount: 2,
+      })),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText, getByTestId } = render(
+      <EndpointListItem {...defaultProps} />
+    );
+    expect(getByText(OPENSTACK_ENDPOINT.name)).toBeTruthy();
+    expect(getByText(OPENSTACK_ENDPOINT.description)).toBeTruthy();
+    expect(getByTestId("EndpointLogos").textContent).toBe(
+      OPENSTACK_ENDPOINT.type
+    );
+  });
+
+  it("renders usage", () => {
+    const { getByText } = render(<EndpointListItem {...defaultProps} />);
+    expect(defaultProps.getUsage).toHaveBeenCalledWith(OPENSTACK_ENDPOINT);
+    expect(getByText("2 migrations, 3 replicas")).toBeTruthy();
+  });
+
+  it("renders N/A when no description", () => {
+    const newProps = {
+      ...defaultProps,
+      item: {
+        ...OPENSTACK_ENDPOINT,
+        description: "",
+      },
+    };
+    const { getByText } = render(<EndpointListItem {...newProps} />);
+    expect(getByText("N/A")).toBeTruthy();
+  });
+
+  it("renders selected checkbox", () => {
+    render(<EndpointListItem {...defaultProps} selected />);
+    const checkbox = TestUtils.selectContains("EndpointListItem__Checkbox")!;
+    const style = window.getComputedStyle(checkbox);
+    expect(style.opacity).toBe("1");
+  });
+});

+ 0 - 58
src/components/modules/EndpointModule/EndpointListItem/test.tsx

@@ -1,58 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TestWrapper from "@src/utils/TestWrapper";
-import EndpointListItem from ".";
-
-const wrap = props =>
-  new TestWrapper(shallow(<EndpointListItem {...props} />), "endpointListItem");
-
-describe("EndpointListItem Component", () => {
-  it("renders item properties", () => {
-    const wrapper = wrap({
-      item: { name: "name-to-test", description: "description-to-test" },
-      getUsage: () => {
-        return {};
-      },
-    });
-    expect(wrapper.findText("name")).toBe("name-to-test");
-    expect(wrapper.findText("description")).toBe("description-to-test");
-  });
-
-  it("renders usage count", () => {
-    const wrapper = wrap({
-      item: {},
-      getUsage: () => {
-        return { replicasCount: 12, migrationsCount: 11 };
-      },
-    });
-    expect(wrapper.findText("usageCount")).toBe("11 migrations, 12 replicas");
-  });
-
-  it("dispatches onClick", () => {
-    const onClick = sinon.spy();
-    const wrapper = wrap({
-      item: { name: "t" },
-      getUsage: () => {
-        return {};
-      },
-      onClick,
-    });
-    wrapper.find("content-t").simulate("click");
-    expect(onClick.calledOnce).toBe(true);
-  });
-});

+ 58 - 0
src/components/modules/EndpointModule/EndpointLogos/EndpointLogos.spec.tsx

@@ -0,0 +1,58 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import EndpointLogos from "./EndpointLogos";
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {},
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+  },
+}));
+
+describe("EndpointLogos", () => {
+  let defaultProps: EndpointLogos["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      endpoint: "openstack",
+      height: 64,
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<EndpointLogos {...defaultProps} />);
+    expect(TestUtils.select("EndpointLogos__Logo")).toBeTruthy();
+  });
+
+  it("renders generic logo", () => {
+    const { getByText } = render(
+      <EndpointLogos {...defaultProps} endpoint="new-endpoint" />
+    );
+    expect(getByText("new-endpoint")).toBeTruthy();
+  });
+
+  it("doesn't render for unsupported height", () => {
+    render(<EndpointLogos {...defaultProps} height={1} />);
+    expect(TestUtils.select("EndpointLogos__Logo")).toBeFalsy();
+  });
+});

+ 89 - 0
src/components/modules/EndpointModule/EndpointLogos/resources/Generic.spec.tsx

@@ -0,0 +1,89 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { ThemePalette } from "@src/components/Theme";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import Generic from "./Generic";
+
+describe("Generic", () => {
+  let defaultProps: Generic["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      name: "Generic",
+      size: { w: 64, h: 64 },
+      disabled: false,
+      white: false,
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<Generic {...defaultProps} />);
+    expect(getByText(defaultProps.name)).toBeTruthy();
+  });
+
+  it.each`
+    height | expectedFontSize
+    ${32}  | ${"14px"}
+    ${42}  | ${"18px"}
+  `(
+    "renders with height $height and font size $expectedFontSize",
+    ({ height, expectedFontSize }) => {
+      render(<Generic {...defaultProps} size={{ w: height, h: height }} />);
+      const wrapper = TestUtils.select("Generic__Wrapper")!;
+      const style = window.getComputedStyle(wrapper);
+      expect(style.fontSize).toBe(expectedFontSize);
+    }
+  );
+
+  it.each`
+    height | expectedLogoWidth | expectedLogoHeight
+    ${64}  | ${"49px"}         | ${"43px"}
+    ${128} | ${"80px"}         | ${"70px"}
+  `(
+    "renders with height $height and logo width $expectedLogoWidth and logo height $expectedLogoHeight",
+    ({ height, expectedLogoWidth, expectedLogoHeight }) => {
+      render(<Generic {...defaultProps} size={{ w: height, h: height }} />);
+      const wrapper = TestUtils.select("Generic__Logo")!;
+      const style = window.getComputedStyle(wrapper);
+      expect(style.maxWidth).toBe(expectedLogoWidth);
+      expect(style.maxHeight).toBe(expectedLogoHeight);
+    }
+  );
+
+  it("renders 32px with white color", () => {
+    render(<Generic {...defaultProps} size={{ w: 32, h: 32 }} white />);
+    const wrapper = TestUtils.select("Generic__Wrapper")!;
+    const style = window.getComputedStyle(wrapper);
+    expect(style.color).toBe("white");
+  });
+
+  it("renders 128px with disabled color", () => {
+    render(<Generic {...defaultProps} size={{ w: 128, h: 128 }} disabled />);
+    const wrapper = TestUtils.select("Generic__Wrapper")!;
+    const style = window.getComputedStyle(wrapper);
+    expect(TestUtils.rgbToHex(style.color)).toBe(ThemePalette.grayscale[3]);
+  });
+
+  it("doesn't render unsupported size", () => {
+    const { container } = render(
+      <Generic {...defaultProps} size={{ w: 100, h: 100 }} />
+    );
+    expect(container.firstChild).toBeNull();
+  });
+});

+ 0 - 42
src/components/modules/EndpointModule/EndpointLogos/test.tsx

@@ -1,42 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TestWrapper from "@src/utils/TestWrapper";
-import EndpointLogos from ".";
-
-const wrap = props =>
-  new TestWrapper(shallow(<EndpointLogos {...props} />), "endpointLogos");
-
-describe("EndpointLogos Component", () => {
-  it("renders 32px aws", () => {
-    const wrapper = wrap({ height: 32, endpoint: "aws" });
-    const logo = wrapper.find("logo");
-    expect(logo.prop("url")).toBe("/api/logos/aws/32");
-  });
-
-  it("renders 128px azure disabled", () => {
-    const wrapper = wrap({ height: 128, endpoint: "azure", disabled: true });
-    const logo = wrapper.find("logo");
-    expect(logo.prop("url")).toBe("/api/logos/azure/128/disabled");
-  });
-
-  it("renders 64px generic logo", () => {
-    const wrapper = wrap({ height: 64, endpoint: "generic" });
-    const logo = wrapper.find("genericLogo");
-    expect(logo.prop("name")).toBe("generic");
-    expect(logo.prop("size").h).toBe(64);
-  });
-});

+ 117 - 0
src/components/modules/EndpointModule/EndpointValidation/EndpointValidation.spec.tsx

@@ -0,0 +1,117 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import DomUtils from "@src/utils/DomUtils";
+import { render } from "@testing-library/react";
+
+import EndpointValidation from "./EndpointValidation";
+
+jest.mock("@src/components/ui/StatusComponents/StatusImage", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="StatusImage">
+      Status: {props.status || "-"}, Loading: {String(props.loading || false)}
+    </div>
+  ),
+}));
+
+jest.mock("@src/utils/DomUtils", () => ({
+  copyTextToClipboard: jest.fn(),
+}));
+
+describe("EndpointValidation", () => {
+  let defaultProps: EndpointValidation["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      loading: false,
+      validation: {
+        valid: true,
+        message: "",
+      },
+      onCancelClick: jest.fn(),
+      onRetryClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText, getByTestId } = render(
+      <EndpointValidation {...defaultProps} />
+    );
+    expect(getByText("Endpoint is Valid")).toBeTruthy();
+    expect(getByTestId("StatusImage").textContent).toBe(
+      "Status: COMPLETED, Loading: false"
+    );
+  });
+
+  it("renders loading", () => {
+    const { getByTestId, getByText } = render(
+      <EndpointValidation {...defaultProps} loading />
+    );
+    expect(getByTestId("StatusImage").textContent).toBe(
+      "Status: -, Loading: true"
+    );
+    expect(getByText("Validating Endpoint")).toBeTruthy();
+  });
+
+  it("renders failed validation", () => {
+    const { getByTestId, getByText } = render(
+      <EndpointValidation
+        {...defaultProps}
+        validation={{
+          valid: false,
+          message: "connection error",
+        }}
+      />
+    );
+    expect(getByTestId("StatusImage").textContent).toBe(
+      "Status: ERROR, Loading: false"
+    );
+    expect(getByText("connection error")).toBeTruthy();
+  });
+
+  it("renders generic error message", () => {
+    const { getByTestId, getByText } = render(
+      <EndpointValidation
+        {...defaultProps}
+        validation={{
+          valid: false,
+          message: "",
+        }}
+      />
+    );
+    expect(getByTestId("StatusImage").textContent).toBe(
+      "Status: ERROR, Loading: false"
+    );
+    expect(getByText("An unexpected error occurred.")).toBeTruthy();
+  });
+
+  it("copies the error message to clipboard", () => {
+    const { getByText } = render(
+      <EndpointValidation
+        {...defaultProps}
+        validation={{
+          valid: false,
+          message: "connection error",
+        }}
+      />
+    );
+    getByText("connection error").click();
+    expect(DomUtils.copyTextToClipboard).toHaveBeenCalledWith(
+      "connection error"
+    );
+  });
+});

+ 0 - 53
src/components/modules/EndpointModule/EndpointValidation/test.tsx

@@ -1,53 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TW from "@src/utils/TestWrapper";
-import EndpointValidation from ".";
-
-const wrap = props =>
-  new TW(shallow(<EndpointValidation {...props} />), "eValidation");
-
-describe("EndpointValidation Component", () => {
-  it("renders loading", () => {
-    const wrapper = wrap({ loading: true });
-    expect(wrapper.find("status").prop("loading")).toBe(true);
-    expect(wrapper.findText("title")).toBe("Validating Endpoint");
-  });
-
-  it("renders valid", () => {
-    const wrapper = wrap({ validation: { valid: true } });
-    expect(wrapper.find("status").prop("status")).toBe("COMPLETED");
-    expect(wrapper.findText("title")).toBe("Endpoint is Valid");
-  });
-
-  it("renders failed with default message", () => {
-    const wrapper = wrap({ validation: {} });
-    expect(wrapper.find("status").prop("status")).toBe("ERROR");
-    expect(wrapper.findText("title")).toBe("Validation Failed");
-    expect(wrapper.findText("errorMessage")).toBe(
-      "An unexpected error occurred.<CopyButton />"
-    );
-  });
-
-  it("renders failed with custom message", () => {
-    const wrapper = wrap({ validation: { message: "custom_message" } });
-    expect(wrapper.find("status").prop("status")).toBe("ERROR");
-    expect(wrapper.findText("title")).toBe("Validation Failed");
-    expect(wrapper.findText("errorMessage")).toBe(
-      "custom_message<CopyButton />"
-    );
-  });
-});

+ 167 - 0
src/components/modules/LicenceModule/LicenceModule.spec.tsx

@@ -0,0 +1,167 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import { DateTime } from "luxon";
+import React from "react";
+
+import { Licence, LicenceServerStatus } from "@src/@types/Licence";
+import { fireEvent, render, waitFor } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import LicenceModule from "./LicenceModule";
+
+jest.mock("@src/components/ui/StatusComponents/StatusImage", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="StatusImage">
+      {props.loading ? "loading" : props.status}
+    </div>
+  ),
+}));
+
+const FUTURE_LICENCE: Licence = {
+  applianceId: "test-id",
+  earliestLicenceExpiryDate: DateTime.now().plus({ years: 1 }).toJSDate(),
+  latestLicenceExpiryDate: DateTime.now().plus({ years: 1 }).toJSDate(),
+  currentPerformedReplicas: 5,
+  currentPerformedMigrations: 3,
+  lifetimePerformedMigrations: 4,
+  lifetimePerformedReplicas: 6,
+  currentAvailableReplicas: 10,
+  currentAvailableMigrations: 5,
+  lifetimeAvailableReplicas: 15,
+  lifetimeAvailableMigrations: 10,
+};
+
+const SERVER_STATUS: LicenceServerStatus = {
+  hostname: "test-hostname",
+  multi_appliance: false,
+  supported_licence_versions: ["v2"],
+  server_local_time: DateTime.now().toISO()!,
+};
+
+describe("LicenceModule", () => {
+  let defaultProps: LicenceModule["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      licenceInfo: FUTURE_LICENCE,
+      licenceServerStatus: SERVER_STATUS,
+      licenceError: null,
+      loadingLicenceInfo: false,
+      addMode: false,
+      addingLicence: false,
+      backButtonText: "Back",
+      onAddLicence: jest.fn(),
+      onRequestClose: jest.fn(),
+      onAddModeChange: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<LicenceModule {...defaultProps} />);
+    getByText("test-id-licencev2");
+  });
+
+  it("changes to add mode when add button is clicked", () => {
+    const { getByText } = render(<LicenceModule {...defaultProps} />);
+    getByText("Add Licence").click();
+    expect(defaultProps.onAddModeChange).toHaveBeenCalledWith(true);
+  });
+
+  it("renders add mode", () => {
+    const { getByText } = render(<LicenceModule {...defaultProps} addMode />);
+    expect(getByText("Drag the Licence file", { exact: false })).toBeTruthy();
+  });
+
+  it("validates invalid licence", async () => {
+    const { getByText } = render(<LicenceModule {...defaultProps} addMode />);
+    fireEvent.change(document.querySelector("textarea")!, {
+      target: { value: "test" },
+    });
+    await waitFor(() => {
+      const addLicenceButton = getByText("Add Licence");
+      expect(addLicenceButton.hasAttribute("disabled")).toBeTruthy();
+    });
+  });
+
+  it("validates valid licence", async () => {
+    const { getByText } = render(<LicenceModule {...defaultProps} addMode />);
+    fireEvent.change(document.querySelector("textarea")!, {
+      target: {
+        value: `-----BEGIN CORIOLIS LICENCE-----
+Version: 2.0
+-----END CORIOLIS LICENCE-----`,
+      },
+    });
+    await waitFor(() => {
+      const addLicenceButton = getByText("Add Licence");
+      expect(addLicenceButton.hasAttribute("disabled")).toBeFalsy();
+    });
+  });
+
+  it("shows loading", () => {
+    const { getByTestId } = render(
+      <LicenceModule {...defaultProps} loadingLicenceInfo />
+    );
+    expect(getByTestId("StatusImage").textContent).toBe("loading");
+  });
+
+  it("shows licence expires today", () => {
+    render(
+      <LicenceModule
+        {...defaultProps}
+        licenceInfo={{
+          ...FUTURE_LICENCE,
+          earliestLicenceExpiryDate: DateTime.now()
+            .plus({ hours: 1 })
+            .toJSDate(),
+          latestLicenceExpiryDate: DateTime.now().plus({ hours: 1 }).toJSDate(),
+        }}
+      />
+    );
+
+    expect(
+      TestUtils.selectAll("LicenceModule__LicenceRowDescription")[0].textContent
+    ).toContain("today at");
+  });
+
+  it("shows licence expired", () => {
+    render(
+      <LicenceModule
+        {...defaultProps}
+        licenceInfo={{
+          ...FUTURE_LICENCE,
+          earliestLicenceExpiryDate: DateTime.now()
+            .minus({ hours: 1 })
+            .toJSDate(),
+          latestLicenceExpiryDate: DateTime.now()
+            .minus({ hours: 1 })
+            .toJSDate(),
+        }}
+      />
+    );
+
+    expect(
+      TestUtils.select("LicenceModule__LicenceRowContent")?.textContent
+    ).toContain("Please contact Cloudbase Solutions");
+  });
+
+  it("renders licence error", () => {
+    const { getByText } = render(
+      <LicenceModule {...defaultProps} licenceError="test-error" />
+    );
+    expect(getByText("test-error")).toBeTruthy();
+  });
+});

+ 93 - 0
src/components/modules/LoginModule/LoginForm/LoginForm.spec.tsx

@@ -0,0 +1,93 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import notificationStore from "@src/stores/NotificationStore";
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import LoginForm from "./LoginForm";
+
+jest.mock("@src/stores/NotificationStore", () => ({
+  alert: jest.fn(),
+}));
+
+describe("LoginForm", () => {
+  let defaultProps: LoginForm["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      className: "class-custom-name",
+      showUserDomainInput: false,
+      loading: false,
+      loginFailedResponse: null,
+      domain: "default",
+      onDomainChange: jest.fn(),
+      onFormSubmit: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<LoginForm {...defaultProps} />);
+    expect(getByText("Username")).toBeTruthy();
+    expect(getByText("Password")).toBeTruthy();
+  });
+
+  it("submits username and password", () => {
+    render(<LoginForm {...defaultProps} />);
+    const userInput = TestUtils.selectAll("TextInput__Input")[0];
+    const passwordInput = TestUtils.selectAll("TextInput__Input")[1];
+    fireEvent.change(userInput, { target: { value: "username" } });
+    fireEvent.change(passwordInput, { target: { value: "password" } });
+    fireEvent.submit(document.querySelector("form")!);
+    expect(defaultProps.onFormSubmit).toHaveBeenCalledWith({
+      username: "username",
+      password: "password",
+    });
+  });
+
+  it("submits domain", () => {
+    render(<LoginForm {...defaultProps} showUserDomainInput={true} />);
+    const domainInput = TestUtils.selectAll("TextInput__Input")[0];
+    expect((domainInput as HTMLInputElement).value).toBe(defaultProps.domain);
+    fireEvent.change(domainInput, { target: { value: "new-domain" } });
+    expect(defaultProps.onDomainChange).toHaveBeenCalledWith("new-domain");
+  });
+
+  it("shows fill all fields error", () => {
+    render(<LoginForm {...defaultProps} />);
+    fireEvent.submit(document.querySelector("form")!);
+    expect(notificationStore.alert).toHaveBeenCalledWith(
+      "Please fill in all fields"
+    );
+  });
+
+  it("renders incorrect crediantials message", () => {
+    const { getByText } = render(
+      <LoginForm {...defaultProps} loginFailedResponse={{ status: 401 }} />
+    );
+    expect(getByText("Incorrect credentials", { exact: false })).toBeTruthy();
+  });
+
+  it("renders other error message", () => {
+    const { getByText } = render(
+      <LoginForm
+        {...defaultProps}
+        loginFailedResponse={{ status: 500, message: "other error" }}
+      />
+    );
+    expect(getByText("other error", { exact: false })).toBeTruthy();
+  });
+});

+ 1 - 1
src/components/modules/LoginModule/LoginForm/LoginForm.tsx

@@ -87,7 +87,7 @@ type Props = {
   className: string;
   showUserDomainInput: boolean;
   loading: boolean;
-  loginFailedResponse: { status: string | number; message?: string };
+  loginFailedResponse: { status: string | number; message?: string } | null;
   domain: string;
   onDomainChange: (domain: string) => void;
   onFormSubmit: (credentials: { username: string; password: string }) => void;

+ 0 - 54
src/components/modules/LoginModule/LoginForm/test.tsx

@@ -1,54 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import LoginForm from ".";
-
-const wrap = props =>
-  new TW(shallow(<LoginForm {...props} domain="default" />), "loginForm");
-
-describe("LoginForm Component", () => {
-  it("renders incorrect credentials", () => {
-    const wrapper = wrap({ loginFailedResponse: { status: 401 } });
-    expect(
-      wrapper.find("errorText").prop("dangerouslySetInnerHTML").__html
-    ).toBe("Incorrect credentials.<br />Please try again."); // eslint-disable-line
-  });
-
-  it("renders server error", () => {
-    const wrapper = wrap({ loginFailedResponse: {} });
-    expect(
-      wrapper.find("errorText").prop("dangerouslySetInnerHTML").__html
-    ).toBe(
-      "Request failed, there might be a problem with the connection to the server."
-    ); // eslint-disable-line
-  });
-
-  it("submits correct info", () => {
-    const onFormSubmit = sinon.spy();
-    const wrapper = wrap({ onFormSubmit });
-    wrapper
-      .find("usernameField")
-      .simulate("change", { target: { value: "usr" } });
-    wrapper
-      .find("passwordField")
-      .simulate("change", { target: { value: "pswd" } });
-    wrapper.shallow.simulate("submit", { preventDefault: () => {} });
-    expect(onFormSubmit.args[0][0].username).toBe("usr");
-    expect(onFormSubmit.args[0][0].password).toBe("pswd");
-  });
-});

+ 0 - 36
src/components/modules/LoginModule/LoginFormField/test.tsx

@@ -1,36 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TestWrapper from "@src/utils/TestWrapper";
-import LoginFormField from ".";
-
-const wrap = props =>
-  new TestWrapper(shallow(<LoginFormField {...props} />), "loginFormField");
-
-describe("LoginFormField Component", () => {
-  it("renders with correct label", () => {
-    const wrapper = wrap({ label: "Username" });
-    expect(wrapper.findText("label")).toBe("Username");
-  });
-
-  it("dispatches change on input change", () => {
-    const onChange = sinon.spy();
-    const wrapper = wrap({ label: "Username", onChange });
-    wrapper.find("input").simulate("change", { t: "t" });
-    expect(onChange.args[0][0].t).toBe("t");
-  });
-});

+ 59 - 0
src/components/modules/LoginModule/LoginOptions/LoginOptions.spec.tsx

@@ -0,0 +1,59 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import LoginOptions, { Props } from "./LoginOptions";
+
+describe("LoginForm", () => {
+  let defaultProps: Props;
+
+  beforeEach(() => {
+    defaultProps = {
+      buttons: [
+        {
+          id: "google",
+          name: "Google",
+        },
+        {
+          id: "microsoft",
+          name: "Microsoft",
+        },
+        {
+          id: "facebook",
+          name: "Facebook",
+        },
+        {
+          id: "github",
+          name: "GitHub",
+        },
+      ],
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<LoginOptions buttons={[]} />);
+    expect(document.querySelectorAll("div").length).toBe(1);
+  });
+
+  it("renders all buttons", () => {
+    const { getByText } = render(<LoginOptions {...defaultProps} />);
+    expect(getByText("Sign in with Google")).toBeTruthy();
+    expect(getByText("Sign in with Microsoft")).toBeTruthy();
+    expect(getByText("Sign in with Facebook")).toBeTruthy();
+    expect(getByText("Sign in with GitHub")).toBeTruthy();
+  });
+});

+ 1 - 1
src/components/modules/LoginModule/LoginOptions/LoginOptions.tsx

@@ -97,7 +97,7 @@ const Logo = styled.div<any>`
   margin: 0 8px 0 8px;
   ${props => buttonStyle(props.id, true)}
 `;
-type Props = {
+export type Props = {
   buttons?: { name: string; id: string }[];
 };
 const LoginOptions = (props: Props) => {

+ 0 - 57
src/components/modules/LoginModule/LoginOptions/test.tsx

@@ -1,57 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TestWrapper from "@src/utils/TestWrapper";
-import LoginOptions from ".";
-
-const wrap = props =>
-  new TestWrapper(shallow(<LoginOptions {...props} />), "loginOptions");
-
-const buttons = [
-  {
-    name: "Google",
-    id: "google",
-    url: "",
-  },
-  {
-    name: "Microsoft",
-    id: "microsoft",
-    url: "",
-  },
-  {
-    name: "Facebook",
-    id: "facebook",
-    url: "",
-  },
-  {
-    name: "GitHub",
-    id: "github",
-    url: "",
-  },
-];
-
-describe("LoginOptions Component", () => {
-  it("renders with all buttons", () => {
-    const wrapper = wrap({ buttons });
-    expect(wrapper.findPartialId("button").length).toBe(4);
-    buttons.forEach(button => {
-      expect(wrapper.findText(`button-${button.id}`)).toBe(
-        `<styled.div />Sign in with ${button.name}`
-      );
-      expect(wrapper.find(`logo-${button.id}`).prop("id")).toBe(button.id);
-    });
-  });
-});

+ 39 - 0
src/components/modules/MetalHubModule/MetalHubListHeader/MetalHubListHeader.spec.tsx

@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import MetalHubListHeader from "./MetalHubListHeader";
+
+describe("MetalHubListHeader", () => {
+  let defaultProps: MetalHubListHeader["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      hideButton: false,
+      error: "",
+      fingerprint: "e4:95:6e:5c:2a:11:d4:1f:a2",
+      visible: true,
+      onCreateClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MetalHubListHeader {...defaultProps} />);
+    expect(getByText("e4:95:6e:5c:...:11:d4:1f:a2")).toBeTruthy();
+    expect(getByText("Add a Bare Metal Server")).toBeTruthy();
+  });
+});

+ 65 - 0
src/components/modules/MetalHubModule/MetalHubListItem/MetalHubListItem.spec.tsx

@@ -0,0 +1,65 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import DateUtils from "@src/utils/DateUtils";
+import { render } from "@testing-library/react";
+import { METALHUB_SERVER_MOCK } from "@tests/mocks/MetalHubServerMock";
+
+import MetalHubListItem from "./MetalHubListItem";
+
+describe("MetalHubListItem", () => {
+  let defaultProps: MetalHubListItem["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: METALHUB_SERVER_MOCK,
+      selected: false,
+      onSelectedChange: jest.fn(),
+      onClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText, getAllByText } = render(
+      <MetalHubListItem {...defaultProps} />
+    );
+    expect(getByText(METALHUB_SERVER_MOCK.hostname!)).toBeTruthy();
+    expect(getByText("Active")).toBeTruthy();
+    expect(getByText(METALHUB_SERVER_MOCK.api_endpoint)).toBeTruthy();
+    expect(
+      getAllByText(
+        DateUtils.getLocalDate(METALHUB_SERVER_MOCK.created_at).toFormat(
+          "yyyy-LL-dd HH:mm:ss"
+        )
+      ).length
+    ).toBe(2);
+  });
+
+  it("renders default hostname when hostname is empty and inactive status", () => {
+    const { getByText } = render(
+      <MetalHubListItem
+        {...defaultProps}
+        item={{
+          ...METALHUB_SERVER_MOCK,
+          hostname: "",
+          active: false,
+        }}
+      />
+    );
+    expect(getByText("No Hostname")).toBeTruthy();
+    expect(getByText("Inactive")).toBeTruthy();
+  });
+});

+ 126 - 0
src/components/modules/MetalHubModule/MetalHubModal/MetalHubModal.spec.tsx

@@ -0,0 +1,126 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import metalHubStore from "@src/stores/MetalHubStore";
+import { fireEvent, render, waitFor } from "@testing-library/react";
+import { METALHUB_SERVER_MOCK } from "@tests/mocks/MetalHubServerMock";
+
+import MetalHubModal from "./MetalHubModal";
+
+describe("MetalHubModal", () => {
+  let defaultProps: MetalHubModal["props"];
+  let metalHubStoreSpies: {
+    clearValidationError: jest.SpyInstance;
+    patchServer: jest.SpyInstance;
+    validateServer: jest.SpyInstance;
+    addServer: jest.SpyInstance;
+  };
+
+  beforeEach(() => {
+    metalHubStoreSpies = {
+      clearValidationError: jest.spyOn(metalHubStore, "clearValidationError"),
+      patchServer: jest.spyOn(metalHubStore, "patchServer").mockResolvedValue(),
+      validateServer: jest
+        .spyOn(metalHubStore, "validateServer")
+        .mockResolvedValue(true),
+      addServer: jest.spyOn(metalHubStore, "addServer").mockResolvedValue({
+        ...METALHUB_SERVER_MOCK,
+      }),
+    };
+    defaultProps = {
+      onRequestClose: jest.fn(),
+      onUpdateDone: jest.fn(),
+    };
+  });
+
+  it("renders without crashing and clears validation error on unmount", () => {
+    const { getByText, unmount } = render(<MetalHubModal {...defaultProps} />);
+    expect(getByText("Add Coriolis Bare Metal Server")).toBeTruthy();
+    unmount();
+    expect(metalHubStoreSpies.clearValidationError).toHaveBeenCalledTimes(1);
+  });
+
+  it("shows the server for editing", () => {
+    const { getByText } = render(
+      <MetalHubModal {...defaultProps} server={{ ...METALHUB_SERVER_MOCK }} />
+    );
+    expect(getByText("Update Coriolis Bare Metal Server")).toBeTruthy();
+    const testInput = (label: string, value: string) => {
+      const input =
+        getByText(label).parentElement!.parentElement!.querySelector("input");
+      expect(input).toBeTruthy();
+      expect(input!.value).toBe(value);
+    };
+    testInput(
+      "Host",
+      METALHUB_SERVER_MOCK.api_endpoint!.split(":")[1].replace("//", "")
+    );
+    testInput(
+      "Port",
+      METALHUB_SERVER_MOCK.api_endpoint!.split(":")[2].replace(/\/.*/, "")
+    );
+  });
+
+  it("renders validation error", () => {
+    metalHubStore.validationError = ["Validation error", "Validation error 2"];
+    const { getByText } = render(
+      <MetalHubModal {...defaultProps} server={{ ...METALHUB_SERVER_MOCK }} />
+    );
+    expect(getByText("Validation error")).toBeTruthy();
+    expect(getByText("Validation error 2")).toBeTruthy();
+    metalHubStore.validationError = [];
+  });
+
+  it("triggers submit on enter key", async () => {
+    render(
+      <MetalHubModal {...defaultProps} server={{ ...METALHUB_SERVER_MOCK }} />
+    );
+    const input = document.querySelector("input")!;
+    fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
+    await waitFor(() => {
+      expect(metalHubStoreSpies.patchServer).toHaveBeenCalledTimes(1);
+    });
+    expect(metalHubStoreSpies.validateServer).toHaveBeenCalledTimes(1);
+  });
+
+  it("highlights invalid fields", () => {
+    const { getByText, getAllByText } = render(
+      <MetalHubModal {...defaultProps} />
+    );
+    fireEvent.click(getByText("Validate and save"));
+    expect(getAllByText("Required field").length).toBeGreaterThan(0);
+  });
+
+  it("adds new server", async () => {
+    const { getByText } = render(<MetalHubModal {...defaultProps} />);
+    const getInput = (label: string): HTMLInputElement =>
+      getByText(label).parentElement!.parentElement!.querySelector("input")!;
+
+    fireEvent.change(getInput("Host"), {
+      target: { value: "api.example.com" },
+    });
+    fireEvent.change(getInput("Port"), { target: { value: "5566" } });
+    fireEvent.click(getByText("Validate and save"));
+    await waitFor(() => {
+      expect(metalHubStoreSpies.addServer).toHaveBeenCalledWith(
+        "https://api.example.com:5566/api/v1"
+      );
+    });
+    expect(metalHubStoreSpies.validateServer).toHaveBeenCalledWith(
+      METALHUB_SERVER_MOCK.id
+    );
+  });
+});

+ 11 - 10
src/components/modules/MetalHubModule/MetalHubModal/MetalHubModal.tsx

@@ -12,22 +12,23 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-import React from "react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled, { css } from "styled-components";
 
-import type { Field as FieldType } from "@src/@types/Field";
+import { MetalHubServer } from "@src/@types/MetalHub";
+import { ThemeProps } from "@src/components/Theme";
 import Button from "@src/components/ui/Button";
-import Modal from "@src/components/ui/Modal";
 import FieldInput from "@src/components/ui/FieldInput";
-
-import KeyboardManager from "@src/utils/KeyboardManager";
-import { ThemeProps } from "@src/components/Theme";
 import LoadingButton from "@src/components/ui/LoadingButton";
-import { MetalHubServer } from "@src/@types/MetalHub";
-import image from "./images/server.svg";
-import metalHubStore from "@src/stores/MetalHubStore";
+import Modal from "@src/components/ui/Modal";
 import StatusIcon from "@src/components/ui/StatusComponents/StatusIcon";
+import metalHubStore from "@src/stores/MetalHubStore";
+import KeyboardManager from "@src/utils/KeyboardManager";
+
+import image from "./images/server.svg";
+
+import type { Field as FieldType } from "@src/@types/Field";
 
 const Wrapper = styled.div`
   padding: 48px 32px 32px 32px;
@@ -302,7 +303,7 @@ class MetalHubModal extends React.Component<Props, State> {
     const message = this.state.saving
       ? "Validating ..."
       : metalHubStore.validationError.length
-      ? metalHubStore.validationError.map(e => <div key="e">{e}</div>)
+      ? metalHubStore.validationError.map(e => <div key={e}>{e}</div>)
       : "Validation successful";
     const status = this.state.saving
       ? "RUNNING"

+ 101 - 0
src/components/modules/MetalHubModule/MetalHubServerDetailsContent/MetalHubServerDetailsContent.spec.tsx

@@ -0,0 +1,101 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import DateUtils from "@src/utils/DateUtils";
+import { render } from "@testing-library/react";
+import { METALHUB_SERVER_MOCK } from "@tests/mocks/MetalHubServerMock";
+import TestUtils from "@tests/TestUtils";
+
+import MetalHubServerDetailsContent from "./MetalHubServerDetailsContent";
+
+jest.mock("@src/components/ui/Arrow", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="Arrow">Orientation: {props.orientation}</div>
+  ),
+}));
+
+describe("MetalHubServerDetailsContent", () => {
+  let defaultProps: MetalHubServerDetailsContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      server: { ...METALHUB_SERVER_MOCK },
+      loading: false,
+      creatingMigration: false,
+      creatingReplica: false,
+      onCreateReplicaClick: jest.fn(),
+      onCreateMigrationClick: jest.fn(),
+      onDeleteClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<MetalHubServerDetailsContent {...defaultProps} />);
+    const getText = (text: string) => {
+      let element: Element | null = null;
+      document.querySelectorAll("*").forEach(el => {
+        if (el.textContent && el.textContent.includes(text)) {
+          element = el;
+        }
+      });
+      if (!element) throw new Error(`Element with text "${text}" not found`);
+      return element;
+    };
+    expect(getText(METALHUB_SERVER_MOCK.hostname!)).toBeTruthy();
+    expect(getText(METALHUB_SERVER_MOCK.api_endpoint)).toBeTruthy();
+    expect(
+      getText(
+        DateUtils.getLocalDate(METALHUB_SERVER_MOCK.created_at).toFormat(
+          "yyyy-LL-dd HH:mm:ss"
+        )
+      )
+    ).toBeTruthy();
+
+    expect(
+      getText(`${METALHUB_SERVER_MOCK.physical_cores} physical`)
+    ).toBeTruthy();
+    expect(
+      getText(`${METALHUB_SERVER_MOCK.logical_cores} logical`)
+    ).toBeTruthy();
+    expect(
+      getText(
+        `${METALHUB_SERVER_MOCK.os_info.os_name} ${METALHUB_SERVER_MOCK.os_info.os_version}`
+      )
+    ).toBeTruthy();
+  });
+
+  it("handles row click", () => {
+    const { getAllByTestId } = render(
+      <MetalHubServerDetailsContent {...defaultProps} />
+    );
+    const row = TestUtils.select("TransferDetailsTable__Row-")!;
+    expect(row).toBeTruthy();
+    expect(getAllByTestId("Arrow")[0].textContent).toBe("Orientation: down");
+    row.click();
+    expect(getAllByTestId("Arrow")[0].textContent).toBe("Orientation: up");
+
+    row.click();
+    expect(getAllByTestId("Arrow")[0].textContent).toBe("Orientation: down");
+  });
+
+  it("renders loading", () => {
+    render(<MetalHubServerDetailsContent {...defaultProps} loading />);
+    expect(
+      TestUtils.select("MetalHubServerDetailsContent__LoadingWrapper")
+    ).toBeTruthy();
+  });
+});

+ 92 - 0
src/components/modules/MinionModule/MinionEndpointModal/MinionEndpointModal.spec.tsx

@@ -0,0 +1,92 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import {
+  OPENSTACK_ENDPOINT_MOCK,
+  VMWARE_ENDPOINT_MOCK,
+} from "@tests/mocks/EndpointsMock";
+import { PROVIDERS_MOCK } from "@tests/mocks/ProvidersMock";
+import TestUtils from "@tests/TestUtils";
+
+import MinionEndpointModal from "./MinionEndpointModal";
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="EndpointLogos">{props.endpoint}</div>
+  ),
+}));
+
+jest.mock("react-transition-group", () => ({
+  CSSTransitionGroup: (props: any) => <div>{props.children}</div>,
+}));
+
+describe("MinionEndpointModal", () => {
+  let defaultProps: MinionEndpointModal["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      providers: PROVIDERS_MOCK,
+      endpoints: [OPENSTACK_ENDPOINT_MOCK, VMWARE_ENDPOINT_MOCK],
+      loading: false,
+      onRequestClose: jest.fn(),
+      onSelectEndpoint: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByTestId } = render(<MinionEndpointModal {...defaultProps} />);
+    expect(getByTestId("EndpointLogos").textContent).toBe("vmware_vsphere");
+  });
+
+  it("renders no endpoints if provider doesn't have support for minions", () => {
+    render(
+      <MinionEndpointModal
+        {...defaultProps}
+        providers={{ ...PROVIDERS_MOCK, vmware_vsphere: { types: [] } }}
+      />
+    );
+    expect(
+      TestUtils.select("MinionEndpointModal__NoEndpoints")?.textContent
+    ).toContain("Please create a Coriolis Endpoint");
+  });
+
+  it("renders no endpoints if no providers", () => {
+    render(<MinionEndpointModal {...defaultProps} providers={null} />);
+    expect(
+      TestUtils.select("MinionEndpointModal__NoEndpoints")?.textContent
+    ).toContain("Please create a Coriolis Endpoint");
+  });
+
+  it("selects an endpoint", () => {
+    const { getByText } = render(<MinionEndpointModal {...defaultProps} />);
+    TestUtils.select("DropdownButton__Wrapper")?.click();
+    TestUtils.select("Dropdown__ListItem-")?.click();
+    getByText("Next").click();
+    expect(defaultProps.onSelectEndpoint).toHaveBeenCalledWith(
+      VMWARE_ENDPOINT_MOCK,
+      "source"
+    );
+  });
+
+  it("renders loading", () => {
+    render(<MinionEndpointModal {...defaultProps} loading />);
+    expect(
+      TestUtils.select("MinionEndpointModal__LoadingWrapper")
+    ).toBeTruthy();
+  });
+});

+ 1 - 1
src/components/modules/MinionModule/MinionEndpointModal/MinionEndpointModal.tsx

@@ -183,7 +183,7 @@ class MinionEndpointModal extends React.Component<Props, State> {
             : providerTypes.DESTINATION_MINION_POOL;
         const types =
           this.props.providers?.[providerName].types.indexOf(providerType);
-        return types && types > -1;
+        return types != null && types > -1;
       }
     );
 

+ 75 - 0
src/components/modules/MinionModule/MinionPoolConfirmationModal/MinionPoolConfirmationModal.spec.tsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+
+import MinionPoolConfirmationModal from "./MinionPoolConfirmationModal";
+
+jest.mock("@src/components/ui/FieldInput", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div
+      data-testid="FieldInput"
+      onClick={() => {
+        props.onChange(true);
+      }}
+    >
+      {props.label} - {String(props.value)}
+    </div>
+  ),
+}));
+
+describe("MinionPoolConfirmationModal", () => {
+  let defaultProps: MinionPoolConfirmationModal["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      onCancelClick: jest.fn(),
+      onExecuteClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<MinionPoolConfirmationModal {...defaultProps} />);
+    let element: Element | null = null;
+    document.querySelectorAll("*").forEach(el => {
+      if (el.textContent && el.textContent.includes("Are you sure")) {
+        element = el;
+      }
+    });
+    if (!element) throw new Error(`Element not found`);
+    expect(element).toBeTruthy();
+  });
+
+  it("handles submit on Enter key press", () => {
+    render(<MinionPoolConfirmationModal {...defaultProps} />);
+
+    fireEvent.keyDown(document, { key: "Enter" });
+
+    expect(defaultProps.onExecuteClick).toHaveBeenCalledWith(false);
+  });
+
+  it("executes with force flag", () => {
+    const { getByTestId } = render(
+      <MinionPoolConfirmationModal {...defaultProps} />
+    );
+
+    fireEvent.click(getByTestId("FieldInput"));
+    fireEvent.click(document.querySelectorAll("button")[1]);
+
+    expect(defaultProps.onExecuteClick).toHaveBeenCalledWith(true);
+  });
+});

+ 102 - 0
src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolDetailsContent.spec.tsx

@@ -0,0 +1,102 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import { OPENSTACK_ENDPOINT_MOCK } from "@tests/mocks/EndpointsMock";
+import { MINION_POOL_DETAILS_MOCK } from "@tests/mocks/MinionPoolMock";
+import { REPLICA_MOCK } from "@tests/mocks/TransferMock";
+import TestUtils from "@tests/TestUtils";
+
+import MinionPoolDetailsContent from "./MinionPoolDetailsContent";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="EndpointLogos">{props.endpoint}</div>
+  ),
+}));
+jest.mock(
+  "@src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolEvents",
+  () => ({
+    __esModule: true,
+    default: () => <div data-testid="MinionPoolEvents"></div>,
+  })
+);
+
+describe("MinionPoolDetailsContent", () => {
+  let defaultProps: MinionPoolDetailsContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MINION_POOL_DETAILS_MOCK,
+      itemId: "minion-pool-id",
+      replicas: [REPLICA_MOCK],
+      migrations: [],
+      endpoints: [OPENSTACK_ENDPOINT_MOCK],
+      schema: [
+        {
+          name: "name",
+          label: "Name",
+          type: "text",
+          required: true,
+          disabled: false,
+        },
+      ],
+      schemaLoading: false,
+      loading: false,
+      page: "",
+      onAllocate: jest.fn(),
+      onDeleteMinionPoolClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(
+      <MinionPoolDetailsContent {...defaultProps} />
+    );
+    expect(getByText(MINION_POOL_DETAILS_MOCK.id)).toBeTruthy();
+    expect(getByText(MINION_POOL_DETAILS_MOCK.notes!)).toBeTruthy();
+  });
+
+  it("calls allocate callback", () => {
+    const { getByText } = render(
+      <MinionPoolDetailsContent
+        {...defaultProps}
+        item={{ ...MINION_POOL_DETAILS_MOCK, status: "DEALLOCATED" }}
+      />
+    );
+    getByText("Allocate").click();
+    expect(defaultProps.onAllocate).toHaveBeenCalled();
+  });
+
+  it("renders loading", () => {
+    render(<MinionPoolDetailsContent {...defaultProps} loading />);
+    expect(TestUtils.select("MinionPoolDetailsContent__Loading")).toBeTruthy();
+  });
+
+  it("renders machines page", () => {
+    render(<MinionPoolDetailsContent {...defaultProps} page="machines" />);
+    expect(TestUtils.select("MinionPoolMachines")).toBeTruthy();
+  });
+
+  it("renders events page", () => {
+    const { getByTestId } = render(
+      <MinionPoolDetailsContent {...defaultProps} page="events" />
+    );
+    expect(getByTestId("MinionPoolEvents")).toBeTruthy();
+  });
+});

+ 137 - 0
src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolEvents.spec.tsx

@@ -0,0 +1,137 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import { MINION_POOL_DETAILS_MOCK } from "@tests/mocks/MinionPoolMock";
+import TestUtils from "@tests/TestUtils";
+
+import MinionPoolEvents from "./MinionPoolEvents";
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    maxMinionPoolEventsPerPage: 1,
+  },
+}));
+
+describe("MinionPoolEvents", () => {
+  let defaultProps: MinionPoolEvents["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MINION_POOL_DETAILS_MOCK,
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<MinionPoolEvents {...defaultProps} />);
+    expect(TestUtils.select("MinionPoolEvents__Message")?.textContent).toBe(
+      MINION_POOL_DETAILS_MOCK.events[0].message
+    );
+  });
+
+  it.each`
+    fromLabel             | toLabel
+    ${"Events"}           | ${"Progress Updates"}
+    ${"Events"}           | ${"Events & Progress Updates"}
+    ${"INFO Event Level"} | ${"DEBUG Event Level"}
+    ${"INFO Event Level"} | ${"ERROR Event Level"}
+    ${"Descending Order"} | ${"Ascending Order"}
+  `("filters by $fromLabel to $toLabel", ({ fromLabel, toLabel }) => {
+    render(<MinionPoolEvents {...defaultProps} />);
+    let filterDropdown: HTMLElement | null = null;
+    TestUtils.selectAll("DropdownLink__Label").forEach(element => {
+      if (element.textContent === fromLabel) {
+        filterDropdown = element;
+      }
+    });
+    expect(filterDropdown).toBeTruthy();
+    filterDropdown!.click();
+    let listItem: HTMLElement | null = null;
+    TestUtils.selectAll("DropdownLink__ListItem-").forEach(element => {
+      if (element.textContent === toLabel) {
+        listItem = element;
+      }
+    });
+    expect(listItem).toBeTruthy();
+    listItem!.click();
+
+    TestUtils.selectAll("DropdownLink__Label").forEach(element => {
+      if (element.textContent === toLabel) {
+        filterDropdown = element;
+      }
+    });
+    expect(filterDropdown).toBeTruthy();
+  });
+
+  describe("Pagination", () => {
+    const showAllEvents = () => {
+      let showAllEvents: HTMLElement | null = null;
+      TestUtils.selectAll("DropdownLink__Label").forEach(element => {
+        if (element.textContent === "Events") {
+          showAllEvents = element;
+        }
+      });
+      expect(showAllEvents).toBeTruthy();
+      showAllEvents!.click();
+      let listItem: HTMLElement | null = null;
+      TestUtils.selectAll("DropdownLink__ListItem-").forEach(element => {
+        if (element.textContent === "Events & Progress Updates") {
+          listItem = element;
+        }
+      });
+      expect(listItem).toBeTruthy();
+      listItem!.click();
+    };
+
+    it("has pagination", () => {
+      render(<MinionPoolEvents {...defaultProps} />);
+
+      // pagination is not visible for 1 event
+      expect(TestUtils.select("Pagination__Wrapper")!).toBeFalsy();
+
+      showAllEvents();
+
+      // pagination is visible for more than 1 event
+      expect(TestUtils.select("Pagination__Wrapper")!).toBeTruthy();
+    });
+
+    it("goes to next page and back", () => {
+      render(<MinionPoolEvents {...defaultProps} />);
+
+      showAllEvents();
+
+      expect(
+        TestUtils.select("Pagination__PagePrevious")!.hasAttribute("disabled")
+      ).toBeTruthy();
+      expect(TestUtils.select("Pagination__PageNumber")!.textContent).toBe(
+        "1 of 3"
+      );
+
+      TestUtils.select("Pagination__PageNext")!.click();
+      expect(
+        TestUtils.select("Pagination__PagePrevious")!.hasAttribute("disabled")
+      ).toBeFalsy();
+      expect(TestUtils.select("Pagination__PageNumber")!.textContent).toBe(
+        "2 of 3"
+      );
+
+      TestUtils.select("Pagination__PagePrevious")!.click();
+      expect(TestUtils.select("Pagination__PageNumber")!.textContent).toBe(
+        "1 of 3"
+      );
+    });
+  });
+});

+ 0 - 11
src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolEvents.tsx

@@ -93,17 +93,6 @@ type State = {
   orderDir: OrderDir;
 };
 class MinionPoolEvents extends React.Component<Props, State> {
-  static sortData(
-    data: MinionPoolEventProgressUpdate[]
-  ): MinionPoolEventProgressUpdate[] {
-    return data
-      .slice()
-      .sort(
-        (a, b) =>
-          new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
-      );
-  }
-
   state = {
     allEvents: [] as MinionPoolEventProgressUpdate[],
     prevLenghts: [0, 0],

+ 106 - 0
src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolMachines.spec.tsx

@@ -0,0 +1,106 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { MIGRATION_MOCK, REPLICA_MOCK } from "@tests/mocks/TransferMock";
+import TestUtils from "@tests/TestUtils";
+
+import MinionPoolMachines from "./MinionPoolMachines";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+describe("MinionPoolMachines", () => {
+  let defaultProps: MinionPoolMachines["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MINION_POOL_MOCK,
+      replicas: [REPLICA_MOCK],
+      migrations: [MIGRATION_MOCK],
+    };
+  });
+
+  const filterBy = (fromLabel: string, toLabel: string) => {
+    let filterDropdown: HTMLElement | null = null;
+    TestUtils.selectAll("DropdownLink__Label").forEach(element => {
+      if (element.textContent === fromLabel) {
+        filterDropdown = element;
+      }
+    });
+    expect(filterDropdown).toBeTruthy();
+
+    filterDropdown!.click();
+
+    let filterItem: HTMLElement | null = null;
+    TestUtils.selectAll("DropdownLink__ListItem-").forEach(element => {
+      if (element.textContent === toLabel) {
+        filterItem = element;
+      }
+    });
+    expect(filterItem).toBeTruthy();
+    filterItem!.click();
+  };
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MinionPoolMachines {...defaultProps} />);
+    expect(
+      TestUtils.select("MinionPoolMachines__HeaderText")?.textContent
+    ).toBe("1 minion machine, 1 allocated");
+    expect(
+      getByText(`ID: ${MINION_POOL_MOCK.minion_machines[0].id}`)
+    ).toBeTruthy();
+  });
+
+  it("filters correctly", () => {
+    render(<MinionPoolMachines {...defaultProps} />);
+    filterBy("All", "Allocated");
+    expect(
+      TestUtils.selectAll("MinionPoolMachines__MachineWrapper").length
+    ).toBe(1);
+
+    filterBy("Allocated", "Not Allocated");
+    expect(
+      TestUtils.selectAll("MinionPoolMachines__MachineWrapper").length
+    ).toBe(0);
+  });
+
+  it("renders no machines", () => {
+    render(
+      <MinionPoolMachines
+        {...defaultProps}
+        item={{ ...MINION_POOL_MOCK, minion_machines: [] }}
+      />
+    );
+    expect(TestUtils.select("MinionPoolMachines__NoMachines")).toBeTruthy();
+  });
+
+  it("handles row click", () => {
+    render(<MinionPoolMachines {...defaultProps} />);
+    const arrow = TestUtils.select(
+      "Arrow__Wrapper",
+      TestUtils.select("MinionPoolMachines__Row-")!
+    );
+    expect(arrow).toBeTruthy();
+    expect(arrow!.attributes.getNamedItem("orientation")!.value).toBe("down");
+
+    TestUtils.select("MinionPoolMachines__Row-")!.click();
+    expect(arrow!.attributes.getNamedItem("orientation")!.value).toBe("up");
+
+    TestUtils.select("MinionPoolMachines__Row-")!.click();
+    expect(arrow!.attributes.getNamedItem("orientation")!.value).toBe("down");
+  });
+});

+ 74 - 0
src/components/modules/MinionModule/MinionPoolDetailsContent/MinionPoolMainDetails.spec.tsx

@@ -0,0 +1,74 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import { OPENSTACK_ENDPOINT_MOCK } from "@tests/mocks/EndpointsMock";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { MIGRATION_MOCK, REPLICA_MOCK } from "@tests/mocks/TransferMock";
+
+import MinionPoolMainDetails from "./MinionPoolMainDetails";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="EndpointLogos">{props.endpoint}</div>
+  ),
+}));
+
+describe("MinionPoolMainDetails", () => {
+  let defaultProps: MinionPoolMainDetails["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MINION_POOL_MOCK,
+      replicas: [REPLICA_MOCK],
+      migrations: [MIGRATION_MOCK],
+      schema: [],
+      schemaLoading: false,
+      endpoints: [OPENSTACK_ENDPOINT_MOCK],
+      bottomControls: <div data-testid="bottom-controls">BC</div>,
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText, getByTestId } = render(
+      <MinionPoolMainDetails {...defaultProps} />
+    );
+    expect(getByText(MINION_POOL_MOCK.notes!)).toBeTruthy();
+    expect(getByText(OPENSTACK_ENDPOINT_MOCK.name)).toBeTruthy();
+    expect(getByTestId("bottom-controls")).toBeTruthy();
+    expect(
+      getByText(MINION_POOL_MOCK.environment_options.option_1)
+    ).toBeTruthy();
+    expect(getByText("Object Option - Object Option 1")).toBeTruthy();
+    expect(
+      getByText(MINION_POOL_MOCK.environment_options.array_option[0])
+    ).toBeTruthy();
+    expect(getByText("source_value=destination_value")).toBeTruthy();
+  });
+
+  it("renders missing endpoint", () => {
+    render(<MinionPoolMainDetails {...defaultProps} endpoints={[]} />);
+    let missingEndpoint: Element | null = null;
+    document.querySelectorAll("*").forEach(element => {
+      if (element.textContent === "Endpoint is missing") {
+        missingEndpoint = element;
+      }
+    });
+    expect(missingEndpoint).toBeTruthy();
+  });
+});

+ 39 - 0
src/components/modules/MinionModule/MinionPoolListItem/MinionPoolListItem.spec.tsx

@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+
+import MinionPoolListItem from "./MinionPoolListItem";
+
+describe("MinionPoolListItem", () => {
+  let defaultProps: MinionPoolListItem["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MINION_POOL_MOCK,
+      selected: false,
+      onClick: jest.fn(),
+      endpointType: jest.fn(),
+      onSelectedChange: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MinionPoolListItem {...defaultProps} />);
+    expect(getByText(MINION_POOL_MOCK.name)).toBeTruthy();
+  });
+});

+ 113 - 0
src/components/modules/MinionModule/MinionPoolModal/MinionPoolModalContent.spec.tsx

@@ -0,0 +1,113 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+import { OPENSTACK_ENDPOINT_MOCK } from "@tests/mocks/EndpointsMock";
+import TestUtils from "@tests/TestUtils";
+
+import MinionPoolModalContent from "./MinionPoolModalContent";
+
+jest.mock("@src/plugins/default/ContentPlugin", () => jest.fn(() => null));
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => <div data-testid={props.endpoint} />,
+}));
+
+const DEFAULT_SCHEMA_MOCK = [
+  {
+    name: "name",
+    type: "string",
+    required: true,
+  },
+  {
+    name: "endpoint_id",
+    type: "string",
+    required: true,
+  },
+  {
+    name: "platform",
+    type: "string",
+    required: true,
+  },
+];
+
+const ENV_SCHEMA_MOCK = [
+  {
+    name: "env_option",
+    type: "string",
+  },
+  {
+    name: "required_env_option",
+    type: "string",
+    required: true,
+  },
+];
+
+describe("MinionPoolModalContent", () => {
+  let defaultProps: MinionPoolModalContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      envOptionsDisabled: false,
+      defaultSchema: [...DEFAULT_SCHEMA_MOCK],
+      envSchema: [...ENV_SCHEMA_MOCK],
+      invalidFields: [ENV_SCHEMA_MOCK[1].name],
+      endpoint: OPENSTACK_ENDPOINT_MOCK,
+      platform: "source",
+      optionsLoading: false,
+      optionsLoadingSkipFields: [],
+      disabled: false,
+      cancelButtonText: "Cancel",
+      getFieldValue: jest.fn(),
+      onFieldChange: jest.fn(),
+      onResizeUpdate: jest.fn(),
+      scrollableRef: jest.fn(),
+      onCreateClick: jest.fn(),
+      onCancelClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MinionPoolModalContent {...defaultProps} />);
+    expect(getByText("Environment Options")).toBeTruthy();
+  });
+
+  it("calls resize on simple / advanced toggle", () => {
+    const { getByText } = render(<MinionPoolModalContent {...defaultProps} />);
+    getByText("Advanced").click();
+    expect(defaultProps.onResizeUpdate).toHaveBeenCalled();
+  });
+
+  it("filters non required fields", () => {
+    const { getByText } = render(<MinionPoolModalContent {...defaultProps} />);
+    expect(TestUtils.selectAll("FieldInput__LabelText")[1].textContent).toBe(
+      "Required Env Option"
+    );
+    getByText("Advanced").click();
+    expect(TestUtils.selectAll("FieldInput__LabelText")[1].textContent).toBe(
+      "Env Option"
+    );
+  });
+
+  it("fires onFieldChange", () => {
+    render(<MinionPoolModalContent {...defaultProps} />);
+    fireEvent.change(TestUtils.select("TextInput__Input")!, {
+      target: { value: "test" },
+    });
+    expect(defaultProps.onFieldChange).toHaveBeenCalled();
+  });
+});

+ 0 - 55
src/components/modules/NavigationModule/DetailsNavigation/test.tsx

@@ -1,55 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TestWrapper from "@src/utils/TestWrapper";
-import DetailsNavigation from ".";
-
-const wrap = props =>
-  new TestWrapper(
-    shallow(<DetailsNavigation {...props} />),
-    "detailsNavigation"
-  );
-const items = [
-  { label: "Item 1", value: "item-1" },
-  { label: "Item 2", value: "item-2" },
-  { label: "Item 3", value: "item-3" },
-];
-
-describe("DetailsNavigation Component", () => {
-  // it('renders 3 items', () => {
-  //   let wrapper = wrap({ items})
-  //   console.log(wrapper.find('dn-wrapper').debug())
-  //   // items.forEach(item => {
-  //   //   expect(wrapper.find(item.value).shallow.dive().dive()).toBe(item.label)
-  //   // })
-  // })
-
-  it("has items with correct href attribute", () => {
-    const wrapper = wrap({ items, itemType: "replica", itemId: "item-id" });
-    expect(wrapper.find(items[0].value).prop("to")).toBe(
-      "/replica/item-1/item-id"
-    );
-  });
-
-  it("has items with correct href attribute, if items have no value", () => {
-    const wrapper = wrap({
-      items: [{ label: "Item 1", value: "" }],
-      itemType: "migration",
-      itemId: "item-id",
-    });
-    expect(wrapper.find("").prop("to")).toBe("/migration/item-id");
-  });
-});

+ 1 - 1
src/components/modules/NavigationModule/Navigation/Navigation.tsx

@@ -229,7 +229,7 @@ const CbsLogoSmall = styled.a<any>`
   display: flex;
   transition: opacity ${ANIMATION};
 `;
-export const TEST_ID = "navigation";
+
 type Props = {
   currentPage?: string;
   className?: string;

+ 42 - 0
src/components/modules/NavigationModule/NavigationMini/NavigationMini.spec.tsx

@@ -0,0 +1,42 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import NavigationMini from "./";
+import TestUtils from "@tests/TestUtils";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+jest.mock("@src/components/modules/NavigationModule/Navigation", () => ({
+  __esModule: true,
+  default: (props: any) => (
+    <div data-testid="navigation">open: {String(props.open)}</div>
+  ),
+}));
+
+describe("NavigationMini", () => {
+  it("renders without crasing", () => {
+    render(<NavigationMini />);
+    expect(TestUtils.select("NavigationMini__Wrapper")).toBeTruthy();
+  });
+
+  it("toggles the menu", () => {
+    const { getByTestId } = render(<NavigationMini />);
+    expect(getByTestId("navigation").textContent).toBe("open: false");
+    TestUtils.select("NavigationMini__MenuImage")!.click();
+    expect(getByTestId("navigation").textContent).toBe("open: true");
+  });
+});

+ 0 - 2
src/components/modules/NavigationModule/NavigationMini/NavigationMini.tsx

@@ -71,8 +71,6 @@ const NavigationStyled = styled(Navigation)<any>`
   z-index: 9;
 `;
 
-export const TEST_ID = "navigationMini";
-
 type State = {
   open: boolean;
 };

+ 197 - 0
src/components/modules/ProjectModule/ProjectDetailsContent/ProjectDetailsContent.spec.tsx

@@ -0,0 +1,197 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { Project, RoleAssignment } from "@src/@types/Project";
+import { render } from "@testing-library/react";
+
+import ProjectDetailsContent from "./";
+import { User } from "@src/@types/User";
+import TestUtils from "@tests/TestUtils";
+
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+const PROJECT: Project = {
+  id: "project-id",
+  name: "project-name",
+};
+const USER: User = {
+  id: "user-id",
+  name: "user-name",
+  project: PROJECT,
+  email: "user-email",
+  enabled: true,
+};
+const ROLE_ASSIGNMENT: RoleAssignment = {
+  scope: {
+    project: PROJECT,
+  },
+  role: {
+    id: "role-id",
+    name: "role-name",
+  },
+  user: USER,
+};
+
+describe("ProjectDetailsContent", () => {
+  let defaultProps: ProjectDetailsContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      project: PROJECT,
+      loading: false,
+      users: [USER],
+      usersLoading: false,
+      roleAssignments: [ROLE_ASSIGNMENT],
+      roles: [ROLE_ASSIGNMENT.role],
+      loggedUserId: "admin",
+      onEnableUser: jest.fn(),
+      onRemoveUser: jest.fn(),
+      onUserRoleChange: jest.fn(),
+      onAddMemberClick: jest.fn(),
+      onDeleteClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<ProjectDetailsContent {...defaultProps} />);
+    expect(getByText(PROJECT.name)).toBeTruthy();
+    expect(getByText(PROJECT.id)).toBeTruthy();
+  });
+
+  describe("user actions", () => {
+    const openActionsDropdown = () => {
+      const dropdowns = TestUtils.selectAll("DropdownLink__LinkButton");
+      let actionsDropdown;
+
+      for (const dropdown of dropdowns) {
+        if (dropdown.textContent?.includes("Actions")) {
+          actionsDropdown = dropdown;
+          break;
+        }
+      }
+
+      expect(actionsDropdown).toBeTruthy();
+      actionsDropdown?.click();
+    };
+
+    const clickAction = (action: string) => {
+      const items = TestUtils.selectAll("DropdownLink__ListItem-");
+      let actionItem;
+
+      for (const item of items) {
+        if (item.textContent?.includes(action)) {
+          actionItem = item;
+          break;
+        }
+      }
+
+      expect(actionItem).toBeTruthy();
+      actionItem?.click();
+    };
+
+    it("removes user", () => {
+      render(<ProjectDetailsContent {...defaultProps} />);
+
+      openActionsDropdown();
+      clickAction("Remove");
+
+      expect(TestUtils.select("AlertModal__Message")).toBeTruthy();
+      TestUtils.select("AlertModal__Buttons")
+        ?.querySelectorAll("button")[1]
+        .click();
+
+      expect(defaultProps.onRemoveUser).toBeCalled();
+    });
+
+    it("cancels removing user", () => {
+      render(<ProjectDetailsContent {...defaultProps} />);
+
+      openActionsDropdown();
+      clickAction("Remove");
+
+      expect(TestUtils.select("AlertModal__Message")).toBeTruthy();
+      TestUtils.select("AlertModal__Buttons")
+        ?.querySelectorAll("button")[0]
+        .click();
+
+      expect(defaultProps.onRemoveUser).not.toBeCalled();
+    });
+
+    it("enables user", () => {
+      render(
+        <ProjectDetailsContent
+          {...defaultProps}
+          users={[{ ...USER, enabled: false }]}
+        />
+      );
+
+      openActionsDropdown();
+      clickAction("Enable");
+
+      expect(defaultProps.onEnableUser).toBeCalled();
+    });
+
+    it("handles invalid action", () => {
+      const component = new ProjectDetailsContent(defaultProps);
+      component.handleUserAction(USER, { label: "Invalid", value: "invalid" });
+
+      expect(defaultProps.onEnableUser).not.toBeCalled();
+      expect(defaultProps.onRemoveUser).not.toBeCalled();
+    });
+  });
+
+  it("renders loading", () => {
+    render(<ProjectDetailsContent {...defaultProps} loading />);
+    expect(
+      TestUtils.select("ProjectDetailsContent__LoadingWrapper")
+    ).toBeTruthy();
+  });
+
+  it("changes user role", () => {
+    render(<ProjectDetailsContent {...defaultProps} />);
+    const dropdowns = TestUtils.selectAll("DropdownLink__LinkButton");
+    let roleDropdown;
+
+    for (const dropdown of dropdowns) {
+      if (dropdown.textContent?.includes(ROLE_ASSIGNMENT.role.name)) {
+        roleDropdown = dropdown;
+        break;
+      }
+    }
+
+    expect(roleDropdown).toBeTruthy();
+    roleDropdown?.click();
+
+    const items = TestUtils.selectAll("DropdownLink__ListItem-");
+    let roleItem;
+
+    for (const item of items) {
+      if (item.textContent?.includes(ROLE_ASSIGNMENT.role.name)) {
+        roleItem = item;
+        break;
+      }
+    }
+
+    expect(roleItem).toBeTruthy();
+    roleItem?.click();
+
+    expect(defaultProps.onUserRoleChange).toBeCalledWith(
+      USER,
+      ROLE_ASSIGNMENT.role.id,
+      false
+    );
+  });
+});

+ 7 - 13
src/components/modules/ProjectModule/ProjectDetailsContent/ProjectDetailsContent.tsx

@@ -12,22 +12,22 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
+import { observer } from "mobx-react";
 import React from "react";
 import { Link } from "react-router-dom";
-import { observer } from "mobx-react";
 import styled, { css } from "styled-components";
 
+import { ThemePalette, ThemeProps } from "@src/components/Theme";
 import AlertModal from "@src/components/ui/AlertModal";
-import Table from "@src/components/ui/Table";
-import CopyValue from "@src/components/ui/CopyValue";
+import Button from "@src/components/ui/Button";
 import CopyMultilineValue from "@src/components/ui/CopyMultilineValue";
-import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
+import CopyValue from "@src/components/ui/CopyValue";
 import DropdownLink from "@src/components/ui/Dropdowns/DropdownLink";
-import Button from "@src/components/ui/Button";
+import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
+import Table from "@src/components/ui/Table";
 
 import type { Project, RoleAssignment, Role } from "@src/@types/Project";
 import type { User } from "@src/@types/User";
-import { ThemePalette, ThemeProps } from "@src/components/Theme";
 
 const Wrapper = styled.div<any>`
   ${ThemeProps.exactWidth(ThemeProps.contentWidth)}
@@ -170,13 +170,7 @@ class ProjectDetailsContent extends React.Component<Props, State> {
           <Button onClick={this.props.onAddMemberClick}>Add Member</Button>
         </ButtonsColumn>
         <ButtonsColumn>
-          <Button
-            alert
-            hollow
-            onClick={() => {
-              this.props.onDeleteClick();
-            }}
-          >
+          <Button alert hollow onClick={this.props.onDeleteClick}>
             Delete Project
           </Button>
         </ButtonsColumn>

+ 0 - 83
src/components/modules/ProjectModule/ProjectDetailsContent/test.tsx

@@ -1,83 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import TW from "@src/utils/TestWrapper";
-import type { Project, Role, RoleAssignment } from "@src/@types/Project";
-import type { User } from "@src/@types/User";
-import ProjectDetailsContent from ".";
-
-type Props = {
-  project: ?Project;
-  loading: boolean;
-  users: User[];
-  usersLoading: boolean;
-  deleteDisabled: boolean;
-  roleAssignments: RoleAssignment[];
-  roles: Role[];
-  loggedUserId: string;
-};
-const wrap = (props: Props) =>
-  new TW(
-    shallow(
-      <ProjectDetailsContent
-        onAddMemberClick={() => {}}
-        onDeleteClick={() => {}}
-        onEditProjectClick={() => {}}
-        onEnableUser={() => {}}
-        onRemoveUser={() => {}}
-        onUserRoleChange={() => {}}
-        {...props}
-      />
-    ),
-    "pdContent"
-  );
-const projects: Project[] = [
-  { id: "project-1", name: "Project 1" },
-  { id: "project-2", name: "Project 2" },
-];
-const users: User[] = [
-  { id: "user-1", name: "User 1", email: "email1", project: projects[0] },
-  { id: "user-2", name: "User 2", email: "email2", project: projects[1] },
-];
-const roles: Role[] = [
-  { id: "role-1", name: "Role 1" },
-  { id: "role-2", name: "Role 2" },
-];
-const roleAssignments: RoleAssignment[] = [
-  { user: users[0], role: roles[0], scope: { project: projects[0] } },
-  { user: users[1], role: roles[1], scope: { project: projects[0] } },
-];
-describe("ProjectDetailsContent Component", () => {
-  it("renders info", () => {
-    const wrapper = wrap({
-      project: projects[0],
-      loading: false,
-      users,
-      usersLoading: false,
-      deleteDisabled: false,
-      roleAssignments,
-      roles,
-      loggedUserId: "user-1",
-    });
-    expect(wrapper.find("name").prop("value")).toBe("Project 1");
-    expect(wrapper.find("id").prop("value")).toBe("project-1");
-    const rows = wrapper.find("members").prop("items");
-    expect(rows[0][1].props.selectedItems.length).toBe(1);
-    expect(rows[0][1].props.selectedItems[0]).toBe("role-1");
-    expect(rows[1][1].props.selectedItems.length).toBe(1);
-    expect(rows[1][1].props.selectedItems[0]).toBe("role-2");
-  });
-});

+ 55 - 0
src/components/modules/ProjectModule/ProjectListItem/ProjectListItem.spec.tsx

@@ -0,0 +1,55 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+
+import ProjectListItem from ".";
+
+describe("ProjectListItem", () => {
+  let defaultProps: ProjectListItem["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: {
+        id: "project-id",
+        name: "project-name",
+      },
+      onClick: jest.fn(),
+      getMembers: jest.fn(),
+      isCurrentProject: jest.fn(),
+      onSwitchProjectClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<ProjectListItem {...defaultProps} />);
+    expect(getByText(defaultProps.item.name)).toBeTruthy();
+  });
+
+  it("switches project", () => {
+    render(<ProjectListItem {...defaultProps} />);
+    const switchProjectButton = Array.from(
+      document.querySelectorAll("button")
+    ).find(el => el.textContent?.includes("Switch"));
+    expect(switchProjectButton).toBeTruthy();
+
+    fireEvent.mouseDown(switchProjectButton!);
+    fireEvent.mouseUp(switchProjectButton!);
+
+    switchProjectButton!.click();
+    expect(defaultProps.onSwitchProjectClick).toHaveBeenCalled();
+  });
+});

+ 0 - 81
src/components/modules/ProjectModule/ProjectListItem/test.tsx

@@ -1,81 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import ProjectListItem from ".";
-import type { Project } from "@src/@types/Project";
-
-type Props = {
-  item: Project;
-  onClick: () => void;
-  getMembers: (projectId: string) => number;
-  isCurrentProject: (projectId: string) => boolean;
-  onSwitchProjectClick: (projectId: string) => void;
-};
-
-const wrap = (props: Props) =>
-  new TW(shallow(<ProjectListItem {...props} />), "plItem");
-
-const item: Project = {
-  id: "p_id",
-  name: "p_name",
-  description: "p_description",
-  enabled: true,
-};
-describe("ProjectListItem Component", () => {
-  it("renders with correct data", () => {
-    const wrapper = wrap({
-      item,
-      onClick: () => {},
-      getMembers: () => 3,
-      isCurrentProject: () => true,
-      onSwitchProjectClick: () => {},
-    });
-    expect(wrapper.findText("name")).toBe(item.name);
-    expect(wrapper.findText("description")).toBe(item.description);
-    expect(wrapper.findText("members")).toBe("3");
-    expect(wrapper.findText("enabled")).toBe("Yes");
-    expect(wrapper.findText("currentButton", false, true)).toBe("Current");
-  });
-
-  it("dispatches click", () => {
-    const onClick = sinon.spy();
-    const wrapper = wrap({
-      item,
-      onClick,
-      getMembers: () => 3,
-      isCurrentProject: () => true,
-      onSwitchProjectClick: () => {},
-    });
-    wrapper.find("content").click();
-    expect(onClick.calledOnce).toBe(true);
-  });
-
-  it("dispatches switch project click", () => {
-    const onSwitchProjectClick = sinon.spy();
-    const wrapper = wrap({
-      item,
-      onClick: () => {},
-      getMembers: () => 3,
-      isCurrentProject: () => true,
-      onSwitchProjectClick,
-    });
-    wrapper.find("currentButton").click();
-    expect(onSwitchProjectClick.calledOnce).toBe(true);
-    expect(onSwitchProjectClick.args[0][0]).toBe("p_id");
-  });
-});

+ 0 - 151
src/components/modules/ProjectModule/ProjectMemberModal/test.tsx

@@ -1,151 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import type { User } from "@src/@types/User";
-import type { Project, Role } from "@src/@types/Project";
-import ProjectMemberModal from ".";
-
-type Props = {
-  loading: boolean;
-  users: User[];
-  projects: Project[];
-  onRequestClose: () => void;
-  onAddClick: (user: User, isNew: boolean, roles: Role[]) => void;
-  roles: Role[];
-};
-const wrap = (props: Props) =>
-  new TW(shallow(<ProjectMemberModal {...props} />), "pmModal");
-const users: User[] = [
-  { id: "user-1", name: "User 1", email: "", project: { id: "", name: "" } },
-  { id: "user-2", name: "User 2", email: "", project: { id: "", name: "" } },
-];
-const projects: Project[] = [
-  { id: "project-1", name: "Project 1" },
-  { id: "project-2", name: "Project 2" },
-];
-const roles: Role[] = [
-  { id: "role-1", name: "Role 1" },
-  { id: "role-2", name: "Role 2" },
-  { id: "role-3", name: "Role 3" },
-];
-describe("ProjectMemberModal Component", () => {
-  it("renders existing user form", () => {
-    const wrapper = wrap({
-      loading: false,
-      users,
-      projects,
-      roles,
-      onRequestClose: () => {},
-      onAddClick: () => {},
-    });
-    expect(wrapper.find("users").prop("items")[1].value).toBe(users[1].id);
-    expect(wrapper.find("roles").prop("enum")[1].id).toBe(roles[1].id);
-    expect(wrapper.find("users").prop("highlight")).toBe(false);
-    expect(wrapper.find("roles").prop("highlight")).toBe(false);
-    expect(wrapper.find("users").prop("disabled")).toBe(false);
-    expect(wrapper.find("roles").prop("disabled")).toBe(false);
-  });
-
-  it("highlights required fields in existing user form", () => {
-    const wrapper = wrap({
-      loading: false,
-      users,
-      projects,
-      roles,
-      onRequestClose: () => {},
-      onAddClick: () => {},
-    });
-    expect(wrapper.find("users").length).toBe(1);
-    wrapper.find("addButton").click();
-    expect(wrapper.find("users").prop("highlight")).toBe(true);
-    expect(wrapper.find("roles").prop("highlight")).toBe(true);
-  });
-
-  it("renders new user form and highlights required", () => {
-    const wrapper = wrap({
-      loading: false,
-      users,
-      projects,
-      roles,
-      onRequestClose: () => {},
-      onAddClick: () => {},
-    });
-    wrapper.find("formToggle").simulate("change", { value: "new" });
-    expect(wrapper.find("users").length).toBe(0);
-    expect(wrapper.find("field-username").prop("highlight")).toBe(false);
-    expect(wrapper.find("field-description").prop("highlight")).toBe(false);
-    expect(wrapper.find("field-Primary Project").prop("highlight")).toBe(false);
-    expect(wrapper.find("roles").prop("highlight")).toBe(false);
-    expect(wrapper.find("field-password").prop("highlight")).toBe(false);
-    expect(wrapper.find("field-confirm_password").prop("highlight")).toBe(
-      false
-    );
-    expect(wrapper.find("field-Email").prop("highlight")).toBe(false);
-    wrapper.find("addButton").click();
-    expect(wrapper.find("field-username").prop("highlight")).toBe(true);
-    expect(wrapper.find("field-description").prop("highlight")).toBe(false);
-    expect(wrapper.find("field-Primary Project").prop("highlight")).toBe(false);
-    expect(wrapper.find("roles").prop("highlight")).toBe(true);
-    expect(wrapper.find("field-password").prop("highlight")).toBe(true);
-    expect(wrapper.find("field-confirm_password").prop("highlight")).toBe(
-      false
-    );
-    expect(wrapper.find("field-Email").prop("highlight")).toBe(false);
-  });
-
-  it("dispatches add click with correct data", () => {
-    const onAddClick = sinon.spy();
-    const wrapper = wrap({
-      loading: false,
-      users,
-      projects,
-      roles,
-      onRequestClose: () => {},
-      onAddClick,
-    });
-    wrapper.find("formToggle").simulate("change", { value: "new" });
-    wrapper.find("field-username").simulate("change", "new-username");
-    wrapper.find("roles").simulate("change", "role-2");
-    wrapper.find("roles").simulate("change", "role-1");
-    wrapper.find("roles").simulate("change", "role-2");
-    wrapper.find("roles").simulate("change", "role-3");
-    wrapper.find("field-password").simulate("change", "new-password");
-    wrapper.find("field-confirm_password").simulate("change", "new-password");
-    wrapper.find("addButton").click();
-    const userArg = onAddClick.args[0][0];
-    const rolesArg: Role[] = onAddClick.args[0][2];
-    expect(userArg.name).toBe("new-username");
-    expect(userArg.password).toBe("new-password");
-    expect(rolesArg.length).toBe(2);
-    expect(rolesArg[0].id).toBe("role-1");
-    expect(rolesArg[1].id).toBe("role-3");
-  });
-
-  it("disabled on loading", () => {
-    const wrapper = wrap({
-      loading: true,
-      users,
-      projects,
-      roles,
-      onRequestClose: () => {},
-      onAddClick: () => {},
-    });
-    expect(wrapper.find("users").prop("disabled")).toBe(true);
-    expect(wrapper.find("roles").prop("disabled")).toBe(true);
-  });
-});

+ 0 - 75
src/components/modules/ProjectModule/ProjectModal/test.tsx

@@ -1,75 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import type { Project } from "@src/@types/Project";
-import ProjectModal from ".";
-
-type Props = {
-  project?: ?Project;
-  isNewProject?: boolean;
-  loading: boolean;
-  onRequestClose: () => void;
-  onUpdateClick: (project: Project) => void;
-};
-
-const wrap = (props: Props) =>
-  new TW(shallow(<ProjectModal {...props} />), "projectModal");
-
-describe("ProjectModal Component", () => {
-  it("doesn't dispatch click if project name is not filled", () => {
-    const onUpdateClick = sinon.spy();
-    const wrapper = wrap({
-      isNewProject: true,
-      loading: false,
-      onRequestClose: () => {},
-      onUpdateClick,
-    });
-    expect(wrapper.findText("updateButton", false, true)).toBe("New Project");
-    wrapper.find("updateButton").click();
-    expect(onUpdateClick.called).toBe(false);
-    expect(wrapper.find("field-project_name").prop("highlight")).toBe(true);
-  });
-
-  it("dispatches click if project is filled", () => {
-    const onUpdateClick = sinon.spy();
-    const wrapper = wrap({
-      isNewProject: false,
-      project: { id: "project", name: "Project Name" },
-      loading: false,
-      onRequestClose: () => {},
-      onUpdateClick,
-    });
-    expect(wrapper.findText("updateButton", false, true)).toBe(
-      "Update Project"
-    );
-    wrapper.find("updateButton").click();
-    expect(onUpdateClick.called).toBe(true);
-  });
-
-  it("has disabled fields on loading", () => {
-    const wrapper = wrap({
-      isNewProject: false,
-      project: { id: "project", name: "Project Name" },
-      loading: true,
-      onRequestClose: () => {},
-      onUpdateClick: () => {},
-    });
-    expect(wrapper.find("updateButton").prop("disabled")).toBe(true);
-    expect(wrapper.find("field-project_name").prop("disabled")).toBe(true);
-  });
-});

+ 83 - 0
src/components/modules/SetupModule/SetupPageEmailBody/SetupPageEmailBody.spec.tsx

@@ -0,0 +1,83 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { CustomerInfoBasic, CustomerInfoTrial } from "@src/@types/InitialSetup";
+import DomUtils from "@src/utils/DomUtils";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import SetupPageEmailBody from "./";
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {},
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+  },
+}));
+
+jest.mock("@src/utils/DomUtils", () => ({
+  copyTextToClipboard: jest.fn(),
+}));
+
+const CUSTOMER_INFO_BASIC: CustomerInfoBasic = {
+  fullName: "John Doe",
+  email: "email@email.com",
+  company: "Company",
+  country: "Country",
+};
+
+const customerInfoTrial: CustomerInfoTrial = {
+  interestedIn: "migrations",
+  sourcePlatform: "vmware_vsphere",
+  destinationPlatform: "openstack",
+};
+
+describe("SetupPageEmailBody", () => {
+  let defaultProps: SetupPageEmailBody["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      customerInfoBasic: CUSTOMER_INFO_BASIC,
+      customerInfoTrial: customerInfoTrial,
+      licenceType: "trial",
+      applianceId: "appliance-id",
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<SetupPageEmailBody {...defaultProps} />);
+    expect(
+      Array.from(document.querySelectorAll("*")).find(el =>
+        el.textContent?.includes(CUSTOMER_INFO_BASIC.fullName)
+      )
+    ).toBeTruthy();
+  });
+
+  it("handles copy", () => {
+    render(<SetupPageEmailBody {...defaultProps} />);
+    TestUtils.select("CopyButton__Wrapper")!.click();
+    expect(DomUtils.copyTextToClipboard).toHaveBeenCalled();
+  });
+
+  it("copy is not called if no email template", () => {
+    const component = new SetupPageEmailBody(defaultProps);
+    component.handleCopy();
+    expect(DomUtils.copyTextToClipboard).not.toHaveBeenCalled();
+  });
+});

+ 12 - 21
src/components/modules/SetupModule/SetupPageEmailBody/SetupPageEmailBody.tsx

@@ -12,20 +12,21 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-import * as React from "react";
 import { observer } from "mobx-react";
+import * as React from "react";
 import styled from "styled-components";
+
 import {
   CustomerInfoBasic,
   CustomerInfoTrial,
   SetupPageLicenceType,
 } from "@src/@types/InitialSetup";
-import { customerInfoSetupStoreValueToString } from "@src/stores/SetupStore";
-import notificationStore from "@src/stores/NotificationStore";
-import { ThemePalette } from "@src/components/Theme";
-import SetupPageServerError from "@src/components/modules/SetupModule/ui/SetupPageServerError";
 import SetupPageInputWrapper from "@src/components/modules/SetupModule/ui/SetupPageInputWrapper";
+import SetupPageServerError from "@src/components/modules/SetupModule/ui/SetupPageServerError";
+import { ThemePalette } from "@src/components/Theme";
 import CopyButton from "@src/components/ui/CopyButton";
+import { customerInfoSetupStoreValueToString } from "@src/stores/SetupStore";
+import DomUtils from "@src/utils/DomUtils";
 
 const Wrapper = styled.div``;
 const Link = styled.a`
@@ -59,28 +60,18 @@ type Props = {
 class SetupPageEmailBody extends React.Component<Props> {
   emailTemplate: HTMLElement | null = null;
 
-  handleCopy(event?: React.ClipboardEvent) {
+  async handleCopy(event?: React.ClipboardEvent) {
     event?.preventDefault();
 
     if (!this.emailTemplate) {
       return;
     }
 
-    try {
-      const range = document.createRange();
-      range.selectNode(this.emailTemplate);
-      window.getSelection()?.removeAllRanges();
-      window.getSelection()?.addRange(range);
-      document.execCommand("copy");
-      if (!event) {
-        notificationStore.alert(
-          "The email body was succesfully copied to clipboard",
-          "success"
-        );
-      }
-    } catch (err) {
-      notificationStore.alert("Error copying to clipboard", "error");
-    }
+    const range = document.createRange();
+    range.selectNode(this.emailTemplate);
+    window.getSelection()?.removeAllRanges();
+    window.getSelection()?.addRange(range);
+    await DomUtils.copyTextToClipboard(window.getSelection()?.toString() || "");
   }
 
   render() {

+ 8 - 11
src/components/modules/NavigationModule/Navigation/test.tsx → src/components/modules/SetupModule/SetupPageHelp/SetupPageHelp.spec.tsx

@@ -1,5 +1,5 @@
 /*
-Copyright (C) 2017  Cloudbase Solutions SRL
+Copyright (C) 2023  Cloudbase Solutions SRL
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
@@ -13,17 +13,14 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import React from "react";
-import { shallow } from "enzyme";
-import TW from "@src/utils/TestWrapper";
-import Navigation from ".";
 
-const wrap = props => new TW(shallow(<Navigation {...props} />), "navigation");
+import { render } from "@testing-library/react";
 
-describe("Navigation Component", () => {
-  it("selects the current page", () => {
-    const wrapper = wrap({ currentPage: "endpoints" });
-    expect(wrapper.find("item-endpoints").prop("selected")).toBe(true);
-    expect(wrapper.find("item-replicas").prop("selected")).toBe(false);
-    expect(wrapper.find("item-migrations").prop("selected")).toBe(false);
+import SetupPageHelp from ".";
+
+describe("SetupPageHelp", () => {
+  it("renders without crashing", () => {
+    const { getByText } = render(<SetupPageHelp />);
+    expect(getByText("Coriolis® Help")).toBeTruthy();
   });
 });

+ 2 - 2
src/components/modules/SetupModule/SetupPageHelp/SetupPageHelp.tsx

@@ -36,7 +36,7 @@ const OpenInNewIconWrapper = styled.div`
   transform: scale(0.6);
 `;
 type Props = {
-  style: React.CSSProperties;
+  style?: React.CSSProperties;
 };
 
 @observer
@@ -49,7 +49,7 @@ class SetupPageHelp extends React.Component<Props> {
           Click the link below to view the Coriolis® documentation. There you
           can find all the help you need to get you started.
         </p>
-        <Help href="https://cloudbase.it/coriolis-overview/" target="_balnk">
+        <Help href="https://cloudbase.it/coriolis-overview/" target="_blank">
           Coriolis® Documentation
           <OpenInNewIconWrapper
             dangerouslySetInnerHTML={{

+ 162 - 0
src/components/modules/SetupModule/SetupPageLegal/SetupPageLegal.spec.tsx

@@ -0,0 +1,162 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import SetupPageLegal from "./";
+
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {
+      openstack: 2,
+      vmware_vsphere: 1,
+    },
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+  },
+}));
+
+describe("SetupPageLegal", () => {
+  let defaultProps: SetupPageLegal["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      licenceType: "trial",
+      customerInfoTrial: {
+        interestedIn: "migrations",
+        sourcePlatform: "vmware_vsphere",
+        destinationPlatform: "openstack",
+      },
+      onCustomerInfoChange: jest.fn(),
+      onLegalChange: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<SetupPageLegal {...defaultProps} />);
+    expect(getByText("Coriolis® Trial License")).toBeTruthy();
+  });
+
+  it("fires interestedIn change event", () => {
+    const { rerender } = render(<SetupPageLegal {...defaultProps} />);
+    const findInputByLabel = (label: string) =>
+      Array.from(document.querySelectorAll("label"))
+        .find(el => el.textContent?.includes(label))!
+        .querySelector("input")!;
+
+    const replicasInput = findInputByLabel("Replicas");
+    expect(replicasInput).toBeTruthy();
+    replicasInput.click();
+
+    expect(defaultProps.onCustomerInfoChange).toHaveBeenCalledWith(
+      "interestedIn",
+      "replicas"
+    );
+
+    const bothInput = findInputByLabel("Both");
+    expect(bothInput).toBeTruthy();
+    bothInput.click();
+
+    expect(defaultProps.onCustomerInfoChange).toHaveBeenCalledWith(
+      "interestedIn",
+      "both"
+    );
+
+    rerender(
+      <SetupPageLegal
+        {...defaultProps}
+        customerInfoTrial={{
+          ...defaultProps.customerInfoTrial,
+          interestedIn: "replicas",
+        }}
+      />
+    );
+
+    const migrationsInput = findInputByLabel("Migrations");
+    expect(migrationsInput).toBeTruthy();
+    migrationsInput.click();
+
+    expect(defaultProps.onCustomerInfoChange).toHaveBeenCalledWith(
+      "interestedIn",
+      "migrations"
+    );
+  });
+
+  it("fires legal agreement change event", () => {
+    render(<SetupPageLegal {...defaultProps} />);
+    const findCheckboxByText = (text: string) =>
+      Array.from(TestUtils.selectAll("Checkbox__Wrapper")).find(el =>
+        el.parentElement?.textContent?.includes(text)
+      )!;
+
+    const privacyCheckbox = findCheckboxByText("Privacy Policy");
+    expect(privacyCheckbox).toBeTruthy();
+
+    privacyCheckbox.click();
+    expect(defaultProps.onLegalChange).toHaveBeenCalledWith(false);
+
+    const eulaCheckbox = findCheckboxByText("EULA");
+    expect(eulaCheckbox).toBeTruthy();
+
+    eulaCheckbox.click();
+    expect(defaultProps.onLegalChange).toHaveBeenCalledWith(true);
+
+    const findLabelByText = (text: string) =>
+      Array.from(TestUtils.selectAll("SetupPageLegal__CheckboxLabel")).find(
+        el => el.textContent?.includes(text)
+      )!;
+
+    const privacyLabel = findLabelByText("Privacy Policy");
+    expect(privacyLabel).toBeTruthy();
+    privacyLabel.click();
+    expect(defaultProps.onLegalChange).toHaveBeenCalledWith(false);
+
+    const eulaLabel = findLabelByText("EULA");
+    expect(eulaLabel).toBeTruthy();
+    eulaLabel.click();
+    expect(defaultProps.onLegalChange).toHaveBeenCalledWith(false);
+  });
+
+  it.each`
+    platformType     | itemIndex | expectedProvider
+    ${"Source"}      | ${2}      | ${"openstack"}
+    ${"Destination"} | ${3}      | ${"aws"}
+  `(
+    "fires $platformType platform change event",
+    ({ platformType, itemIndex, expectedProvider }) => {
+      render(<SetupPageLegal {...defaultProps} />);
+      const platformDropdown = Array.from(
+        TestUtils.selectAll("Dropdown__Wrapper")
+      ).find(el =>
+        el.parentElement?.parentElement?.textContent?.includes(
+          `${platformType} Platform`
+        )
+      )!;
+
+      expect(platformDropdown).toBeTruthy();
+      TestUtils.select("DropdownButton__Wrapper", platformDropdown)!.click();
+      TestUtils.selectAll("Dropdown__ListItem-")[itemIndex].click();
+
+      expect(defaultProps.onCustomerInfoChange).toHaveBeenCalledWith(
+        `${platformType.toLowerCase()}Platform`,
+        expectedProvider
+      );
+    }
+  );
+});

+ 99 - 0
src/components/modules/SetupModule/SetupPageLicence/SetupPageLicence.spec.tsx

@@ -0,0 +1,99 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import SetupPageLicence from "./";
+
+describe("SetupPageLicence", () => {
+  let defaultProps: SetupPageLicence["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      customerInfo: {
+        fullName: "John Doe",
+        email: "email@email.com",
+        company: "Company",
+        country: "RO",
+      },
+      highlightEmail: false,
+      highlightEmptyFields: false,
+      licenceType: "trial",
+      onUpdateCustomerInfo: jest.fn(),
+      onSubmit: jest.fn(),
+      onLicenceTypeChange: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<SetupPageLicence {...defaultProps} />);
+    const fullNameInput = Array.from(
+      TestUtils.selectAll("SetupPageInputWrapper__Label")
+    )
+      .find(el => el.textContent?.includes("Full name"))!
+      .parentElement?.querySelector("input")!;
+
+    expect(fullNameInput).toBeTruthy();
+    expect(fullNameInput.value).toBe("John Doe");
+  });
+
+  it("submits form", () => {
+    render(<SetupPageLicence {...defaultProps} />);
+    fireEvent.submit(document.querySelector("form")!);
+
+    expect(defaultProps.onSubmit).toHaveBeenCalled();
+  });
+
+  it.each`
+    label          | fieldName     | newValue
+    ${"Full name"} | ${"fullName"} | ${"New Name"}
+    ${"Email"}     | ${"email"}    | ${"new@email.com"}
+    ${"Company"}   | ${"company"}  | ${"New Company"}
+  `("fires $label change event", ({ label, fieldName, newValue }) => {
+    render(<SetupPageLicence {...defaultProps} />);
+    const findInputByLabel = (label: string) =>
+      Array.from(TestUtils.selectAll("SetupPageInputWrapper__Label"))
+        .find(el => el.textContent?.includes(label))!
+        .parentElement!.querySelector("input")!;
+
+    const input = findInputByLabel(label);
+    fireEvent.change(input, { target: { value: newValue } });
+
+    expect(defaultProps.onUpdateCustomerInfo).toHaveBeenCalledWith(
+      fieldName,
+      newValue
+    );
+  });
+
+  it("fires country change event", async () => {
+    render(<SetupPageLicence {...defaultProps} />);
+    const countryInput = Array.from(
+      TestUtils.selectAll("SetupPageInputWrapper__Label")
+    )
+      .find(el => el.textContent?.includes("Country"))!
+      .parentElement?.querySelector("input")!;
+
+    fireEvent.change(countryInput, { target: { value: "Unite" } });
+
+    fireEvent.click(TestUtils.selectAll("AutocompleteDropdown__ListItem-")[1]);
+
+    expect(defaultProps.onUpdateCustomerInfo).toHaveBeenCalledWith(
+      "country",
+      "United Arab Emirates"
+    );
+  });
+});

+ 18 - 19
src/components/modules/WizardModule/WizardPageContent/test.tsx → src/components/modules/SetupModule/SetupPageModuleWrapper/SetupPageModuleWrapper.spec.tsx

@@ -1,5 +1,5 @@
 /*
-Copyright (C) 2017  Cloudbase Solutions SRL
+Copyright (C) 2023  Cloudbase Solutions SRL
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
@@ -13,25 +13,24 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import React from "react";
-import { shallow } from "enzyme";
-import TW from "@src/utils/TestWrapper";
-import WizardPageContent from ".";
 
-const wrap = (props: any) =>
-  new TW(
-    shallow(
-      <WizardPageContent wizardData={{}} onContentRef={() => {}} {...props} />
-    ),
-    "wpContent"
-  );
+import { render } from "@testing-library/react";
 
-describe("WizardPageContent Component", () => {
-  it("renders wizard type page", () => {
-    const wrapper = wrap({
-      page: { id: "type", title: "Wizard Type" },
-      type: "replica",
-    });
-    expect(wrapper.findText("header")).toBe("Wizard Type Replica");
-    expect(wrapper.shallow.find("WizardType").prop("selected")).toBe("replica");
+import SetupPageModuleWrapper from ".";
+
+describe("SetupPageModuleWrapper", () => {
+  let defaultProps: SetupPageModuleWrapper["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      children: <div>children</div>,
+      actions: <div>actions</div>,
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<SetupPageModuleWrapper {...defaultProps} />);
+    expect(getByText("children")).toBeTruthy();
+    expect(getByText("actions")).toBeTruthy();
   });
 });

+ 14 - 12
src/components/modules/NavigationModule/NavigationMini/test.tsx → src/components/modules/SetupModule/SetupPageWelcome/SetupPageWelcome.spec.tsx

@@ -1,5 +1,5 @@
 /*
-Copyright (C) 2017  Cloudbase Solutions SRL
+Copyright (C) 2023  Cloudbase Solutions SRL
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
@@ -13,19 +13,21 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import React from "react";
-import { shallow } from "enzyme";
 
-import TW from "@src/utils/TestWrapper";
-import NavigationMini, { TEST_ID } from ".";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
 
-const wrap = () => new TW(shallow(<NavigationMini />), TEST_ID);
+import SetupPageWelcome from "./";
 
-describe("NavigationMini Component", () => {
-  it("toggles the navigation state", () => {
-    const wrapper = wrap();
-    const button = () => wrapper.find("toggleButton");
-    expect(button().prop("open")).toBe(false);
-    button().simulate("click");
-    expect(button().prop("open")).toBe(true);
+describe("SetupPageWelcome", () => {
+  let defaultProps: SetupPageWelcome["props"];
+
+  beforeEach(() => {
+    defaultProps = {};
+  });
+
+  it("renders without crashing", () => {
+    render(<SetupPageWelcome {...defaultProps} />);
+    expect(TestUtils.select("SetupPageWelcome__Wrapper")).toBeTruthy();
   });
 });

+ 37 - 0
src/components/modules/SetupModule/ui/SetupPageBackButton/SetupPageBackButton.spec.tsx

@@ -0,0 +1,37 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import SetupPageBackButton from "./";
+
+describe("SetupPageBackButton", () => {
+  let defaultProps: SetupPageBackButton["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      onClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<SetupPageBackButton {...defaultProps} />);
+    expect(
+      TestUtils.select("SetupPageBackButton__Wrapper")?.textContent
+    ).toContain("Back");
+  });
+});

+ 50 - 0
src/components/modules/SetupModule/ui/SetupPagePasswordStrength/SetupPagePasswordStrength.spec.tsx

@@ -0,0 +1,50 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { ThemePalette } from "@src/components/Theme";
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import SetupPagePasswordStrength from "./";
+
+describe("SetupPagePasswordStrength", () => {
+  let defaultProps: SetupPagePasswordStrength["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      value: "password",
+    };
+  });
+
+  it("renders without crashing", () => {
+    render(<SetupPagePasswordStrength {...defaultProps} />);
+    expect(TestUtils.select("SetupPagePasswordStrength__Wrapper")).toBeTruthy();
+  });
+
+  it.each`
+    password            | status           | color
+    ${"a"}              | ${"VERY_WEAK"}   | ${ThemePalette.alert}
+    ${"A###d1!"}        | ${"WEAK"}        | ${ThemePalette.alert}
+    ${"A###$d123!"}     | ${"REASONABLE"}  | ${ThemePalette.warning}
+    ${"AmweyQe$d123!"}  | ${"STRONG"}      | ${"#758400"}
+    ${"AmwueyQe$d123!"} | ${"VERY_STRONG"} | ${"green"}
+  `("renders $color for $status password: $password", ({ password, color }) => {
+    render(<SetupPagePasswordStrength value={password} />);
+    const bar = TestUtils.select("SetupPagePasswordStrength__Bar")!;
+    const background = window.getComputedStyle(bar).background;
+    expect(TestUtils.rgbToHex(background)).toBe(color);
+  });
+});

+ 38 - 0
src/components/modules/TemplateModule/DetailsTemplate/DetailsTemplate.spec.tsx

@@ -0,0 +1,38 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import DetailsTemplate, { Props } from ".";
+
+describe("DetailsTemplate", () => {
+  let defaultProps: Props;
+
+  beforeEach(() => {
+    defaultProps = {
+      pageHeaderComponent: <div>pageHeaderComponent</div>,
+      contentHeaderComponent: <div>contentHeaderComponent</div>,
+      contentComponent: <div>contentComponent</div>,
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<DetailsTemplate {...defaultProps} />);
+    expect(getByText("pageHeaderComponent")).toBeTruthy();
+    expect(getByText("contentHeaderComponent")).toBeTruthy();
+    expect(getByText("contentComponent")).toBeTruthy();
+  });
+});

+ 1 - 1
src/components/modules/TemplateModule/DetailsTemplate/DetailsTemplate.tsx

@@ -27,7 +27,7 @@ const Content = styled.div<any>`
   flex-direction: column;
   min-height: 0;
 `;
-type Props = {
+export type Props = {
   pageHeaderComponent: React.ReactNode;
   contentHeaderComponent: React.ReactNode;
   contentComponent: React.ReactNode;

+ 30 - 0
src/components/modules/TemplateModule/EmptyTemplate/EmptyTemplate.spec.tsx

@@ -0,0 +1,30 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import EmptyTemplate from ".";
+
+describe("EmptyTemplate", () => {
+  it("renders without crashing", () => {
+    const { getByText } = render(
+      <EmptyTemplate>
+        <div>contentComponent</div>
+      </EmptyTemplate>
+    );
+    expect(getByText("contentComponent")).toBeTruthy();
+  });
+});

+ 34 - 0
src/components/modules/TemplateModule/MainTemplate/MainTemplate.spec.tsx

@@ -0,0 +1,34 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import MainTemplate from ".";
+
+describe("MainTemplate", () => {
+  it("renders without crashing", () => {
+    const { getByText } = render(
+      <MainTemplate
+        navigationComponent={<div>navigationComponent</div>}
+        headerComponent={<div>headerComponent</div>}
+        listComponent={<div>listComponent</div>}
+      />
+    );
+    expect(getByText("navigationComponent")).toBeTruthy();
+    expect(getByText("headerComponent")).toBeTruthy();
+    expect(getByText("listComponent")).toBeTruthy();
+  });
+});

+ 32 - 0
src/components/modules/TemplateModule/WizardTemplate/WizardTemplate.spec.tsx

@@ -0,0 +1,32 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import WizardTemplate from ".";
+
+describe("WizardTemplate", () => {
+  it("renders without crashing", () => {
+    const { getByText } = render(
+      <WizardTemplate
+        pageHeaderComponent={<div>pageHeaderComponent</div>}
+        pageContentComponent={<div>pageContentComponent</div>}
+      />
+    );
+    expect(getByText("pageHeaderComponent")).toBeTruthy();
+    expect(getByText("pageContentComponent")).toBeTruthy();
+  });
+});

+ 59 - 0
src/components/modules/TransferModule/DeleteReplicaModal/DeleteReplicaModal.spec.tsx

@@ -0,0 +1,59 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import DeleteReplicaModal from "./";
+
+describe("DeleteReplicaModal", () => {
+  let defaultProps: DeleteReplicaModal["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      hasDisks: false,
+      onDeleteReplica: jest.fn(),
+      onDeleteDisks: jest.fn(),
+      onRequestClose: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<DeleteReplicaModal {...defaultProps} />);
+    expect(getByText("Delete Replica")).toBeTruthy();
+  });
+
+  it("renders with disks", () => {
+    render(<DeleteReplicaModal {...defaultProps} hasDisks />);
+    expect(
+      TestUtils.select("DeleteReplicaModal__ExtraMessage")?.textContent
+    ).toContain("has been executed at least once");
+  });
+
+  it("is multiple replica selection with disks", () => {
+    render(
+      <DeleteReplicaModal {...defaultProps} hasDisks isMultiReplicaSelection />
+    );
+    expect(
+      TestUtils.select("DeleteReplicaModal__ExtraMessage")?.textContent
+    ).toContain("have been executed at least once");
+  });
+
+  it("renders loading", () => {
+    render(<DeleteReplicaModal {...defaultProps} loading />);
+    expect(TestUtils.select("DeleteReplicaModal__Loading")).toBeTruthy();
+  });
+});

+ 3 - 4
src/components/modules/TransferModule/DeleteReplicaModal/DeleteReplicaModal.tsx

@@ -12,16 +12,15 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-import React from "react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 
-import Modal from "@src/components/ui/Modal";
+import { ThemePalette } from "@src/components/Theme";
 import Button from "@src/components/ui/Button";
+import Modal from "@src/components/ui/Modal";
 import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
 
-import { ThemePalette } from "@src/components/Theme";
-
 const Wrapper = styled.div<any>`
   display: flex;
   flex-direction: column;

+ 230 - 0
src/components/modules/TransferModule/Executions/Executions.spec.tsx

@@ -0,0 +1,230 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import {
+  EXECUTION_MOCK,
+  EXECUTION_TASKS_MOCK,
+} from "@tests/mocks/ExecutionsMock";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import TestUtils from "@tests/TestUtils";
+
+import Executions from "./";
+
+describe("Executions", () => {
+  let defaultProps: Executions["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      executions: [EXECUTION_MOCK],
+      executionsTasks: [EXECUTION_TASKS_MOCK],
+      loading: false,
+      tasksLoading: false,
+      instancesDetails: [INSTANCE_MOCK],
+      onChange: jest.fn(),
+      onCancelExecutionClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<Executions {...defaultProps} />);
+    expect(getByText(EXECUTION_TASKS_MOCK.tasks[0].id)).toBeTruthy();
+    expect(getByText(EXECUTION_MOCK.id)).toBeTruthy();
+  });
+
+  it("sets selected execution on new props", () => {
+    const { getByText, rerender } = render(<Executions {...defaultProps} />);
+    rerender(
+      <Executions
+        {...defaultProps}
+        executions={[
+          EXECUTION_MOCK,
+          { ...EXECUTION_MOCK, id: "new-id", status: "RUNNING" },
+        ]}
+      />
+    );
+    expect(getByText("new-id")).toBeTruthy();
+
+    rerender(
+      <Executions
+        {...defaultProps}
+        executions={[
+          EXECUTION_MOCK,
+          { ...EXECUTION_MOCK, id: "new-id", status: "COMPLETED" },
+          { ...EXECUTION_MOCK, id: "new-id-2", status: "RUNNING" },
+        ]}
+      />
+    );
+    expect(getByText("new-id-2")).toBeTruthy();
+
+    rerender(<Executions {...defaultProps} executions={[EXECUTION_MOCK]} />);
+    expect(getByText(EXECUTION_MOCK.id)).toBeTruthy();
+  });
+
+  it("renders with no executions", () => {
+    const { getByText, rerender } = render(<Executions {...defaultProps} />);
+    expect(getByText(EXECUTION_MOCK.id)).toBeTruthy();
+
+    rerender(<Executions {...defaultProps} executions={[]} />);
+    expect(getByText("This replica has not been executed yet.")).toBeTruthy();
+  });
+
+  it("doesn't dispatch onChange if no executions", () => {
+    const { rerender } = render(
+      <Executions {...defaultProps} executions={[]} />
+    );
+    rerender(<Executions {...defaultProps} executions={[]} />);
+    expect(defaultProps.onChange).not.toHaveBeenCalled();
+  });
+
+  it("handles previous executions", () => {
+    const { rerender } = render(
+      <Executions
+        {...defaultProps}
+        executions={[
+          EXECUTION_MOCK,
+          { ...EXECUTION_MOCK, id: "new-id", status: "RUNNING" },
+        ]}
+      />
+    );
+    const previousArrow = () =>
+      TestUtils.selectAll(
+        "Arrow__Wrapper",
+        TestUtils.select("Timeline__Wrapper")!
+      )[0];
+    previousArrow().click();
+    expect(defaultProps.onChange).toHaveBeenLastCalledWith(EXECUTION_MOCK.id);
+
+    rerender(<Executions {...defaultProps} />);
+    previousArrow().click();
+    expect(defaultProps.onChange).toHaveBeenLastCalledWith(EXECUTION_MOCK.id);
+  });
+
+  it("doesn't handle previous executions in edge cases", () => {
+    const executionsComponent = new Executions(defaultProps);
+    executionsComponent.handlePreviousExecutionClick();
+    expect(defaultProps.onChange).not.toHaveBeenCalled();
+  });
+
+  it("handles next executions", () => {
+    render(
+      <Executions
+        {...defaultProps}
+        executions={[
+          EXECUTION_MOCK,
+          { ...EXECUTION_MOCK, id: "new-id", status: "RUNNING" },
+        ]}
+      />
+    );
+    const nextArrow = TestUtils.selectAll(
+      "Arrow__Wrapper",
+      TestUtils.select("Timeline__Wrapper")!
+    )[1];
+    nextArrow.click();
+    expect(defaultProps.onChange).toHaveBeenLastCalledWith("new-id");
+
+    const previousArrow = () =>
+      TestUtils.selectAll(
+        "Arrow__Wrapper",
+        TestUtils.select("Timeline__Wrapper")!
+      )[0];
+    previousArrow().click();
+
+    nextArrow.click();
+    expect(defaultProps.onChange).toHaveBeenLastCalledWith("new-id");
+  });
+
+  it("doesn't handle next executions in edge cases", () => {
+    const executionsComponent = new Executions(defaultProps);
+    executionsComponent.handleNextExecutionClick();
+    expect(defaultProps.onChange).not.toHaveBeenCalled();
+  });
+
+  it("handles timeline item click", () => {
+    render(
+      <Executions
+        {...defaultProps}
+        executions={[
+          EXECUTION_MOCK,
+          { ...EXECUTION_MOCK, id: "new-id", status: "RUNNING" },
+        ]}
+      />
+    );
+    const timelineItem = TestUtils.select("Timeline__Item-");
+    expect(timelineItem).toBeTruthy();
+    timelineItem!.click();
+    expect(defaultProps.onChange).toHaveBeenLastCalledWith(EXECUTION_MOCK.id);
+  });
+
+  it("handles cancel execution click", () => {
+    const newExecution = { ...EXECUTION_MOCK, id: "new-id", status: "RUNNING" };
+    render(
+      <Executions
+        {...defaultProps}
+        executions={[EXECUTION_MOCK, newExecution]}
+      />
+    );
+    const cancelExecutionButton = Array.from(
+      document.querySelectorAll("button")
+    ).find(el => el.textContent === "Cancel Execution");
+    expect(cancelExecutionButton).toBeTruthy();
+    cancelExecutionButton!.click();
+    expect(defaultProps.onCancelExecutionClick).toHaveBeenCalledWith(
+      newExecution
+    );
+  });
+
+  it("force cancels execution", () => {
+    const newExecution = {
+      ...EXECUTION_MOCK,
+      id: "new-id",
+      status: "CANCELLING",
+    };
+    render(
+      <Executions
+        {...defaultProps}
+        executions={[EXECUTION_MOCK, newExecution]}
+      />
+    );
+    const cancelExecutionButton = Array.from(
+      document.querySelectorAll("button")
+    ).find(el => el.textContent === "Force Cancel Execution");
+    expect(cancelExecutionButton).toBeTruthy();
+    cancelExecutionButton!.click();
+    expect(defaultProps.onCancelExecutionClick).toHaveBeenCalledWith(
+      newExecution,
+      true
+    );
+  });
+
+  it("renders loading", () => {
+    render(<Executions {...defaultProps} loading />);
+    expect(TestUtils.select("Executions__LoadingWrapper")).toBeTruthy();
+  });
+
+  it("deletes execution", () => {
+    const deleteExecution = jest.fn();
+    render(
+      <Executions {...defaultProps} onDeleteExecutionClick={deleteExecution} />
+    );
+    const deleteExecutionButton = Array.from(
+      document.querySelectorAll("button")
+    ).find(el => el.textContent === "Delete Execution");
+    expect(deleteExecutionButton).toBeTruthy();
+    deleteExecutionButton!.click();
+    expect(deleteExecution).toHaveBeenCalledWith(EXECUTION_MOCK);
+  });
+});

+ 2 - 6
src/components/modules/TransferModule/Executions/Executions.tsx

@@ -129,16 +129,12 @@ class Executions extends React.Component<Props, State> {
 
       if (this.props.executions.length > props.executions.length) {
         const isSelectedAvailable = props.executions.find(
-          e =>
-            this.state.selectedExecution &&
-            e.id === this.state.selectedExecution.id
+          e => e.id === this.state.selectedExecution?.id
         );
         if (!isSelectedAvailable) {
           const lastIndex = this.props.executions
             ? this.props.executions.findIndex(
-                e =>
-                  this.state.selectedExecution &&
-                  e.id === this.state.selectedExecution.id
+                e => e.id === this.state.selectedExecution?.id
               )
             : -1;
           if (props.executions.length) {

+ 0 - 111
src/components/modules/TransferModule/Executions/test.tsx

@@ -1,111 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import Executions from ".";
-
-const wrap = props => new TW(shallow(<Executions {...props} />), "executions");
-
-const item = {
-  executions: [
-    { id: "execution-1", number: 1, status: "ERROR", created_at: new Date() },
-    {
-      id: "execution-2",
-      number: 2,
-      status: "COMPLETED",
-      created_at: new Date(),
-    },
-    {
-      id: "execution-3",
-      number: 3,
-      status: "CANCELED",
-      created_at: new Date(),
-    },
-    { id: "execution-4", number: 4, status: "RUNNING", created_at: new Date() },
-  ],
-};
-
-describe("Executions Component", () => {
-  it("selects last execution by default", () => {
-    const wrapper = wrap({ item });
-    expect(wrapper.findText("number")).toBe("Execution #4");
-  });
-
-  it("selects previous execution on previous click", () => {
-    const wrapper = wrap({ item });
-    wrapper.find("timeline").simulate("previousClick");
-    expect(wrapper.findText("number")).toBe("Execution #3");
-    wrapper.find("timeline").simulate("previousClick");
-    expect(wrapper.findText("number")).toBe("Execution #2");
-  });
-
-  it("selects next execution on next click", () => {
-    const wrapper = wrap({ item });
-    wrapper.find("timeline").simulate("previousClick");
-    wrapper.find("timeline").simulate("previousClick");
-    wrapper.find("timeline").simulate("nextClick");
-    expect(wrapper.findText("number")).toBe("Execution #3");
-  });
-
-  it("doesn't select next execution on next click if not possible", () => {
-    const wrapper = wrap({ item });
-    wrapper.find("timeline").simulate("nextClick");
-    expect(wrapper.findText("number")).toBe("Execution #4");
-  });
-
-  it("shows cancel button on running executions", () => {
-    const wrapper = wrap({ item });
-    expect(wrapper.find("cancelButton").length).toBe(1);
-    expect(wrapper.find("deleteButton").length).toBe(0);
-  });
-
-  it("shows delete button on non-running executions", () => {
-    const wrapper = wrap({ item });
-    wrapper.find("timeline").simulate("previousClick");
-    expect(wrapper.find("cancelButton").length).toBe(0);
-    expect(wrapper.find("deleteButton").length).toBe(1);
-  });
-
-  it("dispatches cancel click", () => {
-    const onCancelExecutionClick = sinon.spy();
-    const wrapper = wrap({ item, onCancelExecutionClick });
-    wrapper.find("cancelButton").simulate("click");
-    expect(onCancelExecutionClick.calledOnce).toBe(true);
-  });
-
-  it("dispatches delete click", () => {
-    const onDeleteExecutionClick = sinon.spy();
-    const wrapper = wrap({ item, onDeleteExecutionClick });
-    wrapper.find("timeline").simulate("previousClick");
-    wrapper.find("deleteButton").simulate("click");
-    expect(onDeleteExecutionClick.calledOnce).toBe(true);
-  });
-
-  it("renders no executions", () => {
-    const wrapper = wrap({ item: {} });
-    expect(wrapper.findText("noExTitle")).toBe(
-      "It looks like there are no executions in this replica."
-    );
-  });
-
-  it("dispatches execute click", () => {
-    const onExecuteClick = sinon.spy();
-    const wrapper = wrap({ item: {}, onExecuteClick });
-    wrapper.find("executeButton").simulate("click");
-    expect(onExecuteClick.calledOnce).toBe(true);
-  });
-});

+ 105 - 0
src/components/modules/TransferModule/MainDetails/MainDetails.spec.tsx

@@ -0,0 +1,105 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+import {
+  OPENSTACK_ENDPOINT_MOCK,
+  VMWARE_ENDPOINT_MOCK,
+} from "@tests/mocks/EndpointsMock";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { STORAGE_BACKEND_MOCK } from "@tests/mocks/StoragesMock";
+import { REPLICA_MOCK } from "@tests/mocks/TransferMock";
+import TestUtils from "@tests/TestUtils";
+
+import MainDetails from "./";
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => <div>{props.endpoint}</div>,
+}));
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+describe("MainDetails", () => {
+  let defaultProps: MainDetails["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: REPLICA_MOCK,
+      minionPools: [MINION_POOL_MOCK],
+      storageBackends: [STORAGE_BACKEND_MOCK],
+      destinationSchema: [],
+      destinationSchemaLoading: false,
+      sourceSchema: [],
+      sourceSchemaLoading: false,
+      instancesDetails: [INSTANCE_MOCK],
+      instancesDetailsLoading: false,
+      endpoints: [OPENSTACK_ENDPOINT_MOCK, VMWARE_ENDPOINT_MOCK],
+      bottomControls: <div>Bottom controls</div>,
+      loading: false,
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MainDetails {...defaultProps} />);
+    expect(getByText(REPLICA_MOCK.id)).toBeTruthy();
+    expect(getByText("Bottom controls")).toBeTruthy();
+  });
+
+  it("renders missing endpoint", () => {
+    const { getByText } = render(
+      <MainDetails
+        {...defaultProps}
+        item={{ ...REPLICA_MOCK, destination_endpoint_id: "missing" }}
+      />
+    );
+    expect(getByText("Endpoint is missing")).toBeTruthy();
+  });
+
+  it("renders loading", () => {
+    render(<MainDetails {...defaultProps} loading />);
+    expect(TestUtils.select("MainDetails__Loading")).toBeTruthy();
+  });
+
+  it("renders allocating minions error", () => {
+    render(
+      <MainDetails
+        {...defaultProps}
+        item={{
+          ...REPLICA_MOCK,
+          last_execution_status: "ERROR_ALLOCATING_MINIONS",
+        }}
+      />
+    );
+    expect(
+      Array.from(document.querySelectorAll("*")).find(el =>
+        el.textContent?.includes("error allocating minion machines")
+      )
+    ).toBeTruthy();
+  });
+
+  it("shows password", () => {
+    const { getByText } = render(<MainDetails {...defaultProps} />);
+    const passwordEl = TestUtils.select("PasswordValue__Wrapper")!;
+    expect(passwordEl).toBeTruthy();
+    expect(passwordEl.textContent).toBe("•••••••••");
+
+    passwordEl.click();
+    expect(
+      getByText(REPLICA_MOCK.destination_environment.password)
+    ).toBeTruthy();
+  });
+});

+ 0 - 80
src/components/modules/TransferModule/MainDetails/test.tsx

@@ -1,80 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import moment from "moment";
-import TW from "@src/utils/TestWrapper";
-import MainDetails from ".";
-
-const wrap = props =>
-  new TW(shallow(<MainDetails {...props} />), "mainDetails");
-
-const endpoints = [
-  { id: "endpoint-1", name: "Endpoint OPS", type: "openstack" },
-  { id: "endpoint-2", name: "Endpoint AZURE", type: "azure" },
-];
-const item = {
-  origin_endpoint_id: "endpoint-1",
-  destination_endpoint_id: "endpoint-2",
-  id: "item-id",
-  created_at: new Date(2017, 10, 24, 16, 15),
-  instances: ["instance_1"],
-  type: "Replica",
-  notes: "A description",
-};
-const instancesDetails = [
-  {
-    instance_name: "instance_1",
-    devices: { nics: [{ network_name: "network_1" }] },
-  },
-];
-
-describe("MainDetails Component", () => {
-  it("renders with endpoint missing", () => {
-    const wrapper = wrap({ item: {}, endpoints: [] });
-    expect(wrapper.findText("missing-source")).toBe(
-      "<StatusIcon />Endpoint is missing"
-    );
-    expect(wrapper.findText("missing-target")).toBe(
-      "<StatusIcon />Endpoint is missing"
-    );
-  });
-
-  it("renders endpoint info", () => {
-    const wrapper = wrap({ item, endpoints, instancesDetails });
-    expect(wrapper.find("id").prop("value")).toBe("item-id");
-    const localDate = moment(item.created_at).add(
-      -new Date().getTimezoneOffset(),
-      "minutes"
-    );
-    expect(wrapper.find("created").prop("value")).toBe(
-      localDate.format("YYYY-MM-DD HH:mm:ss")
-    );
-    // expect(wrapper.find('name-source').shallow.dive().dive().text()).toBe('Endpoint OPS')
-    // expect(wrapper.findText('name-target')).toBe('Endpoint AZURE')
-    expect(wrapper.find("description").prop("value")).toBe("A description");
-  });
-
-  it("renders endpoints logos", () => {
-    const wrapper = wrap({ item, endpoints, instancesDetails });
-    expect(wrapper.find("sourceLogo").prop("endpoint")).toBe("openstack");
-    expect(wrapper.find("targetLogo").prop("endpoint")).toBe("azure");
-  });
-
-  it("renders loading", () => {
-    const wrapper = wrap({ item: {}, endpoints: [], loading: true });
-    expect(wrapper.find("loading").length).toBe(1);
-  });
-});

+ 74 - 0
src/components/modules/TransferModule/MigrationDetailsContent/MigrationDetailsContent.spec.tsx

@@ -0,0 +1,74 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import MigrationDetailsContent from ".";
+import { MIGRATION_ITEM_DETAILS_MOCK } from "@tests/mocks/TransferMock";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { STORAGE_BACKEND_MOCK } from "@tests/mocks/StoragesMock";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import { NETWORK_MOCK } from "@tests/mocks/NetworksMock";
+import {
+  OPENSTACK_ENDPOINT_MOCK,
+  VMWARE_ENDPOINT_MOCK,
+} from "@tests/mocks/EndpointsMock";
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => <div>{props.endpoint}</div>,
+}));
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+
+describe("MigrationDetailsContent", () => {
+  let defaultProps: MigrationDetailsContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: MIGRATION_ITEM_DETAILS_MOCK,
+      itemId: MIGRATION_ITEM_DETAILS_MOCK.id,
+      minionPools: [MINION_POOL_MOCK],
+      detailsLoading: false,
+      storageBackends: [STORAGE_BACKEND_MOCK],
+      instancesDetails: [INSTANCE_MOCK],
+      instancesDetailsLoading: false,
+      networks: [NETWORK_MOCK],
+      sourceSchema: [],
+      sourceSchemaLoading: false,
+      destinationSchema: [],
+      destinationSchemaLoading: false,
+      endpoints: [OPENSTACK_ENDPOINT_MOCK, VMWARE_ENDPOINT_MOCK],
+      page: "",
+      onDeleteMigrationClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<MigrationDetailsContent {...defaultProps} />);
+    expect(getByText(MIGRATION_ITEM_DETAILS_MOCK.id)).toBeTruthy();
+  });
+
+  it("renders tasks page", () => {
+    const { getByText } = render(
+      <MigrationDetailsContent {...defaultProps} page="tasks" />
+    );
+    expect(
+      getByText(
+        MIGRATION_ITEM_DETAILS_MOCK.tasks[0].task_type.replace("_", " ")
+      )
+    ).toBeTruthy();
+  });
+});

+ 6 - 7
src/components/modules/TransferModule/MigrationDetailsContent/MigrationDetailsContent.tsx

@@ -12,23 +12,22 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-import React from "react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 
-import Button from "@src/components/ui/Button";
+import { MigrationItemDetails } from "@src/@types/MainItem";
+import { MinionPool } from "@src/@types/MinionPool";
+import { Network } from "@src/@types/Network";
 import DetailsNavigation from "@src/components/modules/NavigationModule/DetailsNavigation";
 import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import Tasks from "@src/components/modules/TransferModule/Tasks";
+import { ThemeProps } from "@src/components/Theme";
+import Button from "@src/components/ui/Button";
 
 import type { Instance } from "@src/@types/Instance";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Field } from "@src/@types/Field";
-import { MigrationItemDetails } from "@src/@types/MainItem";
-import { MinionPool } from "@src/@types/MinionPool";
-import { Network } from "@src/@types/Network";
-import { ThemeProps } from "@src/components/Theme";
-
 const Wrapper = styled.div<any>`
   display: flex;
   justify-content: center;

+ 0 - 77
src/components/modules/TransferModule/MigrationDetailsContent/test.tsx

@@ -1,77 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import MigrationDetailsContent from ".";
-
-const wrap = props =>
-  new TW(shallow(<MigrationDetailsContent {...props} />), "mdContent");
-
-const tasks = [
-  {
-    progress_updates: [
-      { message: "the task has a progress of 50%", created_at: new Date() },
-      { message: "the task is almost done", created_at: new Date() },
-    ],
-    exception_details: "Exception details",
-    status: "RUNNING",
-    created_at: new Date(),
-    depends_on: ["depends on id"],
-    id: "task-2",
-    task_type: "Task name 2",
-  },
-];
-const endpoints = [
-  { id: "endpoint-1", name: "Endpoint OPS", type: "openstack" },
-  { id: "endpoint-2", name: "Endpoint AZURE", type: "azure" },
-];
-const item = {
-  origin_endpoint_id: "endpoint-1",
-  destination_endpoint_id: "endpoint-2",
-  id: "item-id",
-  created_at: new Date(2017, 10, 24, 16, 15),
-  tasks,
-  destination_environment: { description: "A description" },
-  type: "Migration",
-};
-
-describe("MigrationDetailsContent Component", () => {
-  it("renders main details page", () => {
-    const wrapper = wrap({ endpoints, item, page: "" });
-    expect(wrapper.find("mainDetails").prop("item").id).toBe("item-id");
-  });
-
-  it("renders tasks page", () => {
-    const wrapper = wrap({ endpoints, item, page: "tasks" });
-    expect(wrapper.find("tasks").prop("items")[0].id).toBe("task-2");
-  });
-
-  it("renders details loading", () => {
-    const wrapper = wrap({ endpoints, item, page: "", detailsLoading: true });
-    expect(wrapper.find("mainDetails").prop("loading")).toBe(true);
-  });
-
-  it("dispatches delete click", () => {
-    const onDeleteMigrationClick = sinon.spy();
-    const wrapper = wrap({ endpoints, item, page: "", onDeleteMigrationClick });
-    wrapper
-      .find("mainDetails")
-      .prop("bottomControls")
-      .props.children.props.onClick();
-    expect(onDeleteMigrationClick.calledOnce).toBe(true);
-  });
-});

+ 131 - 0
src/components/modules/TransferModule/ReplicaDetailsContent/ReplicaDetailsContent.spec.tsx

@@ -0,0 +1,131 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import Schedule from "@src/components/modules/TransferModule/Schedule";
+import ScheduleStore from "@src/stores/ScheduleStore";
+import { render } from "@testing-library/react";
+import {
+  OPENSTACK_ENDPOINT_MOCK,
+  VMWARE_ENDPOINT_MOCK,
+} from "@tests/mocks/EndpointsMock";
+import {
+  EXECUTION_MOCK,
+  EXECUTION_TASKS_MOCK,
+} from "@tests/mocks/ExecutionsMock";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { NETWORK_MOCK } from "@tests/mocks/NetworksMock";
+import { STORAGE_BACKEND_MOCK } from "@tests/mocks/StoragesMock";
+import { REPLICA_ITEM_DETAILS_MOCK } from "@tests/mocks/TransferMock";
+
+import ReplicaDetailsContent from "./";
+
+const scheduleStoreMock = jest.createMockFromModule<typeof ScheduleStore>(
+  "@src/stores/ScheduleStore"
+);
+
+jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({
+  __esModule: true,
+  default: (props: any) => <div>{props.endpoint}</div>,
+}));
+jest.mock("react-router-dom", () => ({ Link: "a" }));
+jest.mock("@src/utils/Config", () => ({
+  config: {
+    providerSortPriority: {},
+    providerNames: {
+      openstack: "OpenStack",
+      vmware_vsphere: "VMware vSphere",
+    },
+    providersDisabledExecuteOptions: ["metal"],
+  },
+}));
+jest.mock("@src/components/modules/TransferModule/Schedule", () => ({
+  __esModule: true,
+  default: (props: Schedule["props"]) => (
+    <div
+      data-testid="ScheduleComponent"
+      onClick={() => props.onTimezoneChange("utc")}
+    >
+      Timezone: {props.timezone}
+    </div>
+  ),
+}));
+
+describe("ReplicaDetailsContent", () => {
+  let defaultProps: ReplicaDetailsContent["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      item: REPLICA_ITEM_DETAILS_MOCK,
+      itemId: REPLICA_ITEM_DETAILS_MOCK.id,
+      endpoints: [OPENSTACK_ENDPOINT_MOCK, VMWARE_ENDPOINT_MOCK],
+      sourceSchema: [],
+      sourceSchemaLoading: false,
+      destinationSchema: [],
+      destinationSchemaLoading: false,
+      networks: [NETWORK_MOCK],
+      instancesDetails: [INSTANCE_MOCK],
+      instancesDetailsLoading: false,
+      scheduleStore: scheduleStoreMock,
+      page: "",
+      detailsLoading: false,
+      executions: [EXECUTION_MOCK],
+      executionsLoading: false,
+      executionsTasks: [EXECUTION_TASKS_MOCK],
+      executionsTasksLoading: false,
+      minionPools: [MINION_POOL_MOCK],
+      storageBackends: [STORAGE_BACKEND_MOCK],
+      onExecutionChange: jest.fn(),
+      onCancelExecutionClick: jest.fn(),
+      onDeleteExecutionClick: jest.fn(),
+      onExecuteClick: jest.fn(),
+      onCreateMigrationClick: jest.fn(),
+      onDeleteReplicaClick: jest.fn(),
+      onAddScheduleClick: jest.fn(),
+      onScheduleChange: jest.fn(),
+      onScheduleRemove: jest.fn(),
+      onScheduleSave: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<ReplicaDetailsContent {...defaultProps} />);
+    expect(getByText(REPLICA_ITEM_DETAILS_MOCK.id)).toBeTruthy();
+  });
+
+  it("renders executions page", () => {
+    const { getByText } = render(
+      <ReplicaDetailsContent {...defaultProps} page="executions" />
+    );
+    expect(getByText(EXECUTION_MOCK.id)).toBeTruthy();
+  });
+
+  it("rendes schedules page", () => {
+    const { getByTestId } = render(
+      <ReplicaDetailsContent {...defaultProps} page="schedule" />
+    );
+    expect(getByTestId("ScheduleComponent")).toBeTruthy();
+  });
+
+  it("fires timezone change", () => {
+    const { getByTestId, getByText } = render(
+      <ReplicaDetailsContent {...defaultProps} page="schedule" />
+    );
+    expect(getByText("Timezone: local")).toBeTruthy();
+    getByTestId("ScheduleComponent").click();
+    expect(getByText("Timezone: utc")).toBeTruthy();
+  });
+});

+ 9 - 9
src/components/modules/TransferModule/ReplicaDetailsContent/ReplicaDetailsContent.tsx

@@ -12,27 +12,27 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
+import { observer } from "mobx-react";
 import React from "react";
 import styled from "styled-components";
-import { observer } from "mobx-react";
 
-import scheduleStore from "@src/stores/ScheduleStore";
-import Button from "@src/components/ui/Button";
+import { ReplicaItemDetails } from "@src/@types/MainItem";
+import { MinionPool } from "@src/@types/MinionPool";
 import DetailsNavigation from "@src/components/modules/NavigationModule/DetailsNavigation";
-import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import Executions from "@src/components/modules/TransferModule/Executions";
+import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import Schedule from "@src/components/modules/TransferModule/Schedule";
+import { ThemeProps } from "@src/components/Theme";
+import Button from "@src/components/ui/Button";
+import scheduleStore from "@src/stores/ScheduleStore";
+import configLoader from "@src/utils/Config";
+
 import type { Instance } from "@src/@types/Instance";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Execution, ExecutionTasks } from "@src/@types/Execution";
 import type { Network } from "@src/@types/Network";
 import type { Field } from "@src/@types/Field";
 import type { Schedule as ScheduleType } from "@src/@types/Schedule";
-import { ReplicaItemDetails } from "@src/@types/MainItem";
-import { MinionPool } from "@src/@types/MinionPool";
-import { ThemeProps } from "@src/components/Theme";
-import configLoader from "@src/utils/Config";
-
 const Wrapper = styled.div<any>`
   display: flex;
   justify-content: center;

+ 0 - 134
src/components/modules/TransferModule/ReplicaDetailsContent/test.tsx

@@ -1,134 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import ReplicaDetailsContent from ".";
-
-const wrap = props =>
-  new TW(shallow(<ReplicaDetailsContent {...props} />), "rdContent");
-
-const endpoints = [
-  { id: "endpoint-1", name: "Endpoint OPS", type: "openstack" },
-  { id: "endpoint-2", name: "Endpoint AZURE", type: "azure" },
-];
-const item = {
-  origin_endpoint_id: "endpoint-1",
-  destination_endpoint_id: "endpoint-2",
-  id: "item-id",
-  created_at: new Date(2017, 10, 24, 16, 15),
-  destination_environment: { description: "A description" },
-  type: "Replica",
-  executions: [
-    { id: "execution-1", status: "ERROR", created_at: new Date() },
-    { id: "execution-2", status: "COMPLETED", created_at: new Date() },
-    { id: "execution-2-1", status: "CANCELED", created_at: new Date() },
-    { id: "execution-3", status: "RUNNING", created_at: new Date() },
-  ],
-};
-
-describe("ReplicaDetailsContent Component", () => {
-  it("renders main details page", () => {
-    const wrapper = wrap({ endpoints, item, page: "" });
-    expect(wrapper.find("mainDetails").prop("item").id).toBe("item-id");
-  });
-
-  it("renders executions page", () => {
-    const wrapper = wrap({ endpoints, item, page: "executions" });
-    expect(wrapper.find("executions").prop("item").executions[1].id).toBe(
-      "execution-2"
-    );
-  });
-
-  it("renders details loading", () => {
-    const wrapper = wrap({ endpoints, item, page: "", detailsLoading: true });
-    expect(wrapper.find("mainDetails").prop("loading")).toBe(true);
-  });
-
-  it("renders schedule page", () => {
-    const wrapper = wrap({
-      endpoints,
-      item,
-      page: "schedule",
-      scheduleStore: { schedules: [] },
-    });
-    expect(wrapper.find("schedule").prop("schedules").length).toBe(0);
-  });
-
-  it("has `Create migration` button disabled if endpoint is missing", () => {
-    const wrapper = wrap({ endpoints, item: null, page: "" });
-    const bottomControls = new TW(
-      shallow(wrapper.find("mainDetails").prop("bottomControls")),
-      "rdContent"
-    );
-    expect(bottomControls.find("createButton").prop("disabled")).toBe(true);
-  });
-
-  it("has `Create migration` button enabled if the last status is completed", () => {
-    const newItem = {
-      ...item,
-      executions: [
-        ...item.executions,
-        { id: "execution-4", status: "COMPLETED", created_at: new Date() },
-      ],
-    };
-    const wrapper = wrap({ endpoints, item: newItem, page: "" });
-    const bottomControls = new TW(
-      shallow(wrapper.find("mainDetails").prop("bottomControls")),
-      "rdContent"
-    );
-    expect(bottomControls.find("createButton").prop("disabled")).toBe(false);
-  });
-
-  it("dispaches create migration click", () => {
-    const onCreateMigrationClick = sinon.spy();
-    const wrapper = wrap({ endpoints, item, page: "", onCreateMigrationClick });
-    const bottomControls = new TW(
-      shallow(wrapper.find("mainDetails").prop("bottomControls")),
-      "rdContent"
-    );
-    bottomControls.find("createButton").click();
-    expect(onCreateMigrationClick.calledOnce).toBe(true);
-  });
-
-  it("has `Create migration` button disabled if endpoint is missing and last status is completed", () => {
-    const newItem = {
-      ...item,
-      origin_endpoint_id: "missing",
-      executions: [
-        ...item.executions,
-        { id: "execution-4", status: "COMPLETED", created_at: new Date() },
-      ],
-    };
-    const wrapper = wrap({ endpoints, item: newItem, page: "" });
-    const bottomControls = new TW(
-      shallow(wrapper.find("mainDetails").prop("bottomControls")),
-      "rdContent"
-    );
-    expect(bottomControls.find("createButton").prop("disabled")).toBe(true);
-  });
-
-  it("dispatches delete click", () => {
-    const onDeleteReplicaClick = sinon.spy();
-    const wrapper = wrap({ endpoints, item, page: "", onDeleteReplicaClick });
-    const bottomControls = new TW(
-      shallow(wrapper.find("mainDetails").prop("bottomControls")),
-      "rdContent"
-    );
-    bottomControls.find("deleteButton").click();
-    expect(onDeleteReplicaClick.calledOnce).toBe(true);
-  });
-});

+ 75 - 0
src/components/modules/TransferModule/ReplicaExecutionOptions/ReplicaExecutionOptions.spec.tsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import { fireEvent, render } from "@testing-library/react";
+import TestUtils from "@tests/TestUtils";
+
+import ReplicaExecutionOptions from "./";
+
+jest.mock("@src/plugins/default/ContentPlugin", () => jest.fn(() => null));
+
+describe("ReplicaExecutionOptions", () => {
+  let defaultProps: ReplicaExecutionOptions["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      options: {
+        shutdown_instances: true,
+      },
+      disableExecutionOptions: false,
+      onChange: jest.fn(),
+      executionLabel: "Execute",
+      onCancelClick: jest.fn(),
+      onExecuteClick: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<ReplicaExecutionOptions {...defaultProps} />);
+    expect(getByText(defaultProps.executionLabel)).toBeTruthy();
+  });
+
+  it("executes on Enter", () => {
+    render(<ReplicaExecutionOptions {...defaultProps} />);
+    fireEvent.keyDown(document.body, { key: "Enter" });
+    expect(defaultProps.onExecuteClick).toHaveBeenCalled();
+  });
+
+  it("returns original field value if options is null", () => {
+    render(
+      <ReplicaExecutionOptions
+        {...defaultProps}
+        options={{ shutdown_instances: null }}
+      />
+    );
+    expect(TestUtils.select("Switch__Wrapper")?.textContent).toBe("No");
+  });
+
+  it("handles value change", () => {
+    render(<ReplicaExecutionOptions {...defaultProps} />);
+    fireEvent.click(TestUtils.select("Switch__InputWrapper")!);
+    expect(defaultProps.onChange).toHaveBeenCalledWith(
+      "shutdown_instances",
+      false
+    );
+  });
+
+  it("handles execute click", () => {
+    const { getByText } = render(<ReplicaExecutionOptions {...defaultProps} />);
+    fireEvent.click(getByText(defaultProps.executionLabel));
+    expect(defaultProps.onExecuteClick).toHaveBeenCalled();
+  });
+});

+ 0 - 70
src/components/modules/TransferModule/ReplicaExecutionOptions/test.tsx

@@ -1,70 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import ReplicaExecutionOptions from ".";
-
-import { executionOptions } from "@src/constants";
-
-const wrap = props =>
-  new TW(shallow(<ReplicaExecutionOptions {...props} />), "reOptions");
-
-describe("ReplicaExecutionOptions Component", () => {
-  it("renders executionOptions from config", () => {
-    const wrapper = wrap();
-    executionOptions.forEach(option => {
-      expect(wrapper.find(`option-${option.name}`).prop("name")).toBe(
-        option.name
-      );
-    });
-  });
-
-  it("renders executionOptions with default values", () => {
-    const wrapper = wrap();
-    executionOptions.forEach(option => {
-      expect(wrapper.find(`option-${option.name}`).prop("value")).toBe(
-        option.defaultValue || undefined
-      );
-    });
-  });
-
-  it("renders executionOptions with given values", () => {
-    const wrapper = wrap({ options: { shutdown_instances: true } });
-    expect(wrapper.find("option-shutdown_instances").prop("value")).toBe(true);
-  });
-
-  it("dispaches cancel click", () => {
-    const onCancelClick = sinon.spy();
-    const wrapper = wrap({ onCancelClick });
-    wrapper.find("cancelButton").click();
-    expect(onCancelClick.calledOnce).toBe(true);
-  });
-
-  it("renders custom execution button label", () => {
-    const wrapper = wrap({ executionLabel: "custom_exec" });
-    expect(wrapper.find("execButton").shallow.dive().dive().text()).toBe(
-      "custom_exec"
-    );
-  });
-
-  it("dispaches execution click", () => {
-    const onExecuteClick = sinon.spy();
-    const wrapper = wrap({ onExecuteClick });
-    wrapper.find("execButton").click();
-    expect(onExecuteClick.args[0][0][0].name).toBe(executionOptions[0].name);
-  });
-});

+ 154 - 0
src/components/modules/TransferModule/ReplicaMigrationOptions/ReplicaMigrationOptions.spec.tsx

@@ -0,0 +1,154 @@
+/*
+Copyright (C) 2023  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+
+import WizardScripts from "@src/components/modules/WizardModule/WizardScripts";
+import { fireEvent, render } from "@testing-library/react";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import { MINION_POOL_MOCK } from "@tests/mocks/MinionPoolMock";
+import { REPLICA_ITEM_DETAILS_MOCK } from "@tests/mocks/TransferMock";
+import TestUtils from "@tests/TestUtils";
+
+import ReplicaMigrationOptions from "./";
+
+jest.mock("@src/plugins/default/ContentPlugin", () => jest.fn(() => null));
+jest.mock("@src/components/modules/WizardModule/WizardScripts", () => ({
+  __esModule: true,
+  default: (props: WizardScripts["props"]) => (
+    <div data-testid="ScriptsComponent">
+      <div data-testid="ScriptsUploaded">
+        {props.uploadedScripts.map(s => s.scriptContent).join(", ")}
+      </div>
+      <div
+        data-testid="ScriptsRemove"
+        onClick={() => {
+          props.onScriptDataRemove(props.uploadedScripts[0]);
+        }}
+      />
+      <div data-testid="ScriptsRemoved">
+        {props.removedScripts.map(s => s.scriptContent).join(", ")}
+      </div>
+      <div
+        data-testid="ScriptsCancel"
+        onClick={() => {
+          props.onCancelScript("windows", null);
+          props.scrollableRef &&
+            props.scrollableRef(null as any as HTMLElement);
+        }}
+      />
+      <div
+        data-testid="ScriptsUpload"
+        onClick={() => {
+          props.onScriptUpload({
+            scriptContent: `script-content-${Math.random()}`,
+            fileName: `script-name.ps1`,
+            global: "windows",
+          });
+        }}
+      />
+    </div>
+  ),
+}));
+
+describe("ReplicaMigrationOptions", () => {
+  let defaultProps: ReplicaMigrationOptions["props"];
+
+  beforeEach(() => {
+    defaultProps = {
+      instances: [INSTANCE_MOCK],
+      transferItem: REPLICA_ITEM_DETAILS_MOCK,
+      minionPools: [
+        MINION_POOL_MOCK,
+        { ...MINION_POOL_MOCK, id: "pool2", name: "Pool2" },
+      ],
+      loadingInstances: false,
+      onCancelClick: jest.fn(),
+      onMigrateClick: jest.fn(),
+      onResizeUpdate: jest.fn(),
+    };
+  });
+
+  it("renders without crashing", () => {
+    const { getByText } = render(<ReplicaMigrationOptions {...defaultProps} />);
+    expect(getByText("Migrate")).toBeTruthy();
+  });
+
+  it("executes on Enter", () => {
+    render(<ReplicaMigrationOptions {...defaultProps} />);
+    fireEvent.keyDown(document.body, { key: "Enter" });
+    expect(defaultProps.onMigrateClick).toHaveBeenCalled();
+  });
+
+  it("calls onResizeUpdate on selectedBarButton state change", () => {
+    render(<ReplicaMigrationOptions {...defaultProps} />);
+    fireEvent.click(TestUtils.selectAll("ToggleButtonBar__Item-")[1]);
+    expect(defaultProps.onResizeUpdate).toHaveBeenCalled();
+  });
+
+  it("handles value change", () => {
+    render(<ReplicaMigrationOptions {...defaultProps} />);
+    expect(TestUtils.select("Switch__Wrapper")?.textContent).toBe("Yes");
+    fireEvent.click(TestUtils.select("Switch__InputWrapper")!);
+    expect(TestUtils.select("Switch__Wrapper")?.textContent).toBe("No");
+  });
+
+  it("handles script operations", () => {
+    const { getByTestId } = render(
+      <ReplicaMigrationOptions {...defaultProps} />
+    );
+    fireEvent.click(TestUtils.selectAll("ToggleButtonBar__Item-")[1]);
+    fireEvent.click(getByTestId("ScriptsUpload"));
+    expect(getByTestId("ScriptsUploaded").textContent).toContain(
+      "script-content"
+    );
+    fireEvent.click(getByTestId("ScriptsCancel"));
+    expect(getByTestId("ScriptsUploaded").textContent).toBe("");
+
+    fireEvent.click(getByTestId("ScriptsUpload"));
+    expect(getByTestId("ScriptsUploaded").textContent).toContain(
+      "script-content"
+    );
+    expect(getByTestId("ScriptsRemoved").textContent).toBe("");
+    fireEvent.click(getByTestId("ScriptsRemove"));
+    expect(getByTestId("ScriptsRemoved").textContent).toContain(
+      "script-content"
+    );
+  });
+
+  it("doesn't render minion pool mappings", () => {
+    const { rerender } = render(<ReplicaMigrationOptions {...defaultProps} />);
+    expect(document.body.textContent).toContain("Minion Pool Mappings");
+
+    rerender(<ReplicaMigrationOptions {...defaultProps} minionPools={[]} />);
+    expect(document.body.textContent).not.toContain("Minion Pool Mappings");
+  });
+
+  it("changes minion pool mappings value", () => {
+    render(<ReplicaMigrationOptions {...defaultProps} />);
+    fireEvent.click(TestUtils.select("DropdownButton__Wrapper-")!);
+    const dropdownItem = TestUtils.selectAll("Dropdown__ListItem-")[2];
+    expect(dropdownItem.textContent).toBe("Pool2");
+    fireEvent.click(dropdownItem);
+    expect(TestUtils.select("DropdownButton__Label-")?.textContent).toBe(
+      "Pool2"
+    );
+  });
+
+  it("handles migrate click", () => {
+    const { getByText } = render(<ReplicaMigrationOptions {...defaultProps} />);
+    fireEvent.click(getByText("Migrate"));
+    expect(defaultProps.onMigrateClick).toHaveBeenCalled();
+  });
+});

+ 13 - 14
src/components/modules/TransferModule/ReplicaMigrationOptions/ReplicaMigrationOptions.tsx

@@ -12,28 +12,27 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-import React from "react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 
+import { TransferItemDetails } from "@src/@types/MainItem";
+import { MinionPool } from "@src/@types/MinionPool";
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from "@src/components/modules/WizardModule/WizardOptions";
+import WizardScripts from "@src/components/modules/WizardModule/WizardScripts";
+import { ThemeProps } from "@src/components/Theme";
 import Button from "@src/components/ui/Button";
 import FieldInput from "@src/components/ui/FieldInput";
+import LoadingButton from "@src/components/ui/LoadingButton";
 import ToggleButtonBar from "@src/components/ui/ToggleButtonBar";
-import WizardScripts from "@src/components/modules/WizardModule/WizardScripts";
-
-import LabelDictionary from "@src/utils/LabelDictionary";
 import KeyboardManager from "@src/utils/KeyboardManager";
+import LabelDictionary from "@src/utils/LabelDictionary";
 
-import type { Field } from "@src/@types/Field";
-import type { Instance, InstanceScript } from "@src/@types/Instance";
-import { TransferItemDetails } from "@src/@types/MainItem";
-import { MinionPool } from "@src/@types/MinionPool";
-import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from "@src/components/modules/WizardModule/WizardOptions";
-import { ThemeProps } from "@src/components/Theme";
-import replicaMigrationFields from "./replicaMigrationFields";
 import replicaMigrationImage from "./images/replica-migration.svg";
-import LoadingButton from "@src/components/ui/LoadingButton";
+import replicaMigrationFields from "./replicaMigrationFields";
 
+import type { Field } from "@src/@types/Field";
+import type { Instance, InstanceScript } from "@src/@types/Instance";
 const Wrapper = styled.div<any>`
   display: flex;
   flex-direction: column;
@@ -172,7 +171,7 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
     });
   }
 
-  handleCanceScript(global: string | null, instanceName: string | null) {
+  handleCancelScript(global: string | null, instanceName: string | null) {
     this.setState(prevState => ({
       uploadedScripts: prevState.uploadedScripts.filter(s =>
         global ? s.global !== global : s.instanceId !== instanceName
@@ -278,7 +277,7 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
           this.handleScriptRemove(s);
         }}
         onCancelScript={(g, i) => {
-          this.handleCanceScript(g, i);
+          this.handleCancelScript(g, i);
         }}
         uploadedScripts={this.state.uploadedScripts}
         removedScripts={this.state.removedScripts}

+ 0 - 50
src/components/modules/TransferModule/ReplicaMigrationOptions/test.tsx

@@ -1,50 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { shallow } from "enzyme";
-import sinon from "sinon";
-import TW from "@src/utils/TestWrapper";
-import ReplicaMigrationOptions from ".";
-
-const wrap = props =>
-  new TW(
-    shallow(
-      <ReplicaMigrationOptions
-        instances={[]}
-        onMigrateClick={() => {}}
-        loadingInstances={false}
-        defaultSkipOsMorphing={false}
-        {...props}
-      />
-    ),
-    "rmOptions"
-  );
-
-describe("ReplicaMigrationOptions Component", () => {
-  it("dispatches cancel click", () => {
-    const onCancelClick = sinon.spy();
-    const wrapper = wrap({ onCancelClick });
-    wrapper.find("cancelButton").click();
-    expect(onCancelClick.calledOnce).toBe(true);
-  });
-
-  it("dispatches migrate click", () => {
-    const onMigrateClick = sinon.spy();
-    const wrapper = wrap({ onMigrateClick });
-    wrapper.find("execButton").click();
-    expect(onMigrateClick.args[0][0][0].name).toBe("clone_disks");
-    expect(onMigrateClick.args[0][0][0].value).toBe(true);
-  });
-});

部分文件因文件數量過多而無法顯示