I have built an ExpressJS server application over the course of a couple of years for internal use at work. If you're interested, it is a clinical decision support tool used in many hospitals. It has grown to be quite large at this point and is not so easily changed.
Anyway, I grew up programming before test suites were really a thing, but I know I should now use them. I have settled on using Jest. I found out that whereas I had my server definition and app entry point in the same file, I needed to break them apart so I can import just the server stuff in tests. I have done a bit of refactoring so that instead of just an index.js file, I now have both index.js and server.js files. See below code.
The problem I am having, however, is that my server file has a ton of imports which add functionality to the server, mostly middleware, but also a reference to a file called appDB.js which loads a bunch of Sequelize stuff (using Tedious (MSSQL)). Of course, when I run the few tests I have built so far the tests pass but I get errors stating that imports (of Tedious) were attempted after Jest environment was torn down. I then get errors related to Sequelize not working (since it couldn't import what it needed).
In server.js, I import the appDB.js file (which has the Sequelize references) because a little further down in server.js, I call seq.sync to create (if they don't exist) the main tables of the application. And further down, I include the session table definition since middleware needs it. I also load a copy of the permissions hierarchy (AbilityGroups.findAll) from the database, if necessary.
Any ideas for refactoring this so Jest will not throw errors? I suppose once there aren't errors, I should 'mock' some DB functionality unless directly testing my DB, right? All of the examples for using Jest with a DB that I have found have been inadequate because a) they used MongoDB (which has a helper module for Jest) directly and not Sequelize, and b) nobody was using middleware which needed info from the database.
Here are my files: server.js (defines the server)-
import 'babel-polyfill';
import http from 'http';
import https from 'https';
import fs from 'fs';
import express from 'express';
import session from 'express-session';
import cors from 'cors';
import path from 'path';
import morgan from 'morgan';
import helmet from 'helmet';
import nocache from 'nocache';
import passport from 'passport';
import flash from 'connect-flash';
import cookieParser from 'cookie-parser';
import routes from './routes';
// require('./sequelize-fixes');
import {Abilities, AbilityGroups, seq, Session} from './appDB'; // This only contains the admin tables, not the HL7 stuff
import winston from './logging/winstonConfig';
// import Agenda from 'agenda'; // TODO uncomment
// import Agendash from 'agendash'; // TODO uncomment
let cacheProvider = require('./cache-provider')
/*
Colors to use in console.log
Reset = "\x1b[0m"
Bright = "\x1b[1m"
Dim = "\x1b[2m"
Underscore = "\x1b[4m"
Blink = "\x1b[5m"
Reverse = "\x1b[7m"
Hidden = "\x1b[8m"
FgBlack = "\x1b[30m"
FgRed = "\x1b[31m"
FgGreen = "\x1b[32m"
FgYellow = "\x1b[33m"
FgBlue = "\x1b[34m"
FgMagenta = "\x1b[35m"
FgCyan = "\x1b[36m"
FgWhite = "\x1b[37m"
BgBlack = "\x1b[40m"
BgRed = "\x1b[41m"
BgGreen = "\x1b[42m"
BgYellow = "\x1b[43m"
BgBlue = "\x1b[44m"
BgMagenta = "\x1b[45m"
BgCyan = "\x1b[46m"
BgWhite = "\x1b[47m"
*/
// @TODO remove the "force: true" or set it to false for production as this DROPs all tables each time we run the app
/**
* Do this here so we can sync the WinstonSequelize model too
*/
seq.sync({
force: false
})// TODO use .then() to log a Winston entry; can't log to DB until DB is ready
.catch(function(err) {
throw new Error(err);
});
import { extDef } from './model/session';
let debug = require('debug')('sql-rest-api:server');
require('./authN/passportConfig')(passport);
let SequelizeStore = require('connect-session-sequelize')(session.Store);
let app = express();
// const agendaConnectionOpts = {db: {address: 'localhost:27017/agenda', collection: 'agendaJobs', options: { useNewUrlParser: true }}}; // TODO uncomment
// const agenda = new Agenda(agendaConnectionOpts); // TODO uncomment
let corsOptions = {
origin: true, // 'https://' + process.env.ORIGIN_DOMAIN, // TODO change this to whatever we use for the live site
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
exposedHeaders: 'Content-Disposition',
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
credentials: true // required to send cookies from browsers; didn't need this for postman
};
// view engine setup
// app.set('views', path.join(__dirname, 'views'));
// app.set('view engine', 'pug');
/**
* Normalize a port into a number, string, or false.
* @param {string} val - The port, input as a string to be parsed as an integer.
* @return Return the original val if no integer can be parsed; return integer port number if val was parsable; otherwise return false.
*/
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');//== process.env.PORT is set by iisnode
app.set('port', port);
let whichServer = '';
/**
* Create HTTPS server.
*/
if (process.env.NODE_ENV !== 'production') {
whichServer = 'HTTPS';
const options = {
key: fs.readFileSync('./src/tls/sutternow.local.key.pem'),
cert: fs.readFileSync('./src/tls/sutternow.local.crt.pem')
};
app.server = https.createServer(options, app);
} else {
whichServer = 'HTTP';
app.server = http.createServer(app);
}
/**
* Setup morgan format
*/
morgan.token('id', function (req) {
return req.sessionID
});
morgan.token('statusMessage', function (req, res) {
if (typeof req.flash === 'function' && typeof req.flash('error') !== 'undefined' && req.flash('error').length !== 0) {
return req.flash('error').slice(-1)[0];
} else {
return res.statusMessage;
}
});
// This format is for Morgan (HTTP) messages only; see winstonConfig for other messages' format
// let morganFormat = ':status :statusMessage - SessionID\: :id :remote-addr ":method :url HTTP/:http-version" ":referrer" ":user-agent" ":res[content-length]"';
let morganFormat2 = new String('"status"\:":status","statusMessage"\:":statusMessage","sessionID"\:":id","remote"\:":remote-addr","method"\:":method","url"\:":url","http"\:":http-version","referrer"\:":referrer","agent"\:":user-agent","length"\:":res[content-length]"');
// let morganFormat2 = new String(':id :status :statusMessage :remote-addr :method :url :http-version :referrer :user-agent :res[content-length]');
app.use(express.static(path.join(__dirname, 'public')));
app.use(morgan(morganFormat2, { stream: winston.stream }));
app.use(express.json()); // https://expressjs.com/en/api.html for other settings including body size limit
app.use(express.urlencoded({ extended: true }));
// Middleware
app.use(helmet()); // Add Helmet as a middleware
app.use(nocache());
app.use(cors(corsOptions));
app.use(cookieParser());
//=========================================================================
//---------------------------BEGIN PASSPORT SETUP--------------------------
//=========================================================================
// see the express-session and connect-session-sequelize docs for parameter descriptions
let sessParams = {
secret: process.env.SESSION_SECRET || 'secret',
resave: false,
saveUninitialized: false,// not sure if this should be true
cookie: {
domain: process.env.ORIGIN_DOMAIN,
sameSite: 'strict',
httpOnly: false, // if this isn't set to false, we can't read it via javascript on the client
maxAge: 1000*60*20, // 20 minutes
path: '/' // / is the root path of the domain
},
store: new SequelizeStore({
db: seq,
table: 'Session',
extendDefaultFields: extDef,
checkExpirationInterval: 0
}),
rolling: true, // this allows maxAge to be reset with every request, so it moves with activity
unset: 'keep' // this is supposed to keep rows in the DB
};
if (app.get('env') !== 'production') { // TODO check that we only want this in dev
// app.set('trust proxy', 1) // trust first proxy; set this if behind a proxy
sessParams.cookie.secure = true // serve secure cookies
}
app.use(session(sessParams));
app.use(passport.initialize());
app.use(passport.session()); // persistent login sessions
app.use(flash()); // use connect-flash for flash messages stored in session
//=========================================================================
//---------------------------END PASSPORT SETUP----------------------------
//=========================================================================
// API routes V1
// app.use('/dash', ensureAuthenticated, Agendash(agenda)); // TODO use this line instead of the next one
// app.use('/dash', Agendash(agenda)); // TODO uncomment for test
let virtualDir = ''
if (app.get('env') === 'production') {
virtualDir = '/server'
}
app.use(virtualDir + '/v1', routes);
/**
* error handler
* error handling must be placed after all other app.use calls; https://expressjs.com/en/guide/error-handling.html
*/
app.use(function(err, req, res, next) {
let route = '';
console.log('Called second', err.message); //see catchall for what is called first
console.log('Called status', err.status);
console.log('Called statusCode', err.statusCode);
// won't catch 401 Unauthorized errors. See authenticate.js for 401s
if (!err.statusCode) err.statusCode = 500; // Sets a generic server error status code if none is part of the err
// let data = JSON.parse(req.user.dataValues.data);
// ${data.flash.error[0]}
if (typeof err.route !== 'undefined') {
route = err.route
} else {
route = 'Generic'
}
if (typeof req.user !== 'undefined' && req.user !== null) {
if (typeof req.user.dataValues === 'undefined') {
winston.error(`${err.statusCode || 500} ${err.message} - SessionID: ${req.sessionID} (${req.user.displayName})\" ${req.ip} \"${req.method} ${req.originalUrl} HTTP/${req.httpVersion}\" \"${req.headers['referer']}\" \"${req.headers['user-agent']}\" \"${req.headers['content-length']}\"`,
{username: req.user.sAMAccountName, sessionID: req.sessionID, ip: req.ip, referrer: req.headers['referer'], url: req.originalUrl, query: req.method, route: route});
} else {
winston.error(`${err.statusCode || 500} ${err.message} - SessionID: ${req.user.dataValues.sid} (${req.user.dataValues.username})\" ${req.ip} \"${req.method} ${req.originalUrl} HTTP/${req.httpVersion}\" \"${req.headers['referer']}\" \"${req.headers['user-agent']}\" \"${req.headers['content-length']}\"`,
{username: req.user.dataValues.username, sessionID: req.user.dataValues.sid, ip: req.ip, referrer: req.headers['referer'], url: req.originalUrl, query: req.method, route: route});
}
} else {
winston.error(`${err.statusCode || 500} ${err.message} - \" ${req.ip} \"${req.method} ${req.originalUrl} HTTP/${req.httpVersion}\" \"${req.headers['referer']}\" \"${req.headers['user-agent']}\" \"${req.headers['content-length']}\"`,
{sessionID: req.sessionID, ip: req.ip, referrer: req.headers['referer'], url: req.originalUrl, query: req.method, route: route});
}
// console.log('errorrrr', err.message);
// console.log('reqqqq', req);
if (err.shouldRedirect || err.statusCode === 500) {
return res.status(err.statusCode).json({"Message": "Login failed. " + err.message});
// res.status(err.statusCode).send({"customMessage": err.message});
// return res.json({"Message": "Login failed. " + err.message});
// res.send('error', { error: err }) // Renders a error.html for the user
} else {
//The below message can be found in the catch's error.response.data item
return res.status(err.statusCode).json({"Message": "Login failed. " + err.message}); // If shouldRedirect is not defined in our error, sends our original err data
// res.status(err.statusCode).send({"customMessage": err.message});
// return res.json({"Message": "Login failed. " + err.message});
}
});
// production error handler
/*const HTTP_SERVER_ERROR = 500;
app.use(function(err, req, res, next) {
if (res.headersSent) {
console.log('headers already sent');
return next(err);
}
console.log('handling error');
return res.status(err.status || HTTP_SERVER_ERROR).render('500');
});*/
cacheProvider.start(function (err) {
if (err) console.error(err)
})
/**
* Since we need the permissions set (CASL abilities) for every instance of this server app, let's load it immmediately
* and put it in our cache
*/
let memCache = cacheProvider.instance()
let cachedAbilities = memCache.get('displayAbilities');
if (cachedAbilities === undefined) {
AbilityGroups.findAll({include: Abilities}).then(abilities => {
let newAbilitiesArray = []
abilities.forEach((element) => {
element.abilities.forEach((el) => {
el.id = newAbilitiesArray.length + 1
if (typeof el.inverted === 'undefined') {
el.inverted = false
}
newAbilitiesArray.push(el)
})
})
memCache.set('displayAbilities', newAbilitiesArray);
console.log('\x1b[32m%s\x1b[0m', 'set displayAbilities');
cachedAbilities = memCache.get('displayAbilities');
// console.log('get displayAbilities', cachedAbilities);
winston.info(`\"Fetched all abilities (display) for SYSTEM\" - SessionID: NONE\"`,
{
username: 'system',
sessionID: 'none',
ip: 'localhost',
referrer: 'startup',
url: 'none',
query: 'N/A',
route: 'Server Startup'
});
}).catch(err => {
if (err) {
// Not sure how to get an error here. ensureAuthenticated handles invalid users attempting this GET.
// console.log(err);
err.route = 'Main index';
err.statusCode = 'GET_DISPLAY_ABILITIES_ERROR';
err.status = 'GET DISPLAY ABILITIES ERROR';
if (app.get('env') === 'production') {
console.log('stack redacted');
err.stack = '';// We want to obscure any data the user shouldn't see.
}
// next(err);
}
});
}
export {
app,
port,
whichServer,
normalizePort
// agenda,
};
index.js (My program entry file)-
import { app, port, whichServer } from './server';
let myApp = app;
/**
* Listen on provided port, on all network interfaces.
*/
myApp.server.listen(port);
myApp.server.on('error', onError);
myApp.server.on('listening', onListening);
process.on('unhandledRejection', onUnhandledRejection);
process.on('uncaughtException', onUncaughtException);
// async function graceful() {
// await agenda.stop(); // TODO for agenda, this whole block must be moved to another file/module and imported. it will throw errors at runtime in prod, but will work in dev.
// }
// process.on('SIGINT', graceful);
function onUnhandledRejection(error) {
// Will print "unhandledRejection err is not defined"
console.log('unhandledRejection', error.message);
}
function onUncaughtException(error) {
console.log('unhandledException', error.message);
}
/**
* Event listener for HTTP server "error" event.
* @param {Error} error - An error object.
*/
function onError(error) {
console.log('onError');
if (error.syscall !== 'listen') {
throw error;
}
let bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// adding this line to include winston logging
// winston.error(`${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`);
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
let addr = myApp.server.address();
let bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
// the following probably won't get to the DB because it isn't online when it is called
// winston.info('Listening on ' + bind, {sessionID: 'SYSTEM', username: 'SYSTEM', ip: addr});
console.log('Listening on ' + bind + ', DBHOST: ' + process.env.APPDB_HOST + ', Protocol: ' + whichServer);
}
appDB.js (pulls in various model files and defines relationships)-
/**
* @module appDB
*/
require('./sequelize-fixes');
import Sequelize from 'sequelize';
import config from './appDBConfig';
// user administration
import RolesModel from './model/userAdmin/roles';
import AbilityGroupModel from './model/userAdmin/ability_grouping';
import AbilitiesModel from './model/userAdmin/abilities';
import UsersRolesModel from './model/userAdmin/users_roles';
import RolePermsModel from './model/userAdmin/role_perms';
import UserModel from './model/userAdmin/user';
import GroupsRolesModel from './model/userAdmin/groups_roles';
import GroupModel from './model/userAdmin/group';
// security
import { SessionModel } from './model/session';
import WatchdogModel from './model/watchdog';
// application functions
import InterventionsModel from './model/interventions';
import MyReportsModel from './model/userReports/myReports';
import ReportLogModel from './model/userReports/reportLog';
import ReportListModel from './model/userReports/reportList';
import DowntimesModel from './model/downtimes';
import { app } from "./server";
/**
* @constant {Sequelize} seq
*/
const seq = new Sequelize(config);
// user administration
const Roles = RolesModel({ seq });
const AbilityGroups = AbilityGroupModel({ seq });
const Abilities = AbilitiesModel({ seq });
const RolePerms = RolePermsModel({ seq });
const Users = UserModel({ seq });
const UsersRoles = UsersRolesModel({ seq });
const Groups = GroupModel({ seq });
const GroupsRoles = GroupsRolesModel({ seq });
// security
const Session = SessionModel({ seq });
const Watchdog = WatchdogModel({ seq });
// application functions
const Interventions = InterventionsModel({ seq });
const MyReports = MyReportsModel({ seq });
const ReportLog = ReportLogModel({ seq });
const ReportList = ReportListModel({ seq });
const Downtimes = DowntimesModel({ seq });
AbilityGroups.hasMany(Abilities);
Abilities.belongsTo(AbilityGroups);
Users.belongsToMany(Roles, {through: 'users_roles', foreignKey: 'user_id'});
Roles.belongsToMany(Users, {through: 'users_roles', foreignKey: 'role_id'});
Groups.belongsToMany(Roles, {through: 'groups_roles', foreignKey: 'group_id'});
Roles.belongsToMany(Groups, {through: 'groups_roles', foreignKey: 'role_id'});
Abilities.belongsToMany(Roles, {through: 'role_perms', foreignKey: 'perm_id'});
Roles.belongsToMany(Abilities, {through: 'role_perms', foreignKey: 'role_id'});
Users.hasMany(MyReports, {foreignKey: 'user_id', sourceKey: 'id'});
MyReports.belongsTo(Users, {foreignKey: 'user_id', targetKey: 'id', onDelete: 'NO ACTION'});
Users.hasMany(MyReports, {foreignKey: 'username', sourceKey: 'username'});
MyReports.belongsTo(Users, {foreignKey: 'username', targetKey: 'username', onDelete: 'NO ACTION'});
Users.hasMany(ReportLog, {foreignKey: 'user_id', sourceKey: 'id'});
ReportLog.belongsTo(Users, {foreignKey: 'user_id', targetKey: 'id', onDelete: 'NO ACTION'});
Users.hasMany(ReportLog, {foreignKey: 'username', sourceKey: 'username'});
ReportLog.belongsTo(Users, {foreignKey: 'username', targetKey: 'username', onDelete: 'NO ACTION'});
const errorMsg = function (error) {
if (app.get('env') === 'production') {
console.log('stack redacted');
error.stack = '';// We want to obscure any data the user shouldn't see.
}
if (error.name === 'SequelizeUniqueConstraintError') {
// console.log('error: ', error);
/* console.log('inside', error.errors);
console.log('thingy', error.errors[0].message);
console.log('name', error.name); */
return 'Error: Field must be unique. "' + error.errors[0].value + '" already exists.'
}
if (error.name === 'SequelizeValidationError') {
return 'Error: Validation failed. "' + error.errors[0].message + '".'
}
};
export {
seq,
Roles,
Abilities,
AbilityGroups,
RolePerms,
Users,
UsersRoles,
Groups,
GroupsRoles,
Session,
Watchdog,
errorMsg,
Interventions,
MyReports,
ReportLog,
ReportList,
Downtimes
};
server.test.js (my first test file)-
import { normalizePort } from './server';
import supertest from 'supertest';
jest.useFakeTimers()
describe('Normalize PORT', () => {
describe('Named PIPE', () => {
test('should return the named pipe', () => {
expect(normalizePort('Foo')).toBe('Foo');
});
});
describe('Number PORT', () => {
test('should return the port number', () => {
expect(normalizePort(8090)).toBe(8090);
});
});
});