diff --git a/package.json b/package.json
index e26d541..a098d15 100644
--- a/package.json
+++ b/package.json
@@ -5,12 +5,15 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
- "test": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/",
+ "test": "npm run test:all",
+ "test:all": "export KOA_ENV=test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/biz-logic/ test/unit/rest-api/ test/e2e/automated/",
+ "test:unit:lib": "export KOA_ENV=test && mocha --exit --timeout 15000 test/unit/biz-logic/",
+ "test:unit:rest": "export KOA_ENV=test && mocha --exit --timeout 15000 test/unit/rest-api/",
+ "test:e2e:auto": "export KOA_ENV=test && mocha --exit --timeout 15000 test/e2e/automated/",
"lint": "standard --env mocha --fix",
"docs": "./node_modules/.bin/apidoc -i src/ -o docs",
- "coverage": "export KOA_ENV=test && npm run prep-test && nyc report --reporter=text-lcov | coveralls",
- "coverage:report": "export KOA_ENV=test && npm run prep-test && nyc --reporter=html mocha --exit --timeout 15000 test/unit/",
- "prep-test": "node util/users/delete-all-test-users.js"
+ "coverage": "nyc report --reporter=text-lcov | coveralls",
+ "coverage:report": "export KOA_ENV=test && nyc --reporter=html mocha --exit --timeout 15000 test/unit/biz-logic/ test/unit/rest-api/ test/e2e/automated/"
},
"keywords": [
"koa-api-boilerplate",
diff --git a/src/lib/admin.js b/src/lib/admin.js
index e6bfdc8..7b08455 100644
--- a/src/lib/admin.js
+++ b/src/lib/admin.js
@@ -49,11 +49,14 @@ class Admin {
data: {
user: {
email: 'system@system.com',
- password: context.password
+ password: context.password,
+ name: 'admin'
}
}
}
const result = await _this.axios.request(options)
+ // console.log('admin.data: ', result.data)
+
context.email = result.data.user.email
context.id = result.data.user._id
context.token = result.data.token
diff --git a/src/lib/nodemailer.js b/src/lib/nodemailer.js
index 4835bfe..426fae6 100644
--- a/src/lib/nodemailer.js
+++ b/src/lib/nodemailer.js
@@ -20,6 +20,7 @@ class NodeMailer {
_this.transporter = _this.createTransporter()
}
+ // Define an email server 'transport' for nodemailer
createTransporter () {
const transporter = _this.nodemailer.createTransport({
host: _this.config.emailServer,
@@ -78,7 +79,7 @@ class NodeMailer {
const subject = data.subject
const to = data.to
- const emailUser = data.email // email from the user who initiated the sharing email
+ // const emailUser = data.email // email from the user who initiated the sharing email
const payload = data.payloadTitle
const bodyJson = data
@@ -103,9 +104,7 @@ class NodeMailer {
})
// This paragraph just be added to the message for
// emails that are not password reset ones
- const paragraphTag = `
${emailUser} would like to share the document ${payload} with you through
- LaunchpadIP.net.
-
`
+ const paragraphTag = 'This is a test email'
const htmlMsg = `${subject}:
${payload === 'Email reset' ? '' : paragraphTag}
diff --git a/src/lib/users.js b/src/lib/users.js
new file mode 100644
index 0000000..ee55d6c
--- /dev/null
+++ b/src/lib/users.js
@@ -0,0 +1,150 @@
+/*
+ This library contains business-logic for dealing with users. Most of these
+ functions are called by the /user REST API endpoints.
+*/
+
+const UserModel = require('../models/users')
+const wlogger = require('./wlogger')
+
+class UserLib {
+ constructor (configObj) {
+ // Encapsulate dependencies
+ this.UserModel = UserModel
+ }
+
+ // Create a new user model and add it to the Mongo database.
+ async createUser (userObj) {
+ try {
+ // Input Validation
+ if (!userObj.email || typeof userObj.email !== 'string') {
+ throw new Error("Property 'email' must be a string!")
+ }
+ if (!userObj.password || typeof userObj.password !== 'string') {
+ throw new Error("Property 'password' must be a string!")
+ }
+ if (!userObj.name || typeof userObj.name !== 'string') {
+ throw new Error("Property 'name' must be a string!")
+ }
+
+ const user = new this.UserModel(userObj)
+
+ // Enforce default value of 'user'
+ user.type = 'user'
+
+ // Save the new user model to the database.
+ await user.save()
+
+ // Generate a JWT token for the user.
+ const token = user.generateToken()
+
+ // Convert the database model to a JSON object.
+ const userData = user.toJSON()
+
+ // Delete the password property.
+ delete userData.password
+
+ return { userData, token }
+ } catch (err) {
+ // console.log('createUser() error: ', err)
+ wlogger.error('Error in lib/users.js/createUser()')
+ throw err
+ }
+ }
+
+ // Returns an array of all user models in the Mongo database.
+ async getAllUsers () {
+ try {
+ // Get all user models. Delete the password property from each model.
+ const users = await this.UserModel.find({}, '-password')
+
+ return users
+ } catch (err) {
+ wlogger.error('Error in lib/users.js/getAllUsers()')
+ throw err
+ }
+ }
+
+ // Get the model for a specific user.
+ async getUser (params) {
+ try {
+ const { id } = params
+
+ const user = await this.UserModel.findById(id, '-password')
+
+ // Throw a 404 error if the user isn't found.
+ if (!user) {
+ const err = new Error('User not found')
+ err.status = 404
+ throw err
+ }
+
+ return user
+ } catch (err) {
+ // console.log('Error in getUser: ', err)
+
+ if (err.status === 404) throw err
+
+ // Return 422 for any other error
+ err.status = 422
+ err.message = 'Unprocessable Entity'
+ throw err
+ }
+ }
+
+ async updateUser (existingUser, newData) {
+ try {
+ // Input Validation
+ // Optional inputs, but they must be strings if included.
+ if (newData.email && typeof newData.email !== 'string') {
+ throw new Error("Property 'email' must be a string!")
+ }
+ if (newData.name && typeof newData.name !== 'string') {
+ throw new Error("Property 'name' must be a string!")
+ }
+ if (newData.password && typeof newData.password !== 'string') {
+ throw new Error("Property 'password' must be a string!")
+ }
+
+ // Save a copy of the original user type.
+ const userType = existingUser.type
+ // console.log('userType: ', userType)
+
+ // If user 'type' property is sent by the client
+ if (newData.type) {
+ if (typeof newData.type !== 'string') {
+ throw new Error("Property 'type' must be a string!")
+ }
+
+ // Unless the calling user is an admin, they can not change the user type.
+ if (userType !== 'admin') {
+ throw new Error("Property 'type' can only be changed by Admin user")
+ }
+ }
+
+ // Overwrite any existing data with the new data.
+ Object.assign(existingUser, newData)
+
+ // Save the changes to the database.
+ await existingUser.save()
+
+ // Delete the password property.
+ delete existingUser.password
+
+ return existingUser
+ } catch (err) {
+ wlogger.error('Error in lib/users.js/updateUser()')
+ throw err
+ }
+ }
+
+ async deleteUser (user) {
+ try {
+ await user.remove()
+ } catch (err) {
+ wlogger.error('Error in lib/users.js/deleteUser()')
+ throw err
+ }
+ }
+}
+
+module.exports = UserLib
diff --git a/src/middleware/validators.js b/src/middleware/validators.js
index 2fb4e15..9e9dc86 100644
--- a/src/middleware/validators.js
+++ b/src/middleware/validators.js
@@ -120,6 +120,7 @@ class Validators {
// console.log(`Err: Could not find user.`)
ctx.throw(401)
}
+ // console.log('ctx.state.user: ', ctx.state.user)
// console.log(`ctx.state.user: ${JSON.stringify(ctx.state.user, null, 2)}`)
// Ensure the calling user and the target user are the same.
diff --git a/src/modules/users/controller.js b/src/modules/users/controller.js
index 47418b6..e1dbb5f 100644
--- a/src/modules/users/controller.js
+++ b/src/modules/users/controller.js
@@ -1,10 +1,19 @@
+// User database model.
const User = require('../../models/users')
+// User library for business logic.
+const UserLib = require('../../lib/users')
+
+const wlogger = require('../../lib/wlogger')
+
let _this
class UserController {
constructor () {
- _this = this
+ // Encapsulate dependencies
this.User = User
+ this.userLib = new UserLib()
+
+ _this = this
}
/**
@@ -47,51 +56,22 @@ class UserController {
* }
*/
async createUser (ctx) {
- const userObj = ctx.request.body.user
try {
- /*
- * ERROR HANDLERS
- *
- */
- // Required property
- if (!userObj.email || typeof userObj.email !== 'string') {
- throw new Error("Property 'email' must be a string!")
- }
+ const userObj = ctx.request.body.user
- // This validation is not permissive to different TLDs like this one:
- // someone@somewhere.link. Removing it until it can be updated.
- // const isEmail = await _this.validateEmail(user.email)
- // if (!isEmail) {
- // throw new Error("Property 'email' must be email format!")
- // }
-
- if (!userObj.password || typeof userObj.password !== 'string') {
- throw new Error("Property 'password' must be a string!")
- }
-
- if (userObj.name && typeof userObj.name !== 'string') {
- throw new Error("Property 'name' must be a string!")
- }
-
- const user = new _this.User(userObj)
- // Enforce default value of 'user'
- user.type = 'user'
-
- await user.save()
-
- const token = user.generateToken()
- const response = user.toJSON()
-
- delete response.password
+ const { userData, token } = await _this.userLib.createUser(userObj)
+ // console.log('userData: ', userData)
+ // console.log('token: ', token)
ctx.body = {
- user: response,
+ user: userData,
token
}
} catch (err) {
// console.log(`err.message: ${err.message}`)
// console.log('err: ', err)
- ctx.throw(422, err.message)
+ // ctx.throw(422, err.message)
+ _this.handleError(ctx, err)
}
}
@@ -125,10 +105,12 @@ class UserController {
*/
async getUsers (ctx) {
try {
- const users = await _this.User.find({}, '-password')
+ const users = await _this.userLib.getAllUsers()
+
ctx.body = { users }
- } catch (error) {
- ctx.throw(404)
+ } catch (err) {
+ wlogger.error('Error in users/controller.js/getUsers(): '.err)
+ ctx.throw(422, err.message)
}
}
@@ -160,28 +142,15 @@ class UserController {
*
* @apiUse TokenError
*/
-
async getUser (ctx, next) {
try {
- const user = await _this.User.findById(ctx.params.id, '-password')
- if (!user) {
- ctx.throw(404)
- }
+ const user = await _this.userLib.getUser(ctx.params)
ctx.body = {
user
}
} catch (err) {
- // Handle different error types.
- if (
- err === 404 ||
- err.name === 'CastError' ||
- err.message.toString().includes('Not Found')
- ) {
- ctx.throw(404)
- }
-
- ctx.throw(500)
+ _this.handleError(ctx, err)
}
if (next) {
@@ -231,60 +200,17 @@ class UserController {
* @apiUse TokenError
*/
async updateUser (ctx) {
- // Values obtain from user request.
- // This variable is intended to validate the properties
- // sent by the client
- const userObj = ctx.request.body.user
-
- const user = ctx.body.user
try {
- /*
- * ERROR HANDLERS
- *
- */
- // Required property
- if (userObj.email && typeof userObj.email !== 'string') {
- throw new Error("Property 'email' must be a string!")
- }
+ const existingUser = ctx.body.user
+ const newData = ctx.request.body.user
- const isEmail = await _this.validateEmail(userObj.email)
- if (userObj.email && !isEmail) {
- throw new Error("Property 'email' must be email format!")
- }
- if (userObj.password && typeof userObj.password !== 'string') {
- throw new Error("Property 'password' must be a string!")
- }
- if (userObj.name && typeof userObj.name !== 'string') {
- throw new Error("Property 'name' must be a string!")
- }
- if (userObj.projects && !Array.isArray(userObj.projects)) {
- throw new Error("Property 'projects' must be a Array!")
- }
- // Save a copy of the original user type.
- const userType = user.type
-
- // If user type property is sent by the client
- if (userObj.type) {
- if (typeof userObj.type !== 'string') {
- throw new Error("Property 'type' must be a string!")
- }
- // TODO: Here we can validate the user types allowed
-
- // Unless the calling user is an admin, they can not change the user type.
- if (userType !== 'admin') {
- throw new Error("Property 'type' can only be changed by Admin user")
- }
- }
-
- Object.assign(user, ctx.request.body.user)
-
- await user.save()
+ const user = await _this.userLib.updateUser(existingUser, newData)
ctx.body = {
user
}
- } catch (error) {
- ctx.throw(422, error.message)
+ } catch (err) {
+ ctx.throw(422, err.message)
}
}
@@ -308,11 +234,33 @@ class UserController {
* @apiUse TokenError
*/
async deleteUser (ctx) {
- const user = ctx.body.user
- await user.remove()
- ctx.status = 200
- ctx.body = {
- success: true
+ try {
+ const user = ctx.body.user
+
+ // await user.remove()
+ await _this.userLib.deleteUser(user)
+
+ ctx.status = 200
+ ctx.body = {
+ success: true
+ }
+ } catch (err) {
+ ctx.throw(422, err.message)
+ }
+ }
+
+ // DRY error handler
+ handleError (ctx, err) {
+ // If an HTTP status is specified by the buisiness logic, use that.
+ if (err.status) {
+ if (err.message) {
+ ctx.throw(err.status, err.message)
+ } else {
+ ctx.throw(err.status)
+ }
+ } else {
+ // By default use a 422 error if the HTTP status is not specified.
+ ctx.throw(422, err.message)
}
}
diff --git a/test/e2e/automated/README.md b/test/e2e/automated/README.md
new file mode 100644
index 0000000..283c815
--- /dev/null
+++ b/test/e2e/automated/README.md
@@ -0,0 +1,7 @@
+# Automated End-to-end Tests
+
+This contains the original boilerplate tests, which are end-to-end tests. These tests are fully automated and test the system directly by making REST API calls with axios.
+
+These tests function exactly the same as a normal user would, by making real REST API calls to the software. As a result, they are fine for testing internal system components like authorization and user handling. However, they are inappropriate for testing sophisticated endpoints that involve complex operations. For example, interacting with a blockchain, pinging other network systems, or writing data to a secondary database.
+
+There is some redundancy between these tests and the unit tests. The focus is on *how* the tests are executed. The unit tests call the libraries directly (internally). These e2e tests use the REST API (externally).
diff --git a/test/e2e/automated/a01-auth.rest-e2e.js b/test/e2e/automated/a01-auth.rest-e2e.js
new file mode 100644
index 0000000..52099ee
--- /dev/null
+++ b/test/e2e/automated/a01-auth.rest-e2e.js
@@ -0,0 +1,119 @@
+/*
+ End-to-end tests for /auth endpoints.
+
+ This test sets up the environment for other e2e tests.
+*/
+
+// Public npm libraries
+const assert = require('chai').assert
+const axios = require('axios').default
+
+// Local support libraries
+const config = require('../../../config')
+const app = require('../../../bin/server')
+const testUtils = require('../../utils/test-utils')
+const AdminLib = require('../../../src/lib/admin')
+const adminLib = new AdminLib()
+
+// const request = supertest.agent(app.listen())
+const context = {}
+
+const LOCALHOST = `http://localhost:${config.port}`
+
+describe('Auth', () => {
+ before(async () => {
+ // This should be the first instruction. It starts the REST API server.
+ await app.startServer()
+
+ // Delete all previous users in the database.
+ await testUtils.deleteAllUsers()
+
+ // Create a new admin user.
+ await adminLib.createSystemUser()
+
+ const userObj = {
+ email: 'test@test.com',
+ password: 'pass',
+ name: 'test'
+ }
+ const testUser = await testUtils.createUser(userObj)
+ // console.log('TestUser: ', testUser)
+
+ context.user = testUser.user
+ context.token = testUser.token
+ })
+
+ describe('POST /auth', () => {
+ it('should throw 401 if credentials are incorrect', async () => {
+ try {
+ const options = {
+ method: 'post',
+ url: `${LOCALHOST}/auth`,
+ data: {
+ email: 'test@test.com',
+ password: 'wrongpassword'
+ }
+ }
+
+ const result = await axios(options)
+
+ // console.log(`result: ${JSON.stringify(result, null, 2)}`)
+
+ console.log(
+ `result stringified: ${JSON.stringify(result.data, null, 2)}`
+ )
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ assert(err.response.status === 401, 'Error code 401 expected.')
+ }
+ })
+
+ it('should throw 401 if email is wrong format', async () => {
+ try {
+ const options = {
+ method: 'post',
+ url: `${LOCALHOST}/auth`,
+ data: {
+ email: 'wrongEmail',
+ password: 'wrongpassword'
+ }
+ }
+
+ await axios(options)
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ assert(err.response.status === 401, 'Error code 401 expected.')
+ }
+ })
+
+ it('should auth user', async () => {
+ try {
+ const options = {
+ method: 'post',
+ url: `${LOCALHOST}/auth`,
+ data: {
+ email: 'test@test.com',
+ password: 'pass'
+ }
+ }
+ const result = await axios(options)
+ // console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
+
+ assert(result.status === 200, 'Status Code 200 expected.')
+ assert(
+ result.data.user.email === 'test@test.com',
+ 'Email of test expected'
+ )
+ assert(
+ result.data.user.password === undefined,
+ 'Password expected to be omited'
+ )
+ } catch (err) {
+ console.log(
+ 'Error authenticating test user: ' + JSON.stringify(err, null, 2)
+ )
+ throw err
+ }
+ })
+ })
+})
diff --git a/test/e2e/automated/a02-users.rest-e2e.js b/test/e2e/automated/a02-users.rest-e2e.js
new file mode 100644
index 0000000..5ecea89
--- /dev/null
+++ b/test/e2e/automated/a02-users.rest-e2e.js
@@ -0,0 +1,786 @@
+const testUtils = require('../../utils/test-utils')
+const assert = require('chai').assert
+const config = require('../../../config')
+const axios = require('axios').default
+const sinon = require('sinon')
+
+const util = require('util')
+util.inspect.defaultOptions = { depth: 1 }
+
+const LOCALHOST = `http://localhost:${config.port}`
+
+const context = {}
+
+const UserController = require('../../../src/modules/users/controller')
+let uut
+let sandbox
+
+// const mockContext = require('../../unit/mocks/ctx-mock').context
+
+describe('Users', () => {
+ before(async () => {
+ // console.log(`config: ${JSON.stringify(config, null, 2)}`)
+
+ // Create a second test user.
+ const userObj = {
+ email: 'test2@test.com',
+ password: 'pass2',
+ name: 'test2'
+ }
+ const testUser = await testUtils.createUser(userObj)
+ // console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`)
+
+ context.user2 = testUser.user
+ context.token2 = testUser.token
+ context.id2 = testUser.user._id
+
+ // Get the JWT used to log in as the admin 'system' user.
+ const adminJWT = await testUtils.getAdminJWT()
+ // console.log(`adminJWT: ${admi nJWT}`)
+ context.adminJWT = adminJWT
+
+ // const admin = await testUtils.loginAdminUser()
+ // context.adminJWT = admin.token
+
+ // const admin = await adminLib.loginAdmin()
+ // console.log(`admin: ${JSON.stringify(admin, null, 2)}`)
+ })
+
+ beforeEach(() => {
+ uut = new UserController()
+
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(() => sandbox.restore())
+
+ describe('POST /users - Create User', () => {
+ it('should reject signup when data is incomplete', async () => {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/users`,
+ data: {
+ email: 'test2@test.com'
+ }
+ }
+
+ await axios(options)
+
+ /* console.log(
+ `result stringified: ${JSON.stringify(result.data, null, 2)}`
+ ) */
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ assert(err.response.status === 422, 'Error code 422 expected.')
+ }
+ })
+
+ it('should reject signup if no email property is provided', async () => {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/users`,
+ data: {
+ user: {
+ password: 'pass2'
+ }
+ }
+ }
+ await axios(options)
+
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ // console.log('err', err)
+ assert.equal(err.response.status, 422)
+ assert.include(err.response.data, "Property 'email' must be a string")
+ }
+ })
+
+ it('should reject signup if no password property is provided', async () => {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/users`,
+ data: {
+ user: {
+ email: 'test2@test.com'
+ }
+ }
+ }
+ await axios(options)
+
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.include(
+ err.response.data,
+ "Property 'password' must be a string"
+ )
+ }
+ })
+
+ it('should reject if name property property is not string', async () => {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/users`,
+ data: {
+ user: {
+ email: 'test322@test.com',
+ password: 'supersecretpassword',
+ name: 1234
+ }
+ }
+ }
+ await axios(options)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.response.status, 422)
+ assert.include(err.response.data, "Property 'name' must be a string")
+ }
+ })
+
+ it("should signup of type 'user' by default", async () => {
+ const options = {
+ method: 'post',
+ url: `${LOCALHOST}/users`,
+ data: {
+ user: {
+ email: 'test3@test.com',
+ password: 'supersecretpassword',
+ name: 'test3'
+ }
+ }
+ }
+ const result = await axios(options)
+ // console.log(`result: ${JSON.stringify(result, null, 2)}`)
+
+ context.user = result.data.user
+ context.token = result.data.token
+
+ assert(result.status === 200, 'Status Code 200 expected.')
+ assert(
+ result.data.user.email === 'test3@test.com',
+ 'Email of test expected'
+ )
+ assert(
+ result.data.user.password === undefined,
+ 'Password expected to be omited'
+ )
+ assert.property(result.data, 'token', 'Token property exists.')
+ assert.equal(result.data.user.type, 'user')
+ })
+ })
+
+ describe('GET /users', () => {
+ it('should not fetch users if the authorization header is missing', async () => {
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not fetch users if the authorization header is missing the scheme', async () => {
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: '1'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not fetch users if the authorization header has invalid scheme', async () => {
+ const { token } = context
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Unknown ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not fetch users if token is invalid', async () => {
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: 'Bearer 1'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should fetch all users', async () => {
+ const { token } = context
+
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ const result = await axios(options)
+
+ const users = result.data.users
+ // console.log(`users: ${util.inspect(users)}`)
+
+ assert.hasAnyKeys(users[0], ['type', '_id', 'email'])
+ assert.isNumber(users.length)
+ })
+
+ it('should return a 422 http status if biz-logic throws an error', async () => {
+ try {
+ const { token } = context
+
+ // Force an error
+ sandbox
+ .stub(uut.userLib, 'getAllUsers')
+ .rejects(new Error('test error'))
+
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.fail('Unexpected code path!')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.equal(err.response.data, 'test error')
+ }
+ })
+ })
+
+ describe('GET /users/:id', () => {
+ it('should not fetch user if token is invalid', async () => {
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: 'Bearer 1'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it("should throw 404 if user doesn't exist", async () => {
+ const { token } = context
+
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users/5fa4bd7ee1828f5f4d8ed004`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 404)
+ }
+ })
+
+ it('should throw 422 for invalid input', async () => {
+ const { token } = context
+
+ try {
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ }
+ })
+
+ it('should fetch own user', async () => {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'GET',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ const result = await axios(options)
+
+ const user = result.data.user
+ // console.log(`user: ${util.inspect(user)}`)
+
+ assert.property(user, 'type')
+ assert.property(user, 'email')
+
+ assert.property(user, '_id')
+ assert.equal(user._id, _id)
+
+ assert.notProperty(
+ user,
+ 'password',
+ 'Password property should not be returned'
+ )
+ })
+ })
+
+ describe('PUT /users/:id', () => {
+ it('should not update user if token is invalid', async () => {
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: 'Bearer 1'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should throw 401 if non-admin updating other user', async () => {
+ const { token } = context
+
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not be able to update user type', async () => {
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${context.user._id.toString()}`,
+ headers: {
+ Authorization: `Bearer ${context.token}`
+ },
+ data: {
+ user: {
+ email: 'test@test.com',
+ password: 'password',
+ name: 'new name',
+ type: 'test'
+ }
+ }
+ }
+ await axios(options)
+
+ // console.log(`Users: ${JSON.stringify(result.data, null, 2)}`)
+
+ // assert(result.status === 200, 'Status Code 200 expected.')
+ // assert(result.data.user.type === 'user', 'Type should be unchanged.')
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.include(
+ err.response.data,
+ "Property 'type' can only be changed by Admin user"
+ )
+ }
+ })
+
+ it('should not be able to update other user when not admin', async () => {
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
+ headers: {
+ Authorization: `Bearer ${context.token}`
+ },
+ data: {
+ user: {
+ name: 'This should not work'
+ }
+ }
+ }
+ await axios(options)
+
+ // console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
+
+ assert(false, 'Unexpected result')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not be able to update if name property is wrong', async () => {
+ try {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: {
+ email: 'testToUpdate@test.com',
+ name: {},
+ password: 'password'
+ }
+ }
+ }
+ await axios(options)
+ } catch (error) {
+ assert.equal(error.response.status, 422)
+ assert.include(error.response.data, "Property 'name' must be a string!")
+ }
+ })
+
+ it('should not be able to update if password property is not string', async () => {
+ const { token } = context
+ const _id = context.user._id
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: {
+ password: 1234,
+ email: 'test@test.com',
+ name: 'test'
+ }
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.include(
+ err.response.data,
+ "Property 'password' must be a string!"
+ )
+ }
+ })
+
+ it('should not be able to update if email is not string', async () => {
+ const { token } = context
+ const _id = context.user._id
+ try {
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: {
+ email: 1234
+ }
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.include(err.response.data, "Property 'email' must be a string!")
+ }
+ })
+
+ it('should not be able to update type property if is not string', async () => {
+ try {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: {
+ type: 1,
+ email: 'test@test.com',
+ name: 'test',
+ password: 'password'
+ }
+ }
+ }
+ await axios(options)
+ } catch (err) {
+ assert.equal(err.response.status, 422)
+ assert.include(err.response.data, "Property 'type' must be a string!")
+ }
+ })
+
+ it('should be able to update other user when admin', async () => {
+ const adminJWT = context.adminJWT
+
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
+ headers: {
+ Authorization: `Bearer ${adminJWT}`
+ },
+ data: {
+ user: {
+ name: 'This should work',
+ email: 'test4@test.com',
+ password: 'password'
+ }
+ }
+ }
+
+ const result = await axios(options)
+ // console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
+
+ const userName = result.data.user.name
+ assert.equal(userName, 'This should work')
+ })
+
+ it('should update user with minimum inputs', async () => {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: { email: 'testToUpdate@test.com' }
+ }
+ }
+
+ const result = await axios(options)
+ const user = result.data.user
+ // console.log(`user: ${util.inspect(user)}`)
+
+ assert.property(user, 'type')
+ assert.property(user, 'email')
+
+ assert.property(user, '_id')
+ assert.equal(user._id, _id)
+
+ assert.notProperty(
+ user,
+ 'password',
+ 'Password property should not be returned'
+ )
+ assert.equal(user.email, 'testToUpdate@test.com')
+ })
+
+ it('should update user with all inputs', async () => {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'PUT',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ data: {
+ user: {
+ email: 'testToUpdate@test.com',
+ name: 'my name',
+ username: 'myUsername'
+ }
+ }
+ }
+ const result = await axios(options)
+
+ const user = result.data.user
+ // console.log(`user: ${util.inspect(user)}`)
+
+ assert.property(user, 'type')
+ assert.property(user, 'email')
+ assert.property(user, 'name')
+
+ assert.property(user, '_id')
+ assert.equal(user._id, _id)
+ assert.notProperty(
+ user,
+ 'password',
+ 'Password property should not be returned'
+ )
+ assert.equal(user.name, 'my name')
+ assert.equal(user.email, 'testToUpdate@test.com')
+ assert.equal(user.username, 'myUsername')
+ })
+ })
+
+ describe('DELETE /users/:id', () => {
+ it('should not delete user if token is invalid', async () => {
+ try {
+ const options = {
+ method: 'DELETE',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: 'Bearer 1'
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should throw 401 if deleting invalid user', async () => {
+ const { token } = context
+
+ try {
+ const options = {
+ method: 'DELETE',
+ url: `${LOCALHOST}/users/1`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ await axios(options)
+
+ assert.equal(true, false, 'Unexpected behavior')
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should not be able to delete other users unless admin', async () => {
+ try {
+ const options = {
+ method: 'DELETE',
+ url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
+ headers: {
+ Authorization: `Bearer ${context.token}`
+ }
+ }
+ await axios(options)
+ } catch (err) {
+ assert.equal(err.response.status, 401)
+ }
+ })
+
+ it('should delete own user', async () => {
+ const _id = context.user._id
+ const token = context.token
+
+ const options = {
+ method: 'DELETE',
+ url: `${LOCALHOST}/users/${_id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ const result = await axios(options)
+ // console.log(`result: ${util.inspect(result.data.success)}`)
+
+ assert.equal(result.data.success, true)
+ })
+
+ it('should be able to delete other users when admin', async () => {
+ const id = context.id2
+ const adminJWT = context.adminJWT
+
+ const options = {
+ method: 'DELETE',
+ url: `${LOCALHOST}/users/${id}`,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${adminJWT}`
+ }
+ }
+ const result = await axios(options)
+ // console.log(`result: ${util.inspect(result.data)}`)
+
+ assert.equal(result.data.success, true)
+ })
+ })
+})
diff --git a/test/unit/README.md b/test/unit/README.md
index 8cddb82..acd1a9d 100644
--- a/test/unit/README.md
+++ b/test/unit/README.md
@@ -1,3 +1,8 @@
-The files in this directory are named so as to control the order in which
-the files are executed by mocha. a01... runs first. The order is important,
-as some tests downstream depend on tests upstream.
+# Unit Tests
+Unit tests are defined as testing the smallest possible unit of a function. They also do not make any live network calls.
+
+Unit tests are broken up by directory:
+
+- [biz-logic](./biz-logic) tests the business logic libraries.
+- [rest-api](./rest-api) tests the REST API specific handling of the router.
+- json-rpc (coming soon) tests the JSON-RPC routing using ipfs-coord library.
diff --git a/test/unit/biz-logic/README.md b/test/unit/biz-logic/README.md
new file mode 100644
index 0000000..8c5cdff
--- /dev/null
+++ b/test/unit/biz-logic/README.md
@@ -0,0 +1,3 @@
+# Business Logic Unit Tests
+
+The unit tests in this directly are concerned with business logic libraries in the /src/lib folder. These are the methods that should be triggered by REST API endpoints. These tests are not concerned with the handling of the REST API request/response, but by the code that is triggered by those endpoints. It also tests any business logic that is not directly associated with a REST API endpoint.
diff --git a/test/unit/biz-logic/a02-users.lib-unit.js b/test/unit/biz-logic/a02-users.lib-unit.js
new file mode 100644
index 0000000..2885e2f
--- /dev/null
+++ b/test/unit/biz-logic/a02-users.lib-unit.js
@@ -0,0 +1,357 @@
+/*
+ Unit tests for the src/lib/users.js business logic library.
+
+ TODO: verify that an admin can change the type of a user
+*/
+
+// Public npm libraries
+const mongoose = require('mongoose')
+const assert = require('chai').assert
+const sinon = require('sinon')
+
+// Local support libraries
+const config = require('../../../config')
+const testUtils = require('../../utils/test-utils')
+
+// Unit under test (uut)
+const UserLib = require('../../../src/lib/users')
+
+describe('#users', () => {
+ let uut
+ let sandbox
+ let testUser = {}
+
+ before(async () => {
+ // Connect to the Mongo Database.
+ console.log(`Connecting to database: ${config.database}`)
+ mongoose.Promise = global.Promise
+ mongoose.set('useCreateIndex', true) // Stop deprecation warning.
+ await mongoose.connect(config.database, {
+ useUnifiedTopology: true,
+ useNewUrlParser: true
+ })
+
+ // Delete all previous users in the database.
+ await testUtils.deleteAllUsers()
+ })
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+
+ uut = new UserLib()
+ })
+
+ afterEach(() => sandbox.restore())
+
+ after(() => {
+ mongoose.connection.close()
+ })
+
+ describe('#createUser', () => {
+ it('should throw an error if no input is given', async () => {
+ try {
+ await uut.createUser()
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ // assert.equal(err.status, 422)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should throw an error if email is not provided', async () => {
+ try {
+ await uut.createUser({})
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ assert.include(err.message, "Property 'email' must be a string!")
+ }
+ })
+
+ it('should throw an error if password is not provided', async () => {
+ try {
+ const usrObj = {
+ email: 'test@test.com'
+ }
+
+ await uut.createUser(usrObj)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ assert.include(err.message, "Property 'password' must be a string!")
+ }
+ })
+
+ it('should throw an error if name is not provided', async () => {
+ try {
+ const usrObj = {
+ email: 'test@test.com',
+ password: 'password'
+ }
+
+ await uut.createUser(usrObj)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ assert.include(err.message, "Property 'name' must be a string!")
+ }
+ })
+
+ it('should catch and throw DB errors', async () => {
+ try {
+ // Force an error with the database.
+ sandbox.stub(uut, 'UserModel').throws(new Error('test error'))
+
+ const usrObj = {
+ email: 'test@test.com',
+ password: 'password',
+ name: 'test'
+ }
+
+ await uut.createUser(usrObj)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ assert.include(err.message, 'test error')
+ }
+ })
+
+ it('should create a new user in the DB', async () => {
+ // Note: The user created in this test is used by the getUser, update,
+ // and delete tests.
+
+ const usrObj = {
+ email: 'test01@test.com',
+ password: 'test',
+ name: 'test01'
+ }
+
+ const { userData, token } = await uut.createUser(usrObj)
+
+ testUser = userData
+
+ // Assert that the user model has the expected properties with expected values.
+ assert.property(userData, 'type')
+ assert.equal(userData.type, 'user')
+ assert.property(userData, '_id')
+ assert.property(userData, 'email')
+ assert.property(userData, 'name')
+
+ // Assert that the JWT token was generated for this user.
+ assert.isString(token)
+ assert.include(token, 'eyJ')
+ })
+ })
+
+ describe('#getAllUsers', () => {
+ it('should return all users from the database', async () => {
+ const users = await uut.getAllUsers()
+ // console.log(`users: ${JSON.stringify(users, null, 2)}`)
+
+ assert.isArray(users)
+ })
+
+ it('should catch and throw an error', async () => {
+ try {
+ // Force an error.
+ sandbox.stub(uut.UserModel, 'find').rejects(new Error('test error'))
+
+ await uut.getAllUsers()
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ assert.include(err.message, 'test error')
+ }
+ })
+ })
+
+ describe('#getUser', () => {
+ it('should throw 422 if no id given.', async () => {
+ try {
+ await uut.getUser()
+
+ assert.fail('Unexpected code path.')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'Unprocessable Entity')
+ }
+ })
+
+ it('should throw 422 for malformed id', async () => {
+ try {
+ const params = { id: 1 }
+ await uut.getUser(params)
+
+ assert.fail('Unexpected code path.')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'Unprocessable Entity')
+ }
+ })
+
+ it('should throw 404 if user is not found', async () => {
+ try {
+ const params = { id: '5fa4bd7ee1828f5f4d3ed004' }
+ await uut.getUser(params)
+
+ assert.fail('Unexpected code path.')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 404)
+ assert.include(err.message, 'User not found')
+ }
+ })
+
+ it('should return the user model', async () => {
+ const params = { id: testUser._id }
+ const result = await uut.getUser(params)
+ // console.log('result: ', result)
+
+ // Replace the JSON model with an actual Mongoos model. Used by later
+ // test cases.
+ testUser = result
+
+ // Assert that the expected properties for the user model exist.
+ assert.property(result, 'type')
+ assert.property(result, '_id')
+ assert.property(result, 'email')
+ assert.property(result, 'name')
+ })
+ })
+
+ describe('#updateUser', () => {
+ it('should throw an error if no input is given', async () => {
+ try {
+ await uut.updateUser()
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should throw an error if email is not a string', async () => {
+ try {
+ await uut.updateUser(testUser, {
+ email: 1234
+ })
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, "Property 'email' must be a string!")
+ }
+ })
+
+ it('should throw an error if name is not a string', async () => {
+ try {
+ const newData = {
+ name: 1234
+ }
+
+ await uut.updateUser(testUser, newData)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, "Property 'name' must be a string!")
+ }
+ })
+
+ it('should throw an error if non-string password given', async () => {
+ try {
+ const newData = {
+ email: 'test@test.com',
+ name: 'test',
+ password: 1234
+ }
+
+ await uut.updateUser(testUser, newData)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, "Property 'password' must be a string!")
+ }
+ })
+
+ it('should throw an error for malformed type given', async () => {
+ try {
+ const newData = {
+ email: 'test@test.com',
+ password: 'password',
+ name: 'test',
+ type: 1234
+ }
+
+ await uut.updateUser(testUser, newData)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, "Property 'type' must be a string!")
+ }
+ })
+
+ it('should throw an error if normal user tries to change themselves into an admin', async () => {
+ try {
+ const newData = {
+ email: 'test@test.com',
+ password: 'password',
+ name: 'test',
+ type: 'admin'
+ }
+
+ await uut.updateUser(testUser, newData)
+
+ assert.fail('Unexpected code path')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, "Property 'type' can only be changed by Admin user")
+ }
+ })
+
+ it('should update the user model', async () => {
+ const newData = {
+ email: 'test@test.com',
+ password: 'password',
+ name: 'testy tester'
+ }
+
+ const result = await uut.updateUser(testUser, newData)
+
+ // Assert that expected properties and values exist.
+ assert.property(result, '_id')
+ assert.property(result, 'email')
+ assert.equal(result.email, 'test@test.com')
+ assert.property(result, 'name')
+ assert.equal(result.name, 'testy tester')
+ })
+
+ // TODO: verify that an admin can change the type of a user
+ })
+
+ describe('#deleteUser', () => {
+ it('should throw error if no user provided', async () => {
+ try {
+ await uut.deleteUser()
+
+ assert.fail('Unexpected code path.')
+ } catch (err) {
+ // console.log(err)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should delete the user from the database', async () => {
+ await uut.deleteUser(testUser)
+
+ assert.isOk('Not throwing an error is a pass!')
+ })
+ })
+})
diff --git a/test/unit/a01-auth.spec.js b/test/unit/old-tests/a01-auth.spec.js
similarity index 100%
rename from test/unit/a01-auth.spec.js
rename to test/unit/old-tests/a01-auth.spec.js
diff --git a/test/unit/a02-users.spec.js b/test/unit/old-tests/a02-users.spec.js
similarity index 100%
rename from test/unit/a02-users.spec.js
rename to test/unit/old-tests/a02-users.spec.js
diff --git a/test/unit/a03-nodemailer.spec.js b/test/unit/old-tests/a03-nodemailer.spec.js
similarity index 100%
rename from test/unit/a03-nodemailer.spec.js
rename to test/unit/old-tests/a03-nodemailer.spec.js
diff --git a/test/unit/a04-contact.spec.js b/test/unit/old-tests/a04-contact.spec.js
similarity index 100%
rename from test/unit/a04-contact.spec.js
rename to test/unit/old-tests/a04-contact.spec.js
diff --git a/test/unit/a05-passport.spec.js b/test/unit/old-tests/a05-passport.spec.js
similarity index 100%
rename from test/unit/a05-passport.spec.js
rename to test/unit/old-tests/a05-passport.spec.js
diff --git a/test/unit/a06-logapi.spec.js b/test/unit/old-tests/a06-logapi.spec.js
similarity index 100%
rename from test/unit/a06-logapi.spec.js
rename to test/unit/old-tests/a06-logapi.spec.js
diff --git a/test/unit/a07-json-files.spec.js b/test/unit/old-tests/a07-json-files.spec.js
similarity index 100%
rename from test/unit/a07-json-files.spec.js
rename to test/unit/old-tests/a07-json-files.spec.js
diff --git a/test/unit/a08-validators.spec.js b/test/unit/old-tests/a08-validators.spec.js
similarity index 100%
rename from test/unit/a08-validators.spec.js
rename to test/unit/old-tests/a08-validators.spec.js
diff --git a/test/unit/a09-admin.spec.js b/test/unit/old-tests/a09-admin.spec.js
similarity index 100%
rename from test/unit/a09-admin.spec.js
rename to test/unit/old-tests/a09-admin.spec.js
diff --git a/test/unit/utils.js b/test/unit/old-tests/utils.js
similarity index 100%
rename from test/unit/utils.js
rename to test/unit/old-tests/utils.js
diff --git a/test/unit/rest-api/README.md b/test/unit/rest-api/README.md
new file mode 100644
index 0000000..eeab4e9
--- /dev/null
+++ b/test/unit/rest-api/README.md
@@ -0,0 +1,9 @@
+# REST API Unit Tests
+
+The tests in this directory are unit tests of REST API. These tests are not
+concerned with the business logic behind the endpoints. They are only concerned
+with the handling of the REST API endpoint. These tests answer questions like:
+
+- Is the endpoint responding properly when the business logic throws an error?
+- When returning an error, is it returning the proper HTTP response?
+- When returning success, is it returning the correct payload?
diff --git a/test/unit/rest-api/a02-users.rest-unit.js b/test/unit/rest-api/a02-users.rest-unit.js
new file mode 100644
index 0000000..0ec57b2
--- /dev/null
+++ b/test/unit/rest-api/a02-users.rest-unit.js
@@ -0,0 +1,254 @@
+/*
+ Unit tests for the REST API handler for the /users endpoints.
+*/
+
+// Public npm libraries
+const assert = require('chai').assert
+const sinon = require('sinon')
+const mongoose = require('mongoose')
+
+// Local support libraries
+const config = require('../../../config')
+const testUtils = require('../../utils/test-utils')
+const User = require('../../../src/models/users')
+
+const UserController = require('../../../src/modules/users/controller')
+let uut
+let sandbox
+let ctx
+
+const mockContext = require('../../unit/mocks/ctx-mock').context
+
+describe('Users', () => {
+ let testUser = {}
+
+ before(async () => {
+ // Connect to the Mongo Database.
+ mongoose.Promise = global.Promise
+ mongoose.set('useCreateIndex', true) // Stop deprecation warning.
+ await mongoose.connect(config.database, {
+ useUnifiedTopology: true,
+ useNewUrlParser: true
+ })
+
+ // Delete all previous users in the database.
+ await testUtils.deleteAllUsers()
+
+ // console.log(`config: ${JSON.stringify(config, null, 2)}`)
+
+ // Create a second test user.
+ // const userObj = {
+ // email: 'test2@test.com',
+ // password: 'pass2'
+ // }
+ // const testUser = await testUtils.createUser(userObj)
+ // console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`)
+
+ // context.user2 = testUser.user
+ // context.token2 = testUser.token
+ // context.id2 = testUser.user._id
+
+ // Get the JWT used to log in as the admin 'system' user.
+ // const adminJWT = await testUtils.getAdminJWT()
+ // // console.log(`adminJWT: ${adminJWT}`)
+ // context.adminJWT = adminJWT
+
+ // const admin = await testUtils.loginAdminUser()
+ // context.adminJWT = admin.token
+
+ // const admin = await adminLib.loginAdmin()
+ // console.log(`admin: ${JSON.stringify(admin, null, 2)}`)
+ })
+
+ beforeEach(() => {
+ uut = new UserController()
+
+ sandbox = sinon.createSandbox()
+
+ // Mock the context object.
+ ctx = mockContext()
+ })
+
+ afterEach(() => sandbox.restore())
+
+ after(() => {
+ mongoose.connection.close()
+ })
+
+ describe('#POST /users', () => {
+ it('should return 422 status on biz logic error', async () => {
+ try {
+ await uut.createUser(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should return 200 status on success', async () => {
+ ctx.request.body = {
+ user: {
+ email: 'test02@test.com',
+ password: 'test',
+ name: 'test02'
+ }
+ }
+
+ await uut.createUser(ctx)
+
+ // Assert the expected HTTP response
+ assert.equal(ctx.status, 200)
+
+ // Assert that expected properties exist in the returned data.
+ assert.property(ctx.response.body, 'user')
+ assert.property(ctx.response.body, 'token')
+
+ // Used by downstream tests.
+ testUser = ctx.response.body.user
+ // console.log('testUser: ', testUser)
+ })
+ })
+
+ describe('GET /users', () => {
+ it('should return 422 status on arbitrary biz logic error', async () => {
+ try {
+ // Force an error
+ sandbox
+ .stub(uut.userLib, 'getAllUsers')
+ .rejects(new Error('test error'))
+
+ await uut.getUsers(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'test error')
+ }
+ })
+
+ it('should return 200 status on success', async () => {
+ await uut.getUsers(ctx)
+
+ // Assert the expected HTTP response
+ assert.equal(ctx.status, 200)
+
+ // Assert that expected properties exist in the returned data.
+ assert.property(ctx.response.body, 'users')
+ })
+ })
+
+ describe('GET /users/:id', () => {
+ it('should return 422 status on arbitrary biz logic error', async () => {
+ try {
+ // Force an error
+ sandbox.stub(uut.userLib, 'getUser').rejects(new Error('test error'))
+
+ await uut.getUser(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'test error')
+ }
+ })
+
+ it('should return 200 status on success', async () => {
+ // Mock dependencies
+ sandbox.stub(uut.userLib, 'getUser').resolves({ _id: '123' })
+
+ await uut.getUser(ctx)
+
+ // Assert the expected HTTP response
+ assert.equal(ctx.status, 200)
+
+ // Assert that expected properties exist in the returned data.
+ assert.property(ctx.response.body, 'user')
+ })
+
+ it('should return other error status passed by biz logic', async () => {
+ try {
+ // Mock dependencies
+ const testErr = new Error('test error')
+ testErr.status = 404
+ sandbox.stub(uut.userLib, 'getUser').rejects(testErr)
+
+ await uut.getUser(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ assert.equal(err.status, 404)
+ assert.include(err.message, 'test error')
+ }
+ })
+ })
+
+ describe('PUT /users/:id', () => {
+ it('should return 422 if no input data given', async () => {
+ try {
+ await uut.updateUser(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should return 200 on success', async () => {
+ // Prep the testUser data.
+ // console.log('testUser: ', testUser)
+ testUser.password = 'password'
+ delete testUser.type
+
+ // Replace the testUser variable with an actual model from the DB.
+ const existingUser = await User.findById(testUser._id)
+
+ ctx.body = {
+ user: existingUser
+ }
+ ctx.request.body = {
+ user: testUser
+ }
+
+ await uut.updateUser(ctx)
+
+ // Assert the expected HTTP response
+ assert.equal(ctx.status, 200)
+
+ // Assert that expected properties exist in the returned data.
+ assert.property(ctx.response.body, 'user')
+ })
+ })
+
+ describe('DELETE /users/:id', () => {
+ it('should return 422 if no input data given', async () => {
+ try {
+ await uut.deleteUser(ctx)
+
+ assert.fail('Unexpected result')
+ } catch (err) {
+ // console.log(err)
+ assert.equal(err.status, 422)
+ assert.include(err.message, 'Cannot read property')
+ }
+ })
+
+ it('should return 200 status on success', async () => {
+ // Replace the testUser variable with an actual model from the DB.
+ const existingUser = await User.findById(testUser._id)
+
+ ctx.body = {
+ user: existingUser
+ }
+
+ await uut.deleteUser(ctx)
+
+ // Assert the expected HTTP response
+ assert.equal(ctx.status, 200)
+ })
+ })
+})
diff --git a/test/utils/test-utils.js b/test/utils/test-utils.js
new file mode 100644
index 0000000..31b26b0
--- /dev/null
+++ b/test/utils/test-utils.js
@@ -0,0 +1,170 @@
+/*
+ Utility functions used to prepare the environment for tests.
+*/
+
+// Public NPM libraries
+const mongoose = require('mongoose')
+const axios = require('axios').default
+
+// Local libraries
+const config = require('../../config')
+const User = require('../../src/models/users')
+
+const LOCALHOST = `http://localhost:${config.port}`
+
+// Remove all collections from the DB.
+async function cleanDb () {
+ for (const collection in mongoose.connection.collections) {
+ const collections = mongoose.connection.collections
+ if (collections.collection) {
+ // const thisCollection = mongoose.connection.collections[collection]
+ // console.log(`thisCollection: ${JSON.stringify(thisCollection, null, 2)}`)
+
+ await collection.deleteMany()
+ }
+ }
+}
+
+// Delete all users in the database. This ensures there is no previous state
+// to confuse tests.
+async function deleteAllUsers () {
+ try {
+ // Get all the users in the DB.
+ const users = await User.find({}, '-password')
+ // console.log(`users: ${JSON.stringify(users, null, 2)}`)
+
+ // Delete each user.
+ for (let i = 0; i < users.length; i++) {
+ const thisUser = users[i]
+ await thisUser.remove()
+ }
+ } catch (err) {
+ console.error('Error in test-utils.js/deleteAllUsers()')
+ }
+}
+
+// This function is used to create new users.
+// userObj = {
+// username,
+// password
+// }
+async function createUser (userObj) {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/users`,
+ data: {
+ user: {
+ email: userObj.email,
+ password: userObj.password,
+ name: userObj.name
+ }
+ }
+ }
+
+ const result = await axios(options)
+
+ const retObj = {
+ user: result.data.user,
+ token: result.data.token
+ }
+
+ return retObj
+ } catch (err) {
+ console.log(
+ 'Error in utils.js/createUser(): ' + JSON.stringify(err, null, 2)
+ )
+ throw err
+ }
+}
+
+async function loginTestUser () {
+ try {
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/auth`,
+ data: {
+ email: 'test@test.com',
+ password: 'pass'
+ }
+ }
+
+ const result = await axios(options)
+
+ // console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
+
+ const retObj = {
+ token: result.data.token,
+ user: result.data.user.username,
+ id: result.data.user._id.toString()
+ }
+
+ return retObj
+ } catch (err) {
+ console.log(
+ 'Error authenticating test user: ' + JSON.stringify(err, null, 2)
+ )
+ throw err
+ }
+}
+
+async function loginAdminUser () {
+ try {
+ const FILENAME = `../../config/system-user-${config.env}.json`
+ const adminUserData = require(FILENAME)
+ console.log(`adminUserData: ${JSON.stringify(adminUserData, null, 2)}`)
+
+ const options = {
+ method: 'POST',
+ url: `${LOCALHOST}/auth`,
+ data: {
+ email: adminUserData.email,
+ password: adminUserData.password,
+ name: 'admin'
+ }
+ }
+
+ const result = await axios(options)
+
+ // console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
+
+ const retObj = {
+ token: result.data.token,
+ user: result.data.user.username,
+ id: result.data.user._id.toString()
+ }
+
+ return retObj
+ } catch (err) {
+ console.log(
+ 'Error authenticating test admin user: ' + JSON.stringify(err, null, 2)
+ )
+ throw err
+ }
+}
+
+// Retrieve the admin user JWT token from the JSON file it's saved at.
+async function getAdminJWT () {
+ try {
+ // process.env.KOA_ENV = process.env.KOA_ENV || 'dev'
+ // console.log(`env: ${process.env.KOA_ENV}`)
+
+ const FILENAME = `../../config/system-user-${config.env}.json`
+ const adminUserData = require(FILENAME)
+ // console.log(`adminUserData: ${JSON.stringify(adminUserData, null, 2)}`)
+
+ return adminUserData.token
+ } catch (err) {
+ console.error('Error in test/utils.js/getAdminJWT()')
+ throw err
+ }
+}
+
+module.exports = {
+ cleanDb,
+ createUser,
+ loginTestUser,
+ loginAdminUser,
+ getAdminJWT,
+ deleteAllUsers
+}