commit 2403bdfcee30dde0cc0223d0d1933226c2f790c6 Author: Timothy J Warren Date: Fri Feb 19 12:58:16 2016 -0500 First commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69505fa --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +################################################################################ +# Environment Configuration +################################################################################ + +NODE_ENV=development +HOST=localhost + +# Enable/disable protocols +HTTP=true +HTTPS=false + +# Server ports +HTTP_PORT=8000 +HTTPS_PORT=3443 + +# Certificate paths are relative to the server.js file +HTTPS_CONFIG_KEY=localhost.key +HTTPS_CONFIG_CERT=localhost.crt \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b4b628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Created by https://www.gitignore.io/api/node,osx,webstorm,eclipse + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +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 +.npm + +# 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/docs/* +public/api-docs/* +public/coverage/* + +# Don't commit environment file +.env \ 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/LICENSE b/LICENSE new file mode 100644 index 0000000..9715f58 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Timothy Warren + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..180266e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ProgBlog + +A simple node blog with built-in code snippet highlighting diff --git a/app/Container.js b/app/Container.js new file mode 100644 index 0000000..f1bd158 --- /dev/null +++ b/app/Container.js @@ -0,0 +1,123 @@ +'use strict'; + +const errors = require('errors'); +const express = require('express'); +const path = require('path'); +const autoLoad = require('./config/container-autoload'); + +/** + * Determine the appropriate path to a module relative to the 'app' folder + * + * @private + * @param {string} modulePath - the raw path to the module + * @return {string} - the normalized path to the module + */ +function 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('./') || modulePath.includes(__dirname)) + ) { + includePath = path.join(__dirname, modulePath); + } + + return includePath; +} + +/** + * Container for keeping track of dependencies + */ +class Container { + constructor() { + const app = express(); + let container = new Map(); + + // Save the base app object + container.set('app', app); + + // Preload some configured modules + autoLoad.map((module) => { + let moduleMap = module; + + // Normalize config into [key, value] + if (! Array.isArray(module)) { + moduleMap = [module, module]; + } + + // Actually require the module + moduleMap[1] = require(normalizeIncludePath(moduleMap[1])); + + container.set.apply(container, moduleMap); + }); + + this._container = container; + } + + /** + * 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 this._container.has(name); + } + + /** + * Return an existing object instance + * + * @param {string} name - name of the item + * @return {Object|undefined} - the item, or undefined if it doesn't exist + */ + get(name) { + if (this.has(name)) { + return this._container.get(name); + } + + try { + return this._require(name); + } catch (e) { + return; + } + } + + /** + * 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.set(name, object); + return this; + } + + /** + * Does a native require, relative to the lib folder, + * and returns the value + * + * @private + * @param {string} modulePath - name of the module to require + * @return {*} - the value returned from require + */ + _require(modulePath) { + // If the value is already saved, just return it + if (this.has(modulePath)) { + return this.get(modulePath); + } + + // Save the item for later + let item = require(normalizeIncludePath(modulePath)); + this.set(modulePath, item); + + return item; + } +} + +module.exports = new Container(); diff --git a/app/base/ApiModel.js b/app/base/ApiModel.js new file mode 100644 index 0000000..edc3d16 --- /dev/null +++ b/app/base/ApiModel.js @@ -0,0 +1,104 @@ +'use strict'; + +const container = require('../Container'), + axios = require('axios'); +const Model = container.get('base/Model'); + +/** + * Base class for API Models + * + * Wraps convenience methods for making API requests. + * + * @param {string} [baseUrl] - a base url for api requests + * @extends Model + */ +class ApiModel extends Model { + /** + * Create a new APIModel + * + * @constructor + * @param {string} [baseUrl] - a base url for api requests + */ + constructor(baseUrl) { + super(); + + let apiConfig = {}; + + if (baseUrl) { + apiConfig.baseUrl = baseUrl; + } + + this.client = axios(apiConfig); + } + + /** + * Make a 'HEAD' HTTP request + * + * @param {string} url - the url to request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + head(url, config) { + return this.client.head(url, config); + } + + /** + * Make a 'GET' HTTP request + * + * @param {string} url - the url to request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + get(url, config) { + return this.client.get(url, config); + } + + /** + * Make a 'DELETE' HTTP request + * + * @param {string} url - the url to request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + delete(url, config) { + return this.client.delete(url, config); + } + + /** + * Make a 'POST' HTTP request + * + * @param {string} url - the url to request + * @param {Object} data - the data for the current request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + post(url, data, config) { + return this.client.post(url, data, config); + } + + /** + * Make a 'PUT' HTTP request + * + * @param {string} url - the url to request + * @param {Object} data - the data for the current request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + put(url, data, config) { + return this.client.put(url, data, config); + } + + /** + * Make a 'PATCH' HTTP request + * + * @param {string} url - the url to request + * @param {Object} data - the data for the current request + * @param {Object} [config] - options for the current request + * @return {Promise} - promise wrapping the request + */ + patch(url, data, config) { + return this.client.patch(url, data, config); + } +} + +module.exports = ApiModel; \ No newline at end of file diff --git a/app/base/Config.js b/app/base/Config.js new file mode 100644 index 0000000..7c60e45 --- /dev/null +++ b/app/base/Config.js @@ -0,0 +1,180 @@ +'use strict'; + +const container = require('../Container'); +const path = container.get('path'); + +// Load environment file +require('dotenv').config({ + path: path.resolve(__dirname, '../../.env'), +}); + +const fs = require('fs'); + +const glob = require('glob'); +const configSslPath = path.resolve(__dirname, '../config/ssl'); +const configPath = path.resolve(__dirname, '../config'); + +const defaultConfig = new Map([ + ['host', 'localhost'], + ['http', true], + ['http-port', 80], + ['https', false], + ['https-port', 443], + ['node-env', 'development'], +]); + +const configFiles = glob.sync(`${configPath}/**/*.js`); + +/** + * Config management class + * + * Hierarchy of config options + * 1. Directly defined config options + * 2. Environment variables + * 3. Default values + */ +class Config { + constructor() { + let _configMap = new Map(); + + // Load files in config folder under their + // own respective namespaces + configFiles.forEach((fullPath) => { + let key = path.basename(fullPath, '.js'); + _configMap.set(key, require(fullPath)); + }); + + this._config = _configMap; + } + + /** + * Returns the list of available configuration keys + * + * @return {array} - the list of configuration keys + */ + keys() { + let keys = []; + + for (let key of this._config.keys()) { + keys.push(key); + } + + return keys; + } + + /** + * Determine if a config value exists + * + * @param {string|symbol} key - the config value key + * @return {boolean} - whether the config value exists + */ + has(key) { + return this._config.has(key); + } + + /** + * Determine whether an environment variable is currently defined + * + * @param {string} key - the environment varaible to look for + * @return {boolean} - whether the environment variable is defined + */ + hasEnv(key) { + return process.env[this._envName(key)] != null; + } + + /** + * Retrieve a config value + * + * @param {string|symbol} key - the name of the config value + * @return {*} - the configuration value + */ + get(key) { + if (! this.has(key)) { + // Fallback to environment variables + let envKey = key.toUpperCase().replace('-', '_'); + if (this.hasEnv(envKey)) { + let envValue = this.getEnv(envKey); + this._config.set(key, envValue); + return envValue; + } + + // Fallback to default values + if (defaultConfig.has(key)) { + let defaultValue = defaultConfig.get(key); + this._config.set(key, defaultValue); + return defaultValue; + } + } + + return this._config.get(key); + } + + /** + * Get the value of an environment variable + * + * @param {string} key - the environment variable to get + * @return {string|undefined} - the value of the environment variable + */ + getEnv(key) { + let raw = process.env[this._envName(key)]; + return this._normalizeValue(raw); + } + + /** + * Get equivalent environment variable for config key + * + * @private + * @param {string} key - the config key + * @return {string} - the environment variable name + */ + _envName(key) { + return key.toUpperCase().replace('-', '_'); + } + + /** + * Attempt to parse javascript types from strings + * + * @private + * @param {string} val - the string value + * @return {string|number|boolean} - the 'parsed' value + */ + _normalizeValue(val) { + let bool = /^true|false$/; + let integer = /^([0-9]+)$/; + + if (String(val).search(integer) !== -1) { + return parseInt(val, 10); + } + + if (String(val).search(bool) !== -1) { + switch (val) { + case 'true': + return true; + + // return overrides break + + case 'false': + return false; + + // return overrides break + } + } + + return val; + } + + /** + * Set a config variable (mainly for testing) + * + * @private + * @param {mixed} key - the key to set + * @param {mixed} val - the value for the key + * @return {Config} - the config instance + */ + _set(key, val) { + this._config.set(key, val); + return this; + } +} + +module.exports = new Config(); diff --git a/app/base/Controller.js b/app/base/Controller.js new file mode 100644 index 0000000..e4abf7f --- /dev/null +++ b/app/base/Controller.js @@ -0,0 +1,74 @@ +'use strict'; + +const container = require('../Container'); +const express = require('express'); +const errors = require('errors'); +const getArgs = require('getargs'); +const loadRoutes = container.get('base/util/route-loader'); +const _ = container.get('_'); + +/** + * Controller setup and utility class + */ +class Controller { + + /** + * Get route mapping + * + * @param {string} baseRoutePath - the path to the folder containing + * controllers/routes + * @return {map} - Maps route prefixes to their respective functions + */ + static getRouteMap(baseRoutePath) { + const routeMap = {}; + const pathFileMap = loadRoutes(baseRoutePath); + + _(pathFileMap).forEach((routeFile, routePath) => { + let rawRoutes = require(routeFile); + routeMap[routePath] = Controller._parseRoutes(rawRoutes); + }); + + return routeMap; + } + + /** + * Simplify route generation with an object + * + * @private + * @param {Object} routeObject - the object laying out the routes for the + * current controller + * @return {Router} - the updated express.Router object + */ + static _parseRoutes(routeObject) { + const router = express.Router(); + + _(routeObject).forEach((httpMethods, currentPath) => { + _(httpMethods).forEach((routeFunction, currentHttpMethod) => { + let routerMethod = router[currentHttpMethod]; + return router[currentHttpMethod](currentPath, routeFunction); + }); + }); + + return router; + } + + /** + * Send a specific http error code + * + * @param {number} code - The error Number + * @param {Object} [options={}] - Options, such as message and explanation + * @param {function} next - The callback to pass the error to + * @return {void} + */ + static HttpError(/*code, options, next*/) { + let args = getArgs('code:Number, [options]:object, next:function', arguments); + args.options = args.options || {}; + + let methodName = `Http${args.code}Error`; + let err = new errors[methodName](args.options); + + return args.next(err); + } +} + +module.exports = Controller; \ No newline at end of file diff --git a/app/base/HttpServer.js b/app/base/HttpServer.js new file mode 100644 index 0000000..e3ceafe --- /dev/null +++ b/app/base/HttpServer.js @@ -0,0 +1,33 @@ +'use strict'; + +const http = require('http'), + logger = require('winston'); + +const Server = require('./Server'); + +/** + * Class for creating an http server + * + * @extends Server + * @param {Express} app - current express instance + * @param {number} port - the port to listen on + */ +class HttpServer extends Server { + constructor(app, port) { + super(); + 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; + } +} + +module.exports = HttpServer; \ No newline at end of file diff --git a/app/base/HttpsServer.js b/app/base/HttpsServer.js new file mode 100644 index 0000000..db73f92 --- /dev/null +++ b/app/base/HttpsServer.js @@ -0,0 +1,34 @@ +'use strict'; + +const https = require('https'), + logger = require('winston'); + +const Server = require('./Server'); + +/** + * Class for creating an https server + * + * @extends Server + * @param {Express} app - current express instance + * @param {number} port - the port to listen on + * @param {Object} options - https server options + */ +class HttpsServer extends Server { + constructor(app, port, options) { + super(); + let server = https.createServer(options, 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; + } +} + +module.exports = HttpsServer; \ No newline at end of file diff --git a/app/base/Model.js b/app/base/Model.js new file mode 100644 index 0000000..ec3e504 --- /dev/null +++ b/app/base/Model.js @@ -0,0 +1,10 @@ +'use strict'; + +/** + * Base Model class + */ +class Model { + +} + +module.exports = Model; \ No newline at end of file diff --git a/app/base/Server.js b/app/base/Server.js new file mode 100644 index 0000000..83ff440 --- /dev/null +++ b/app/base/Server.js @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Base Class for wrapping HTTP/HTTPS servers + */ +class Server { + constructor() {} + /** + * Event listener for HTTP(s) 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 = Server; \ No newline at end of file diff --git a/app/base/util/route-loader.js b/app/base/util/route-loader.js new file mode 100644 index 0000000..7e0dcbc --- /dev/null +++ b/app/base/util/route-loader.js @@ -0,0 +1,30 @@ +'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.replace(/\\/g, '/'); + + let paths = glob.sync(`${path}/**/*.js`); + paths = paths.sort(); + paths = paths.map((path) => path.replace('\\', '/')); + + 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/app/bootstrap.js b/app/bootstrap.js new file mode 100644 index 0000000..3004e99 --- /dev/null +++ b/app/bootstrap.js @@ -0,0 +1,31 @@ +'use strict'; + +const container = require('./Container'); +const _ = container.get('_'); +const app = container.get('app'); +const path = container.get('path'); +const Controller = container.get('base/Controller'); +const Config = container.get('base/Config'); + +module.exports = (function () { + const baseRoutePath = path.join(__dirname, 'controllers'); + + // Set up templating + const view = Config.get('view-engine'); + view.setup(app); + + // load middleware + Config.get('middleware').forEach((mw) => app.use(mw)); + + // automatically set up routing by folder structure + let routeMap = Controller.getRouteMap(baseRoutePath); + _(routeMap).forEach((routeFunction, routePrefix) => { + app.use(routePrefix, routeFunction); + }); + + // load error handlers + Config.get('error-handlers').forEach((handler) => app.use(handler)); + + return app; + +}()); \ No newline at end of file diff --git a/app/config/container-autoload.js b/app/config/container-autoload.js new file mode 100644 index 0000000..33bc1d2 --- /dev/null +++ b/app/config/container-autoload.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * A list of modules to insert into the Container at start up + * + * Modules to be loaded with an alias should have an array with the alias + * then the module name. Otherwise, just a string with the module name + * + * @type {array} + */ +module.exports = [ + ['_', 'lodash'], + 'path', + ['promisify', 'helpers/promisify'], +]; \ No newline at end of file diff --git a/app/config/error-handlers.js b/app/config/error-handlers.js new file mode 100644 index 0000000..3561bc1 --- /dev/null +++ b/app/config/error-handlers.js @@ -0,0 +1,43 @@ +'use strict'; + +// ----------------------------------------------------------------------------- +// Error handlers +// ----------------------------------------------------------------------------- + +const container = require('../Container'); +const app = container.get('app'); +const HTTP_CODE_MAP = require('http').STATUS_CODES; +const errors = require('errors'); + +let errorHandlers = new Set([ + + function handle404(req, res, next) { + // if no route matches, send a 404 + if (! req.route) { + let err = new errors.Http404Error(); + return next(err); + } + }, + + // general error handler + function handleError(err, req, res, next) { + let httpStatus = err.status || 500; + let message = err.message || HTTP_CODE_MAP[httpStatus]; + + res.status(httpStatus); + + let output = { + status: httpStatus, + message: 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/app/config/middleware.js b/app/config/middleware.js new file mode 100644 index 0000000..776b45a --- /dev/null +++ b/app/config/middleware.js @@ -0,0 +1,40 @@ +'use strict'; + +// ----------------------------------------------------------------------------- +// Middleware +// ----------------------------------------------------------------------------- + +const bodyParser = require('body-parser'); +const cookieParser = require('cookie-parser'); +const express = require('express'); +const helmet = require('helmet'); +const requestLogger = require('morgan'); +const path = require('path'); +const session = require('express-session'); + +let middleware = new Set([ + + // some security settings controlled by helmet + helmet.frameguard(), + helmet.hidePoweredBy(), + helmet.ieNoOpen(), + helmet.noSniff(), + helmet.xssFilter(), + + // session setup + session({ + name: 'blogid', + resave: false, + saveUninitialized: false, + secret: '76al;p9vse', + }), + + // 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/app/config/view-engine.js b/app/config/view-engine.js new file mode 100644 index 0000000..628e023 --- /dev/null +++ b/app/config/view-engine.js @@ -0,0 +1,15 @@ +'use strict'; + +// Stupid template engine requires coffescript for some reason +const cs = require('coffee-script'); +cs.register(); + +const path = require('path'); +const hulk = require('hulk-hogan'); + +module.exports.setup = (app) => { + let viewPath = path.resolve(__dirname, '../views'); + app.set('views', viewPath); + app.set('view options', { layout: false }); + app.set('view engine', 'hulk'); +}; \ No newline at end of file diff --git a/app/controllers/index.js b/app/controllers/index.js new file mode 100644 index 0000000..1c1ba9b --- /dev/null +++ b/app/controllers/index.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + '/': { + + // Get homepage + get: (req, res) => { + return res.json({ + status: 200, + data: { + index: { title: 'Express' }, + }, + }); + }, + + put: (req, res, next) => { + return next(); + }, + }, +}; \ No newline at end of file diff --git a/app/helpers/promisify.js b/app/helpers/promisify.js new file mode 100644 index 0000000..b749eb9 --- /dev/null +++ b/app/helpers/promisify.js @@ -0,0 +1,26 @@ +'use strict'; + +/*eslint-disable prefer-arrow-callback*/ +/** + * Function to convert a callback function into a promise + * + * @see http://eddmann.com/posts/promisifying-error-first-asynchronous-callbacks-in-javascript/ + * @example promisify(fs.readFile)('hello.txt', 'utf8') + * .then(console.log) + * .catch(console.error) + * @param {Function} fn - the callback function to convert + * @return {Promise} - the new promise + */ +function promisify(fn) { + return function () { + let args = [].slice.call(arguments); + return new Promise(function (resolve, reject) { + fn.apply(undefined, args.concat((error, value) => { + return error ? reject(error) : resolve(value); + })); + }); + }; +} + +module.exports = promisify; +/*eslint-enable prefer-arrow-callback*/ diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..2eaf9d9 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,245 @@ +'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 = [ + 'app/base/**/*.js', + 'app/config/**/*.js', + 'app/controllers/**/*.js', + 'app/helpers/**/*.js', + 'app/models/**/*.js', + 'app/*.js', +]; + +/* + * Path to unit test files + */ +const UNIT_TEST_FILES = ['test/unit/**/*_test.js']; + +/* + * Path to integration test files + */ +const INTEGRATION_TEST_FILES = ['test/integration/**/*_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 + 'no-case-declarations': [2], // Don't define variables in switch labels + 'no-const-assign': [2], // Highlight instances where assigning to const declaration + 'no-new-symbol': [2], // Symbol is not a constructor, don't use the new keyword + 'no-unused-labels': [2], // Error on labels in code that aren't used + }, +}; + +/* + * Configuration values for mocha + */ +const MOCHA_SETTINGS = { + ui: 'tdd', + bail: true, + slow: 1000, + timeout: 5000, +}; + +/* + * 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: 'app/', + dest: 'public/api-docs/', + }, done); +}); + +/* + * Run all documentation generation tasks + */ +gulp.task('docs', ['src-docs', 'api-docs']); + +/* + * Run integration tests + */ +gulp.task('integration-test', ['lint'], () => { + return gulp.src(INTEGRATION_TEST_FILES) + .pipe(mocha(MOCHA_SETTINGS)); +}); + +/* + * Run unit tests + */ +gulp.task('unit-test', ['lint'], () => { + return gulp.src(UNIT_TEST_FILES) + .pipe(mocha(MOCHA_SETTINGS)); +}); + +/* + * Run all tests + */ +gulp.task('test', ['lint'], () => { + return gulp.src(TEST_FILES) + .pipe(mocha(MOCHA_SETTINGS)); +}); + +/* + * 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 integration tests + */ +gulp.task('coverage', ['lint', 'pre-coverage'], () => { + return pipe(gulp.src(UNIT_TEST_FILES), [ + mocha(MOCHA_SETTINGS), + 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/package.json b/package.json new file mode 100644 index 0000000..59c282f --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "author": "Timothy J. Warren", + "dependencies": { + "axios": "^0.9.1", + "body-parser": "~1.13.2", + "ci-node-query": "^3.1.0", + "coffee-script": "^1.10.0", + "cookie-parser": "~1.3.5", + "debug": "~2.2.0", + "dotenv": "^2.0.0", + "errors": "^0.3.0", + "eslint": "^1.10.3", + "express": "4.*", + "express-session": "^1.13.0", + "getargs": "0.0.8", + "glob": "^6.0.4", + "helmet": "^1.1.0", + "highlight.js": "^9.1.0", + "hulk-hogan": "0.0.9", + "lodash": "^4.5.0", + "marked": "^0.3.5", + "morgan": "~1.6.1", + "nodemon": "^1.9.0", + "winston": "^2.1.1" + }, + "description": "An Opinionated Take on express with use of ES6 features", + "devDependencies": { + "chai": "^3.4.1", + "chai-as-promised": "^5.2.0", + "eslint": "1.10.*", + "gulp": "3.9.*", + "gulp-apidoc": "0.2.*", + "gulp-documentation": "2.1.*", + "gulp-eslint": "^2.0.0", + "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": "nodemon server.js", + "gulp": "gulp default", + "test": "gulp test" + }, + "version": "0.0.1" +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server.js b/server.js new file mode 100644 index 0000000..078970a --- /dev/null +++ b/server.js @@ -0,0 +1,51 @@ +'use strict'; + +const fs = require('fs'); +const container = require('./app/Container'); +const app = container.get('./bootstrap'); +const config = container.get('base/Config'); +const HttpServer = container.get('base/HttpServer'); +const HttpsServer = container.get('base/HttpsServer'); + +/** + * 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; +} + +// Create HTTP Server +if (true === config.get('http')) { + let port = normalizePort(config.get('http-port')); + app.set('port', port); + container.set('http-server', new HttpServer(app, port)); +} + +// Create HTTPs Server +if (true === config.get('https')) { + const httpsPort = normalizePort(config.get('https-port')); + const httpsConfig = { + key: fs.readFileSync(config.get('https-config-key')), + cert: fs.readFileSync(config.get('https-config-cert')), + }; + app.set('https-port', httpsPort); + container.set('https-server', new HttpsServer(app, httpsPort, httpsConfig)); +} + +// End of server.js \ No newline at end of file diff --git a/test/integration/index_test.js b/test/integration/index_test.js new file mode 100644 index 0000000..5699cd0 --- /dev/null +++ b/test/integration/index_test.js @@ -0,0 +1,53 @@ +'use strict'; + +const testBase = require('../test-base'); +const expect = testBase.expect; +const supertest = require('supertest'); + +let server = supertest.agent('http://localhost:3000'); + +suite('Example integration 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' }, + }, + }); + return done(err); + }); + }); + test('404 page works as expected', (done) => { + server.get('/foo') + .expect('Content-type', /json/) + .end((err, res) => { + expect(res.status).to.equal(404); + expect(res.body.status).to.equal(String(res.status)); + expect(res.body.message).to.be.ok; + + let expected = { + status: '404', + message: 'Not Found', + }; + + if (process.env.NODE_ENV === 'development') { + expected.error = { + code: '404', + status: '404', + name: 'Http404Error', + message: 'Not Found', + }; + } + + expect(res.body).to.deep.equal(expected); + + return done(err); + }); + }); +}); \ 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..f1de27b --- /dev/null +++ b/test/test-base.js @@ -0,0 +1,71 @@ +'use strict'; + +const path = require('path'); + +// Set up chai as promised to allow for +// better testing of promises +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +// Load environment file +require('dotenv').config({ + path: path.resolve(__dirname, '../.env'), +}); + +/** + * Base Object for unit test utilities + */ +const testBase = { + + /** + * Chai expect assertion library + */ + expect: 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 + */ + require(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 + */ + requireNoCache(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/Config_test.js b/test/unit/base/Config_test.js new file mode 100644 index 0000000..80adb55 --- /dev/null +++ b/test/unit/base/Config_test.js @@ -0,0 +1,109 @@ +'use strict'; + +const testBase = require('../../test-base'); +const expect = testBase.expect; +const glob = require('glob'); +const path = require('path'); + +suite('Config class tests', () => { + let config = null; + setup(() => { + config = testBase.require('app/base/Config'); + }); + + test('Get a non-existent config value', () => { + let actual = config.get('foo'); + expect(actual).to.be.undefined; + }); + + suite('Value cascade', () => { + + suite('Explicit values are loaded first', () => { + setup(() => { + config._set('foo', 'bar'); + }); + + test(`'foo' exists in config`, () => { + expect(config.has('foo')).to.be.true; + }); + test(`value of 'foo' is returned by get method`, () => { + expect(config.get('foo')).to.be.equal('bar'); + }); + }); + + suite('Environment values are loaded before defaults', () => { + setup(() => { + process.env.BAR = 'baz'; + }); + + test(`'bar' is not directly defined in config`, () => { + expect(config.has('bar')).to.be.false; + }); + test(`'bar' is defined in environment`, () => { + expect(config.hasEnv('bar')).to.be.true; + expect(config.getEnv('bar')).to.be.equal(process.env.BAR); + }); + test(`Gets 'bar' from environment`, () => { + expect(config.get('bar')).to.be.equal(config.getEnv('bar')); + }); + test(`'foo' is now cached in config`, () => { + expect(config.has('bar')).to.be.true; + }); + + teardown(() => { + delete process.env.BAR; + }); + }); + + suite('Default values are loaded as a last resort', () => { + setup(() => { + delete process.env.HOST; + }); + + test(`'host' is not directly defined in config`, () => { + expect(config.has('host')).to.be.false; + }); + test(`'host' is not defined in environment`, () => { + expect(config.hasEnv('host')).to.be.false; + }); + test(`Get default value of 'host'`, () => { + expect(config.get('host')).to.equal('localhost'); + }); + test(`'host' is now cached in config`, () => { + expect(config.has('host')).to.be.true; + }); + }); + }); + + suite('normalizeValue', () => { + test(`'true' returns true`, () => { + let val = config._normalizeValue('true'); + expect(val).to.be.true; + }); + test(`'false' returns false`, () => { + let val = config._normalizeValue('false'); + expect(val).to.be.false; + }); + test(`'436' returns 436`, () => { + let val = config._normalizeValue('436'); + expect(val).to.be.equal(436); + }); + test(`'foo' returns 'foo'`, () => { + let val = config._normalizeValue('foo'); + expect(val).to.be.equal('foo'); + }); + }); + + test('Keys returns values in config', () => { + // Get the list of keys for files + let configFiles = glob.sync( + path.resolve(__dirname, '../../../app/config/**/*.js') + ); + let expected = configFiles.map((fullPath) => { + return path.basename(fullPath, '.js'); + }); + let actual = config.keys(); + + expect(actual).to.be.deep.equal(expected); + }); +}); \ No newline at end of file diff --git a/test/unit/base/Container_test.js b/test/unit/base/Container_test.js new file mode 100644 index 0000000..2d8b0d9 --- /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.requireNoCache('app/Container'); + }); + + test('Multiple requires return the same instance', () => { + let container2 = testBase.require('app/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 undefined', () => { + expect(container.get('aseiutj')).to.be.undefined; + }); + }); + + suite('get method require', () => { + test('Returns same object as testInclude', () => { + let containerFile = container.get('./bootstrap'); + let testFile = testBase.require('app/bootstrap'); + + expect(containerFile).to.be.equal(testFile); + }); + + test('Returns same object as native require', () => { + let containerFile = container.get('express'); + let nativeRequire = require('express'); + + expect(containerFile).to.be.equal(nativeRequire); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/base/Controller_test.js b/test/unit/base/Controller_test.js new file mode 100644 index 0000000..42c517e --- /dev/null +++ b/test/unit/base/Controller_test.js @@ -0,0 +1,28 @@ +'use strict'; + +const testBase = require('../../test-base'); +const errors = require('errors'); +const expect = testBase.expect; + +suite('Controller tests', () => { + let controller = null; + + setup(() => { + controller = testBase.requireNoCache('app/base/Controller'); + }); + + suite('HttpError Tests', () => { + test('500 Error', (done) => { + controller.HttpError(500, (err) => { + expect(err).to.deep.equal(new errors.Http500Error()); + return done(); + }); + }); + test('401 Error', (done) => { + controller.HttpError(401, (err) => { + expect(err).to.deep.equal(new errors.Http401Error()); + return done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/base/util/route-loader_test.js b/test/unit/base/util/route-loader_test.js new file mode 100644 index 0000000..7e01482 --- /dev/null +++ b/test/unit/base/util/route-loader_test.js @@ -0,0 +1,25 @@ +'use strict'; + +const path = require('path'); +const testBase = require('../../../test-base'); +const expect = testBase.expect; + +let routeLoader = testBase.require('app/base/util/route-loader'); + +function getPath(filePath) { + return path.join(__dirname, filePath) + .replace(/\\/g, '/'); +} + +suite('Util - Route Loader', () => { + test('routeLoader creates accurate route mapping', () => { + let actual = routeLoader(path.join(__dirname, 'test-routes')); + let expected = { + '/api/foo/bar': getPath('test-routes/api/foo/bar.js'), + '/api/foo': getPath('test-routes/api/foo.js'), + '/': getPath('test-routes/index.js'), + }; + + expect(expected).to.be.deep.equal(actual); + }); +}); \ No newline at end of file diff --git a/test/unit/base/util/test-routes/api/foo.js b/test/unit/base/util/test-routes/api/foo.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/base/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/base/util/test-routes/api/foo/bar.js b/test/unit/base/util/test-routes/api/foo/bar.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/base/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/base/util/test-routes/index.js b/test/unit/base/util/test-routes/index.js new file mode 100644 index 0000000..6ee0f18 --- /dev/null +++ b/test/unit/base/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 diff --git a/test/unit/helpers/promisify_test.js b/test/unit/helpers/promisify_test.js new file mode 100644 index 0000000..05fc28e --- /dev/null +++ b/test/unit/helpers/promisify_test.js @@ -0,0 +1,21 @@ +'use strict'; + +const testBase = require('../../test-base'); +const expect = testBase.expect; +const fs = require('fs'); +const promisify = testBase.require('app/helpers/promisify'); + +suite('Promisify', () => { + test('Promisify returns a promise', () => { + let actual = promisify(fs.readFile)('../../test-base.js'); + expect(actual).to.be.a('Promise'); + }); + test('Promisify fs.readFile resolves', () => { + let actual = promisify(fs.readFile)('../../test-base.js'); + expect(actual).to.be.fulfilled; + }); + test('Promisify fs.readFile fails on non-existent file', () => { + let actual = promisify(fs.readFile)('foo.txt'); + expect(actual).to.be.rejected; + }); +}); \ No newline at end of file