Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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(<TransferItemModal {...defaultProps} />);
expect(getByText("Edit Transfer")).toBeTruthy();
});

it("renders the mapping of the source NIC", async () => {
render(<TransferItemModal {...defaultProps} />);
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(
<TransferItemModal
{...defaultProps}
instancesDetails={[buildInstance(NEW_NETWORK_NAME)]}
/>,
);
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(<TransferItemModal {...defaultProps} instancesDetails={[]} />);
await openNetworkMapping();

expect(getSourceNicNames()).toEqual([MAPPED_NETWORK_NAME]);
});

it("sends the mapping of the source NIC on update", async () => {
const { getByText } = render(<TransferItemModal {...defaultProps} />);
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(
<TransferItemModal
{...defaultProps}
instancesDetails={[buildInstance(NEW_NETWORK_NAME)]}
/>,
);
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(<TransferItemModal {...defaultProps} />);
await act(async () => {
TestUtils.select("Panel__ReloadButton")!.click();
});

expect(defaultProps.onReloadClick).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,35 @@ class TransferItemModal extends React.Component<Props, State> {
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,
Expand Down
34 changes: 21 additions & 13 deletions src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -220,6 +220,12 @@ class TransferDetailsPage extends React.Component<Props, State> {
return transferStore.transferDetails;
}

get instancesDetails(): Instance[] {
return this.state.dbInstancesDetails.length
? this.state.dbInstancesDetails
: instanceStore.instancesDetails;
}

getLastExecution() {
const executions = transferStore.executionsList;
if (executions.length) {
Expand Down Expand Up @@ -301,6 +307,7 @@ class TransferDetailsPage extends React.Component<Props, State> {
cache: boolean;
transferId?: string;
showLoading?: boolean;
reloadInstances?: boolean;
onDetailsLoaded?: () => void;
}) {
await transferStore.getTransferDetails({
Expand Down Expand Up @@ -348,9 +355,13 @@ class TransferDetailsPage extends React.Component<Props, State> {
);
}

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 })),
Expand Down Expand Up @@ -389,7 +400,10 @@ class TransferDetailsPage extends React.Component<Props, State> {
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);
Expand Down Expand Up @@ -692,7 +706,7 @@ class TransferDetailsPage extends React.Component<Props, State> {
}

handleEditTransferReload() {
this.loadTransferWithInstances({ cache: false });
this.loadTransferWithInstances({ cache: false, reloadInstances: true });
}

handleUpdateComplete(redirectTo: string) {
Expand Down Expand Up @@ -740,9 +754,7 @@ class TransferDetailsPage extends React.Component<Props, State> {
}}
transfer={transfer}
destinationEndpoint={destinationEndpoint}
instancesDetails={
this.state.dbInstancesDetails || instanceStore.instancesDetails
}
instancesDetails={this.instancesDetails}
instancesDetailsLoading={instanceStore.loadingInstancesDetails}
networks={networkStore.networks}
networksLoading={networkStore.loading}
Expand Down Expand Up @@ -843,9 +855,7 @@ class TransferDetailsPage extends React.Component<Props, State> {
<TransferDetailsContent
item={transfer}
itemId={this.transferId}
instancesDetails={
this.state.dbInstancesDetails || instanceStore.instancesDetails
}
instancesDetails={this.instancesDetails}
instancesDetailsLoading={
instanceStore.loadingInstancesDetails ||
endpointStore.storageLoading ||
Expand Down Expand Up @@ -968,9 +978,7 @@ class TransferDetailsPage extends React.Component<Props, State> {
m.platform === "destination",
)}
loadingInstances={instanceStore.loadingInstancesDetails}
instances={
this.state.dbInstancesDetails || instanceStore.instancesDetails
}
instances={this.instancesDetails}
onCancelClick={() => {
this.handleCloseDeploymentModal();
}}
Expand Down
Loading