From 5fecd50e7cb1c819e1b9c09c4e33d98d9dee0589 Mon Sep 17 00:00:00 2001 From: Timothy J Warren Date: Mon, 25 Jan 2016 09:19:28 -0500 Subject: [PATCH] Add basic structure --- .gitignore | 35 ++++ .jscsrc | 111 +++++++++++ gulpfile.js | 219 ++++++++++++++++++++++ lib/Container.js | 96 ++++++++++ lib/app.js | 23 +++ lib/base/ApiModel.js | 10 + lib/base/Config.js | 32 ++++ lib/base/HttpServer.js | 69 +++++++ lib/base/Model.js | 7 + lib/config/error-handlers.js | 37 ++++ lib/config/middleware.js | 31 +++ lib/routes/index.js | 16 ++ lib/routes/users.js | 11 ++ lib/util/route-loader.js | 29 +++ package.json | 63 +++++++ server.js | 37 ++++ test/end-to-end/index_test.js | 51 +++++ test/mocha.opts | 5 + test/test-base.js | 60 ++++++ test/unit/.gitkeep | 0 test/unit/base/Container_test.js | 77 ++++++++ test/unit/util/route-loader_test.js | 18 ++ test/unit/util/test-routes/api/foo.js | 13 ++ test/unit/util/test-routes/api/foo/bar.js | 13 ++ test/unit/util/test-routes/index.js | 13 ++ 25 files changed, 1076 insertions(+) create mode 100644 .jscsrc create mode 100644 gulpfile.js create mode 100644 lib/Container.js create mode 100644 lib/app.js create mode 100644 lib/base/ApiModel.js create mode 100644 lib/base/Config.js create mode 100644 lib/base/HttpServer.js create mode 100644 lib/base/Model.js create mode 100644 lib/config/error-handlers.js create mode 100644 lib/config/middleware.js create mode 100644 lib/routes/index.js create mode 100644 lib/routes/users.js create mode 100644 lib/util/route-loader.js create mode 100644 package.json create mode 100644 server.js create mode 100644 test/end-to-end/index_test.js create mode 100644 test/mocha.opts create mode 100644 test/test-base.js create mode 100644 test/unit/.gitkeep create mode 100644 test/unit/base/Container_test.js create mode 100644 test/unit/util/route-loader_test.js create mode 100644 test/unit/util/test-routes/api/foo.js create mode 100644 test/unit/util/test-routes/api/foo/bar.js create mode 100644 test/unit/util/test-routes/index.js diff --git a/.gitignore b/.gitignore index e920c16..9d7127b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Created by https://www.gitignore.io/api/node,osx,webstorm,eclipse + +### Node ### # Logs logs *.log @@ -24,6 +27,7 @@ coverage build/Release # Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules # Optional npm cache directory @@ -31,3 +35,34 @@ node_modules # Optional REPL history .node_repl_history + + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Don't commit generated docs +public/* \ No newline at end of file diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..f8f9f5e --- /dev/null +++ b/.jscsrc @@ -0,0 +1,111 @@ +{ + "disallowEmptyBlocks": true, + "disallowKeywords": [ + "with", + "eval" + ], + "disallowKeywordsOnNewLine": [ + "else" + ], + "disallowMixedSpacesAndTabs": true, + "disallowMultipleLineBreaks": true, + "disallowMultipleLineStrings": true, + "disallowQuotedKeysInObjects": true, + "disallowSpaceAfterObjectKeys": true, + "disallowSpaceAfterPrefixUnaryOperators": false, + "disallowSpaceBeforeBinaryOperators": [ + "," + ], + "disallowSpaceBeforeComma": { + "allExcept": [ + "sparseArrays" + ] + }, + "disallowSpaceBeforePostfixUnaryOperators": [ + "++", + "--" + ], + "disallowSpaceBeforeSemicolon": true, + "disallowSpacesInCallExpression": true, + "disallowSpacesInFunctionDeclaration": { + "beforeOpeningRoundBrace": true + }, + "disallowSpacesInNamedFunctionExpression": { + "beforeOpeningRoundBrace": true + }, + "disallowSpacesInsideArrayBrackets": true, + "disallowSpacesInsideBrackets": true, + "disallowSpacesInsideParentheses": { + "only": [ + "!", + "{", + "}" + ] + }, + "disallowTrailingWhitespace": true, + "disallowYodaConditions": true, + "esnext": true, + "requireBlocksOnNewline": 1, + "requireCamelCaseOrUpperCaseIdentifiers": true, + "requireCapitalizedConstructors": true, + "requireCommaBeforeLineBreak": true, + "requireCurlyBraces": [ + "if", + "else", + "for", + "while", + "do", + "try", + "catch" + ], + "requireDotNotation": { + "allExcept": [ + "keywords" + ] + }, + "requireLineFeedAtFileEnd": false, + "requirePaddingNewLinesAfterBlocks": true, + "requirePaddingNewLinesBeforeLineComments": { + "allExcept": "firstAfterCurly" + }, + "requireParenthesesAroundIIFE": true, + "requireSemicolons": true, + "requireSpaceAfterBinaryOperators": true, + "requireSpaceAfterComma": true, + "requireSpaceAfterKeywords": [ + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "return", + "try", + "catch", + "typeof" + ], + "requireSpaceBeforeBinaryOperators": true, + "requireSpaceBeforeBlockStatements": true, + "requireSpaceBetweenArguments": true, + "requireSpacesInAnonymousFunctionExpression": { + "beforeOpeningRoundBrace": true, + "allExcept": [ + "shorthand" + ] + }, + "requireSpacesInConditionalExpression": true, + "requireSpacesInForStatement": true, + "requireSpacesInsideObjectBrackets": "all", + "requireTrailingComma": { + "ignoreSingleLine": true + }, + "safeContextKeyword": "_this", + "validateIndentation": "\t", + "validateLineBreaks": "LF", + "validateQuoteMarks": { + "mark": "'", + "escape": true, + "ignoreJSX": true + } +} \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..5fad31b --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,219 @@ +'use strict'; + +const apidoc = require('gulp-apidoc'), + documentation = require('gulp-documentation'), + eslint = require('gulp-eslint'), + gulp = require('gulp'), + istanbul = require('gulp-istanbul'), + jscs = require('gulp-jscs'), + mocha = require('gulp-mocha'), + pipe = require('gulp-pipe'), + nsp = require('gulp-nsp'); + +/* + * Path(s) to all source files + */ +const SRC_FILES = ['lib/**/*.js']; + +/* + * Path to unit test files + */ +const UNIT_TEST_FILES = ['test/unit/**/*_test.js']; + +/* + * Path to integration test files + */ +const INTEGRATION_TEST_FILES = ['test/end-to-end/**/*_test.js']; + +/* + * Path(s) to all test files + */ +const TEST_FILES = ['test/**/*_test.js']; + +/* + * Configuration values for eslint + */ +const ESLINT_SETTINGS = { + env: { + node: true, + es6: true, + }, + + // Each rule has an error level (0-2), and some have extra parameters + // 0 turns a rule off + // 1 makes a rule give a warning + // 2 makes a rule fail linting + rules: { + 'linebreak-style': [2, 'unix'], // Only unix line endings + 'arrow-parens': [2, 'always'], // No parens on arrow functions with one param + 'no-console': [1], // Avoid using console methods + 'no-constant-condition': [1], + 'no-extra-semi': [1], // Enliminate extra semicolons + 'no-func-assign': [1], + 'no-obj-calls': [2], + 'no-unexpected-multiline': [2], // Catch syntax errors due to automatic semicolon insertion + 'no-unneeded-ternary': [2], // Avoid redundant ternary expressions + radix: [2], // Require radix parameter on parseInt + 'no-with': [2], // No use of with construct + 'no-eval': [2], // No use of eval + 'no-unreachable': [1], // Avoid code that is not reachable + 'no-irregular-whitespace': [1], // Highlight whitespace that isn't a tab or space + 'no-new-wrappers': [2], // Avoid using primitive constructors + 'no-new-func': [2], // Avoid Function constructor eval + curly: [2, 'multi-line'], // Require braces for if,for,while,do contructs that are not on the same line + 'no-implied-eval': [2], // Avoid camoflauged eval + 'no-invalid-this': [2], + 'constructor-super': [2], + 'no-dupe-args': [2], // Disallow functions to have more than one parameter with the same name + 'no-dupe-keys': [2], // Disaalow objects to have more than one property with the same name + 'no-dupe-class-members': [2], // Disallow classes to have more than one method/memeber with the same name + 'no-this-before-super': [2], + 'prefer-arrow-callback': [1], // Prefer arrow functions for callbacks + 'no-var': [2], // Use let or const instead of var + 'valid-jsdoc': [1], + semi: [2, 'always'], // Require use of semicolons + strict: [2, 'global'], // have a global 'use strict' in every code file + 'callback-return': [1], // return when invoking a callback + 'object-shorthand': [1, 'methods'], // Prefer shorthand for functions in object literals/classes, but avoid property shorthand + 'prefer-template': [1], // Prefer template strings eg. `Hello ${name}`, to string concatenation + }, +}; + +/* + * Check syntax and style of test/miscellaneous files + */ +gulp.task('lint-tests', () => { + const LINT_TESTS_FILES = TEST_FILES.concat([ + 'gulpfile.js', + 'server.js', + ]); + + // eslint + pipe(gulp.src(LINT_TESTS_FILES), [ + eslint(ESLINT_SETTINGS), + eslint.format(), + eslint.failAfterError(), + ]); + + // JSCS rules are defined in /.jscsrc + pipe(gulp.src(['test/**/*.js', 'gulpfile.js']), [ + jscs(), + jscs.reporter(), + ]); +}); + +/* + * Check syntax and style of source files + */ +gulp.task('lint-src', () => { + // eslint + pipe(gulp.src(SRC_FILES), [ + eslint(ESLINT_SETTINGS), + eslint.format(), + eslint.failAfterError(), + ]); + + // JSCS + // JSCS rules are defined in /.jscsrc + pipe(gulp.src(SRC_FILES), [ + jscs(), + jscs.reporter(), + ]); +}); + +/* + * Run all lint tasks + */ +gulp.task('lint', ['lint-src', 'lint-tests']); + +/* + * Create internal method documentation of source files + */ +gulp.task('src-docs', () => { + pipe(gulp.src(SRC_FILES), [ + documentation({ + format: 'html', + }), + gulp.dest('public/docs'), + ]); +}); + +/* + * Create api documentation from source files + */ +gulp.task('api-docs', (done) => { + apidoc({ + src: 'lib/', + dest: 'public/api-docs/', + }, done); +}); + +/* + * Run all documentation generation tasks + */ +gulp.task('docs', ['src-docs', 'api-docs']); + +/* + * Run all tests + */ +gulp.task('test', ['lint'], () => { + return pipe(gulp.src(TEST_FILES), [ + mocha({ + ui: 'tdd', + bail: true, + slow: 1000, + timeout: 5000, + }), + ]); +}); + +/* + * Run hooks for istanbul coverage generation + */ +gulp.task('pre-coverage', () => { + return pipe(gulp.src(SRC_FILES), [ + istanbul(), + istanbul.hookRequire(), + ]); +}); + +/* + * Run unit tests and generate code coverage + * + * Does not run end-to-end tests + */ +gulp.task('coverage', ['lint', 'pre-coverage'], () => { + return pipe(gulp.src(UNIT_TEST_FILES), [ + mocha({ + ui: 'tdd', + bail: true, + slow: 1000, + timeout: 5000, + }), + istanbul.writeReports({ + dir: 'public/coverage', + reporters:['lcov', 'lcovonly', 'html', 'text'], + }), + ]); +}); + +/* + * Check dependencies for known security vulnerabilites + */ +gulp.task('audit', (cb) => { + nsp({ + package: `${__dirname}/package.json`, + }, cb); +}); + +/* + * Run all tasks + */ +gulp.task('default', [ + 'audit', + 'lint', + 'docs', + 'coverage', +]); + +// End of gulpfile.js \ No newline at end of file diff --git a/lib/Container.js b/lib/Container.js new file mode 100644 index 0000000..2c02364 --- /dev/null +++ b/lib/Container.js @@ -0,0 +1,96 @@ +'use strict'; + +const express = require('express'), + path = require('path'); + +/** + * Container for keeping track of dependencies + */ +class Container { + + /** + * Create the object container + * + * @constructor + */ + constructor() { + const app = express(); + this._container = { + app: app, + }; + } + + /** + * Determine if an item exists in the container + * + * @param {String} name - name of the item + * @return {Boolean} - whether the item exists in the container + */ + has(name) { + return (name in this._container); + } + + /** + * Return an existing object instance + * + * @param {String} name - name of the item + * @return {Object|Null} - the item, or null if it doesn't exist + */ + get(name) { + if (! this.has(name)) { + return null; + } + + return this._container[name]; + } + + /** + * Set an object in the container + * + * @param {String} name - name to associate with object + * @param {Object} object - item to keep track of + * @return {Container} - the container instance + */ + set(name, object) { + this._container[name] = object; + return this; + } + + /** + * Does a native require, relative to the lib folder, + * and returns the value + * + * @param {String} modulePath - name of the module to require + * @return {mixed} - the value returned from require + */ + require(modulePath) { + // If the value is already saved, just return it + if (this.has(modulePath)) { + return this.get(modulePath); + } + + let includeName = modulePath; + + // Allow referencing local modules without using a ./ + // eg. util/route-loader instead of ./util/route-loader + if ( + modulePath.includes('/') && + ! (modulePath.startsWith('./') || modulePath.includes(__dirname)) + ) { + includeName = path.join(__dirname, modulePath); + } + + return require(includeName); + } +} + +let instance = null; + +module.exports = (function () { + + if (instance === null) { + instance = new Container(); + } + + return instance; +}()); \ No newline at end of file diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..6c06200 --- /dev/null +++ b/lib/app.js @@ -0,0 +1,23 @@ +'use strict'; + +const container = require('./Container'); +const loadRoutes = container.require('util/route-loader'), + path = container.require('path'); + +const app = container.get('app'); +const middleware = container.require('config/middleware'); +const routes = loadRoutes(path.join(__dirname, 'routes')); +const errorHandlers = container.require('config/error-handlers'); + +// load middleware +middleware.forEach((mw) => app.use(mw)); + +// automatically set up routing by folder structure +Object.keys(routes).reverse().forEach((path) => { + app.use(path, require(routes[path])); +}); + +// load error handlers +errorHandlers.forEach((handler) => app.use(handler)); + +module.exports = app; \ No newline at end of file diff --git a/lib/base/ApiModel.js b/lib/base/ApiModel.js new file mode 100644 index 0000000..6525c9f --- /dev/null +++ b/lib/base/ApiModel.js @@ -0,0 +1,10 @@ +'use strict'; + +const container = require('../Container'); +const Model = container.require('base/Model'); + +class ApiModel extends Model { + +} + +module.exports = ApiModel; \ No newline at end of file diff --git a/lib/base/Config.js b/lib/base/Config.js new file mode 100644 index 0000000..eb34174 --- /dev/null +++ b/lib/base/Config.js @@ -0,0 +1,32 @@ +'use strict'; + +let nconf = require('nconf'); + +/** + * Config management class + */ +class Config { + constructor() { + // Use, in order of preference: + // - command line arguments + // - environment variables + // - config files + nconf.argv() + .env() + .file({ + + }); + } + + /** + * Retreive a config value + * + * @param {String} key - the name of the config value + * @return {mixed} - the configuration value + */ + get(key) { + + } +} + +module.exports = Config; \ No newline at end of file diff --git a/lib/base/HttpServer.js b/lib/base/HttpServer.js new file mode 100644 index 0000000..1c5d736 --- /dev/null +++ b/lib/base/HttpServer.js @@ -0,0 +1,69 @@ +'use strict'; + +const http = require('http'), + logger = require('winston'); + +/** + * Class for creating an http server + * + * @param {Express} app - current express instance + * @param {Number} port - the port to listen on + */ +class HttpServer { + /** + * Creates an HTTP Server + * + * @constructor + * @param {Express} app - current express instance + * @param {Number} port - the port to listen on + */ + constructor(app, port) { + let server = http.createServer(app); + server.listen(port); + server.on('error', this.onError); + server.on('listening', () => { + let addr = server.address(); + let bind = typeof addr === 'string' + ? `pipe ${addr}` + : `port ${addr.port}`; + logger.info(`Listening on ${bind}`); + }); + + this.server = server; + } + + /** + * Event listener for HTTP server "error" event. + * + * @param {Error} error - the error object + * @return {Null} - Does not return a value + * @throws {Error} + */ + onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + let port = this.server.address().port; + + let bind = typeof port === 'string' + ? `Pipe ${port}` + : `Port ${port}`; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + logger.error(`${bind} requires elevated privileges`); + process.exit(1); + break; + case 'EADDRINUSE': + logger.error(`${bind} is already in use`); + process.exit(1); + break; + default: + throw error; + } + } +} + +module.exports = HttpServer; \ No newline at end of file diff --git a/lib/base/Model.js b/lib/base/Model.js new file mode 100644 index 0000000..df125a8 --- /dev/null +++ b/lib/base/Model.js @@ -0,0 +1,7 @@ +'use strict'; + +class Model { + +} + +module.exports = Model; \ No newline at end of file diff --git a/lib/config/error-handlers.js b/lib/config/error-handlers.js new file mode 100644 index 0000000..2c0c629 --- /dev/null +++ b/lib/config/error-handlers.js @@ -0,0 +1,37 @@ +'use strict'; + +// ----------------------------------------------------------------------------- +// Error handlers +// ----------------------------------------------------------------------------- + +const container = require('../Container'); +const app = container.get('app'); + +let errorHandlers = [ + + // catch 404 and forward to error handler + (req, res, next) => { + let err = new Error('Not Found'); + err.status = 404; + next(err); + }, + + // general error handler + (err, req, res, next) => { + res.status(err.status || 500); + + let output = { + status: err.status, + message: err.message, + }; + + // Show stack trace in development environment + if (app.get('env') === 'development') { + output.error = err; + } + + res.json(output); + }, +]; + +module.exports = errorHandlers; \ No newline at end of file diff --git a/lib/config/middleware.js b/lib/config/middleware.js new file mode 100644 index 0000000..5214d72 --- /dev/null +++ b/lib/config/middleware.js @@ -0,0 +1,31 @@ +'use strict'; + +// ----------------------------------------------------------------------------- +// Middleware +// ----------------------------------------------------------------------------- + +const bodyParser = require('body-parser'), + cookieParser = require('cookie-parser'), + express = require('express'), + helmet = require('helmet'), + requestLogger = require('morgan'), + path = require('path'); + +let middleware = [ + + // some security settings controlled by helmet + helmet.frameguard(), + helmet.hidePoweredBy(), + helmet.ieNoOpen(), + helmet.noSniff(), + helmet.xssFilter(), + + // basic express middleware + requestLogger('combined'), + bodyParser.json(), + bodyParser.urlencoded({ extended: false }), + cookieParser(), + express.static(path.join(__dirname, '../../public')), +]; + +module.exports = middleware; \ No newline at end of file diff --git a/lib/routes/index.js b/lib/routes/index.js new file mode 100644 index 0000000..4e787ac --- /dev/null +++ b/lib/routes/index.js @@ -0,0 +1,16 @@ +'use strict'; + +const express = require('express'); +let router = express.Router(); + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.json({ + status: 200, + data: { + index: { title: 'Express' }, + }, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/lib/routes/users.js b/lib/routes/users.js new file mode 100644 index 0000000..636a7d7 --- /dev/null +++ b/lib/routes/users.js @@ -0,0 +1,11 @@ +'use strict'; + +const express = require('express'); +let router = express.Router(); + +/* GET users listing. */ +router.get('/', (req, res, next) => { + res.send('respond with a resource'); +}); + +module.exports = router; diff --git a/lib/util/route-loader.js b/lib/util/route-loader.js new file mode 100644 index 0000000..fa0a2b7 --- /dev/null +++ b/lib/util/route-loader.js @@ -0,0 +1,29 @@ +'use strict'; + +const glob = require('glob'); + +/** + * Map Routes to route files + * + * @param {String} path - folder with Routes + * @return {Object} - Object mapping routes to their files + */ +module.exports = function routeLoader(path) { + + const basePath = path; + + let paths = glob.sync(`${path}/**/*.js`); + paths = paths.sort(); + + let routes = {}; + + paths.forEach((path) => { + let routePath = path.replace(basePath, '') + .replace('.js', '') + .replace('index', ''); + + routes[routePath] = path; + }); + + return routes; +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f5af09 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "author": "Timothy J. Warren", + "dependencies": { + "bluebird": "^3.1.1", + "body-parser": "~1.13.2", + "cookie-parser": "~1.3.5", + "debug": "~2.2.0", + "express": "4.*", + "glob": "^6.0.4", + "helmet": "^1.1.0", + "morgan": "~1.6.1", + "nconf": "^0.8.2", + "node-dev": "^2.7.1", + "winston": "^2.1.1" + }, + "description": "An Opinionated Take on express with use of ES6 features", + "devDependencies": { + "chai": "3.4.*", + "eslint": "1.10.*", + "gulp": "3.9.*", + "gulp-apidoc": "0.2.*", + "gulp-documentation": "2.1.*", + "gulp-eslint": "1.1.*", + "gulp-istanbul": "^0.10.3", + "gulp-jscs": "3.0.*", + "gulp-mocha": "2.2.*", + "gulp-nsp": "^2.3.0", + "gulp-pipe": "1.0.*", + "istanbul": "0.4.*", + "mocha": "2.3.*", + "pre-commit": "^1.1.2", + "supertest": "^1.1.0" + }, + "directories": { + "lib": "./lib" + }, + "engines": { + "node": ">4.0.0" + }, + "files": [ + "lib/", + "server.js" + ], + "license": "MIT", + "main": "./server.js", + "name": "crispy-train", + "pre-commit": { + "silent": false, + "run": [ + "gulp" + ] + }, + "repository": { + "type": "git", + "url": "https://github.com/timw4mail/crispy-train.git" + }, + "scripts": { + "start": "node-dev server.js", + "gulp": "gulp default", + "test": "gulp test" + }, + "version": "0.0.1" +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..813b2cc --- /dev/null +++ b/server.js @@ -0,0 +1,37 @@ +'use strict'; + +const container = require('./lib/Container'); +const app = container.require('./app'), + logger = require('winston'), + HttpServer = container.require('base/HttpServer'); + +/** + * Normalize a port into a number, string, or false. + * + * @private + * @param {mixed} val - port value + * @return {mixed} - normalized value + */ +function normalizePort(val) { + let port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + + +// Get port from environment and store in Express. +let port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +// Create HTTP Server +let server = new HttpServer(app, port); \ No newline at end of file diff --git a/test/end-to-end/index_test.js b/test/end-to-end/index_test.js new file mode 100644 index 0000000..23fd68a --- /dev/null +++ b/test/end-to-end/index_test.js @@ -0,0 +1,51 @@ +'use strict'; + +const supertest = require('supertest'), + expect = require('chai').expect; + +let server = supertest.agent('http://localhost:3000'); + +suite('Example tests', () => { + test('Index page works as expected', (done) => { + server.get('/') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + expect(res.status).to.equal(200); + expect(res.body.error).to.be.undefined; + expect(res.body).to.deep.equal({ + status: 200, + data: { + index: { title: 'Express' }, + }, + }); + done(); + }); + }); + + test('404 page works as expected', (done) => { + server.get('/foo') + .expect('Content-type', /json/) + .expect(404) + .end((err, res) => { + expect(res.status).to.equal(404); + expect(res.body.status).to.equal(res.status); + expect(res.body.message).to.be.ok; + + let expected = { + status: 404, + message: 'Not Found', + }; + + if (process.env.NODE_ENV === 'development') { + expected.error = { + status: 404, + }; + } + + expect(res.body).to.deep.equal(expected); + + done(); + }); + }); +}); \ No newline at end of file diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..6226de3 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,5 @@ +test/**/*.js +--ui tdd +--reporter spec +--slow 1000 +--timeout 5000 \ No newline at end of file diff --git a/test/test-base.js b/test/test-base.js new file mode 100644 index 0000000..8350b19 --- /dev/null +++ b/test/test-base.js @@ -0,0 +1,60 @@ +'use strict'; + +const path = require('path'); + +/** + * Base Object for unit test utilities + */ +const testBase = { + + /** + * Chai expect assertion library + */ + expect: require('chai').expect, + + /** + * Determine the appropriate path to a module relative to the root folder + * + * @param {String} modulePath - the raw path to the module + * @return {String} - the normalized path to the module + */ + _normalizeIncludePath(modulePath) { + const basePath = path.resolve(path.join(__dirname, '../')); + + let includePath = modulePath; + + // Allow referencing local modules without using a ./ + // eg. util/route-loader instead of ./util/route-loader + if (modulePath.includes('/') && ! modulePath.startsWith('./')) { + includePath = path.join(basePath, modulePath); + } + + return includePath; + }, + + /** + * Load a module relative to the root folder + * + * @param {String} modulePath - path to the module, relative to the tests/ folder + * @return {mixed} - whatever the module returns + */ + testRequire(modulePath) { + const includePath = testBase._normalizeIncludePath(modulePath); + return require(includePath); + }, + + /** + * Load a module relative to the root folder, but first delete + * the module from the require cache + * + * @param {String} modulePath - path to the module, relative to the tests/ folder + * @return {mixed} - whatever the module returns + */ + testRequireNoCache(modulePath) { + const includePath = testBase._normalizeIncludePath(modulePath); + delete require.cache[includePath]; + return require(includePath); + }, +}; + +module.exports = testBase; \ No newline at end of file diff --git a/test/unit/.gitkeep b/test/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/base/Container_test.js b/test/unit/base/Container_test.js new file mode 100644 index 0000000..3892de9 --- /dev/null +++ b/test/unit/base/Container_test.js @@ -0,0 +1,77 @@ +'use strict'; + +const testBase = require('../../test-base'); +const expect = testBase.expect; + +suite('Dependency Container tests', () => { + let container = null; + + setup(() => { + // Delete cached version of container class that may have been required + // at an earlier time. The container module has a saved state to keep track + // of modules during normal use. Removing the cached version ensures that + // a new instance of the module is used. + container = testBase.testRequireNoCache('lib/Container'); + }); + + test('Multiple requires return the same instance', () => { + let container2 = testBase.testRequire('lib/Container'); + expect(container2).to.be.equal(container); + }); + + suite('has method', () => { + setup(() => { + container.set('foobar', { + foo: { + bar: [1, 2, 3], + }, + }); + }); + + test('Item "foobar" exists', () => { + expect(container.has('foobar')).to.be.true; + }); + + test('Item "abc" does not exist', () => { + expect(container.has('abc')).to.be.false; + }); + }); + + suite('Get/set functionality', () => { + let obj = { + foo: { + bar: [1, 2, 3], + }, + }; + + test('Set method returns Container', () => { + let actual = container.set('foobar', obj); + expect(actual).to.be.equal(container); + }); + + test('Get method returns set object', () => { + let actual = container.get('foobar'); + expect(actual).to.be.equal(obj); + }); + + test('Attempt to get non-existent item returns null', () => { + expect(container.get('aseiutj')).to.be.null; + }); + }); + + suite('require method', () => { + test('Returns same object as testInclude', () => { + let containerFile = container.require('app'); + let testFile = testBase.testRequire('lib/app'); + + expect(containerFile).to.be.equal(testFile); + }); + + test('Returns same object as native require', () => { + let containerFile = container.require('express'); + let nativeRequire = require('express'); + + expect(containerFile).to.be.equal(nativeRequire); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/util/route-loader_test.js b/test/unit/util/route-loader_test.js new file mode 100644 index 0000000..4e92ad2 --- /dev/null +++ b/test/unit/util/route-loader_test.js @@ -0,0 +1,18 @@ +'use strict'; + +const expect = require('chai').expect, + path = require('path'); +let routeLoader = require(path.join(__dirname, '/../../../lib/util/route-loader')); + +suite('Util - Route Loader', () => { + test('routeLoader creates accurate route mapping', () => { + let actual = routeLoader(path.join(__dirname, 'test-routes')); + let expected = { + '/api/foo/bar': path.join(__dirname, 'test-routes/api/foo/bar.js'), + '/api/foo': path.join(__dirname, 'test-routes/api/foo.js'), + '/': path.join(__dirname, 'test-routes/index.js'), + }; + + expect(expected).to.be.deep.equal(actual); + }); +}); \ No newline at end of file diff --git a/test/unit/util/test-routes/api/foo.js b/test/unit/util/test-routes/api/foo.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/util/test-routes/api/foo.js @@ -0,0 +1,13 @@ +'use strict'; + +const express = require('express'); +let router = express.Router(); + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.json({ + index: { title: 'Express' }, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/test/unit/util/test-routes/api/foo/bar.js b/test/unit/util/test-routes/api/foo/bar.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/util/test-routes/api/foo/bar.js @@ -0,0 +1,13 @@ +'use strict'; + +const express = require('express'); +let router = express.Router(); + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.json({ + index: { title: 'Express' }, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/test/unit/util/test-routes/index.js b/test/unit/util/test-routes/index.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/util/test-routes/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const express = require('express'); +let router = express.Router(); + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.json({ + index: { title: 'Express' }, + }); +}); + +module.exports = router; \ No newline at end of file