Source: test/db/DBObjectDeleteTemplate.js

/**
 * @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;