diff --git a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx new file mode 100644 index 00000000..f2be716a --- /dev/null +++ b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx @@ -0,0 +1,222 @@ +/* +Copyright (C) 2026 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 . +*/ + +import React, { act } 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 { NETWORK_MOCK } from "@tests/mocks/NetworksMock"; +import { TRANSFER_ITEM_DETAILS_MOCK } from "@tests/mocks/TransferMock"; +import TestUtils from "@tests/TestUtils"; + +import { Instance } from "@src/@types/Instance"; +import { UpdateData } from "@src/@types/MainItem"; +import transferStore from "@src/stores/TransferStore"; + +import TransferItemModal from "./TransferItemModal"; + +jest.mock("@src/stores/ProviderStore", () => ({ + __esModule: true, + default: { + providers: {}, + sourceSchema: [], + destinationSchema: [], + sourceSchemaLoading: false, + destinationSchemaLoading: false, + sourceOptionsPrimaryLoading: false, + sourceOptionsSecondaryLoading: false, + destinationOptionsPrimaryLoading: false, + destinationOptionsSecondaryLoading: false, + loadProviders: jest.fn(), + loadOptionsSchema: jest.fn(), + getOptionsValues: jest.fn(), + hasExecuteNowOptions: jest.fn(() => true), + }, + getFieldChangeOptions: jest.fn(() => null), +})); +jest.mock("@src/stores/TransferStore", () => ({ + __esModule: true, + default: { update: jest.fn() }, +})); +jest.mock("@src/stores/EndpointStore", () => ({ + __esModule: true, + default: { + storageBackends: [], + storageConfigDefault: "storage-config-default", + storageLoading: false, + loadStorage: jest.fn(), + }, +})); +jest.mock("@src/stores/MinionPoolStore", () => ({ + __esModule: true, + default: { minionPools: [], loadMinionPools: jest.fn() }, +})); +jest.mock("@src/stores/NetworkStore", () => ({ + __esModule: true, + default: { loadNetworks: jest.fn() }, +})); +jest.mock("@src/utils/Config", () => ({ + __esModule: true, + default: { + config: { + extraOptionsApiCalls: [], + passwordFields: [], + }, + }, +})); + +// the network which `TRANSFER_ITEM_DETAILS_MOCK.network_map` is mapping +const MAPPED_NETWORK_NAME = "network-name"; +const NEW_NETWORK_NAME = "new-network-name"; + +const buildInstance = (networkName: string): Instance => ({ + ...INSTANCE_MOCK, + devices: { + ...INSTANCE_MOCK.devices, + nics: [{ ...INSTANCE_MOCK.devices.nics[0], network_name: networkName }], + }, +}); + +const openNetworkMapping = async () => { + const navigationItem = Array.from( + TestUtils.selectAll("Panel__NavigationItem"), + ).find(item => item.textContent === "Network Mapping"); + await act(async () => { + navigationItem!.click(); + }); +}; + +const selectFirstNetwork = async () => { + await act(async () => { + TestUtils.selectAll("DropdownButton__Wrapper")[0].click(); + }); + await act(async () => { + TestUtils.selectAll("Dropdown__ListItem-")[0].click(); + }); +}; + +const getSourceNicNames = () => + Array.from(TestUtils.selectAll("WizardNetworks__NetworkName")).map( + nic => nic.textContent, + ); + +const getSelectedNetworkLabel = () => + TestUtils.select("DropdownButton__Label")!.textContent; + +const getSentUpdateData = (): UpdateData => + jest.mocked(transferStore.update).mock.calls[0][0].updateData; + +describe("TransferItemModal", () => { + let defaultProps: TransferItemModal["props"]; + + beforeEach(() => { + jest.clearAllMocks(); + + defaultProps = { + type: "transfer", + isOpen: true, + transfer: TRANSFER_ITEM_DETAILS_MOCK, + sourceEndpoint: VMWARE_ENDPOINT_MOCK, + destinationEndpoint: OPENSTACK_ENDPOINT_MOCK, + instancesDetails: [buildInstance(MAPPED_NETWORK_NAME)], + instancesDetailsLoading: false, + networks: [NETWORK_MOCK], + networksLoading: false, + onRequestClose: jest.fn(), + onUpdateComplete: jest.fn(), + onReloadClick: jest.fn(), + }; + }); + + it("renders without crashing", () => { + const { getByText } = render(); + expect(getByText("Edit Transfer")).toBeTruthy(); + }); + + it("renders the mapping of the source NIC", async () => { + render(); + await openNetworkMapping(); + + expect(getSourceNicNames()).toEqual([MAPPED_NETWORK_NAME]); + expect(getSelectedNetworkLabel()).toBe(NETWORK_MOCK.name); + }); + + it("renders the NIC which was attached to another network on the source", async () => { + render( + , + ); + await openNetworkMapping(); + + expect(getSourceNicNames()).toEqual([NEW_NETWORK_NAME]); + expect(getSelectedNetworkLabel()).toBe("Select Network"); + }); + + it("keeps the existing mappings when there are no instances details", async () => { + render(); + await openNetworkMapping(); + + expect(getSourceNicNames()).toEqual([MAPPED_NETWORK_NAME]); + }); + + it("sends the mapping of the source NIC on update", async () => { + const { getByText } = render(); + await openNetworkMapping(); + await selectFirstNetwork(); + await act(async () => { + getByText("Update").click(); + }); + + expect( + getSentUpdateData().network.map( + mapping => mapping.sourceNic.network_name, + ), + ).toEqual([MAPPED_NETWORK_NAME]); + }); + + it("doesn't send the mappings of the networks removed from the source NICs", async () => { + const { getByText } = render( + , + ); + await openNetworkMapping(); + await selectFirstNetwork(); + await act(async () => { + getByText("Update").click(); + }); + + expect( + getSentUpdateData().network.map( + mapping => mapping.sourceNic.network_name, + ), + ).toEqual([NEW_NETWORK_NAME]); + }); + + it("fires onReloadClick on reload", async () => { + render(); + await act(async () => { + TestUtils.select("Panel__ReloadButton")!.click(); + }); + + expect(defaultProps.onReloadClick).toHaveBeenCalled(); + }); +}); diff --git a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx index 00671acb..4b6071a8 100644 --- a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx +++ b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx @@ -247,12 +247,35 @@ class TransferItemModal extends React.Component { return storageMap; } + getSourceNicsNetworkNames(): string[] { + const networkNames: string[] = []; + this.props.instancesDetails.forEach(instance => { + instance.devices?.nics?.forEach(nic => { + if (!networkNames.includes(nic.network_name)) { + networkNames.push(nic.network_name); + } + }); + }); + return networkNames; + } + getSelectedNetworks(): NetworkMap[] { const selectedNetworks: NetworkMap[] = []; const networkMap: any = this.props.transfer.network_map; + const sourceNicsNetworkNames = this.getSourceNicsNetworkNames(); if (networkMap) { Object.keys(networkMap).forEach(sourceNetworkName => { + // the NICs can be changed on the source platform, so the mappings of + // the networks which are no longer attached to the instances are + // dropped, otherwise they would be sent again on update + if ( + sourceNicsNetworkNames.length && + !sourceNicsNetworkNames.includes(sourceNetworkName) + ) { + return; + } + // if the network mapping was updated, just use the new mapping instead of the old one const updatedMapping = this.state.selectedNetworks.find( m => m.sourceNic.network_name === sourceNetworkName, diff --git a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx index b528dc60..8d18cb6b 100644 --- a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx +++ b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx @@ -48,7 +48,7 @@ import ObjectUtils from "@src/utils/ObjectUtils"; import replicaImage from "./images/replica.svg"; import liveMigrationImage from "./images/live_migration.svg"; -import type { InstanceScript } from "@src/@types/Instance"; +import type { Instance, InstanceScript } from "@src/@types/Instance"; import type { Execution } from "@src/@types/Execution"; import type { Schedule } from "@src/@types/Schedule"; import type { Field } from "@src/@types/Field"; @@ -220,6 +220,12 @@ class TransferDetailsPage extends React.Component { return transferStore.transferDetails; } + get instancesDetails(): Instance[] { + return this.state.dbInstancesDetails.length + ? this.state.dbInstancesDetails + : instanceStore.instancesDetails; + } + getLastExecution() { const executions = transferStore.executionsList; if (executions.length) { @@ -301,6 +307,7 @@ class TransferDetailsPage extends React.Component { cache: boolean; transferId?: string; showLoading?: boolean; + reloadInstances?: boolean; onDetailsLoaded?: () => void; }) { await transferStore.getTransferDetails({ @@ -348,9 +355,13 @@ class TransferDetailsPage extends React.Component { ); } - if (this.hasStoredVmInfo(transfer.info)) { + // The VM info stored on the transfer is only refreshed by a new execution, + // so on an explicit reload the instances details are loaded from the source + // platform, in order to pick up source side changes, such as a changed NIC. + if (this.hasStoredVmInfo(transfer.info) && !options.reloadInstances) { this.populateInstanceStoreFromTransferInfo(transfer.info); } else { + this.setState({ dbInstancesDetails: [] }); instanceStore.loadInstancesDetails({ endpointId: transfer.origin_endpoint_id, instances: transfer.instances.map(n => ({ id: n })), @@ -389,7 +400,10 @@ class TransferDetailsPage extends React.Component { const transfer = this.transfer; if ( transfer && - !this.state.dbInstancesDetails.length && + // the stored VM info is only used when there are no instances details + // loaded, or being loaded, from the source platform + !this.instancesDetails.length && + !instanceStore.loadingInstancesDetails && this.hasStoredVmInfo(transfer.info) ) { this.populateInstanceStoreFromTransferInfo(transfer.info); @@ -692,7 +706,7 @@ class TransferDetailsPage extends React.Component { } handleEditTransferReload() { - this.loadTransferWithInstances({ cache: false }); + this.loadTransferWithInstances({ cache: false, reloadInstances: true }); } handleUpdateComplete(redirectTo: string) { @@ -740,9 +754,7 @@ class TransferDetailsPage extends React.Component { }} transfer={transfer} destinationEndpoint={destinationEndpoint} - instancesDetails={ - this.state.dbInstancesDetails || instanceStore.instancesDetails - } + instancesDetails={this.instancesDetails} instancesDetailsLoading={instanceStore.loadingInstancesDetails} networks={networkStore.networks} networksLoading={networkStore.loading} @@ -843,9 +855,7 @@ class TransferDetailsPage extends React.Component { { m.platform === "destination", )} loadingInstances={instanceStore.loadingInstancesDetails} - instances={ - this.state.dbInstancesDetails || instanceStore.instancesDetails - } + instances={this.instancesDetails} onCancelClick={() => { this.handleCloseDeploymentModal(); }}