/**
* @version 1.2
* @author Boris GBAHOUE
* @file Test template => delete a DBObject and perform various tests on the output of simple find requests
- Overall tests
. DBObject
. findByName always return an Array
. findById returns null when looking for an invalid ID
. 0 or 1 User matching a User.findByName() (unique field)
. Nested objects from soft/hard deletes are not instanceof DBObjects
. Nested objects from DBObject::populate() are instanceof DBObjects
. UserRoute
. User::password is never returned from a soft/hard deletes
- Soft delete
. DeleteData is fully populated in the returned object
. DeleteData fields are valid (deletor, date)
- Hard delete
. linkedObjects are removed from DB
. Object is removed from DB
- Soft & hard delete
. Returned object is preserved
* @module amiwo/test/db
*/
// =============================================================
// BASE SETUP
// =============================================================
// call the packages we need
var debug = require('debug')('amiwo:test');
var u = require('../../util');
var util = require('util');
var when = require('when');
var assert = require('assert');
// load our objects
const Template = require('../Template');
const DBObject = require('../../db/DBObject');
const ResponseJSON = require('../../rest/ResponseJSON');
const DBObjectFactory = require('../../db/DBObjectFactory');
// =============================================================
// CONSTRUCTOR
// =============================================================
/**
* @class
*
* @constructor
* @augments module:amiwo/test~Template
* @param {String} name: valid DBObject name
* @param {Object} uri: [{route, method}] object if not DELETE @ '/api/<name>'
* @param {Object} [options]
* @param {boolean} options.soft: set to true to test a soft delete
* @param {boolean} options.hard: set to true to test a hard delete
* @param {String} options.linkedObjects: JSON of DBObject names associated with the linkedObjects (i.e. nested objects to be removed with the parent object)
* @param {String} options.returnedProp: name of the property which contains the object / objectId
* @param {String} options.check: set to true to test if returned deleted object is the same as before
*
*/
function DBObjectDeleteTemplate(name, data, uri, options) {
Template.call(this);
this._name = name;
this._uri = uri;
this._data = data;
this._IdProp = data.ID;
delete data.ID;
this._options = options || {};
// Options processing
if (this._options.soft || this._options.hard) {
if (u.isEmpty(this._options.returnedProp)) throw new Error("Invalid parameter: returnedProp must be set");
if (this._options.linkedObjects == null) this._options.linkedObjects = {};
if (this._options.unlinkedObjects == null) this._options.unlinkedObjects = {};
}
// (v1.1) Check if we actually have a route to test or if we just need to remove the object
this.$simpleDelete = false;
if (u.isEmpty(this._uri)) {
this._options.soft = false;
this._options.hard = false;
this.$simpleDelete = true;
}
}
/**
* Inherit from `Template`.
*/
util.inherits(DBObjectDeleteTemplate, Template);
// =============================================================
// PUBLIC METHODS
// =============================================================
/**
* Edit a new DBObject from class 'this._name' by calling the associated route
*
* @return {Promise} promise wrapping the created DBObject
*/
DBObjectDeleteTemplate.prototype.execute = function () {
var self = this;
var object;
return when.resolve().then(function() {
// Send a request to back server to soft delete the DBObject
var promise;
if (self._options.soft) {
debug("::AMIWO::TESTS::%s::DELETETEMPLATE::LOG\t\t* Performing soft delete", self._name.toUpperCase());
promise = self.$createRequest(true);
} else {
promise = when.resolve(true);
}
return promise;
}).then(function(ok) {
assert(ok, util.format("Invalid state => ok=%j", ok));
var promise;
if (self._options.hard) {
debug("::AMIWO::TESTS::%s::DELETETEMPLATE::LOG\t\t* Performing hard delete", self._name.toUpperCase());
promise = self.$createRequest(false);
} else if (self.$simpleDelete) {
debug("::AMIWO::TESTS::%s::DELETETEMPLATE::LOG\t\t* Cleaning test object", self._name.toUpperCase());
var opts = {
delete: {
method: "hard"
}
};
promise = DBObjectFactory.createObject(self._name, {_id: self._data[self._IdProp]}).remove(opts)
.then(function(object) {
return when.resolve(true); // normalize ending value
});
} else {
promise = when.resolve(true);
}
return promise;
});
}
// =======================================================================
// PRIVATE METHODS
// =======================================================================
/**
* Creates a request promise to soft/hard delete a DBObject
*
* @param {boolean} soft: set to false for a hard remove
*
* @returns {Promise}
* @private
*/
DBObjectDeleteTemplate.prototype.$createRequest = function (soft) {
var self = this;
var deletor;
var objectToDelete;
var requestTimestamp;
return when.resolve(true).then(function() {
// 1. Load deletor User object from DB (for soft deletes only) and the full object to delete (for compairison later on)
var promiseArray = [];
promiseArray.push(
DBObjectFactory.createObject(self._name).findById(self._data[self._IdProp])
.then(function(result) {
return result.populate();
})
);
if (soft) { // lookup User for deletor
promiseArray.push(DBObjectFactory.createObject("User").findByName(self._data.username));
}
return when.all(promiseArray);
}).then(function(array) {
// 2. Send a request to back server to delete Object
objectToDelete = array[0];
assert(objectToDelete instanceof DBObject, self.$head() + util.format("Invalid objectToDelete received => was expecting instanceof DBObject; received %s", objectToDelete.constructor.name));
if (soft) {
assert(Array.isArray(array[1]), self.$head() + "findByName() must always return an Array");
assert(array[1].length == 1, self.$head() + util.format("Invalid state: %d Users received when looking up for %s", array[1].length, self._data.username));
deletor = array[1][0];
}
var deleteRequest = {
method: self._uri.method,
uri: self._uri.route,
json: true // Automatically stringifies the body to JSON
};
self._data.soft = soft;
if (self._uri.method.toUpperCase() == "GET") {
deleteRequest.qs = self._data;
} else {
deleteRequest.body = self._data;
}
var now = new Date();
requestTimestamp = now.getTime();
return u.request(deleteRequest);
}).then(function(json) {
// 3. Check the validity of the returned object
// Expects a proper ResponseJSON
if (!ResponseJSON.isValid(json)) return when.reject(new Error("::DBOBJECTDELETETEMPLATE::" + self._name.toUpperCase() + "::ERROR Invalid JSON returned from " + self._uri.method + "@" + self._uri.route));
if (json.connection == "ko") return when.reject(new Error("::DBOBJECTDELETETEMPLATE::" + self._name.toUpperCase() + "::ERROR Error JSON returned from " + self._uri.method + "@" + self._uri.route));
var promise;
var object = json.data[self._options.returnedProp];
if (soft) {
// 3.1 - Ensure the soft delete was properly performed
// - Status is properly set
var message = self.$head() + util.format("%s.status not properly set when trying to soft delete object ID %s", self._name, objectToDelete._id);
assert(object.status == DBObject.STATUS.SOFT_DELETED, message);
// - deleteData object is created & populated
var deleteData = object.deleteData;
message = self.$head() + util.format("deleteData property is nully when trying to soft delete %s ID '%s", self._name, object._id);
assert(u.isNotEmpty(deleteData), message);
message = self.$head() + util.format("Soft delete's return object is not fully populated");
assert(deleteData instanceof Object, message);
// - deleteData.deletor == test username's ID
message = self.$head() + util.format("Invalid deletor => expecting %s, received %s", deletor._id, deleteData.deletor);
assert(deleteData.deletor == deletor._id, message);
// - deleteData.date is a Date and "recent"
var time = Date.parse(deleteData.date);
message = self.$head() + util.format("Invalid DeleteData date => %s (not parseable into a Date)", deleteData.date);
assert(!isNaN(time), message);
deleteData.date = new Date(time);
var maxProcessingTime = 100000; //ms
message = self.$head() + util.format("Invalid timestamp, was expecting less than %d ms, got %d (possible timezone error)", maxProcessingTime, (deleteData.date.getTime() - requestTimestamp));
assert((deleteData.date.getTime() - requestTimestamp) < maxProcessingTime, message);
promise = when.resolve(object);
} else {
// 3.1 - Ensure the hard delete was properly performed
promise = [];
// - on the parent DBObject
promise.push(DBObjectFactory.createObject(self._name).findById(objectToDelete._id));
// - on the linkedObjects
var nbRequestSent = 0;
for (var i=0; i < objectToDelete.$linkedObjects.length ; i++) {
if (u.isNotEmpty(objectToDelete[objectToDelete.$linkedObjects[i]])) {
promise.push(self.$findByIdArrayOrObj(self._options.linkedObjects[objectToDelete.$linkedObjects[i]], objectToDelete[objectToDelete.$linkedObjects[i]]));
} else {
promise.push(when.resolve(null)); // placeholder
}
}
// 3.2 - Ensure unlinked objets were NOT removed (and then remove them)
promise.push(when.resolve("separator"));
for (var key in self._options.unlinkedObjects) {
promise.push(
self.$findByIdArrayOrObj(
self._options.unlinkedObjects[key],
objectToDelete[key],
function $removeUnlinkedObject(result) {
assert(u.isNotEmpty(result), self.$head()+util.format("associated %s object should NOT have been removed since it's not part of the linkedObjects", self._options.unlinkedObjects[key]));
return result.remove().then(function(removedObject) {
// [commented out since v1.2] assert(u.smartEqual(removedObject, objectToDelete[key]), self.$head()+util.format("Expecting %j and got %j", objectToDelete[key], removedObject));
return when.resolve(null); // to fit in the next test
});
}
)
);
}
promise = when.all(promise).then(function (array) {
// Expecting only null elements
var expectedArray = Array(objectToDelete.$linkedObjects.length+1).fill(null);
expectedArray.push("separator");
expectedArray = expectedArray.concat(Array(Object.keys(self._options.unlinkedObjects).length).fill(null));
if (!u.smartEqual(array, expectedArray)) {
var message = "";
for (var i=0; i < array.length; i++) {
if (array[i] == "separator") {
break; // unlinked objects were tested already
}
if (array[i] !== null) {
message += util.format("\t\t\t* %s was not removed\n", (i==0) ? "Parent object" : "Nested object ("+objectToDelete.$linkedObjects[i-1]+")");
return when.reject(new Error(self.$head() + "Hard remove > \n" + message));
}
}
}
return when.resolve(json.data[self._options.returnedProp]);
})
}
return promise;
}).then(function(returnedDeletedObject) {
// 4. Ensure we received a fully populated DBObject
if (self._options.check) {
for (var key in objectToDelete.lean()) {
// SPECIAL CASES
// - User
if (key == "password") {
// Password is never returned in simple find requests
assert(returnedDeletedObject.password == undefined, self.$head()+util.format("returnedDeletedObject::password property must be undefined => back should never return the password field from a simple find request"));
// - Event
} else if (key == "creator") {
// creator will be populated but appData.eventData will differ (as they should) => compare the IDs only
assert(returnedDeletedObject._id == objectToDelete._id, self.$head()+util.format("creator::_id property didn't match"));
} else if (["deleteData", "status"].indexOf(key) > -1) {
if (soft) {
// soft delete > objectToDelete didn't have its status modified => ignore (since we tested it above already)
continue;
} else if (returnedDeletedObject.deleteData) {
// hard delete of a formely soft deleted object > returnedDeletedObject.deleteData.date should be a String matching objectToDelete's Date => convert it to Date and test
returnedDeletedObject.deleteData.date = new Date(returnedDeletedObject.deleteData.date);
if (key == "deleteData") objectToDelete[key] = objectToDelete[key].lean();
// Compare the values
var message = self.$head() + util.format("Keys %s don't match: returned deleted object => %j vs. object we wanted to delete => %j", key, objectToDelete[key], returnedDeletedObject[key]);
assert(u.smartEqual(objectToDelete[key], returnedDeletedObject[key]), message);
}
} else if (objectToDelete.hasOwnProperty(key)) {
if ((returnedDeletedObject[key] instanceof Object) && returnedDeletedObject[key].hasOwnProperty("_id")) { // nested object since it has an _id
self.$testNestedObjects(returnedDeletedObject[key], objectToDelete[key], key);
objectToDelete[key] = objectToDelete[key].lean();
} else if (returnedDeletedObject[key] instanceof Object) { // Array or Object => test if we have nested objects
for (var innerKey in returnedDeletedObject[key]) {
if ((returnedDeletedObject[key][innerKey] instanceof Object) && returnedDeletedObject[key][innerKey].hasOwnProperty("_id")) { // nested object since it has an _id
self.$testNestedObjects(returnedDeletedObject[key][innerKey], objectToDelete[key][innerKey], key);
if (objectToDelete[key][innerKey] instanceof DBObject) objectToDelete[key][innerKey] = objectToDelete[key][innerKey].lean();
}
}
// Below's smartEqual() will ensure that both objects will have the same keys
}
// Compare the values
var message = self.$head() + util.format("Keys %s don't match: returned deleted object => %j vs. object we wanted to delete => %j", key, objectToDelete[key], returnedDeletedObject[key]);
assert(u.smartEqual(objectToDelete[key], returnedDeletedObject[key]), message);
}
}
}
return when.resolve(true);
})
}
/**
* DB remove a DBObject; supporting DBObject or Array of DBObjects
*
* @param {String} name: DBObject class name
* @param {DBObject|Array} nestedObject
* @param {function} [fn] promise to chain to the findById promise
*
* @returns {Promise} wrapping null or the first non null item found
* @private
*/
DBObjectDeleteTemplate.prototype.$findByIdArrayOrObj = function(name, nestedObject, fn) {
if (u.isEmpty(nestedObject)) return when.resolve(null);
if (Array.isArray(nestedObject)) {
var promiseArray = [];
for (var i = 0; i < nestedObject.length; i++) {
if (fn) {
promiseArray.push(DBObjectFactory.createObject(name).findById(nestedObject[i]._id).then(fn));
} else {
promiseArray.push(DBObjectFactory.createObject(name).findById(nestedObject[i]._id));
}
}
return when.all(promiseArray).then(function(array) {
// Return the first non null item found (it suffice to generate an error later on)
if (u.isEmpty(array)) return when.resolve(null);
for (var i = 0; i < array.length; i++) {
if (array[i] == null) continue;
return when.resolve(array[i]);
}
return when.resolve(null);
});
} else {
if (fn) {
return DBObjectFactory.createObject(name).findById(nestedObject._id).then(fn);
} else {
return DBObjectFactory.createObject(name).findById(nestedObject._id);
}
}
}
/**
* @private
*/
DBObjectDeleteTemplate.prototype.$testNestedObjects = function(returnedDeletedObject, objectToDelete, key) {
// Check if the output of soft/hard deletes is not a DBObject
assert(!(returnedDeletedObject instanceof DBObject), this.$head() + util.format("Returned nested objects from hard/soft deletes shouldn't be instanceof of DBObject (key = %s)", key));
// Check if the output of DBObject::populate is a DBObject
assert((objectToDelete instanceof DBObject), this.$head() + util.format("Returned nested objects from DBObject::populate() should be instanceof of DBObject (key = %s)", key));
}
/**
* Return debug message header
*
* @returns {String}
*
* @private
*/
DBObjectDeleteTemplate.prototype.$head = function() {
return util.format("::%s::%s::ERROR ", this.constructor.name.toUpperCase(), this._name.toUpperCase());
}
module.exports = DBObjectDeleteTemplate;