Просмотр исходного кода

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 лет назад
Родитель
Сommit
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/releases
 !.yarn/sdks
 !.yarn/sdks
 !.yarn/versions
 !.yarn/versions
+
+
+# testing
+coverage

+ 23 - 9
jest.config.ts

@@ -17,10 +17,29 @@ export default {
   clearMocks: true,
   clearMocks: true,
 
 
   // Indicates whether the coverage information should be collected while executing the test
   // 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
   // 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
   // The directory where Jest should output its coverage files
   // coverageDirectory: undefined,
   // coverageDirectory: undefined,
@@ -31,15 +50,10 @@ export default {
   // ],
   // ],
 
 
   // Indicates which provider should be used to instrument code for coverage
   // 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
   // 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
   // An object that configures minimum threshold enforcement for coverage results
   // coverageThreshold: undefined,
   // coverageThreshold: undefined,

+ 2 - 2
package.json

@@ -18,7 +18,6 @@
     "test": "jest",
     "test": "jest",
     "e2e": "cypress run",
     "e2e": "cypress run",
     "test-release": "node ./tests/testRelease",
     "test-release": "node ./tests/testRelease",
-    "test-coverage": "node ./tests/testCoverage",
     "storybook": "start-storybook"
     "storybook": "start-storybook"
   },
   },
   "devDependencies": {
   "devDependencies": {
@@ -31,7 +30,7 @@
     "@types/file-saver": "^2.0.1",
     "@types/file-saver": "^2.0.1",
     "@types/jest": "^27.0.2",
     "@types/jest": "^27.0.2",
     "@types/js-cookie": "^2.2.6",
     "@types/js-cookie": "^2.2.6",
-    "@types/luxon": "^3.3.2",
+    "@types/luxon": "^3.3.3",
     "@types/moment-timezone": "^0.5.13",
     "@types/moment-timezone": "^0.5.13",
     "@types/react": "^16.13.1",
     "@types/react": "^16.13.1",
     "@types/react-collapse": "^5.0.0",
     "@types/react-collapse": "^5.0.0",
@@ -51,6 +50,7 @@
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-react": "^7.31.7",
     "eslint-plugin-react": "^7.31.7",
     "jest": "^27.3.1",
     "jest": "^27.3.1",
+    "jest-canvas-mock": "^2.5.2",
     "nodemon": "^2.0.4",
     "nodemon": "^2.0.4",
     "prettier": "^2.7.1"
     "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 React from "react";
-import { render } from "@testing-library/react";
-import TestUtils from "@tests/TestUtils";
+
 import { ThemePalette } from "@src/components/Theme";
 import { ThemePalette } from "@src/components/Theme";
+import { render } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 import userEvent from "@testing-library/user-event";
-import DashboardBarChart from ".";
+import TestUtils from "@tests/TestUtils";
+
+import DashboardBarChart from "./";
 
 
 const DATA: DashboardBarChart["props"]["data"] = [
 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;
   licence: Licence | null;
   licenceServerStatus: LicenceServerStatus | null;
   licenceServerStatus: LicenceServerStatus | null;
   loading: boolean;
   loading: boolean;
-  style: any;
+  style?: React.CSSProperties;
   licenceError: string | null;
   licenceError: string | null;
   onAddClick: () => void;
   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
 @observer
 class DashboardPieChart extends React.Component<Props> {
 class DashboardPieChart extends React.Component<Props> {
-  canvas: HTMLCanvasElement | null | undefined;
+  canvas: HTMLCanvasElement | null = null;
 
 
   angles: number[] = [];
   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[];
   migrations: MigrationItem[];
   // eslint-disable-next-line react/no-unused-prop-types
   // eslint-disable-next-line react/no-unused-prop-types
   endpoints: Endpoint[];
   endpoints: Endpoint[];
-  style: any;
+  style: React.CSSProperties;
   loading: boolean;
   loading: boolean;
   onNewClick: () => void;
   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;
     label: React.ReactNode;
     value: string;
     value: string;
   }) => void;
   }) => void;
-  testMode?: boolean;
 };
 };
 
 
 @observer
 @observer
@@ -77,9 +76,6 @@ class DetailsPageHeader extends React.Component<Props, State> {
   stopPolling!: boolean;
   stopPolling!: boolean;
 
 
   UNSAFE_componentWillMount() {
   UNSAFE_componentWillMount() {
-    if (this.props.testMode) {
-      return;
-    }
     this.stopPolling = false;
     this.stopPolling = false;
     this.pollData(true);
     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,
     validationDone: false,
   };
   };
 
 
-  UNSAFE_componentWillReceiveProps(prevProps: Props) {
+  componentDidUpdate(prevProps: Props) {
     if (prevProps.validating && !this.props.validating) {
     if (prevProps.validating && !this.props.validating) {
       this.setState({ validationDone: true });
       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;
   className: string;
   showUserDomainInput: boolean;
   showUserDomainInput: boolean;
   loading: boolean;
   loading: boolean;
-  loginFailedResponse: { status: string | number; message?: string };
+  loginFailedResponse: { status: string | number; message?: string } | null;
   domain: string;
   domain: string;
   onDomainChange: (domain: string) => void;
   onDomainChange: (domain: string) => void;
   onFormSubmit: (credentials: { username: string; password: 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;
   margin: 0 8px 0 8px;
   ${props => buttonStyle(props.id, true)}
   ${props => buttonStyle(props.id, true)}
 `;
 `;
-type Props = {
+export type Props = {
   buttons?: { name: string; id: string }[];
   buttons?: { name: string; id: string }[];
 };
 };
 const LoginOptions = (props: Props) => {
 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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import React from "react";
 import { observer } from "mobx-react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled, { css } from "styled-components";
 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 Button from "@src/components/ui/Button";
-import Modal from "@src/components/ui/Modal";
 import FieldInput from "@src/components/ui/FieldInput";
 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 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 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`
 const Wrapper = styled.div`
   padding: 48px 32px 32px 32px;
   padding: 48px 32px 32px 32px;
@@ -302,7 +303,7 @@ class MetalHubModal extends React.Component<Props, State> {
     const message = this.state.saving
     const message = this.state.saving
       ? "Validating ..."
       ? "Validating ..."
       : metalHubStore.validationError.length
       : metalHubStore.validationError.length
-      ? metalHubStore.validationError.map(e => <div key="e">{e}</div>)
+      ? metalHubStore.validationError.map(e => <div key={e}>{e}</div>)
       : "Validation successful";
       : "Validation successful";
     const status = this.state.saving
     const status = this.state.saving
       ? "RUNNING"
       ? "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;
             : providerTypes.DESTINATION_MINION_POOL;
         const types =
         const types =
           this.props.providers?.[providerName].types.indexOf(providerType);
           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;
   orderDir: OrderDir;
 };
 };
 class MinionPoolEvents extends React.Component<Props, State> {
 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 = {
   state = {
     allEvents: [] as MinionPoolEventProgressUpdate[],
     allEvents: [] as MinionPoolEventProgressUpdate[],
     prevLenghts: [0, 0],
     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;
   display: flex;
   transition: opacity ${ANIMATION};
   transition: opacity ${ANIMATION};
 `;
 `;
-export const TEST_ID = "navigation";
+
 type Props = {
 type Props = {
   currentPage?: string;
   currentPage?: string;
   className?: 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;
   z-index: 9;
 `;
 `;
 
 
-export const TEST_ID = "navigationMini";
-
 type State = {
 type State = {
   open: boolean;
   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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
+import { observer } from "mobx-react";
 import React from "react";
 import React from "react";
 import { Link } from "react-router-dom";
 import { Link } from "react-router-dom";
-import { observer } from "mobx-react";
 import styled, { css } from "styled-components";
 import styled, { css } from "styled-components";
 
 
+import { ThemePalette, ThemeProps } from "@src/components/Theme";
 import AlertModal from "@src/components/ui/AlertModal";
 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 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 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 { Project, RoleAssignment, Role } from "@src/@types/Project";
 import type { User } from "@src/@types/User";
 import type { User } from "@src/@types/User";
-import { ThemePalette, ThemeProps } from "@src/components/Theme";
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   ${ThemeProps.exactWidth(ThemeProps.contentWidth)}
   ${ThemeProps.exactWidth(ThemeProps.contentWidth)}
@@ -170,13 +170,7 @@ class ProjectDetailsContent extends React.Component<Props, State> {
           <Button onClick={this.props.onAddMemberClick}>Add Member</Button>
           <Button onClick={this.props.onAddMemberClick}>Add Member</Button>
         </ButtonsColumn>
         </ButtonsColumn>
         <ButtonsColumn>
         <ButtonsColumn>
-          <Button
-            alert
-            hollow
-            onClick={() => {
-              this.props.onDeleteClick();
-            }}
-          >
+          <Button alert hollow onClick={this.props.onDeleteClick}>
             Delete Project
             Delete Project
           </Button>
           </Button>
         </ButtonsColumn>
         </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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import * as React from "react";
 import { observer } from "mobx-react";
 import { observer } from "mobx-react";
+import * as React from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+
 import {
 import {
   CustomerInfoBasic,
   CustomerInfoBasic,
   CustomerInfoTrial,
   CustomerInfoTrial,
   SetupPageLicenceType,
   SetupPageLicenceType,
 } from "@src/@types/InitialSetup";
 } 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 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 CopyButton from "@src/components/ui/CopyButton";
+import { customerInfoSetupStoreValueToString } from "@src/stores/SetupStore";
+import DomUtils from "@src/utils/DomUtils";
 
 
 const Wrapper = styled.div``;
 const Wrapper = styled.div``;
 const Link = styled.a`
 const Link = styled.a`
@@ -59,28 +60,18 @@ type Props = {
 class SetupPageEmailBody extends React.Component<Props> {
 class SetupPageEmailBody extends React.Component<Props> {
   emailTemplate: HTMLElement | null = null;
   emailTemplate: HTMLElement | null = null;
 
 
-  handleCopy(event?: React.ClipboardEvent) {
+  async handleCopy(event?: React.ClipboardEvent) {
     event?.preventDefault();
     event?.preventDefault();
 
 
     if (!this.emailTemplate) {
     if (!this.emailTemplate) {
       return;
       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() {
   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
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
 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 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);
   transform: scale(0.6);
 `;
 `;
 type Props = {
 type Props = {
-  style: React.CSSProperties;
+  style?: React.CSSProperties;
 };
 };
 
 
 @observer
 @observer
@@ -49,7 +49,7 @@ class SetupPageHelp extends React.Component<Props> {
           Click the link below to view the Coriolis® documentation. There you
           Click the link below to view the Coriolis® documentation. There you
           can find all the help you need to get you started.
           can find all the help you need to get you started.
         </p>
         </p>
-        <Help href="https://cloudbase.it/coriolis-overview/" target="_balnk">
+        <Help href="https://cloudbase.it/coriolis-overview/" target="_blank">
           Coriolis® Documentation
           Coriolis® Documentation
           <OpenInNewIconWrapper
           <OpenInNewIconWrapper
             dangerouslySetInnerHTML={{
             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
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
 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 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
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
 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 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;
   flex-direction: column;
   min-height: 0;
   min-height: 0;
 `;
 `;
-type Props = {
+export type Props = {
   pageHeaderComponent: React.ReactNode;
   pageHeaderComponent: React.ReactNode;
   contentHeaderComponent: React.ReactNode;
   contentHeaderComponent: React.ReactNode;
   contentComponent: 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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import React from "react";
 import { observer } from "mobx-react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 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 Button from "@src/components/ui/Button";
+import Modal from "@src/components/ui/Modal";
 import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
 import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
 
 
-import { ThemePalette } from "@src/components/Theme";
-
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
   flex-direction: column;
   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) {
       if (this.props.executions.length > props.executions.length) {
         const isSelectedAvailable = props.executions.find(
         const isSelectedAvailable = props.executions.find(
-          e =>
-            this.state.selectedExecution &&
-            e.id === this.state.selectedExecution.id
+          e => e.id === this.state.selectedExecution?.id
         );
         );
         if (!isSelectedAvailable) {
         if (!isSelectedAvailable) {
           const lastIndex = this.props.executions
           const lastIndex = this.props.executions
             ? this.props.executions.findIndex(
             ? this.props.executions.findIndex(
-                e =>
-                  this.state.selectedExecution &&
-                  e.id === this.state.selectedExecution.id
+                e => e.id === this.state.selectedExecution?.id
               )
               )
             : -1;
             : -1;
           if (props.executions.length) {
           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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import React from "react";
 import { observer } from "mobx-react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 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 DetailsNavigation from "@src/components/modules/NavigationModule/DetailsNavigation";
 import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import Tasks from "@src/components/modules/TransferModule/Tasks";
 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 { Instance } from "@src/@types/Instance";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Field } from "@src/@types/Field";
 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>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
   justify-content: center;
   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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
+import { observer } from "mobx-react";
 import React from "react";
 import React from "react";
 import styled from "styled-components";
 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 DetailsNavigation from "@src/components/modules/NavigationModule/DetailsNavigation";
-import MainDetails from "@src/components/modules/TransferModule/MainDetails";
 import Executions from "@src/components/modules/TransferModule/Executions";
 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 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 { Instance } from "@src/@types/Instance";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Endpoint, StorageBackend } from "@src/@types/Endpoint";
 import type { Execution, ExecutionTasks } from "@src/@types/Execution";
 import type { Execution, ExecutionTasks } from "@src/@types/Execution";
 import type { Network } from "@src/@types/Network";
 import type { Network } from "@src/@types/Network";
 import type { Field } from "@src/@types/Field";
 import type { Field } from "@src/@types/Field";
 import type { Schedule as ScheduleType } from "@src/@types/Schedule";
 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>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
   justify-content: center;
   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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import React from "react";
 import { observer } from "mobx-react";
 import { observer } from "mobx-react";
+import React from "react";
 import styled from "styled-components";
 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 Button from "@src/components/ui/Button";
 import FieldInput from "@src/components/ui/FieldInput";
 import FieldInput from "@src/components/ui/FieldInput";
+import LoadingButton from "@src/components/ui/LoadingButton";
 import ToggleButtonBar from "@src/components/ui/ToggleButtonBar";
 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 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 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>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
   flex-direction: column;
   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 => ({
     this.setState(prevState => ({
       uploadedScripts: prevState.uploadedScripts.filter(s =>
       uploadedScripts: prevState.uploadedScripts.filter(s =>
         global ? s.global !== global : s.instanceId !== instanceName
         global ? s.global !== global : s.instanceId !== instanceName
@@ -278,7 +277,7 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
           this.handleScriptRemove(s);
           this.handleScriptRemove(s);
         }}
         }}
         onCancelScript={(g, i) => {
         onCancelScript={(g, i) => {
-          this.handleCanceScript(g, i);
+          this.handleCancelScript(g, i);
         }}
         }}
         uploadedScripts={this.state.uploadedScripts}
         uploadedScripts={this.state.uploadedScripts}
         removedScripts={this.state.removedScripts}
         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);
-  });
-});

Некоторые файлы не были показаны из-за большого количества измененных файлов