Unit test framework for Ironic-UI API service

This commit is a first step in the development of a unit test framework for
the Ironic-UI API service. The approach being used is to provide an emulation
of the Ironic backend using a mock that utilizes Angular $httpbackend
handlers to intercept requests targeted at the Ironic-UI server side REST
endpoints.

In addition to demonstrating the mock I have coded some basic tests to
show how it is used. At this point I would like to get feedback from the team,
and consensus on whether we should continue the process of building
additional tests.

Change-Id: Iaec83b0e19b5051ebf1257ddc56efcc6f11ee39d
This commit is contained in:
Peter Piela 2017-05-31 08:28:58 -04:00
parent 852b70efd8
commit b55a1004ff
18 changed files with 1299 additions and 212 deletions

View File

@ -58,6 +58,7 @@
toxPath + 'xstatic/pkg/rickshaw/data/rickshaw.js',
toxPath + 'xstatic/pkg/angular_smart_table/data/smart-table.js',
toxPath + 'xstatic/pkg/angular_lrdragndrop/data/lrdragndrop.js',
toxPath + 'xstatic/pkg/angular_fileupload/data/ng-file-upload-all.js',
toxPath + 'xstatic/pkg/spin/data/spin.js',
toxPath + 'xstatic/pkg/spin/data/spin.jquery.js',
toxPath + 'xstatic/pkg/tv4/data/tv4.js',

View File

@ -57,14 +57,26 @@
- adding new properties
- displaying the list of properties in the set
- changing the value of properties
Collection attributes:
id: Name of the property inside the node object that is used
to store the collection.
formId: Name of the controller variable that can be used to
access the property collection form.
prompt: Label used to prompt the user to add properties
to the collection.
placeholder: Label used to guide the user in providiing property
values.
*/
ctrl.propertyCollections = [
{id: "properties",
formId: "properties_form",
title: gettext("Properties"),
addPrompt: gettext("Add Property"),
placeholder: gettext("Property Name")
},
{id: "extra",
formId: "extra_form",
title: gettext("Extras"),
addPrompt: gettext("Add Extra"),
placeholder: gettext("Extra Property Name")
@ -75,11 +87,13 @@
name: null,
driver: null,
driver_info: {},
properties: {},
extra: {},
network_interface: null
};
angular.forEach(ctrl.propertyCollections, function(collection) {
ctrl.node[collection.id] = {};
});
/**
* @description Get the list of currently active Ironic drivers
*
@ -102,70 +116,6 @@
});
};
/**
* @description Check whether a group contains required properties
*
* @param {DriverProperty[]} group - Property group
* @return {boolean} Return true if the group contains required
* properties, false otherwise
*/
function driverPropertyGroupHasRequired(group) {
var hasRequired = false;
for (var i = 0; i < group.length; i++) {
if (group[i].required) {
hasRequired = true;
break;
}
}
return hasRequired;
}
/**
* @description Convert array of driver property groups to a string
*
* @param {array[]} groups - Array for driver property groups
* @return {string} Output string
*/
function driverPropertyGroupsToString(groups) {
var output = [];
angular.forEach(groups, function(group) {
var groupStr = [];
angular.forEach(group, function(property) {
groupStr.push(property.name);
});
groupStr = groupStr.join(", ");
output.push(['[', groupStr, ']'].join(""));
});
output = output.join(", ");
return ['[', output, ']'].join("");
}
/**
* @description Comaprison function used to sort driver property groups
*
* @param {DriverProperty[]} group1 - First group
* @param {DriverProperty[]} group2 - Second group
* @return {integer} Return:
* < 0 if group1 should precede group2 in an ascending ordering
* > 0 if group2 should precede group1
* 0 if group1 and group2 are considered equal from ordering perpsective
*/
function compareDriverPropertyGroups(group1, group2) {
var group1HasRequired = driverPropertyGroupHasRequired(group1);
var group2HasRequired = driverPropertyGroupHasRequired(group2);
if (group1HasRequired === group2HasRequired) {
if (group1.length === group2.length) {
return group1[0].name.localeCompare(group2[0].name);
} else {
return group1.length - group2.length;
}
} else {
return group1HasRequired ? -1 : 1;
}
return 0;
}
/**
* @description Order driver properties in the form using the following
* rules:
@ -176,7 +126,7 @@
* (2) Required properties with no dependents should be located at the
* top of the form
*
* @return {void}
* @return {[]} Ordered list of groups of strongly related properties
*/
ctrl._sortDriverProperties = function() {
// Build dependency graph between driver properties
@ -221,10 +171,10 @@
components.push(component);
},
groups);
groups.sort(compareDriverPropertyGroups);
groups.sort(baseNodeService.compareDriverPropertyGroups);
$log.debug("Found the following property groups: " +
driverPropertyGroupsToString(groups));
baseNodeService.driverPropertyGroupsToString(groups));
return groups;
};
@ -297,5 +247,28 @@
ctrl.isDriverPropertyActive = function(property) {
return property.isActive();
};
/**
* @description Check whether the node definition form is ready for
* to be submitted.
*
* @return {boolean} True if the form is ready to be submitted,
* otherwise false.
*/
ctrl.readyToSubmit = function() {
var ready = true;
if (ctrl.driverProperties) {
for (var i = 0; i < ctrl.propertyCollections.length; i++) {
var collection = ctrl.propertyCollections[i];
if (ctrl[collection.formId].$invalid) {
ready = false;
break;
}
}
} else {
ready = false;
}
return ready;
};
}
})();

View File

@ -0,0 +1,106 @@
/*
* Copyright 2017 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
describe('horizon.dashboard.admin.ironic.base-node', function () {
var ironicBackendMockService, uibModalInstance;
var ctrl = {};
beforeEach(module('horizon.dashboard.admin.ironic'));
beforeEach(module('horizon.framework.util'));
beforeEach(module(function($provide) {
$provide.value('$uibModal', {});
}));
beforeEach(module(function($provide) {
uibModalInstance = {
dismiss: jasmine.createSpy()
};
$provide.value('$uibModalInstance', uibModalInstance);
}));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.toast.service',
{});
}));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(inject(function($injector) {
ironicBackendMockService =
$injector.get('horizon.dashboard.admin.ironic.backend-mock.service');
ironicBackendMockService.init();
var controller = $injector.get('$controller');
controller('BaseNodeController', {ctrl: ctrl});
}));
afterEach(function() {
ironicBackendMockService.postTest();
});
it('controller should be defined', function () {
expect(ctrl).toBeDefined();
});
it('base construction', function () {
expect(ctrl.drivers).toBeNull();
expect(ctrl.images).toBeNull();
expect(ctrl.loadingDriverProperties).toBe(false);
expect(ctrl.driverProperties).toBeNull();
expect(ctrl.driverPropertyGroups).toBeNull();
expect(ctrl.modalTitle).toBeDefined();
angular.forEach(ctrl.propertyCollections, function(collection) {
expect(Object.getOwnPropertyNames(collection).sort()).toEqual(
PROPERTY_COLLECTION_PROPERTIES.sort());
});
expect(ctrl.propertyCollections)
.toContain(jasmine.objectContaining({id: "properties"}));
expect(ctrl.propertyCollections)
.toContain(jasmine.objectContaining({id: "extra"}));
expect(ctrl.node).toEqual({
name: null,
driver: null,
driver_info: {},
properties: {},
extra: {},
network_interface: null});
expect(Object.getOwnPropertyNames(ctrl).sort()).toEqual(
BASE_NODE_CONTROLLER_PROPERTIES.sort());
});
it('_loadDrivers', function () {
ctrl._loadDrivers();
ironicBackendMockService.flush();
expect(ctrl.drivers).toEqual(ironicBackendMockService.getDrivers());
});
it('_getImages', function () {
ctrl._getImages();
ironicBackendMockService.flush();
expect(ctrl.images).toEqual(ironicBackendMockService.getImages());
});
it('cancel', function () {
ctrl.cancel();
expect(uibModalInstance.dismiss).toHaveBeenCalledWith('cancel');
});
});
})();

View File

@ -125,8 +125,8 @@
</div>
</div>
</form>
<form id="{$ collection.id $}-form"
name="{$ collection.id $}-form">
<form id="ctrl.{$ collection.formId $}"
name="ctrl.{$ collection.formId $}">
<div class="form-group">
<div class="input-group input-group-sm"
ng-repeat="(propertyName, propertyValue) in ctrl.node[collection.id]">
@ -253,10 +253,7 @@
</button>
<button type="submit"
ng-disabled="!ctrl.driverProperties ||
propertiesForm.$invalid ||
extraForm.$invalid ||
instanceInfoForm.$invalid"
ng-disabled="!ctrl.readyToSubmit()"
ng-click="ctrl.submit()"
class="btn btn-primary">
{$ ::ctrl.submitButtonTitle $}

View File

@ -64,7 +64,10 @@
var service = {
DriverProperty: DriverProperty,
PostfixExpr: PostfixExpr,
Graph: Graph
Graph: Graph,
driverPropertyGroupHasRequired: driverPropertyGroupHasRequired,
driverPropertyGroupsToString: driverPropertyGroupsToString,
compareDriverPropertyGroups: compareDriverPropertyGroups
};
var VALID_ADDRESS_HOSTNAME_REGEX = new RegExp(VALID_IPV4_ADDRESS + "|" +
@ -669,6 +672,70 @@
});
};
/**
* @description Check whether a group contains required properties
*
* @param {DriverProperty[]} group - Property group
* @return {boolean} Return true if the group contains required
* properties, false otherwise
*/
function driverPropertyGroupHasRequired(group) {
var hasRequired = false;
for (var i = 0; i < group.length; i++) {
if (group[i].required) {
hasRequired = true;
break;
}
}
return hasRequired;
}
/**
* @description Convert array of driver property groups to a string
*
* @param {array[]} groups - Array of driver property groups
* @return {string} Output string
*/
function driverPropertyGroupsToString(groups) {
var output = [];
angular.forEach(groups, function(group) {
var groupStr = [];
angular.forEach(group, function(property) {
groupStr.push(property.name);
});
groupStr = groupStr.join(", ");
output.push(['[', groupStr, ']'].join(""));
});
output = output.join(", ");
return ['[', output, ']'].join("");
}
/**
* @description Comaprison function used to sort driver property groups
*
* @param {DriverProperty[]} group1 - First group
* @param {DriverProperty[]} group2 - Second group
* @return {integer} Return:
* < 0 if group1 should precede group2 in an ascending ordering
* > 0 if group2 should precede group1
* 0 if group1 and group2 are considered equal from ordering perpsective
*/
function compareDriverPropertyGroups(group1, group2) {
var group1HasRequired = driverPropertyGroupHasRequired(group1);
var group2HasRequired = driverPropertyGroupHasRequired(group2);
if (group1HasRequired === group2HasRequired) {
if (group1.length === group2.length) {
return group1[0].name.localeCompare(group2[0].name);
} else {
return group1.length - group2.length;
}
} else {
return group1HasRequired ? -1 : 1;
}
return 0;
}
return service;
}
})();

View File

@ -224,5 +224,47 @@
expect(ret[1]).toBe(false);
});
});
describe('DriverPropertyGroup', function() {
it('driverPropertyGroupHasRequired', function () {
var dp1 = new service.DriverProperty("dp-1", " Required.", []);
var dp2 = new service.DriverProperty("dp-2", " ", []);
expect(service.driverPropertyGroupHasRequired).toBeDefined();
expect(service.driverPropertyGroupHasRequired([])).toBe(false);
expect(service.driverPropertyGroupHasRequired([dp1])).toBe(true);
expect(service.driverPropertyGroupHasRequired([dp2])).toBe(false);
expect(service.driverPropertyGroupHasRequired([dp1, dp2])).toBe(true);
});
it('driverPropertyGroupsToString', function () {
var dp1 = new service.DriverProperty("dp-1", " Required.", []);
var dp2 = new service.DriverProperty("dp-2", " ", []);
expect(service.driverPropertyGroupsToString).toBeDefined();
expect(service.driverPropertyGroupsToString([])).toBe("[]");
expect(service.driverPropertyGroupsToString([[dp1]]))
.toBe("[[dp-1]]");
expect(service.driverPropertyGroupsToString([[dp1], [dp2]]))
.toBe("[[dp-1], [dp-2]]");
});
it('compareDriverPropertyGroups', function () {
var dp1 = new service.DriverProperty("dp-1", " Required.", []);
var dp2 = new service.DriverProperty("dp-2", " ", []);
expect(service.compareDriverPropertyGroups).toBeDefined();
expect(service.compareDriverPropertyGroups([dp1], [dp1])).toBe(0);
expect(service.compareDriverPropertyGroups([dp1], [dp2])).toBe(-1);
expect(service.compareDriverPropertyGroups([dp2], [dp1])).toBe(1);
// smaller group precedes larger group
expect(service.compareDriverPropertyGroups([dp1], [dp1, dp2]))
.toBe(-1);
// group order decided on lexographic comparison of names of first
// property
expect(service.compareDriverPropertyGroups([dp2, dp1], [dp1, dp2]))
.toBe(1);
});
});
});
})();

View File

@ -1,5 +1,5 @@
/*
* Copyright 2016 Cray Inc.
* Copyright 2017 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -55,16 +55,18 @@
ctrl.modalTitle = gettext("Edit Node");
ctrl.submitButtonTitle = gettext("Update Node");
ctrl.node.instance_info = {};
ctrl.baseNode = null;
var instanceInfoId = "instance_info";
ctrl.propertyCollections.push(
{id: "instance_info",
{id: instanceInfoId,
formId: "instance_info_form",
title: gettext("Instance Info"),
addPrompt: gettext("Add Instance Property"),
placeholder: gettext("Instance Property Name")});
ctrl.node[instanceInfoId] = {};
init(node);
function init(node) {
@ -110,7 +112,7 @@
* @param {object} targetNode - Target node
* @return {object[]} Array of patch instructions
*/
function buildPatch(sourceNode, targetNode) {
ctrl.buildPatch = function(sourceNode, targetNode) {
var patcher = new updatePatchService.UpdatePatch();
var PatchItem = function PatchItem(id, path) {
this.id = id;
@ -130,7 +132,7 @@
});
return patcher.getPatch();
}
};
ctrl.submit = function() {
angular.forEach(ctrl.driverProperties, function(property, name) {
@ -151,7 +153,7 @@
$log.info("Updating node " + JSON.stringify(ctrl.baseNode));
$log.info("to " + JSON.stringify(ctrl.node));
var patch = buildPatch(ctrl.baseNode, ctrl.node);
var patch = ctrl.buildPatch(ctrl.baseNode, ctrl.node);
$log.info("patch = " + JSON.stringify(patch.patch));
if (patch.status === updatePatchService.UpdatePatch.status.OK) {
ironic.updateNode(ctrl.baseNode.uuid, patch.patch).then(function(node) {

View File

@ -0,0 +1,101 @@
/*
* Copyright 2015 Hewlett Packard Enterprise Development Company LP
* Copyright 2016 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
describe('horizon.dashboard.admin.ironic.edit-node', function () {
var ironicBackendMockService, ctrl, editNode, updatePatchService;
beforeEach(module('horizon.dashboard.admin.ironic'));
beforeEach(module('horizon.framework.util'));
beforeEach(module(function($provide) {
$provide.value('$uibModal', {});
}));
beforeEach(module(function($provide) {
$provide.value('$uibModalInstance', {});
}));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.toast.service',
{});
}));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(inject(function($injector) {
ironicBackendMockService =
$injector.get('horizon.dashboard.admin.ironic.backend-mock.service');
ironicBackendMockService.init();
updatePatchService =
$injector.get('horizon.dashboard.admin.ironic.update-patch.service');
var ironicAPI =
$injector.get('horizon.app.core.openstack-service-api.ironic');
ironicAPI.createNode(
{driver: ironicBackendMockService.params.defaultDriver})
.then(function(response) {
editNode = response.data;
var controller = $injector.get('$controller');
ctrl = controller('EditNodeController', {node: editNode});
});
ironicBackendMockService.flush();
}));
afterEach(function() {
ironicBackendMockService.postTest();
});
it('controller should be defined', function () {
expect(ctrl).toBeDefined();
});
it('controller base construction', function () {
expect(ctrl.baseNode).toEqual(
ironicBackendMockService.getNode(editNode.uuid));
expect(ctrl.propertyCollections)
.toContain(jasmine.objectContaining({id: "instance_info"}));
angular.forEach(ctrl.propertyCollections, function(collection) {
expect(Object.getOwnPropertyNames(collection).sort()).toEqual(
PROPERTY_COLLECTION_PROPERTIES.sort());
});
expect(ctrl.node.name).toEqual(editNode.name);
expect(ctrl.node.network_interface).toEqual(editNode.network_interface);
expect(ctrl.node.properties).toEqual(editNode.properties);
expect(ctrl.node.extra).toEqual(editNode.extra);
expect(ctrl.node.instance_info).toEqual(editNode.instance_info);
expect(ctrl.node.uuid).toEqual(editNode.uuid);
var properties = angular.copy(BASE_NODE_CONTROLLER_PROPERTIES);
properties.push('baseNode',
'buildPatch',
'selectedDriver',
'submit');
expect(Object.getOwnPropertyNames(ctrl).sort()).toEqual(
properties.sort());
});
it('buildPatch', function () {
var patch = ctrl.buildPatch(editNode, editNode);
expect(patch.patch).toEqual([]);
expect(patch.status).toEqual(updatePatchService.UpdatePatch.status.OK);
});
});
})();

View File

@ -0,0 +1,88 @@
/*
* Copyright 2017 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
describe('horizon.dashboard.admin.ironic.enroll-node', function () {
var ironicBackendMockService, rootScope, ironicEvents, uibModalInstance;
var ctrl = {};
beforeEach(module('horizon.dashboard.admin.ironic'));
beforeEach(module('horizon.framework.util'));
beforeEach(module(function($provide) {
$provide.value('$uibModal', {});
}));
beforeEach(module(function($provide) {
uibModalInstance = {
close: jasmine.createSpy()
};
$provide.value('$uibModalInstance', uibModalInstance);
}));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.toast.service',
{});
}));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(inject(function($injector) {
rootScope = $injector.get('$rootScope');
ironicEvents = $injector.get('horizon.dashboard.admin.ironic.events');
}));
beforeEach(inject(function($injector) {
ironicBackendMockService =
$injector.get('horizon.dashboard.admin.ironic.backend-mock.service');
ironicBackendMockService.init();
var controller = $injector.get('$controller');
ctrl = controller('EnrollNodeController');
ironicBackendMockService.flush();
}));
afterEach(function() {
ironicBackendMockService.postTest();
});
it('controller should be defined', function () {
expect(ctrl).toBeDefined();
});
it('base construction', function () {
var properties = angular.copy(BASE_NODE_CONTROLLER_PROPERTIES);
properties.push('submit');
expect(Object.getOwnPropertyNames(ctrl).sort()).toEqual(
properties.sort());
});
it('submit - success', function () {
spyOn(rootScope, '$emit');
var nodeName = "node_" + Date.now();
ctrl.node.name = nodeName;
ctrl.node.driver = ironicBackendMockService.params.defaultDriver;
ctrl.submit();
ironicBackendMockService.flush();
expect(rootScope.$emit)
.toHaveBeenCalledWith(ironicEvents.ENROLL_NODE_SUCCESS);
expect(uibModalInstance.close)
.toHaveBeenCalledWith(ironicBackendMockService.getNode(nodeName));
});
});
})();

View File

@ -0,0 +1,383 @@
/*
* © Copyright 2015,2016 Hewlett Packard Enterprise Development Company LP
* © Copyright 2016 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
/**
* @description Service that provides a mock for the Ironic backend.
*/
angular
.module('horizon.dashboard.admin.ironic')
.factory('horizon.dashboard.admin.ironic.backend-mock.service',
ironicBackendMockService);
ironicBackendMockService.$inject = [
'$httpBackend',
'horizon.framework.util.uuid.service'
];
function ironicBackendMockService($httpBackend, uuidService) {
// Default node object.
var defaultNode = {
chassis_uuid: null,
clean_step: {},
console_enabled: false,
driver: undefined,
driver_info: {},
driver_internal_info: {},
extra: {},
inspection_finished_at: null,
inspection_started_at: null,
instance_info: {},
instance_uuid: null,
last_error: null,
maintenance: false,
maintenance_reason: null,
name: null,
network_interface: "flat",
power_state: null,
properties: {},
provision_state: "enroll",
provision_updated_at: null,
raid_config: {},
reservation: null,
resource_class: null,
target_power_state: null,
target_provision_state: null,
target_raid_config: {},
updated_at: null,
uuid: undefined
};
// Value of the next available system port
var nextAvailableSystemPort = 1024;
// Additional service parameters
var params = {
// Currently, all nodes have the same boot device.
bootDevice: {boot_device: 'pxe', persistent: true},
// Console info
consoleType: "shellinabox",
consoleUrl: "http://localhost:",
defaultDriver: "agent_ipmitool"
};
// List of supported drivers
var drivers = [{name: params.defaultDriver}];
// List of images
var images = [];
var service = {
params: params,
init: init,
flush: flush,
postTest: postTest,
getNode: getNode,
nodeGetConsoleUrl: nodeGetConsoleUrl,
getDrivers: getDrivers,
getImages: getImages
};
// Dictionary of active nodes indexed by node-id (uuid and name)
var nodes = {};
return service;
/**
* @description Get and reserve the next available system port.
*
* @return {int} Port number.
*/
function getNextAvailableSystemPort() {
return nextAvailableSystemPort++;
}
/**
* @description Create a backend managed node.
*
* @param {object} params - Dictionary of parameters that define
* the node to be created.
* @return {object | null} Node object, or null if the nde could
* not be created.
*/
function createNode(params) {
var node = null;
if (angular.isDefined(params.driver)) {
node = angular.copy(defaultNode);
angular.forEach(params, function(value, key) {
node[key] = value;
});
if (angular.isUndefined(node.uuid)) {
node.uuid = uuidService.generate();
}
var backendNode = {
base: node,
consolePort: getNextAvailableSystemPort()
};
nodes[node.uuid] = backendNode;
if (node.name !== null) {
nodes[node.name] = backendNode;
}
}
return node;
}
/**
* description Get a specified node.
*
* @param {string} nodeId - Uuid or name of the requested node.
* @return {object} Base node object.
*/
function getNode(nodeId) {
return angular.isDefined(nodes[nodeId]) ? nodes[nodeId].base : undefined;
}
/*
* @description Get the console-url for a specified node.
*
* @param {string} nodeId - Uuid or name of the node.
* @return {string} Console url if the console is enabled, null otherwise.
*/
function nodeGetConsoleUrl(nodeId) {
return nodes[nodeId].base.console_enabled
? service.params.consoleUrl + nodes[nodeId].consolePort
: undefined;
}
/**
* @description Initialize the Backend-Mock service.
* Create the handlers that intercept http requests.
*
* @return {void}
*/
function init() {
// Create node
$httpBackend.whenPOST(/\/api\/ironic\/nodes\/$/)
.respond(function(method, url, data) {
var node = createNode(JSON.parse(data).node);
return [node ? 200 : 400, node];
});
// Delete node
$httpBackend.whenDELETE(/\/api\/ironic\/nodes\/$/)
.respond(function(method, url, data) {
var nodeId = JSON.parse(data).node;
var status = 400;
if (angular.isDefined(nodes[nodeId])) {
var node = nodes[nodeId].base;
if (node.name !== null) {
delete nodes[node.name];
delete nodes[node.uuid];
} else {
delete nodes[nodeId];
}
status = 204;
}
return [status, ""];
});
function _addItem(node, path, value) {
var parts = path.substring(1).split("/");
var leaf = parts.pop();
var obj = node;
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (angular.isUndefined(obj[part])) {
obj[part] = {};
}
obj = obj[part];
}
obj[leaf] = value;
}
function _removeItem(node, path) {
var parts = path.substring(1).split("/");
var leaf = parts.pop();
var obj = node;
for (var i = 0; i < parts.length; i++) {
obj = obj[parts[i]];
}
delete obj[leaf];
}
function _replaceItem(node, path, value) {
if (path === "/name" &&
node.name !== null) {
delete nodes[name];
if (value !== null) {
nodes[value] = node;
}
}
var parts = path.substring(1).split("/");
var leaf = parts.pop();
var obj = node;
for (var i = 0; i < parts.length; i++) {
obj = obj[parts[i]];
}
obj[leaf] = value;
}
// Update node
$httpBackend.whenPATCH(/\/api\/ironic\/nodes\/([^\/]+)$/,
undefined,
undefined,
['nodeId'])
.respond(function(method, url, data, headers, params) {
var status = 400;
var node = service.getNode(params.nodeId);
if (angular.isDefined(node)) {
var patch = JSON.parse(data).patch;
angular.forEach(patch, function(operation) {
switch (operation.op) {
case "add":
_addItem(node, operation.path, operation.value);
break;
case "remove":
_removeItem(node, operation.path);
break;
case "replace":
_replaceItem(node, operation.path, operation.value);
break;
default:
}
});
status = 200;
}
return [status, node];
});
// Get node
$httpBackend.whenGET(/\/api\/ironic\/nodes\/([^\/]+)$/,
undefined,
['nodeId'])
.respond(function(method, url, data, headers, params) {
if (angular.isDefined(nodes[params.nodeId])) {
return [200, nodes[params.nodeId].base];
} else {
return [400, null];
}
});
// Get console
$httpBackend.whenGET(/\/api\/ironic\/nodes\/(.+)\/states\/console/,
undefined,
['nodeId'])
.respond(function(method, url, data, headers, params) {
var node = nodes[params.nodeId];
var consoleEnabled = node.base.console_enabled;
var consoleInfo = consoleEnabled
? {console_type: service.params.consoleType,
url: service.params.consoleUrl + node.consolePort}
: null;
var info = {
console_enabled: consoleEnabled,
console_info: consoleInfo};
return [200, info];
});
// Set console
$httpBackend.whenPUT(/\/api\/ironic\/nodes\/(.+)\/states\/console/,
undefined,
undefined,
['nodeId'])
.respond(function(method, url, data, headers, params) {
data = JSON.parse(data);
nodes[params.nodeId].base.console_enabled = data.enabled;
return [200, {}];
});
// Get the ports belonging to a specified node
$httpBackend.whenGET(/\/api\/ironic\/ports/)
.respond(200, []);
// Get boot device
$httpBackend.whenGET(/\/api\/ironic\/nodes\/([^\/]+)\/boot_device$/,
undefined,
['nodeId'])
.respond(200, service.params.bootDevice);
// Validate the interfaces associated with a specified node
$httpBackend.whenGET(/\/api\/ironic\/nodes\/([^\/]+)\/validate$/,
undefined,
['nodeId'])
.respond(200, []);
// Get the currently available drivers
$httpBackend.whenGET(/\/api\/ironic\/drivers\/$/)
.respond(200, {drivers: drivers});
// Get driver properties
$httpBackend.whenGET(/\/api\/ironic\/drivers\/([^\/]+)\/properties$/,
undefined,
['driverName'])
.respond(200, []);
// Get glance images
$httpBackend.whenGET(/\/api\/glance\/images/)
.respond(200, {items: images});
}
/**
* @description Get the list of supported drivers
*
* @return {[]} Array of driver objects
*/
function getDrivers() {
return drivers;
}
/**
* @description Get the list of images
*
* @return {[]} Array of image objects
*/
function getImages() {
return images;
}
/**
* @description Flush pending requests
*
* @return {void}
*/
function flush() {
$httpBackend.flush();
}
/**
* @description Post test verifications.
* This function should be called after completion of a unit test.
*
* @return {void}
*/
function postTest() {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
}
}
}());

View File

@ -52,9 +52,8 @@
getBootDevice: getBootDevice,
nodeGetConsole: nodeGetConsole,
nodeSetConsoleMode: nodeSetConsoleMode,
nodeSetMaintenance: nodeSetMaintenance,
nodeSetPowerState: nodeSetPowerState,
putNodeInMaintenanceMode: putNodeInMaintenanceMode,
removeNodeFromMaintenanceMode: removeNodeFromMaintenanceMode,
setNodeProvisionState: setNodeProvisionState,
updateNode: updateNode,
updatePort: updatePort,
@ -170,49 +169,32 @@
}
/**
* @description Put the node in maintenance mode.
* @description Set the maintenance state of a node
*
* http://developer.openstack.org/api-ref/baremetal/#set-maintenance-flag
*
* @param {string} uuid UUID or logical name of a node.
* @param {string} reason Reason for why node is being put into
* maintenance mode
* @param {string} nodeId UUID or logical name of a node.
* @param {boolean} mode - True to put the node in maintenance mode,
* false to remove it from maintenance mode.
* @param {string} reason - Reason for putting the node in maintenance.
* @return {promise} Promise
*/
function putNodeInMaintenanceMode(uuid, reason) {
return apiService.patch('/api/ironic/nodes/' + uuid + '/maintenance',
{maint_reason: reason
? reason
: gettext("No reason given.")})
.catch(function(response) {
var msg = interpolate(
gettext('Unable to put the Ironic node %s in maintenance mode: %s'),
[uuid, response.data],
false);
toastService.add('error', msg);
return $q.reject(msg);
});
}
function nodeSetMaintenance(nodeId, mode, reason) {
var url = '/api/ironic/nodes/' + nodeId + '/maintenance';
var promise = mode
? apiService.patch(url,
{maint_reason: reason ? reason
: gettext("No reason given.")})
: apiService.delete(url);
/**
* @description Remove the node from maintenance mode.
*
* http://developer.openstack.org/api-ref/baremetal/#clear-maintenance-flag
*
* @param {string} uuid UUID or logical name of a node.
* @return {promise} Promise
*/
function removeNodeFromMaintenanceMode(uuid) {
return apiService.delete('/api/ironic/nodes/' + uuid + '/maintenance')
.catch(function(response) {
var msg = interpolate(
gettext(
'Unable to remove the Ironic node %s from maintenance mode: %s'),
[uuid, response.data],
false);
toastService.add('error', msg);
return $q.reject(msg);
});
return promise.catch(function(response) {
var msg = interpolate(
gettext('Unable to set Ironic node %s maintenance state: %s'),
[nodeId, response.data],
false);
toastService.add('error', msg);
return $q.reject(msg);
});
}
/**

View File

@ -0,0 +1,322 @@
/**
* Copyright 2016 Cray Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
"use strict";
var IRONIC_API_PROPERTIES = [
'createNode',
'createPort',
'deleteNode',
'deletePort',
'getDrivers',
'getDriverProperties',
'getNode',
'getNodes',
'getPortsWithNode',
'getBootDevice',
'nodeGetConsole',
'nodeSetConsoleMode',
'nodeSetPowerState',
'nodeSetMaintenance',
'setNodeProvisionState',
'updateNode',
'updatePort',
'validateNode'
];
/**
* @description Unit tests for the Ironic-UI API service
*/
describe(
'horizon.dashboard.admin.ironic.service',
function() {
// Name of default driver used to create nodes.
var ironicAPI, ironicBackendMockService, defaultDriver;
/**
* @description Create a node.
*
* @param {object} params - Dictionary of parameters that define the node.
* @return {promise} - Promise containing the newly created node.
*/
function createNode(params) {
return ironicAPI.createNode(params)
.then(function(response) {
return response.data; // node
});
}
/**
* @description Fail the current test
*
* @return {void}
*/
function failTest() {
fail();
}
beforeEach(module('horizon.dashboard.admin.ironic'));
beforeEach(module('horizon.framework.util'));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.toast.service', {
add: function() {}
});
}));
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(inject(function($injector) {
ironicBackendMockService =
$injector.get('horizon.dashboard.admin.ironic.backend-mock.service');
ironicBackendMockService.init();
defaultDriver = ironicBackendMockService.params.defaultDriver;
}));
beforeEach(inject(function($injector) {
ironicAPI =
$injector.get('horizon.app.core.openstack-service-api.ironic');
}));
it('defines the ironicAPI', function() {
expect(ironicAPI).toBeDefined();
});
afterEach(function() {
ironicBackendMockService.postTest();
});
describe('ironicAPI', function() {
it('service API', function() {
expect(Object.getOwnPropertyNames(ironicAPI).sort())
.toEqual(IRONIC_API_PROPERTIES.sort());
});
it('getDrivers', function() {
ironicAPI.getDrivers()
.then(function(drivers) {
expect(drivers.length).toBeGreaterThan(0);
angular.forEach(drivers, function(driver) {
expect(driver.name).toBeDefined();
});
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('createNode - Minimal input data', function() {
createNode({driver: defaultDriver})
.then(function(node) {
expect(node.driver).toEqual(defaultDriver);
expect(node).toEqual(ironicBackendMockService.getNode(node.uuid));
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('createNode - Missing input data', function() {
createNode({})
.then(failTest);
ironicBackendMockService.flush();
});
it('getNode', function() {
createNode({driver: defaultDriver})
.then(function(node1) {
ironicAPI.getNode(node1.uuid).then(function(node2) {
expect(node2).toEqual(node1);
});
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('deleteNode', function() {
createNode({driver: defaultDriver})
.then(function(node) {
return ironicAPI.deleteNode(node.uuid).then(function() {
return node;
});
})
.then(function(node) {
expect(
ironicBackendMockService.getNode(node.uuid)).toBe(undefined);
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('deleteNode - nonexistent node', function() {
ironicAPI.deleteNode(0)
.then(failTest);
ironicBackendMockService.flush();
});
it('updateNode - resource_class', function() {
createNode({driver: defaultDriver})
.then(function(node) {
return ironicAPI.updateNode(
node.uuid,
[{op: "replace",
path: "/resource_class",
value: "some-resource-class"}]).then(
function(node) {
return node;
});
})
.then(function(node) {
expect(node.resource_class).toEqual("some-resource-class");
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('nodeGetConsole - console enabled', function() {
createNode({driver: defaultDriver,
console_enabled: true})
.then(function(node) {
expect(node.console_enabled).toEqual(true);
return node;
})
.then(function(node) {
return ironicAPI.nodeGetConsole(node.uuid).then(
function(consoleData) {
return {node: node, consoleData: consoleData};
});
})
.then(function(data) {
expect(data.consoleData.console_enabled).toEqual(true);
expect(data.consoleData.console_info.console_type)
.toEqual(ironicBackendMockService.params.consoleType);
expect(data.consoleData.console_info.url)
.toEqual(ironicBackendMockService.nodeGetConsoleUrl(
data.node.uuid));
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('nodeGetConsole - console not enabled', function() {
createNode({driver: defaultDriver,
console_enabled: false})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
})
.then(function(node) {
return ironicAPI.nodeGetConsole(node.uuid);
})
.then(function(consoleData) {
expect(consoleData).toEqual(
{console_enabled: false,
console_info: null});
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('nodeSetConsoleMode - Toggle console mode', function() {
createNode({driver: defaultDriver,
console_enabled: false})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
})
.then(function(node) {
ironicAPI.nodeSetConsoleMode(node.uuid, true);
return node;
})
.then(function(node) {
return ironicAPI.getNode(node.uuid);
})
.then(function(node) {
expect(node.console_enabled).toEqual(true);
return node;
})
// Toggle back
.then(function(node) {
ironicAPI.nodeSetConsoleMode(node.uuid, false);
return node;
})
.then(function(node) {
return ironicAPI.getNode(node.uuid);
})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
})
.catch(failTest);
ironicBackendMockService.flush();
});
it('nodeSetConsoleMode - Redundant console set', function() {
createNode({driver: defaultDriver,
console_enabled: false})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
})
.then(function(node) {
ironicAPI.nodeSetConsoleMode(node.uuid, false);
return node;
})
.then(function(node) {
return ironicAPI.getNode(node.uuid);
})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
});
ironicBackendMockService.flush();
});
it('getBootDevice', function() {
createNode({driver: defaultDriver})
.then(function(node) {
expect(node.console_enabled).toEqual(false);
return node;
})
.then(function(node) {
return ironicAPI.getBootDevice(node.uuid)
.then(function(bootDevice) {
return bootDevice;
});
})
.then(function(bootDevice) {
expect(bootDevice).toEqual(
ironicBackendMockService.params.bootDevice);
});
ironicBackendMockService.flush();
});
});
});
})();

View File

@ -40,46 +40,32 @@
};
return service;
/*
* @description Put a specified list of nodes into mainenance.
* A modal dialog is used to prompt the user for a reason for
* putting the nodes in maintenance mode.
*
* @param {object[]} nodes - List of node objects
* @return {promise}
*/
function putNodeInMaintenanceMode(nodes) {
var options = {
controller: "MaintenanceController as ctrl",
templateUrl: basePath + '/maintenance/maintenance.html'
};
return $uibModal.open(options).result.then(function(reason) {
return nodeActions.putNodeInMaintenanceMode(nodes, reason);
});
}
/*
* @description Take a specified list of nodes out of mainenance
*
* @param {object[]} nodes - List of node objects
* @return {promise}
*/
function removeNodeFromMaintenanceMode(nodes) {
return nodeActions.removeNodeFromMaintenanceMode(nodes);
}
/*
* @description Set the maintenance mode of a specified list of nodes
*
* If nodes are being put into maintenance mode a modal dialog is used
* to prompt the user for a reason.
*
* @param {object[]} nodes - List of node objects
* @param {boolean} mode - Desired maintenance state.
* 'true' -> Node is in maintenance mode
* 'false' -> Node is not in maintenance mode
* @return {promise}
*/
*/
function setMaintenance(nodes, mode) {
return mode ? putNodeInMaintenanceMode(nodes)
: removeNodeFromMaintenanceMode(nodes);
var promise;
if (mode) {
var options = {
controller: "MaintenanceController as ctrl",
templateUrl: basePath + '/maintenance/maintenance.html'
};
promise = $uibModal.open(options).result.then(function(reason) {
return nodeActions.setMaintenance(nodes, true, reason);
});
} else {
promise = nodeActions.setMaintenance(nodes, false);
}
return promise;
}
}
})();

View File

@ -60,8 +60,7 @@
deleteNode: deleteNode,
deletePort: deletePort,
setPowerState: setPowerState,
putNodeInMaintenanceMode: putNodeInMaintenanceMode,
removeNodeFromMaintenanceMode: removeNodeFromMaintenanceMode,
setMaintenance: setMaintenance,
setProvisionState: setProvisionState,
getPowerTransitions : getPowerTransitions
};
@ -120,41 +119,37 @@
// maintenance
function putNodeInMaintenanceMode(nodes, reason) {
return applyFuncToNodes(
function(node, reason) {
if (node.maintenance !== false) {
var msg = gettext("Node %s is already in maintenance mode.");
return $q.reject(interpolate(msg, [node.uuid], false));
}
return ironic.putNodeInMaintenanceMode(node.uuid, reason).then(
/**
* @description Set the maintenance state of a list of nodes
*
* @param {object[]} nodes - List of node objects
* @param {boolean} mode - True if the nodes are to be put in
* maintenance mode, otherwise false.
* @param {string} [reason] - Optional reason for putting nodes in
* maintenance mode.
* @return {promise} promise
*/
function setMaintenance(nodes, mode, reason) {
var promises = [];
angular.forEach(nodes, function(node) {
var promise;
if (node.maintenance === mode) {
var msg = gettext(
"Node %s is already in target maintenance state.");
promise = $q.reject(interpolate(msg, [node.uuid], false));
} else {
promise = ironic.nodeSetMaintenance(node.uuid, mode, reason).then(
function (result) {
node.maintenance = true;
node.maintenance_reason = reason;
node.maintenance = mode;
node.maintenance_reason =
mode && angular.isDefined(reason) ? reason : "";
return result;
}
);
},
nodes,
reason);
}
function removeNodeFromMaintenanceMode(nodes) {
return applyFuncToNodes(
function(node) {
if (node.maintenance !== true) {
var msg = gettext("Node %s is not in maintenance mode.");
return $q.reject(interpolate(msg, [node.uuid], false));
}
return ironic.removeNodeFromMaintenanceMode(node.uuid).then(
function (result) {
node.maintenance = false;
node.maintenance_reason = "";
return result;
}
);
},
nodes);
}
promises.push(promise);
});
return $q.all(promises);
}
/*
@ -202,30 +197,6 @@
return deleteModalService.open($rootScope, ports, context);
}
/*
* @name horizon.dashboard.admin.ironic.actions.applyFuncToNodes
* @description Apply a specified function to each member of a
* collection of nodes
*
* @param {function} fn Function to be applied.
* The function should accept a node as the first argument. An optional
* second argument can be used to provide additional information.
* The function should return a promise.
* @param {Array<node>} nodes - Collection of nodes
* @param {object} extra - Additional argument passed to the function
* @return {promise} - Single promise that represents the combined
* return status from all function invocations. The promise is rejected
* if any individual call fails.
*/
function applyFuncToNodes(fn, nodes, extra) {
var promises = [];
angular.forEach(nodes,
function(node) {
promises.push(fn(node, extra));
});
return $q.all(promises);
}
/*
* @name horizon.dashboard.admin.ironic.actions.getPowerTransitions
* @description Get the list of power transitions for a specified

View File

@ -0,0 +1,52 @@
/*
* Copyright 2017 Cray Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
@description Global data used by unit tests.
*/
/* exported BASE_NODE_CONTROLLER_PROPERTIES */
var BASE_NODE_CONTROLLER_PROPERTIES = [
'_getImages',
'_loadDrivers',
'_sortDriverProperties',
'cancel',
'collectionCheckPropertyUnique',
'collectionDeleteProperty',
'driverProperties',
'driverPropertyGroups',
'drivers',
'images',
'isDriverPropertyActive',
'loadDriverProperties',
'loadingDriverProperties',
'modalTitle',
'node',
'propertyCollections',
'readyToSubmit',
'submitButtonTitle',
'validHostNameRegex'];
/* exported PROPERTY_COLLECTION_PROPERTIES */
var PROPERTY_COLLECTION_PROPERTIES = [
'id',
'formId',
'title',
'addPrompt',
'placeholder'
];

View File

@ -1,6 +1,6 @@
{
"name": "ironic-ui",
"version": "1.0.0",
"version": "0.0.0",
"description": "Horizon plugin for OpenStack Ironic.",
"repository": {
"type": "git",

View File

@ -0,0 +1,14 @@
---
features:
- |
A backend mock has been added that enables better unit testing of the
Ironic API service and other Ironic-UI components. The mock utilizes
Angular $httpbackend handlers to intercept requests targeted at the
Ironic-UI server-side REST endpoints, and returns simulated responses.
- |
A number of unit tests have been developed that illustrate the use
of the backend mock.
- |
Although the backend mock is a work in progress, enough
functionality already exists to support test development for
the current set of in-progress features.

View File

@ -15,6 +15,6 @@ testtools>=1.4.0 # MIT
# this is required for the docs build jobs
sphinx!=1.6.1,>=1.5.1 # BSD
oslosphinx>=4.7.0 # Apache-2.0
reno>=1.8.0 # Apache-2.0
reno!=2.3.1,>=1.8.0 # Apache-2.0
# Include horizon as test requirement
http://tarballs.openstack.org/horizon/horizon-master.tar.gz#egg=horizon