Friday, June 20, 2014

Growing Up and Growing Large: Modern JavaScript Web Development Using Sails.js and AngularJS (Part 2 of 3)

Sails.js: A Server-side Solution

One of the early criticisms (and also strengths) of JavaScript, and open source architectures in general, was the conundrum of "too many options".  After all, it was scientifically proven that people (yes, even software developers) prefer solutions over individual components.  Part of growing up is learning from past mistakes, and the JavaScript community has responded in kind, particularly the folks at Giant Squid with Sails.js.

Sails.js attempts to reel in the noise in choosing components for a server-side JavaScript framework.  It is a proper MVC framework, inspired by Ruby on Rails, that gives a developer everything you could possibly need: an MVC pattern, ORM, multiple adapters for data stores, Socket.io support, intelligent routing, and much more, all in a carefully curated solution so that a developer can focus on making great software products versus worrying about getting components to play nicely with each other.




Great, So What Are We Building?

For this exercise, we're going to build a simple movie database called the AgileMovieDB.  Let's assume a directory structure of /AgileMovieDB/Server which will house the Sails.js source code and /AgileMovieDB/Web which will house the AngularJS source code.

This will be a simple application where a user can type in the name (or parts of the name) of a movie or actor on the Angular UI, and our Sails.js Server API spits back search results.

For the "get on with it" crowd, direct your Git to the GitHub Repo  :)

Prerequisites

There's a few pieces of software you ought to install first before we begin this exercise:

  1. Node.js: Server-side JavaScript!  For *nix users, make sure to also install npm, Node.js' equivalent of NuGet or Maven.
  2. MongoDB: A NoSQL document store which will act as our persistence layer
  3. WebStorm: Quite simply, the best IDE for JavaScript development, in my humble opinon
  4. Mongo Plugin for WebStorm: Allows you to browse MongoDB servers right in WebStorm
  5. (Windows Only) MongoVue: A GUI for Windows to browse through Mongo databases


Time to Set Sail

So, let's get started.  While Sails.js could stand alone as a "pure" server-side MVC application, complete with support for template engines such as Jade, we are simply using it as a Server API that backs an AngularJS front-end.  So, in other words, this application will serve as the RESTful services that act as the gatekeeper to your domain objects, as well as house the domain objects themselves.

Getting a skeleton Sails application called Server is as easy as this:

  1. In a terminal or cmd window in the aforementioned AgileMovieDB folder, type sudo npm -g install sails (or simply npm -g install sails for Windows)
  2. Type in sails new Server
  3. Type in cd Server
  4. Type in sails lift
  5. Open a web browser and go to http://localhost:1337
  6. Victory dance
Congratulations, you have your very first Sails.js application!

Understanding the Overall Sails.js Architecture

When you open the /Server directory in WebStorm, you're going to see the basic Sails.js application structure.  It helps to understand what exactly is under the hood before we start hacking away.
  • /api: 99% of your coding will be done here.  This folder contains separations of the MVC pattern, shared services, adapters, and models.
  • /assets: If we were building a classic MVC web application, this is where your UI/UX artifacts would live -- CSS/LESS files, image files, and the like
  • /config: Under-the-hood configurable items for Sails.js, such as what database to connect to, CORS policies, authentication policies, HTTP error handlers, etc
  • /node_modules: Being a Node.js application with library dependencies managed by npm, this is where said library dependencies will live.
  • /views: Again, if we were building a classic MVC web application, this is where your HTML templates would live.  Out of the box, Sails.js supports Jade (as denoted by the *.ejs files)
You'll also notice a few files created by Sails on the root.
  • app.js: Instead of "sails lift", you may optionally run this using Node.js.  When developing in WebStorm, this would be your entry point for debugging.
  • gruntfile.js: The main file for your Grunt task runner.  In JavaScript development, these task runners are your best friends, helping immensely with build automation, running unit tests, and the like

Got It.  So, Let's Talk About this API Folder

Naturally, there's a separation of concerns when developing a Sails application.  It should come as no surprise that this follows the tried and true Model-View-Controller (MVC) pattern.  Let's take a look at each subfolder in /api and understand what goes where.
  • /adapters: If you must create a custom adapter that Sails' built-in ORM, Waterline, needs to use, your custom adapter goes in here and gets registered in global.  9 times out of 10, you won't need to, but there is documentation for any edge cases.
  • /controllers: Self-explanatory -- your controllers go here.  Up-front, they are code-generated using the sails command, which we will see shortly.
  • /models: Self-explanatory -- your domain models go here, annotated in a fashion understood by Waterline, which we will see shortly.  Any Model object gets thrown into global.
  • /policies: This folder houses any custom access control code.  In short, they enforce who can and can't access controllers.  We won't get into this level of complexity for this series, but the documentation has an excellent overview.
  • /services: This folder houses any application-wide "services" in Sails parlance.  These services could be, say, Repositories that interact with Waterline and MongoDB, or services in charge of pulling down and transforming JSON's from external API's.  Any file created here gets thrown into global.

10-4, Ghost Rider.  Let's Sort Out this Domain.

Now that we have a lay of the land in Sails.js, we can begin development.  Let's attack this with a bottom-up approach, being that we have a well-defined (and simple) UI interaction and a well-understood domain.  We're going to track Movies and people involved with Movies.  

Being that we're using a NoSQL data store, we could just model one Movie table and domain object, but let's make it a little relational to ease us normalization junkies into this brave, new, schema-less world.  So, that would involve a Movie entity, a Person entity, and a many-to-many relationship between both via an intermediary entity called MoviePerson.  (a Person can be multiple characters in many movies and also could be multiple roles in the crew, i.e. Executive Producer and Director...and movies contain many people playing multiple roles in the cast or crew).

So, with that in mind, we have three models to build in /api/models: Movie, Person, and MoviePerson.

Let's annotate these objects in the format understood by Waterline.

Movie.js
/**
 * The Movie model
 */
var Movie = {
    tableName: 'Movie',
    attributes: {
        movieId: {
            type: 'INTEGER',
            required: true,
            unique: true
        },
        name: {
            type: 'STRING',
            required: true
        },
        releaseDate: {
            type: 'DATE',
            required: true
        },
        rating: {
            type: 'STRING',
            required: true
        },
        grossEarnings: 'FLOAT'
    }
};

module.exports = Movie;

Person.js
/**
 * The Person model
 */
var Person = {
    tableName: 'Person',
    attributes: {
        personId: {
            type: 'INTEGER',
            required: true,
            unique: true
        },
        firstName: {
            type: 'STRING',
            required: true
        },
        lastName: {
            type: 'STRING',
            required: true
        },
        dateOfBirth: {
            type: 'DATE',
            required: true
        },
        nationality: 'STRING',
        grossEarnings: 'FLOAT'
    }
};

module.exports = Person;

MoviePerson.js
/**
 * The MoviePerson model
 */
var MoviePerson = {
    tableName: 'MoviePerson',
    attributes: {
        movieId: {
            type: 'INTEGER',
            required: true
        },
        personId: {
            type: 'INTEGER',
            required: true
        },
        role: {
            type: 'STRING',
            required: true
        },
        type: {
            type: 'STRING',
            required: true
        }
    }
};

module.exports = MoviePerson;

Simple enough.  But, wait, we forgot something didn't we?  We need to make sure our persistence layer is defined as MongoDB and pointed properly to our installed local MongoDB instance.  For that, let's first obtain the sails-mongo adapter.  
  1. Fire up a command line (or Alt + F12 from WebStorm) and make sure you're on the /Server folder
  2. Type in sudo npm install sails-mongo --save (or simply npm install sails-mongo --save)
Now, let's shuffle over to /config/adapters.js.  It should look like this.

/config/adapters.js
/**
 * Global adapter config
 * 
 * The `adapters` configuration object lets you create different global "saved settings"
 * that you can mix and match in your models.  The `default` option indicates which 
 * "saved setting" should be used if a model doesn't have an adapter specified.
 *
 * Keep in mind that options you define directly in your model definitions
 * will override these settings.
 *
 * For more information on adapter configuration, check out:
 * http://sailsjs.org/#documentation
 */

module.exports.adapters = {

  // If you leave the adapter config unspecified 
  // in a model definition, 'default' will be used.
  'default': 'mongo',

    // MongoDB
    mongo : {
        module: 'sails-mongo',
        host: '127.0.0.1',
        port: 27017,
        database: 'AMDB',
        user: '',
        password: ''
    },

  // Persistent adapter for DEVELOPMENT ONLY
  // (data is preserved when the server shuts down)
  disk: {
    module: 'sails-disk'
  },

  // MySQL is the world's most popular relational database.
  // Learn more: http://en.wikipedia.org/wiki/MySQL
  myLocalMySQLDatabase: {

    module: 'sails-mysql',
    host: 'YOUR_MYSQL_SERVER_HOSTNAME_OR_IP_ADDRESS',
    user: 'YOUR_MYSQL_USER',
    // Psst.. You can put your password in config/local.js instead
    // so you don't inadvertently push it up if you're using version control
    password: 'YOUR_MYSQL_PASSWORD', 
    database: 'YOUR_MYSQL_DB'
  }
};

So, now, our house is order from a domain and persistence perspective.  We have Waterline domain objects that will translate to separate Mongo collections, and our application is now configured to use our local MongoDB instance.

Surfacing Data to the Controller

In classical MVC frameworks like ASP .Net MVC and Spring MVC, it's often an acceptable pattern to have some sort of intermediary between Model/ORM interaction and the Controllers themselves.  In short, the separation of concerns would be Model/ORM/DB --> Business Logic --> Controllers...that is, controllers ought to only be concerned about transport of data, usually in the form of data transfer objects, or DTO's, and there will be a layer between it and the Model/ORM that handles any sort of business logic manipulation.  We're going to follow this pattern for this thought exercise.

Sails.js, out of the box, offers this functionality in the way of the /api/services folder.  Any exports code created here can be accessible globally.  However, for the sake of argument, let's say we want to keep our Sails.js app as skinny as possible and not clutter up global and only "use what we need" in a Controller.  Following this pattern, let's establish another folder in /api called /managers.  Again, a Manager's concerns would be any sort of Model and Business Logic interaction, and it would return resulting DTO's back to the Controller.

We'll create the SearchManager as a standard prototypical JavaScript object with two methods: searchByMovie and searchByPersonText.  Again, our application will have the ability to search for parts of a word or words in movie titles as well as people involved with movies.

We'll need a few DTO's, per our pattern, to reflect this -- one for each of the Model objects (Movie and Person), a specialized one called CastCrewDTO which is meant to represent a Person belonging to a movie playing any number of roles, and a SearchResultDTO to consolidate all of this for the controller to return to consuming applications.  We're going to create a /api/dto folder and throw these JavaScript objects in there.

/api/managers/SearchManager.js
var _ = require('underscore');
var Promise = require('promise');
var MovieDTO = require('../dto/MovieDTO');
var PersonDTO = require('../dto/PersonDTO');
var CastCrewDTO = require('../dto/CastCrewDTO');

/**
 * The Manager layer acts as an intermediary between controllers and the domain
 * For this pattern, the Manager layer simply spits back DTO's for the Controller
 */
function SearchManager() {

};

/**
 * Searches movies by a particular text
 * @param movieText
 * @returns {Promise}
 */
SearchManager.prototype.searchByMovie = function (movieText) {
    var movieDtos = [];
    var self = this;
    return new Promise(
        function (fulfill, reject) {
            Movie.find({ or: [ { like: { name: '%' + movieText + '%' }} ] })
                .done(function (err, movies) {
                    var movieIds = [];
                    if (err || _.isUndefined(movies)) {
                        reject(err || movies);
                    }
                    else {
                        _.each(movies, function (movie) {
                            var movieDto = new MovieDTO();
                            // Add the DTO
                            movieDto.fromEntity(movie);
                            movieDtos.push(movieDto);
                            movieIds.push(movie.movieId);
                        });

                        // Get cast and crew
                        MoviePerson.find({ movieId: movieIds })
                            .done(function(err, moviePersons) {
                                var personIds = [];
                                _.each(moviePersons, function(moviePerson) {
                                    personIds.push(moviePerson.personId);
                                    var castCrewDto = new CastCrewDTO();
                                    castCrewDto.personId = moviePerson.personId;
                                    castCrewDto.role = moviePerson.role;
                                    var movieDto = _.find(movieDtos, function(movieDto) { return movieDto.movieId === moviePerson.movieId });
                                    if (moviePerson.type === 'Cast') {
                                        movieDto.cast.push(castCrewDto);
                                    }
                                    else if (moviePerson.type === 'Crew') {
                                        movieDto.crew.push(castCrewDto);
                                    }
                                });

                                Person.find({ personId: personIds })
                                    .done(function(err, persons) {
                                        _.each(persons, function(person) {
                                            _.each(movieDtos, function(movieDto) {
                                                // TODO: Do a filter instead of a find.  A Person can play multiple roles
                                                var matchedCast = _.find(movieDto.cast, function(castDto) { return castDto.personId === person.personId });
                                                var matchedCrew = _.find(movieDto.crew, function(crewDto) { return crewDto.personId === person.personId });
                                                if (!_.isUndefined(matchedCast)){
                                                    matchedCast.firstName = person.firstName;
                                                    matchedCast.lastName = person.lastName;
                                                    matchedCast.displayName = person.lastName + ', ' + person.firstName;
                                                }
                                                if (!_.isUndefined(matchedCrew)){
                                                    matchedCrew.firstName = person.firstName;
                                                    matchedCrew.lastName = person.lastName;
                                                    matchedCrew.displayName = person.lastName + ', ' + person.firstName;
                                                }
                                            });
                                        });
                                        fulfill(movieDtos);
                                    });
                            }
                        );
                    }
                }
            );
        }
    );
};

/**
 * Searches Persons by a particular text
 * @param personText
 * @returns {Promise}
 */
SearchManager.prototype.searchByPersonText = function (personText) {
    var personDtos = [];
    return new Promise(
        function (fulfill, reject) {
            Person.find({
                or: [
                    { like: { firstName: '%' + personText + '%'} },
                    { like: { lastName: '%' + personText + '%'} }
                ]
            })
                .done(function (err, persons) {
                    if (err || _.isUndefined(persons)) {
                        reject(err || persons);
                    }
                    else {
                        _.each(persons, function (person) {
                            var personDto = new PersonDTO();
                            personDto.fromEntity(person);
                            personDtos.push(personDto);
                        });
                        fulfill(personDtos);
                    }
                });
        }
    );
};

module.exports = SearchManager;


/api/dto/CastCrewDTO.js
function CastCrewDTO() {
    this.firstName = '';
    this.lastName = '';
    this.displayName = '';
    this.role = '';
    this.personId = '';
};

module.exports = CastCrewDTO;


/api/dto/MovieDTO.js
function MovieDTO() {
    this.id = '';
    this.movieId = '';
    this.name = '';
    this.releaseDate = new Date();
    this.rating = '';
    this.grossEarnings = 0.00;
    this.cast = [];
    this.crew = [];
}

MovieDTO.prototype.fromEntity = function(movie) {
    this.id = movie.id
    this.movieId = movie.movieId;
    this.name = movie.name;
    this.releaseDate = movie.releaseDate;
    this.rating = movie.rating;
    this.grossEarnings = movie.grossEarnings;
};


module.exports = MovieDTO;


/api/dto/PersonDTO.js
function PersonDTO() {
    this.id = '';
    this.firstName = '';
    this.lastName = '';
    this.displayName = '';
    this.dateOfBirth = new Date();
    this.nationality = '';
    this.grossEarnings = 0.00;
}

PersonDTO.prototype.fromEntity = function(person) {
    this.id = person.id;
    this.firstName = person.firstName;
    this.lastName = person.lastName;
    this.displayName = person.lastName + ', ' + person.firstName;
    this.dateOfBirth = person.dateOfBirth;
    this.nationality = person.nationality;
    this.grossEarnings = person.grossEarnings;
};


module.exports = PersonDTO;


/api/dto/SearchResultDTO.js
/**
 * A DTO to encapsulate search results for movies or persons
 * @param User
 * @constructor
 */
function SearchResultDTO() {
    this.searchText = '';
    this.movieDtos = [];
    this.personDtos = [];
}

SearchResultDTO.prototype.fromMovieDtos = function(movieDtos) {
    this.movieDtos = movieDtos;
};

SearchResultDTO.prototype.fromPersonDtos = function(personDtos) {
    this.personDtos = personDtos;
};


module.exports = SearchResultDTO;



You'll notice the SearchManager directly invokes the Models we created above.  WebStorm might bark at you for this, but if you recall, anything created in /api/models gets added to global, so when this runs, this code will resolve just fine!

The DTO's themselves are nothing special -- plain JS objects.  You'll notice the PersonDTO and MovieDTO have fromEntity methods that convert the models to the DTO's.  Since CastCrewDTO and SearchResultDTO are specialized logical objects, no such "fromEntity" methods exist for these and we'll set the properties for each as we go.

Controller Methods for Consuming Applications

As was mentioned earlier, controllers can be automatically code-generated.  Let's say we want to create a SearchController, whose methods sort of match what we made above in the SearchManager, with one method called byActorOrMovie.  It is simple as going to a terminal or command line, going to the root of your Sails project (same level as node_modules) and typing in sails generate controller search byActorOrMovie.  Hit enter and you get the skeleton below.

/api/controllers/SearchController.js
/**
 * SearchController
 *
 * @module      :: Controller
 * @description :: A set of functions called `actions`.
 *
 *                 Actions contain code telling Sails how to respond to a certain type of request.
 *                 (i.e. do stuff, then send some JSON, show an HTML page, or redirect to another URL)
 *
 *                 You can configure the blueprint URLs which trigger these actions (`config/controllers.js`)
 *                 and/or override them with custom routes (`config/routes.js`)
 *
 *                 NOTE: The code you write here supports both HTTP and Socket.io automatically.
 *
 * @docs        :: http://sailsjs.org/#!documentation/controllers
 */

module.exports = {
    
  
  /**
   * Action blueprints:
   *    `/search/byActorOrMovie`
   */
   byActorOrMovie: function (req, res) {
    
    // Send a JSON response
    return res.json({
      hello: 'world'
    });
  },




  /**
   * Overrides for the settings in `config/controllers.js`
   * (specific to SearchController)
   */
  _config: {}

  
};



For those of you who have worked on Ruby on Rails, this functionality should seem familiar!  Codegenerating controllers is nice and all, but we obviously need to wire it up to use the SearchManager and the SearchResultDTO we created, so how do we do that?  Well, by the magic of require, of course!

/api/controllers/SearchController.js
var SearchManager = require('../managers/SearchManager');
var SearchResultDTO = require('../dto/SearchResultDTO');

/**
 * SearchController
 *
 * @module      :: Controller
 * @description :: A set of functions called `actions`.
 *
 *                 Actions contain code telling Sails how to respond to a certain type of request.
 *                 (i.e. do stuff, then send some JSON, show an HTML page, or redirect to another URL)
 *
 *                 You can configure the blueprint URLs which trigger these actions (`config/controllers.js`)
 *                 and/or override them with custom routes (`config/routes.js`)
 *
 *                 NOTE: The code you write here supports both HTTP and Socket.io automatically.
 *
 * @docs        :: http://sailsjs.org/#!documentation/controllers
 */

module.exports = {
    
  
  /**
   * Action blueprints:
   *    `/search/byActorOrMovie/{someSearchText}`
   */
   byActorOrMovie: function (req, res) {
      var searchManager = new SearchManager();
      var searchResultDto = new SearchResultDTO();

      // Search by movies, then by persons
      searchManager.searchByMovie(req.params.id).then(
          function(movieDtos) {
              searchResultDto.fromMovieDtos(movieDtos);
              searchManager.searchByPersonText(req.params.id).then(
                  function(personDtos) {
                      searchResultDto.fromPersonDtos(personDtos);
                      searchResultDto.searchText = req.params.id;
                      return res.json(searchResultDto);
                  },
                  function(error){
                      res.serverError(error);
                  }
              )
          },
          function(error) {
              res.serverError(error);
          }
      )
  },

  /**
   * Overrides for the settings in `config/controllers.js`
   * (specific to SearchController)
   */
  _config: {}

  
};



Unit Test Setup

As we wrote the DTO's, SearchManager, and SearchController files above, one thought that must've come to mind is how to unit test all of this code?  For this exercise, we choose the following supporting libraries to help us (which are installed using npm install)

  • Mocha: An easy to use, flexible unit testing framework for Node.js
  • Assert: An assertion library ported from CommonJS
  • Lo-Dash: Many useful helper functions for JavaScript, specifically checking for defined/undefined/null values and collection helpers
  • Proxyquire: An excellent tool to inject stubs in place of anything injected using require
  • grunt-mocha-test: A grunt task for running Mocha tests
These libraries used together provide us with all the unit testing capability we need.  Before we write up some unit tests for the SearchController and SearchManager, we first need to modify the gruntfile.js on the root, as Grunt will be primarily charged with running the Mocha Tests.

/gruntfile.js
    grunt.loadTasks(depsPath + '/grunt-contrib-less/tasks');
    grunt.loadTasks(depsPath + '/grunt-contrib-coffee/tasks');
    grunt.loadNpmTasks('grunt-mocha-test');


And also, we need to modify the grunt.initConfig to actually have a task to use the grunt-mocha-test task runner.

/gruntfile.js
        mochaTest: {
            test: {
                options: {
                    reporter: 'spec'
                },
                src: ['tests/**/*.spec.js']
            }
        },

You'll see it's configured to run any *.spec.js in the /tests folder.  We'll save this gruntfile.js and establish said /tests folder, making a subdirectory structure that closely matches the /api folder to correspond with our components.

Typically, in JavaScript, you would create a test for a .js file with an equivalent .spec.js file, so we will do the same here -- /tests/controllers/SearchController.spec.js for the SearchController and /tests/managers/SearchManager.spec.js for the SearchManager.

Unit Testing Approach

I don't see enough posts about unit testing, which is a key facet of Agile development, so let's quickly review what we intend to accomplish here.

A "true" unit test is one that can stand alone.  That is, I should be able to run this test without lifting Sails, having a web server up, having MongoDB running, etc. (hence a UNIT test).  To accomplish this in classical languages such as Java and C#, we'd develop to interfaces, utilize dependency injection libraries, and create mocks or stubs of any dependent components, such as the ORM, HTTP calls, database calls, etc.

JavaScript, being dynamic in nature, allows us to be even more flexible.  We can mock these dependencies right in-line.  Better yet, as opposed to Mocking libraries which may produce mocks for methods, properties, and fields we don't even use, by writing these stubs ourselves, we actually narrow down what methods, properties, and fields we need to use from dependent modules.

You're going to see the above approach with our unit tests in this particular project.

Unit Testing the Manager

The SearchManager's primary responsibilities are interacting with the Waterline ORM based on some input from the Controller (in this case, a string for which to search in a Movie name or a Person's first or last name), get the object(s) from the ORM, and send back DTO(s) to the controller.

The curveball here is that Sails.js expects the Models to live in global, so our mocked Models will also live there.  We also mocked up some data from the ORM using straight .json files, instead of literally coding them in.

/tests/managers/SearchManager.spec.js
// Injected objects
var _ = require('lodash'),
    assert = require('assert'),
    SearchManager = require('../../api/managers/SearchManager.js'),
    MovieData = require('../mockData/SearchManager/Movie.json'),
    MoviePersonData = require('../mockData/SearchManager/MoviePerson.json'),
    PersonData = require('../mockData/SearchManager/Person.json');

// Mock domain objects
var mockMovie,
    mockMoviePerson,
    mockPerson,
    searchManager;

describe('SearchManager', function(){

    /**
     * Setup mock models
     */
    before(function(){
        // Mocked Movie model
        mockMovie =  {
            find: function(args) {
                {
                    var done = function(cb) {
                        return cb(null, MovieData);
                    };

                    return {
                        done: done
                    };
                }
            }
        };

        // Mocked MoviePersonModel
        mockMoviePerson =  {
            find: function(args) {
                {
                    var done = function(cb) {
                        return cb(null, MoviePersonData);
                    };

                    return {
                        done: done
                    };
                }
            }
        };

        // Mocked Person Data
        mockPerson =  {
            find: function(args) {
                {
                    var done = function(cb) {
                        return cb(null, PersonData);
                    };

                    return {
                        done: done
                    };
                }
            }
        };

        // Set expected globals
        global.Movie = mockMovie;
        global.MoviePerson = mockMoviePerson;
        global.Person = mockPerson;

        // Setup the SearchManager
        searchManager = new SearchManager();

    });

    describe('searchByMovie', function() {
        it('Should get movies with cast and crew', function(done){
            searchManager.searchByMovie('Godfather')
                .then(
                    function(movieDtos) {

                        // Assert that an array of movieDto's was created
                        assert(movieDtos.length > 0);
                        assert(movieDtos[0].name === MovieData[0].name, 'Invalid name');
                        assert(movieDtos[0].movieId === MovieData[0].movieId, 'Invalid movieId');
                        assert(movieDtos[0].releaseDate === MovieData[0].releaseDate, 'Invalid releaseDate');
                        assert(movieDtos[0].rating === MovieData[0].rating, 'Invalid rating');
                        assert(movieDtos[0].grossEarnings === MovieData[0].grossEarnings, 'Invalid grossEarnings');
                        assert(movieDtos[0].cast.length === 2, 'Wrong number of cast members');
                        done();
                    },
                    function(error) {
                        done('General error');
                    }
                )
                .catch(done); // Promise has its own error handler so we need to pass the done function here

        });
    });
});


/tests/mockData/SearchManager/Movie.json
[
    {
        "movieId": 1,
        "name": "The Godfather",
        "releaseDate": "1975-04-21T18:25:43-05:00",
        "rating": "R",
        "grossEarnings": 1200000000.00
    }
]


/tests/mockData/SearchManager/MoviePerson.json
[
    {
        "movieId": 1,
        "personId": 1,
        "role": "Michael Corleone",
        "type": "Cast"
    },
    {
        "movieId": 1,
        "personId": 2,
        "role": "Vito Corleone",
        "type": "Cast"
    }
]


/tests/mockData/SearchManager/Person.json
[
    {
        "personId": 1,
        "firstName": "Al",
        "lastName": "Pacino",
        "dateOfBirth": "1940-04-25T18:25:43-05:00",
        "nationality": "Italian",
        "grossEarnings": 250000000.00
    },
    {
        "personId": 2,
        "firstName": "Marlon",
        "lastName": "Brando",
        "dateOfBirth": "1924-04-03T18:25:43-05:00",
        "nationality": "American",
        "grossEarnings": 280000000.00
    }
]


Unit Testing the Controller

The SearchController's primary responsibilities are taking DTO's from their respective Manager(s) and shuttling them back to any consuming application.  Furthermore, if any errors were thrown downstream, the Controller should also throw an appropriate server error.  In short, the Controller acts as the "plumbing" in between the domain and any consuming applications.

/tests/controllers/SearchController.spec.js
// Dependent objects
var _ = require('underscore'),
    assert = require('assert'),
    MovieDTO = require('../../api/dto/MovieDTO'),
    PersonDTO = require('../../api/dto/PersonDTO'),
    CastCrewDTO = require('../../api/dto/CastCrewDTO'),
    proxyquire = require('proxyquire'),
    Promise = require('promise');

// Mock objects
var req, res,
    SearchController;

describe('SearchController', function(){

    /**
     * Shared objects
     */
    before(function() {
        // Mock req
        req = {
            params: {
                id: 'wedding'
            }
        };

        // Mock res
        // Create fields to hold the res responses and server errors
        res = {
            rawObject: null,
            jsonObject: null,
            errorObject: null,

            json: function(object) {
                this.rawObject = object;
                this.jsonObject = JSON.stringify(object);
                return object;
            },
            serverError: function(error) {
                this.errorObject = error;
                return error;
            }
        };
    });

    describe('on failure of getting data from SearchManager', function() {
        /**
         * Setup Mock Managers for failed calls
         */
        before(function(){

            // Mock SearchManager
            function SearchManager() {
                var searchByMovie = function(movieText) {
                    var then = function(cb, error) {
                        return error('Oh noes!')
                    };

                    return {
                        then: then
                    }
                };

                var searchByPersonText = function(personText) {
                    var then = function(cb, error) {
                        return error('Oh noes!');
                    };

                    return {
                        then: then
                    }
                };

                return {
                    searchByMovie: searchByMovie,
                    searchByPersonText: searchByPersonText
                };
            };

            // Setup the Controller
            SearchController = proxyquire('../../api/controllers/SearchController', { '../managers/SearchManager': SearchManager });
            res.rawObject = null;
            res.jsonObject = null;
            res.errorObject = null;
        });

        it('byActorOrMovie should report a server error', function() {
            SearchController.byActorOrMovie(req, res);
            assert(_.isNull(res.jsonObject));
            assert(!_.isNull(res.errorObject));
            assert(res.errorObject === 'Oh noes!');

        });

    });

    describe('on successfully getting data from SearchManager', function() {
        /**
         * Setup Mock Managers for successful calls
         */
        before(function(){

            // Mock SearchManager
            function SearchManager() {
                var searchByMovie = function(movieText) {
                    var then = function(cb) {
                        var movieDtos = [];
                        var movieDto = new MovieDTO();
                        movieDto.id = 1;
                        movieDto.name = 'Wedding Crashers';
                        movieDto.rating = 'R';
                        movieDto.grossEarnings = 1337000.00

                        var castCrewDto = new CastCrewDTO();
                        castCrewDto.firstName = 'Owen';
                        castCrewDto.lastName = 'Wilson';
                        castCrewDto.displayName = 'Wilson, Owen';
                        castCrewDto.role = 'John Beckwith';
                        castCrewDto.personId = 1;

                        movieDto.cast.push(castCrewDto);
                        movieDtos.push(movieDto);
                        return cb(movieDtos)
                    };

                    return {
                        then: then
                    }
                };

                var searchByPersonText = function(personText) {
                    var then = function(cb) {
                        var personDtos = [];
                        var personDto = new PersonDTO();
                        personDto.id = 1;
                        personDto.firstName = 'Owen';
                        personDto.lastName = 'Wilson';
                        personDto.displayName = 'Wilson, Owen';
                        personDto.dateOfBirth = new Date('1968-11-18');
                        personDto.nationality = 'American';
                        personDto.grossEarnings = 133700.00
                        personDtos.push(personDto);
                        return cb(personDtos);
                    };

                    return {
                        then: then
                    }
                };

                return {
                    searchByMovie: searchByMovie,
                    searchByPersonText: searchByPersonText
                };
            };

            // Setup the Controller
            SearchController = proxyquire('../../api/controllers/SearchController', { '../managers/SearchManager': SearchManager });
            res.rawObject = null;
            res.jsonObject = null;
            res.errorObject = null;
        });

        it('byActorOrMovie should return results', function(){
            SearchController.byActorOrMovie(req, res);
            assert(!_.isNull(res.rawObject));
            assert(!_.isNull(res.jsonObject));
            assert(_.isNull(res.errorObject));
            assert(res.rawObject.searchText === 'wedding');
            assert(res.rawObject.movieDtos.length === 1);
            assert(res.rawObject.personDtos.length === 1);
            assert(res.rawObject.personDtos[0].firstName === 'Owen', 'PersonDTO.firstName');
            assert(res.rawObject.personDtos[0].lastName === 'Wilson', 'PersonDTO.lastName');
            assert(res.rawObject.personDtos[0].displayName === 'Wilson, Owen', 'PersonDTO.displayName');
            assert(res.rawObject.personDtos[0].nationality === 'American', 'PersonDTO.nationality');
            assert(res.rawObject.personDtos[0].grossEarnings === 133700.00, 'PersonDTO.grossEarnings');
            assert(res.rawObject.movieDtos[0].name === 'Wedding Crashers', 'MovieDTO.name');
            assert(res.rawObject.movieDtos[0].rating === 'R', 'MovieDTO.rating')
            assert(res.rawObject.movieDtos[0].grossEarnings === 1337000.00, 'MovieDTO.grossEarnings');
            assert(res.rawObject.movieDtos[0].cast[0].lastName === 'Wilson', 'MovieDTO.cast.lastName');
            assert(res.rawObject.movieDtos[0].cast[0].firstName === 'Owen', 'MovieDTO.cast.firstName');
            assert(res.rawObject.movieDtos[0].cast[0].role === 'John Beckwith', 'MovieDTO.cast.role');
            assert(res.rawObject.movieDtos[0].cast[0].displayName === 'Wilson, Owen', 'MovieDTO.cast.lastName');
        });
    });
});



The curveball with this unit test is how we inject the dependency to SearchManager.  We use a library called proxyquire to inject our mock SearchManager to the Controller.  Think of it as a Mockito or Moq equivalent for JavaScript frameworks that use require.

Unit Testing the Models

In this particular example, being simplistic in nature, we didn't code in any behavior in the Models.  Sails.js allows you to write in methods as part of the attributes of a Model, so if you had said methods implemented, you'd simply require whatever Model it was you're testing and run against those methods.

Running the Unit Tests

Now that we've got our unit tests in place, fire up a terminal or command line, and type in "grunt mochaTest".  You should see the following output

eric@eric-CM6870:~/Projects/AgileMovieDB/agilemoviedb/Server$ grunt mochaTest
Running "mochaTest:test" (mochaTest) task


  SearchController
    on failure of getting data from SearchManager
      ✓ byActorOrMovie should report a server error 
    on successfully getting data from SearchManager
      ✓ byActorOrMovie should return results 

  SearchManager
    searchByMovie
      ✓ Should get movies with cast and crew 


  3 passing (12ms)


Done, without errors.


Seed Some Data!

What good is this Movie database without some sample data?  I've included a simple Node.js script in /seeds called seed.js.  Simply run this to populate your local MongoDB instance with sample movie data from the .json's in that folder.  Note, this seed will not currently work for Sails.js v0.10.x.

Check Out the API!

So, now we can say with pretty good confidence that we've got a working API.  Fire up a terminal, go to the /Server folder, and type in "sails lift".  Alternatively, in WebStorm, find the app.js on the root of /Server and run it.

Let's test our primary endpoint, /search/byActorOrMovie.

/search/byActorOrMovie/b
{
searchText: "b",
movieDtos: [ ],
personDtos: [
{
id: "53a429a41d7e9b4417a67e11",
firstName: "Marlon",
lastName: "Brando",
displayName: "Brando, Marlon",
dateOfBirth: "1924-04-03T18:25:43-05:00",
nationality: "American",
grossEarnings: 280000000
},
{
id: "53a429a41d7e9b4417a67e16",
firstName: "Steven",
lastName: "Spielberg",
displayName: "Spielberg, Steven",
dateOfBirth: "1946-12-18T18:25:43-05:00",
nationality: "American",
grossEarnings: 400000000
}
]
}


/search/byActorOrMovie/ford
{
searchText: "ford",
movieDtos: [ ],
personDtos: [
{
id: "53a429a41d7e9b4417a67e14",
firstName: "Harrison",
lastName: "Ford",
displayName: "Ford, Harrison",
dateOfBirth: "1942-07-14T18:25:43-05:00",
nationality: "American",
grossEarnings: 250000000
},
{
id: "53a429a41d7e9b4417a67e15",
firstName: "Francis",
lastName: "Ford Coppola",
displayName: "Ford Coppola, Francis",
dateOfBirth: "1939-04-07T18:25:43-05:00",
nationality: "American",
grossEarnings: 300000000
}
]
}


/search/byActorOrMovie/star
{
searchText: "star",
movieDtos: [
{
id: "53a429a41d7e9b4417a67e0c",
movieId: 2,
name: "Star Wars",
releaseDate: "1977-01-21T18:25:43-05:00",
rating: "PG-13",
grossEarnings: 1400000000,
cast: [
{
firstName: "Mark",
lastName: "Hamill",
displayName: "Hamill, Mark",
role: "Luke Skywalker",
personId: 4
},
{
firstName: "Harrison",
lastName: "Ford",
displayName: "Ford, Harrison",
role: "Han Solo",
personId: 5
}
],
crew: [ ]
}
],
personDtos: [ ]
}


Pretty neat!

Wrap-up

For the past few years, many have wondered if Node.js as a server-side web API is "enterprise-ready" from a development perspective.  While this project is very simplistic, we went over:
  • Separation of Concerns
  • Dependency Injection
  • Configurability
  • Testability
What we did not cover in this project that Sails.js supports, which I'm sure enterprises are concerned about, to name a few:
  • CORS Configuration
  • Authentication using Passport
  • Access Control Policies
  • Session States
  • Socket.io (i.e. real-time push notifications, or pub/sub) Support
In short, is Sails.js "enterprise-ready"?  I'd say a resounding heck yes.  For smaller companies equipped with skilled JavaScript developers looking to launch scalable, durable products with quick time to market, Sails.js is certainly a compelling option worth an evaluation.

Stay tuned for Part 3 of this 3-part series where we wire up an AngularJS SPA to this Sails.js Web API!

2 comments:

Unknown said...

Hi, thanks for the tutorials. Just noticed you've got:

"Out of the box, Sails.js supports Jade (as denoted by the *.ejs files)"

Shouldn't this be EmbeddedJS instead of Jade? (Ofcourse Sails supports Jade as well)

Nemco said...

Thank you for taking the time and sharing this information with us. It was indeed very helpful and insightful while being straight forward and to the point.
hire angularjs developer