RESTful API

This example project builds on the previous Adding a Database project by using that project to create a RESTful API. That API can be used to access and modify the data in the database. We’ll also add a suite of unit tests to explore our API and ensure that it is working correctly.

Project Deliverables

At the end of this example, we will have a project with the following features:

  1. A RESTful API with several routes for creating, reading, updating, and deleting (CRUD) data in the database
  2. Open API Documentation for API Routes
  3. Full Unit Test Suite with Coverage Metrics
Prior Work

This project picks up right where the last one left off, so if you haven’t completed that one yet, go back and do that before starting this one.

Let’s get started!

Subsections of RESTful API

API Design

YouTube Video

Good API Design

There are many articles online that discuss best practices in API design. For this project, we’re going to follow a few of the most common recommendations:

Let’s start with the first one - we can easily add a version number to our API’s URL paths. This allows us to make breaking changes to the API in the future without breaking any of the current functionality.

API Versioning

Our current application contains data for both a User and a Role model. For this example, we’ll begin by adding a set of RESTful API routes to work with the Role model. In order to add proper versioning to our API, we will want these routes visible at the /api/v1/roles path.

First, we should create the folder structure inside of our routes folder to match the routes used in our API. This means we’ll create an api folder, then a v1 folder, and finally a roles.js file inside of that folder:

API Folder Paths API Folder Paths

Before we create the content in that file, let’s also create a new file in the base routes folder named api.js that will become the base file for all of our API routes:

/**
 * @file API main router
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports router an Express router
 *
 * @swagger
 * tags:
 *   name: api
 *   description: API routes
 */

// Import libraries
import express from "express";

// Import v1 routers
import rolesRouter from "./api/v1/roles.js"

// Create Express router
const router = express.Router();

// Use v1 routers
router.use("/v1/roles", rolesRouter);

/**
 * Gets the list of API versions
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/:
 *   get:
 *     summary: list API versions
 *     tags: [api]
 *     responses:
 *       200:
 *         description: the list of users
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 type: object
 *                 properties:
 *                   version:
 *                     type: string
 *                   url: 
 *                     type: string
 *             example:
 *               - version: "1.0"
 *                 url: /api/v1/
 */
router.get('/', function (req, res, next) {
  res.json([
    {
      version: "1.0",
      url: "/api/v1/"
    }
  ])
})

export default router

This file is very simple - it just outputs all possible API versions (in this case, we just have a single API version). It also imports and uses our new roles router. Finally, it includes some basic Open API documentation for the route it contains. Let’s quickly add some basic content to our roles router, based on the existing content in our users router from before:

/**
 * @file Roles router
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports router an Express router
 *
 * @swagger
 * tags:
 *   name: roles
 *   description: Roles Routes
 */

// Import libraries
import express from "express";

// Create Express router
const router = express.Router();

// Import models
import { Role } from "../../../models/models.js";

// Import logger
import logger from "../../../configs/logger.js"

/**
 * Gets the list of roles
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/roles:
 *   get:
 *     summary: roles list page
 *     description: Gets the list of all roles in the application
 *     tags: [roles]
 *     responses:
 *       200:
 *         description: the list of roles
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/Role'
 */
router.get("/", async function (req, res, next) {
  try {
    const roles = await Role.findAll();
    res.json(roles);
  } catch (error) {
    logger.error(error)
    res.status(500).end()
  }
});

export default router;

Notice that we have added an additional try and catch block to the route function. This will ensure any errors that are thrown by the database get caught and logged without leaking any sensitive data from our API. It is always a good practice to wrap each API method in a try and catch block.

Get All Route Only

For this particular application’s API design, we will only be creating the get all RESTful method for the Role model. This is because we don’t actually want any users of the application modifying the roles themselves, since those roles will eventually be used in the overall authorization structure of the application (to be added in a later example). However, when creating or updating users, we need to be able to access a full list of all available roles, which can be found using this particular API endpoint.

We’ll explore the rest of the RESTful API methods in the User model later in this example.

Controllers and Services

More complex RESTful API designs may include additional files such as controllers and services to add additional structure to the application. For example, there might be multiple API routes that access the same method in a controller, which then uses a service to perform business logic on the data before storing it in the database.

For this example project, we will place most of the functionality directly in our routes to simplify our structure.

You can read more about how to use controllers and services in the MDN Express Tutorial.

Since we are creating routes in a new subfolder, we also need to update our Open API configuration in configs/openapi.js so that we can see the documentation contained in those routes:

// -=-=- other code omitted here -=-=-

// Configure SwaggerJSDoc options
const options = {
  definition: {
    openapi: "3.1.0",
    info: {
      title: "Lost Communities",
      version: "0.0.1",
      description: "Kansas Lost Communities Project",
    },
    servers: [
      {
        url: url(),
      },
    ],
  },
  apis: ["./routes/*.js", "./models/*.js", "./routes/api/v1/*.js"],
};

export default swaggerJSDoc(options);

Now that we’ve created these two basic routers, let’s get them added to our app.js file so they are accessible to the application:

// -=-=- other code omitted here -=-=-

// Import routers
import indexRouter from "./routes/index.js";
import usersRouter from "./routes/users.js";
import apiRouter from "./routes/api.js";

// Create Express application
var app = express();

// Use libraries
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(helmet());
app.use(compression());
app.use(cookieParser());

// Use middlewares
app.use(requestLogger);

// Use static files
app.use(express.static(path.join(import.meta.dirname, "public")));

// Use routers
app.use("/", indexRouter);
app.use("/users", usersRouter);
app.use("/api", apiRouter);

// -=-=- other code omitted here -=-=-

Now, with everything in place, let’s run our application and see if we can access that new route at /api/v1/roles:

$ npm run dev

If everything is working correctly, we should see our roles listed in the output on that page:

List of Roles List of Roles

We should also be able to query the list of API versions at the path /api:

List of API Versions List of API Versions

Finally, we should also check and make sure our Open API documentation at the /docs path is up to date and includes the new routes:

API Documentation API Documentation

Roles Documentation Roles Documentation

There! This gives us a platform to build our new API upon. We’ll continue throughout this example project to add additional routes to the API as well as related unit tests.

Unit Testing

YouTube Video

Testing Web APIs

Now that we have created our first route in our RESTful API, we can start to write unit tests that will confirm our API works as intended. Adding unit testing early in the development process makes it much easier to keep up with unit tests as new features are added or even explore test-driven development!

There are many libraries that can be used to unit test a RESTful API using Node.js and Express. For this project, we’re going to use a number of testing libraries:

To begin, let’s install these libraries as development dependencies in our project using npm:

$ npm install --save-dev mocha chai supertest ajv chai-json-schema-ajv chai-shallow-deep-equal

Now that we have those libraries in place, let’s make a few modifications to our project configuration to make testing more convenient.

ESLint Plugin

To help with formatting and highlighting of our unit tests, we should update the content of our eslint.config.js to recognize items from mocha as follows:

import globals from "globals";
import pluginJs from "@eslint/js";

/** @type {import('eslint').Linter.Config[]} */
export default [
  {
    languageOptions: {
      globals: {
        ...globals.node,
        ...globals.mocha,
      },
    },
    rules: { "no-unused-vars": ["error", { argsIgnorePattern: "next" }] },
  },
  pluginJs.configs.recommended,
];

If working properly, this should also fix any errors visible in VS Code using the ESLint plugin!

Mocha Root Hooks

In testing frameworks such as mocha, we can create hooks that contain actions that should be taken before each test is executed in a file. The mocha framework also has root-level hooks that are actions to be taken before each and every test in every file. We can use a root-level hook to manage setting up a simple database for unit testing, as well as configuring other aspects of our application for testing.

First, let’s create a new test directory in our server folder, and inside of that we’ll create a file hooks.js to contain the testing hooks for our application.

/**
 * @file Root Mocha Hooks
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports mochaHooks A Mocha Root Hooks Object
 */

// Load environment (must be first)
import dotenvx from "@dotenvx/dotenvx";
dotenvx.config({path: ".env.test"})

// Import configuration
import database from "../configs/database.js";
import migrations from '../configs/migrations.js';
import seeds from '../configs/seeds.js';

// Root Hook Runs Before Each Test
export const mochaHooks = {

  // Hook runs once before any tests are executed
  beforeAll(done) {
    // Test database connection
    database.authenticate().then(() => {
      // Run migrations
      migrations.up().then(() => {
        done() 
      });
    });
  },
  
  // Hook runs before each individual test
  beforeEach(done) {
    // Seed the database
    seeds.up().then(() => {
      done();
    })
  },

  // Hook runs after each individual test
  afterEach(done) {
    // Remove all data from the database
    seeds.down({to: 0}).then(() => {
      done();
    });
  }
}

This file contains three hooks. First, the beforeAll hook, which is executed once before any tests are executed, is used to migrate the database. Then, we have the beforeEach() hook, which is executed before each individual test, which will seed the database with some sample data for us to use in our unit tests. Finally, we have an afterEach() hook that will remove any data from the database by undoing all of the seeds, which will truncate each table in the database.

Notice at the top that we are also loading our environment from a new environment file, .env.test. This allows us to use a different environment configuration when we perform testing. So, let’s create that file and populate it with the following content:

LOG_LEVEL=error
PORT=3000
OPENAPI_HOST=http://localhost:3000
OPENAPI_VISIBLE=false
DATABASE_FILE=:memory:
SEED_DATA=false

Here, the two major changes are to switch the log level to error so that we only see errors in the log output, and also to switch the database file to :memory: - a special filename that tells SQLite to create an in-memory database that is excellent for testing.

At this point, we can start writing our unit tests.

Writing Basic Unit Tests

Let’s start with a very simple case - the /api route we created earlier. This is a simple route that only has a single method and outputs a single item, but it already clearly demonstrates how complex unit testing can become.

For these unit tests, we can create a file api.js in the test folder with the following content:

/**
 * @file /api Route Tests
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Load Libraries
import request from 'supertest'
import { use, should } from 'chai'
import chaiJsonSchemaAjv from 'chai-json-schema-ajv'
import chaiShallowDeepEqual from 'chai-shallow-deep-equal'
use(chaiJsonSchemaAjv.create({ verbose: true }))
use(chaiShallowDeepEqual)

// Import Express application
import app from '../app.js';

// Modify Object.prototype for BDD style assertions
should()

These lines will import the various libraries required for these unit tests. We’ll explore how they work as we build the unit tests, but it is also recommended to read the documentation for each library (linked above) to better understand how each one works together in the various unit tests.

Now, let’s write our first unit test, which can be placed right below those lines in the same file:

// -=-=- other code omitted here -=-=-

/**
 * Get all API versions
 */
const getAllVersions = () => {
  it('should list all API versions', (done) => {
    request(app)
      .get('/api/')
      .expect(200)
      .end((err, res) => {
        if (err) return done(err)
        res.body.should.be.an('array')
        res.body.should.have.lengthOf(1)
        done()
      })
  })
}

/**
 * Test /api route
 */
describe('/api', () => {
  describe('GET /', () => {
    getAllVersions()
  })
})

This code looks quite a bit different than the code we’ve been writing so far. This is because the mocha and chai libraries use the Behavior-Driven Development, or BDD, style for writing unit tests. The core idea is that the unit tests should be somewhat “readable” by anyone looking at the code. So, it defines functions such as it and describe that are used to structure the unit tests.

In this example, the getAllVersions function is a unit test function that uses the request library to send a request to our Express app at the /api/ path. When the response is received from that request, we expect the HTTP status code to be 200, and the body of that request should be an array with a length of 1. Hopefully it is clear to see all of that just by reading the code in that function.

The other important concept is the special done function, which is provided as an argument to any unit test function that is testing asynchronous code. Because of the way asynchronous code is handled, the system cannot automatically determine when all promises have been returned. So, once we are done with the unit test and are not waiting for any further async responses, we need to call the done() method. Notice that we call that both at the end of the function, but also in the if statement that checks for any errors returned from the HTTP request.

Finally, at the bottom of the file, we have a few describe statements that actually build the structure that runs each unit test. When the tests are executed, only functions called inside of the describe statements will be executed.

Running Unit Tests

Now that we have created a simple unit test, let’s run it using the mocha test framework. To do this, we’ll add a new script to the package.json file with all of the appropriate options:

{
  ...
  "scripts": {
    "start": "LOG_LEVEL=http node ./bin/www",
    "dev": "nodemon ./bin/www",
    "lint": "npx eslint --fix .",
    "format": "npx prettier . --write",
    "test": "mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit"
  },
  ...
}

Here, we are using the mocha command with many options:

  • --require test/hooks.js - this requires the global hooks file to be used before each test
  • --recursive - this will recursively look for any tests in subdirectories
  • --parallel - this allows tests to run in parallel (this requires the SQLite in-memory database)
  • --timeout 2000 - this will stop any test if it runs for more than 2 seconds
  • --exit - this forces Mocha to stop after all tests have finished

So, now let’s run our tests using that script:

$ npm run test

If everything is working correctly, we should get the following output:

> lost-communities-solution@0.0.1 test
> mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit

[dotenvx@1.34.0] injecting env (6) from .env.test

[dotenvx@1.34.0] injecting env (0) from .env.test
[dotenvx@1.34.0] injecting env (0) from .env

  /api
    GET /
      ✔ should list all API versions


  1 passing (880ms)

Great! It looks like our test already passed!

Just to be sure, let’s quickly modify our test to look for an array of size 2 so that it should fail:

// -=-=- other code omitted here -=-=-

/**
 * Get all API versions
 */
const getAllVersions = () => {
  it('should list all API versions', (done) => {
    request(app)
      .get('/api/')
      .expect(200)
      .end((err, res) => {
        if (err) return done(err)
        res.body.should.be.an('array')
        res.body.should.have.lengthOf(2)
        done()
      })
  })
}

// -=-=- other code omitted here -=-=-

Now, when we run the tests, we should clearly see a failure report instead:

> lost-communities-solution@0.0.1 test
> mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit

[dotenvx@1.34.0] injecting env (6) from .env.test

[dotenvx@1.34.0] injecting env (0) from .env.test
[dotenvx@1.34.0] injecting env (0) from .env

  /api
    GET /
      1) should list all API versions


  0 passing (910ms)
  1 failing

  1) /api
       GET /
         should list all API versions:

      Uncaught AssertionError: expected [ { version: '1.0', url: '/api/v1/' } ] to have a length of 2 but got 1
      + expected - actual

      -1
      +2
      
      at Test.<anonymous> (file:///workspaces/lost-communities-solution/server/test/api.js:31:30)
      at Test.assert (node_modules/supertest/lib/test.js:172:8)
      at Server.localAssert (node_modules/supertest/lib/test.js:120:14)
      at Object.onceWrapper (node:events:638:28)
      at Server.emit (node:events:524:28)
      at emitCloseNT (node:net:2383:8)
      at process.processTicksAndRejections (node:internal/process/task_queues:89:21)

Thankfully, anytime a test fails, we get a very clear and easy to follow error report that pinpoints exactly which line in the test failed, and how the assertion was not met.

Before moving on, let’s update our test so that it should pass again.

Code Coverage

YouTube Video

Code Coverage

It is often helpful to examine the code coverage of our unit tests. Thankfully, there is an easy way to enable that in our project using the c8 library. So, we can start by installing it:

$ npm install --save-dev c8

Once it is installed, we can simply add it to a new script in the package.json file that will run our tests with code coverage:

{
  ...
  "scripts": {
    "start": "LOG_LEVEL=http node ./bin/www",
    "dev": "nodemon ./bin/www",
    "lint": "npx eslint --fix .",
    "format": "npx prettier . --write",
    "test": "mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit",
    "cov": "c8 --reporter=html --reporter=text mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit"
  },
  ...
}

All we have to do is add the c8 command with a few options in front of our existing mocha command.

Now, we can run our tests with code coverage using this script:

$ npm run cov

This time, we’ll see a bunch of additional output on the terminal

> lost-communities-solution@0.0.1 cov
> c8 --reporter=html --reporter=text mocha --require test/hooks.js --recursive --parallel --timeout 2000 --exit

[dotenvx@1.34.0] injecting env (6) from .env.test

[dotenvx@1.34.0] injecting env (0) from .env.test
[dotenvx@1.34.0] injecting env (0) from .env

  /api
    GET /
      ✔ should list all API versions


  1 passing (1s)

------------------------|---------|----------|---------|---------|-------------------------------------------
File                    | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                         
------------------------|---------|----------|---------|---------|-------------------------------------------
All files               |   93.53 |    83.33 |   55.55 |   93.53 |                                           
 server                 |   88.52 |       50 |     100 |   88.52 |                                           
  app.js                |   88.52 |       50 |     100 |   88.52 | 53-59                                     
 server/configs         |   91.86 |    47.36 |     100 |   91.86 |                                           
  database.js           |     100 |      100 |     100 |     100 |                                           
  logger.js             |   85.56 |    30.76 |     100 |   85.56 | 24-25,27-28,30-31,33-34,36-37,39-40,42-43 
  migrations.js         |     100 |      100 |     100 |     100 |                                           
  openapi.js            |   92.85 |    66.66 |     100 |   92.85 | 19-21                                     
  seeds.js              |     100 |      100 |     100 |     100 |                                           
 server/middlewares     |     100 |      100 |     100 |     100 |                                           
  request-logger.js     |     100 |      100 |     100 |     100 |                                           
 server/migrations      |   96.07 |      100 |      50 |   96.07 |                                           
  00_users.js           |   95.55 |      100 |      50 |   95.55 | 44-45                                     
  01_roles.js           |   94.91 |      100 |      50 |   94.91 | 57-59                                     
  02_counties.js        |   96.61 |      100 |      50 |   96.61 | 58-59                                     
  03_communities.js     |   96.61 |      100 |      50 |   96.61 | 58-59                                     
  04_metadata.js        |   96.66 |      100 |      50 |   96.66 | 88-90                                     
  05_documents.js       |   95.71 |      100 |      50 |   95.71 | 68-70                                     
 server/models          |     100 |      100 |     100 |     100 |                                           
  community.js          |     100 |      100 |     100 |     100 |                                           
  county.js             |     100 |      100 |     100 |     100 |                                           
  document.js           |     100 |      100 |     100 |     100 |                                           
  metadata.js           |     100 |      100 |     100 |     100 |                                           
  metadata_community.js |     100 |      100 |     100 |     100 |                                           
  metadata_document.js  |     100 |      100 |     100 |     100 |                                           
  models.js             |     100 |      100 |     100 |     100 |                                           
  role.js               |     100 |      100 |     100 |     100 |                                           
  user.js               |     100 |      100 |     100 |     100 |                                           
  user_role.js          |     100 |      100 |     100 |     100 |                                           
 server/routes          |   68.72 |      100 |     100 |   68.72 |                                           
  api.js                |     100 |      100 |     100 |     100 |                                           
  index.js              |   97.43 |      100 |     100 |   97.43 | 36                                        
  users.js              |    46.8 |      100 |     100 |    46.8 | 52-62,66-73,77-91,95-105,109-138          
 server/routes/api/v1   |   87.71 |      100 |     100 |   87.71 |                                           
  roles.js              |   87.71 |      100 |     100 |   87.71 | 48-54                                     
 server/seeds           |   95.09 |      100 |      50 |   95.09 |                                           
  00_users.js           |   96.36 |      100 |      50 |   96.36 | 54-55                                     
  01_roles.js           |   97.36 |      100 |      50 |   97.36 | 112-114                                   
  02_counties.js        |   95.83 |      100 |      50 |   95.83 | 47-48                                     
  03_communities.js     |   95.65 |      100 |      50 |   95.65 | 45-46                                     
  04_metadata.js        |   89.39 |      100 |      50 |   89.39 | 60-66                                     
  05_documents.js       |   94.82 |      100 |      50 |   94.82 | 56-58

Right away we see that a large part of our application achieves 100% code coverage with a single unit test! This highlights both how tightly interconnected all parts of our application are (such that a single unit test exercises much of the code) but also that code coverage can be a very poor metric for unit test quality (seeing this result we might suspect our application is already well tested with just a single unit test).

We have also enabled the html reporter, so we can see similar results in a coverage folder that appears inside of our server folder. We can use various VS Code Extensions such as Live Preview to view that file in our web browser.

Port Conflict

The Live Preview extension defaults to port 3000, so we recommend digging into the settings and changing the default port to something else before using it.

Coverage Example Coverage Example

In either case, we can see that we’ve already reached 100% coverage on our routes/api.js file. However, as we’ll see in the next section, that doesn’t always mean that we are done writing our unit tests.

Other Tests

YouTube Video

Testing for Other Issues

Let’s consider the scenario where our routes/api.js file was modified slightly to have some incorrect code in it:

// -=-=- other code omitted here -=-=-
router.get('/', function (req, res, next) {
  res.json([
    {
      versoin: "1.0",
      url: "/api/ver1/"
    }
  ])
})

In this example, we have misspelled the version attribute, and also used an incorrect URL for that version of the API. Unfortunately, if we actually make that change to our code, our existing unit test will not catch either error!

So, let’s look at how we can go about catching these errors and ensuring our unit tests are actually valuable.

JSON Schemas

First, it is often helpful to validate the schema of the JSON output by our API. To do that, we’ve installed the ajv JSON schema validator and a chai plugin for using it in a unit test. So, in our test/api.js file, we can add a new test:

// -=-=- other code omitted here -=-=-

/**
 * Check JSON Schema of API Versions
 */
const getAllVersionsSchemaMatch = () => {
  it('all API versions should match schema', (done) => {
    const schema = {
      type: 'array',
      items: {
        type: 'object',
        required: ['version', 'url'],
        properties: {
          version: { type: 'string' },
          url: { type: 'string' },
        },
        additionalProperties: false,
      },
    }
    request(app)
      .get('/api/')
      .expect(200)
      .end((err, res) => {
        if (err) return done(err)
        res.body.should.be.jsonSchema(schema)
        done()
      })
  })
}

/**
 * Test /api route
 */
describe('/api', () => {
  describe('GET /', () => {
    getAllVersions()
    getAllVersionsSchemaMatch()
  })
})

In this test, we create a JSON schema following the AJV Instructions that defines the various attributes that should be present in the output. It is especially important to include the additionalProperties: false line, which helps prevent leaking any unintended attributes.

Now, when we run our tests, we should see that this test fails:

  /api
    GET /
      ✔ should list all API versions
      1) all API versions should match schema


  1 passing (1s)
  1 failing

  1) /api
       GET /
         all API versions should match schema:
     Uncaught AssertionError: expected [ { versoin: '1.0', …(1) } ] to match json-schema
[ { instancePath: '/0', …(7) } ]
      at Test.<anonymous> (file:///workspaces/lost-communities-solution/server/test/api.js:59:28)
...

As we can see, the misspelled version attribute will not match the given schema, causing the test to fail! That shows the value of such a unit test in our code.

Protecting Attributes

Let’s update our route to include the correct attributes, but also add an additional item that shouldn’t be present in the output:

// -=-=- other code omitted here -=-=-
router.get('/', function (req, res, next) {
  res.json([
    {
      version: "1.0",
      url: "/api/ver1/",
      secure_data: "This should not be shared!"
    }
  ])
})

This is an example of Broken Object Properly Level Authorization, one of the top 10 most common API security risks according to OWASP. Often our database models will include attributes that we don’t want to expose to our users, so we want to make sure they aren’t included in the output by accident.

If we run our test again, it should also fail:

  /api
    GET /
      ✔ should list all API versions
      1) all API versions should match schema


  1 passing (1s)
  1 failing

  1) /api
       GET /
         all API versions should match schema:
     Uncaught AssertionError: expected [ { version: '1.0', …(2) } ] to match json-schema
[ { instancePath: '/0', …(7) } ]
      at Test.<anonymous> (file:///workspaces/lost-communities-solution/server/test/api.js:59:28)
...

However, if we remove the line additionalProperties: false from our JSON schema unit test, it will now succeed. So, it is always important for us to remember to include that line in all of our JSON schemas if we want to avoid this particular security flaw.

Checking Values

However, we still have not caught our incorrect value in our API output:

// -=-=- other code omitted here -=-=-
router.get('/', function (req, res, next) {
  res.json([
    {
      version: "1.0",
      url: "/api/ver1/",
      secure_data: "This should not be shared!"
    }
  ])
})

For this, we need to write one additional unit test to check the actual content of the output. For this, we’ll use a deep equality plugin for chai:

// -=-=- other code omitted here -=-=-

/**
 * Check API version exists in list
 */
const findVersion = (version) => {
  it('should contain specific version', (done) => {
    request(app)
      .get('/api/')
      .expect(200)
      .end((err, res) => {
        if (err) return done(err)
        const foundVersion = res.body.find((v) => v.version === version.version)
        foundVersion.should.shallowDeepEqual(version)
        done()
      })
  })
}

/**
 * Test /api route
 */
describe('/api', () => {
  describe('GET /', () => {
    getAllVersions()
    getAllVersionsSchemaMatch()
  })

  describe('version: 1.0', () => {
    const version = {
      version: "1.0",
      url: "/api/v1/"
    }

    describe('GET /', () => {
      findVersion(version)
    })
  })
})

The findVersion unit test will check the actual contents of the output received from the API and compare it to the version object that is provided as input. In our describe statements below, we can see how easy it is to define a simple version object that we can use to compare to the output.

Use the Source!

One common mistake when writing these unit tests is to simply copy the object structure from the code that is being tested. This is considered bad practice since it virtually guarantee that any typos or mistakes are not caught. Instead, when constructing these unit tests, we should always go back to the original source document, typically a design document or API specification, and build our unit tests using that as a guide. This will ensure that our tests will actually catch things such as typos or missing data.

With that test in place, we should once again have a unit test that fails:

  /api
    GET /
      ✔ should list all API versions
      ✔ all API versions should match schema
    version: 1.0
      GET /
        1) should contain specific version


  2 passing (987ms)
  1 failing

  1) /api
       version: 1.0
         GET /
           should contain specific version:

      Uncaught AssertionError: Expected to have "/api/v1/" but got "/api/ver1/" at path "/url".
      + expected - actual

       {
      -  "url": "/api/ver1/"
      +  "url": "/api/v1/"
         "version": "1.0"
       }
      
      at Test.<anonymous> (file:///workspaces/lost-communities-solution/server/test/api.js:76:29)

Thankfully, in the output we clearly see the error, and it is easy to go back to our original design document to correct the error in our code.

Reusing Tests

While it may seem like we are using a very complex structure for these tests, there is actually a very important reason behind it. If done correctly, we can easily reuse most of our tests as we add additional data to the application.

Let’s consider the scenario where we add a second API version to our output:

// -=-=- other code omitted here -=-=-
router.get('/', function (req, res, next) {
  res.json([
    {
      version: "1.0",
      url: "/api/v1/"
    },
    {
      version: "2.0",
      url: "/api/v2/"
    }
  ])
})

To fully test this, all we need to do is update the array size in the getAllVersions and add an additional describe statement for the new version:

// -=-=- other code omitted here -=-=-

/**
 * Get all API versions
 */
const getAllVersions = () => {
  it('should list all API versions', (done) => {
    request(app)
      .get('/api/')
      .expect(200)
      .end((err, res) => {
        if (err) return done(err)
        res.body.should.be.an('array')
        res.body.should.have.lengthOf(2)
        done()
      })
  })
}

// -=-=- other code omitted here -=-=-

/**
 * Test /api route
 */
describe('/api', () => {
  describe('GET /', () => {
    getAllVersions()
    getAllVersionsSchemaMatch()
  })

  describe('version: 1.0', () => {
    const version = {
      version: "1.0",
      url: "/api/v1/"
    }

    describe('GET /', () => {
      findVersion(version)
    })
  })

  describe('version: 2.0', () => {
    const version = {
      version: "2.0",
      url: "/api/v2/"
    }

    describe('GET /', () => {
      findVersion(version)
    })
  })
})

With those minor changes, we see that our code now passes all unit tests:

  /api
    GET /
      ✔ should list all API versions
      ✔ all API versions should match schema
    version: 1.0
      GET /
        ✔ should contain specific version
    version: 2.0
      GET /
        ✔ should contain specific version

By writing reusable functions for our unit tests, we can often deduplicate and simplify our code.

Before moving on, let’s roll back our unit tests and the API to just have a single version. We should make sure all tests are passing before we move ahead!

Testing Roles

YouTube Video

Unit Testing Roles Routes

Now that we’ve created a basic unit test for the /api route, we can now expand on that to test our other existing route, the /api/v1/roles route. Once again, there is only one method inside of this route, the GET ALL method, so the unit tests should be similar between these two routes. The only difference here is this route is now reading from the database instead of just returning a static JSON array.

We can begin by creating a new api folder inside of the test folder, and then a v1 folder inside of that, and finally a new roles.js file to contain our tests. By doing this, the path to our tests match the path to the routes themselves, making it easy to match up the tests with the associated routers.

Inside of that file, we can place the first unit test for the roles routes:

/**
 * @file /api/v1/roles Route Tests
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Load Libraries
import request from "supertest";
import { use, should } from "chai";
import chaiJsonSchemaAjv from "chai-json-schema-ajv";
import chaiShallowDeepEqual from "chai-shallow-deep-equal";
use(chaiJsonSchemaAjv.create({ verbose: true }));
use(chaiShallowDeepEqual);

// Import Express application
import app from "../../../app.js";

// Modify Object.prototype for BDD style assertions
should();

/**
 * Get all Roles
 */
const getAllRoles = () => {
  it("should list all roles", (done) => {
    request(app)
      .get("/api/v1/roles")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("array");
        res.body.should.have.lengthOf(7);
        done();
      });
  });
};


/**
 * Test /api/v1/roles route
 */
describe("/api/v1/roles", () => {
  describe("GET /", () => {
    getAllRoles();
  });
});

Just like before, this unit test will simply send an HTTP GET request to the /api/v1/roles and expect to receive a response that contains an array of 7 elements, which matches the 7 roles defined in the seeds/01_roles.js file.

Adding Additional Formats to AJV

Next, we can create a test to confirm that the structure of that response matches our expectation:

// -=-=- other code omitted here -=-=-

/**
 * Check JSON Schema of Roles
 */
const getRolesSchemaMatch = () => {
  it("all roles should match schema", (done) => {
    const schema = {
      type: "array",
      items: {
        type: "object",
        required: ["id", "role"],
        properties: {
          id: { type: "number" },
          role: { type: "string" },
          createdAt: { type: "string" },
          updatedAt: { type: "string" }
        },
        additionalProperties: false,
      },
    };
    request(app)
      .get("/api/v1/roles")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.jsonSchema(schema);
        done();
      });
  });
};


/**
 * Test /api/v1/roles route
 */
describe("/api/v1/roles", () => {
  describe("GET /", () => {
    getAllRoles();
    getRolesSchemaMatch();
  });
});

However, as we write that test, we might notice that the createdAt and updatedAt fields are just defined as strings, when really they should be storing a timestamp. Thankfully, the AJV Schema Validator has an extension called AJV Formats that adds many new formats we can use. So, let’s install it as a development dependency using npm:

$ npm install --save-dev ajv-formats

Then, we can add it to AJV at the top of our unit tests and use all of the additional types in the AJV Formats documentation in our tests:

/**
 * @file /api/v1/roles Route Tests
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Load Libraries
import request from "supertest";
import { use, should } from "chai";
import Ajv from 'ajv'
import addFormats from 'ajv-formats';
import chaiJsonSchemaAjv from "chai-json-schema-ajv";
import chaiShallowDeepEqual from "chai-shallow-deep-equal";

// Import Express application
import app from "../../../app.js";

// Configure Chai and AJV
const ajv = new Ajv()
addFormats(ajv)
use(chaiJsonSchemaAjv.create({ ajv, verbose: true }));
use(chaiShallowDeepEqual);

// Modify Object.prototype for BDD style assertions
should();

// -=-=- other code omitted here -=-=-

/**
 * Check JSON Schema of Roles
 */
const getRolesSchemaMatch = () => {
  it("all roles should match schema", (done) => {
    const schema = {
      type: "array",
      items: {
        type: "object",
        required: ["id", "role"],
        properties: {
          id: { type: "number" },
          role: { type: "string" },
          createdAt: { type: "string", format: "iso-date-time" },
          updatedAt: { type: "string", format: "iso-date-time"  }
        },
        additionalProperties: false,
      },
    };
    request(app)
      .get("/api/v1/roles")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.jsonSchema(schema);
        done();
      });
  });
};

// -=-=- other code omitted here -=-=-

Now we can use the iso-date-time string format to confirm that the createdAt and updatedAt fields match the expected format. The AJV Formats package supports a number of helpful formats, such as email, uri, uuid, and more.

Testing Each Role

Finally, we should also check that each role we expect to be included in the database is present and accounted for. We can write a single unit test function for this, but we’ll end up calling it several times with different roles:

// -=-=- other code omitted here -=-=-

/**
 * Check Role exists in list
 */
const findRole = (role) => {
  it("should contain '" + role.role + "' role", (done) => {
    request(app)
      .get("/api/v1/roles")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        const foundRole = res.body.find(
          (r) => r.id === role.id,
        );
        foundRole.should.shallowDeepEqual(role);
        done();
      });
  });
};


/**
 * Test /api/v1/roles route
 */
describe("/api/v1/roles", () => {
  describe("GET /", () => {
    getAllRoles();
    getRolesSchemaMatch();
    // List of all expected roles in the application
    const roles = [ 
      {
        id: 1, 
        role: "manage_users"
      },
      {
        id: 2,
        role: "manage_documents"
      },
      {
        id: 3,
        role: "add_documents"
      },
      {
        id: 4,
        role: "manage_communities"
      },
      {
        id: 5,
        role: "add_communities"
      },
      {
        id: 6,
        role: "view_documents"
      },
      {
        id: 7,
        role: "view_communities"
      }
    ]
    roles.forEach( (r) => {
      findRole(r)
    })
  });
});

Here we are creating a simple array of roles, which looks similar to the one that is already present in our seeds/01_roles.js seed file, but importantly it is not copied from that file! Instead, we should go back to the original design documentation for this application, if any, and read the roles from there to make sure they are all correctly added to the database. In this case we don’t have an original design document so we won’t worry about that here.

With all of that in place, let’s run our unit tests and confirm they are working:

$ npm run test

If everything is correct, we should find the following in our output showing all tests are successful:

  /api/v1/roles
    GET /
      ✔ should list all roles
      ✔ all roles should match schema
      ✔ should contain 'manage_users' role
      ✔ should contain 'manage_documents' role
      ✔ should contain 'add_documents' role
      ✔ should contain 'manage_communities' role
      ✔ should contain 'add_communities' role
      ✔ should contain 'view_documents' role
      ✔ should contain 'view_communities' role

There we go! We now have working unit tests for our roles. Now is a great time to lint, format, and then commit and push our work to GitHub before continuing. Below are a couple of important discussions on unit test structure and design that are highly recommended before continuing.

Unit Tests Based on Seed Data

In this application, we are heavily basing our unit tests on the seed data we created in the seeds directory. This is a design choice, and there are many different ways to approach this in practice:

  • Seed data for unit tests could be included as a hook that runs before each unit test
  • Unit tests could assume the database is completely blank and manually insert data as needed as part of the test
  • Different seed data files could be used for testing and production
  • A sample database file or connection could be used for testing instead of seed data

In this case, we believe it makes sense for the application we are testing to have a number of pre-defined roles and users that are populated via seed data when the application is tested and when it is deployed, so we chose to build our unit tests based on the assumption that the existing seed data will be used. However, other application designs may require different testing strategies, so it is always important to consider which method will work best for a given application!

Duplicated Unit Test Code

A keen-eyed observer may notice that the three unit test functions in the test/api.js file are nearly identical to the functions included in the test/api/v1/roles.js file. This is usually the case in unit testing - there is often a large amount of repeated code used to test different parts of an application, especially a RESTful API like this one.

This leads to two different design options:

  • Refactor the code to reduce duplication across unit tests, adding some complexity and interdependence between tests
  • Keep duplicated code to make unit tests more readable and independent of each other

For this application, we will follow the second approach. We feel that unit tests are much more useful if the large majority of the test can be easily seen and understood in a single file. This also means that a change in one test method will not impact other tests, both for good and for bad. So, it may mean modifying and updating the entire test suite is a bit more difficult, but updating individual tests should be much simpler.

Again, this is a design choice that we feel is best for this application, and other applications may be better off with other structures. It is always important to consider these implications when writing unit tests for an application!

Retrieve All

YouTube Video

Users Routes

Now that we have written and tested the routes for the Role model, let’s start working on the routes for the User model. These routes will be much more complex, because we want the ability to add, update, and delete users in our database.

To do this, we’ll create several RESTful routes, which pair HTTP verbs and paths to the various CRUD operations that can be performed on the database. Here is a general list of the actions we want to perform on most models in a RESTful API, based on their associated CRUD operation:

  • Create New (HTTP POST)
  • Retrieve All / Retrieve One (HTTP GET)
  • Update One (HTTP PUT)
  • Delete One (HTTP DELETE)

As we build this new API router, we’ll see each one of these in action.

Retrieve All Route

The first operation we’ll look at is the retrieve all operation, which is one we’re already very familiar with. To begin, we should start by copying the existing file at routes/users.js to routes/api/v1/users.js and modifying it a bit to contain this content:

/**
 * @file Users router
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports router an Express router
 *
 * @swagger
 * tags:
 *   name: users
 *   description: Users Routes
 */

// Import libraries
import express from "express";

// Create Express router
const router = express.Router();

// Import models
import {
  User,
  Role,
} from "../../../models/models.js";

// Import logger
import logger from "../../../configs/logger.js";

/**
 * Gets the list of users
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/users:
 *   get:
 *     summary: users list page
 *     description: Gets the list of all users in the application
 *     tags: [users]
 *     responses:
 *       200:
 *         description: the list of users
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/User'
 */
router.get("/", async function (req, res, next) {
  try {
    const users = await User.findAll({
      include: {
        model: Role,
        as: "roles",
        attributes: ["id", "role"],
        through: {
          attributes: [],
        },
      },
    });
    res.json(users);
  } catch (error) {
    logger.error(error);
    res.status(500).end();
  }
});

export default router;

This is very similar to the code we included in our roles route. The major difference is that the users route will also output the list of roles assigned to the user. There is a lot of great information in the Sequelize Documentation for how to properly query associated records.

We’ll also need to remove the line from our app.js file that directly imports and uses that router:

// -=-=- other code omitted here -=-=-

// Import routers
import indexRouter from "./routes/index.js";
import usersRouter from "./routes/users.js"; // delete this line
import apiRouter from "./routes/api.js";

// -=-=- other code omitted here -=-=-

// Use routers
app.use("/", indexRouter);
app.use("/users", usersRouter); // delete this line
app.use("/api", apiRouter);

// -=-=- other code omitted here -=-=-

Instead, we can now import and link the new router in our routes/api.js file:

// -=-=- other code omitted here -=-=-

// Import v1 routers
import rolesRouter from "./api/v1/roles.js";
import usersRouter from "./api/v1/users.js";

// Create Express router
const router = express.Router();

// Use v1 routers
router.use("/v1/roles", rolesRouter);
router.use("/v1/users", usersRouter);

// -=-=- other code omitted here -=-=-

Before moving on, let’s run our application and make sure that the users route is working correctly:

$ npm run dev

Once it loads, we can navigate to the /api/v1/users URL to see the output:

Retrieve All Ouptut Retrieve All Ouptut

Retrieve All Unit Tests

As we write each of these routes, we’ll also explore the related unit tests. The first three unit tests for this route are very similar to the ones we wrote for the roles routes earlier, so we won’t go into too much detail on these. As expected, we’ll place all of the unit tests for the users routes in the test/api/v1/users.js file:

/**
 * @file /api/v1/users Route Tests
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Load Libraries
import request from "supertest";
import { use, should } from "chai";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import chaiJsonSchemaAjv from "chai-json-schema-ajv";
import chaiShallowDeepEqual from "chai-shallow-deep-equal";

// Import Express application
import app from "../../../app.js";

// Configure Chai and AJV
const ajv = new Ajv();
addFormats(ajv);
use(chaiJsonSchemaAjv.create({ ajv, verbose: true }));
use(chaiShallowDeepEqual);

// Modify Object.prototype for BDD style assertions
should();

// User Schema
const userSchema = {
  type: "object",
  required: ["id", "username"],
  properties: {
    id: { type: "number" },
    username: { type: "string" },
    createdAt: { type: "string", format: "iso-date-time" },
    updatedAt: { type: "string", format: "iso-date-time" },
    roles: {
      type: "array",
      items: {
          type: 'object',
          required: ['id', 'role'],
          properties: {
              id: { type: 'number' },
              role: { type: 'string' },
          },
      },
    }
  },
  additionalProperties: false,
};

/**
 * Get all Users
 */
const getAllUsers = () => {
  it("should list all users", (done) => {
    request(app)
      .get("/api/v1/users")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("array");
        res.body.should.have.lengthOf(4);
        done();
      });
  });
};

/**
 * Check JSON Schema of Users
 */
const getUsersSchemaMatch = () => {
  it("all users should match schema", (done) => {
    const schema = {
      type: "array",
      items: userSchema
    };
    request(app)
      .get("/api/v1/users")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.jsonSchema(schema);
        done();
      });
  });
};

/**
 * Check User exists in list
 */
const findUser = (user) => {
  it("should contain '" + user.username + "' user", (done) => {
    request(app)
      .get("/api/v1/users")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        const foundUser = res.body.find((u) => u.id === user.id);
        foundUser.should.shallowDeepEqual(user);
        done();
      });
  });
};

// List of all expected users in the application
const users = [
  {
    id: 1,
    username: "admin",
  },
  {
    id: 2,
    username: "contributor",
  },
  {
    id: 3,
    username: "manager",
  },
  {
    id: 4,
    username: "user",
  }
];

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  describe("GET /", () => {
    getAllUsers();
    getUsersSchemaMatch();
    
    users.forEach((u) => {
      findUser(u);
    });
  });
});

The major difference to note is in the highlighted section, where we have to add some additional schema information to account for the roles associated attribute that is part of the users object. It is pretty self-explanatory; each object in the array has a set of attributes that match what we used in the unit test for the roles routes.

We also moved the schema for the User response object out of that unit test so we can reuse it in other unit tests, as we’ll see later in this example.

However, we also should add a couple of additional unit tests to confirm that each user has the correct roles assigned, since that is a major part of the security and authorization mechanism we’ll be building for this application. While we could do that as part of the findUser test, let’s go ahead and add separate tests for each of these, which is helpful in debugging anything that is broken or misconfigured.

/**
 * @file /api/v1/users Route Tests
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Load Libraries
import request from "supertest";
import { use, should, expect } from "chai";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import chaiJsonSchemaAjv from "chai-json-schema-ajv";
import chaiShallowDeepEqual from "chai-shallow-deep-equal";

// -=-=- other code omitted here -=-=-

/**
 * Check that User has correct number of roles
 */
const findUserCountRoles = (username, count) => {
  it("user '" + username + "' should have " + count + " roles", (done) => {
    request(app)
      .get("/api/v1/users")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        const foundUser = res.body.find((u) => u.username === username);
        foundUser.roles.should.be.an("array");
        foundUser.roles.should.have.lengthOf(count);
        done();
      });
  });
};

/**
 * Check that User has specific role
 */
const findUserConfirmRole = (username, role) => {
  it("user '" + username + "' should have '" + role + "' role", (done) => {
    request(app)
      .get("/api/v1/users")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        const foundUser = res.body.find((u) => u.username === username);
        expect(foundUser.roles.some((r) => r.role === role)).to.equal(true)
        done();
      });
  });
};

// -=-=- other code omitted here -=-=-

// List of all users and expected roles
const user_roles = [
  {
    username: "admin",
    roles: ["manage_users", "manage_documents", "manage_communities"]
  },
  {
    username: "contributor",
    roles: ["add_documents", "add_communities"]
  },
  {
    username: "manager",
    roles: ["manage_documents", "manage_communities"]
  },
  {
    username: "user",
    roles: ["view_documents", "view_communities"]
  },
];

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  describe("GET /", () => {

    // -=-=- other code omitted here -=-=-
    
    user_roles.forEach((u) => {
      // Check that user has correct number of roles
      findUserCountRoles(u.username, u.roles.length)
      u.roles.forEach((r) => {
        // Check that user has each expected role
        findUserConfirmRole(u.username, r)
      })
    });
  });
});

This code uses an additional assertion, expect, from the chai library, so we have to import it at the top on the highlighted line. These two tests will confirm that the user has the expected number of roles, and also explicitly confirm that each user has each of the expected roles.

Testing Arrays for Containment

When writing unit tests that deal with arrays, it is always important to not only check that the array contains the correct elements, but also that it ONLY contains those elements and no additional elements. A great way to do this is to explicitly check each element the array should contain is present, and then also check the size of the array so that it can only contain those listed elements. Of course, this assumes that each element is only present once in the array!

If we aren’t careful about how these unit tests are constructed, it is possible for arrays to contain additional items. In this case, it might mean that a user is assigned to more roles than they should be, which would be very bad for our application’s security!

With all of these tests in place, let’s go ahead and run them to confirm everything is working properly. Thankfully, with the mocha test runner, we can even specify a single file to run, as shown below:

$ npm run test test/api/v1/users.js

If everything is correct, we should see that this file has 19 tests that pass:

  /api/v1/users
    GET /
      ✔ should list all users
      ✔ all users should match schema
      ✔ should contain 'admin' user
      ✔ should contain 'contributor' user
      ✔ should contain 'manager' user
      ✔ should contain 'user' user
      ✔ user 'admin' should have 3 roles
      ✔ user 'admin' should have 'manage_users' role
      ✔ user 'admin' should have 'manage_documents' role
      ✔ user 'admin' should have 'manage_communities' role
      ✔ user 'contributor' should have 2 roles
      ✔ user 'contributor' should have 'add_documents' role
      ✔ user 'contributor' should have 'add_communities' role
      ✔ user 'manager' should have 2 roles
      ✔ user 'manager' should have 'manage_documents' role
      ✔ user 'manager' should have 'manage_communities' role
      ✔ user 'user' should have 2 roles
      ✔ user 'user' should have 'view_documents' role
      ✔ user 'user' should have 'view_communities' role


  19 passing (1s)

Great! Now is a great time to lint, format, and then commit and push our work to GitHub before continuing.

Retrieve One

YouTube Video

Retrieve One Route

Many RESTful web APIs also include the ability to retrieve a single object from a collection by providing the ID as a parameter to the route. So, let’s go ahead and build that route in our application as well.

Unused in Practice

While this route is an important part of many RESTful web APIs, it can often go unused since most frontend web applications will simply use the retrieve all endpoint to get a list of items, and then it will just cache that result and filter the list to show a user a single entry. However, there are some use cases where this route is extremely useful, so we’ll go ahead and include it in our backend code anyway.

In our routes/api/v1/users.js file, we can add a new route to retrieve a single user based on the user’s ID number:

// -=-=- other code omitted here -=-=-

/**
 * Gets a single user by ID
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/users/{id}:
 *   get:
 *     summary: get single user
 *     description: Gets a single user from the application
 *     tags: [users]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: user ID
 *     responses:
 *       200:
 *         description: a user
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 */
router.get("/:id", async function (req, res, next) {
  try {
    const user = await User.findByPk(req.params.id, {
      include: {
        model: Role,
        as: "roles",
        attributes: ["id", "role"],
        through: {
          attributes: [],
        },
      },
    });
    // if the user is not found, return an HTTP 404 not found status code
    if (user === null) {
      res.status(404).end();
    } else {
      res.json(user);
    }
  } catch (error) {
    logger.error(error);
    res.status(500).end();
  }
});

In this route, we have included a new route parameter id in the path for the route, and we also documented that route parameter in the Open API documentation comment. We then use that id parameter, which will be stored as req.params.id by Express, in the findByPk method available in Sequelize. We can even confirm that our new method appears correctly in our documentation by visiting the /docs route in our application:

Retrieve One Route Retrieve One Route

When we visit that route, we’ll need to include the ID of the user to request in the path, as in /api/v1/users/1. If it is working correctly, we should see data for a single user returned in the browser:

Retrieve One Route Retrieve One Route

Retrieve One Unit Tests

The unit tests for the route to retrieve a single object are nearly identical to the ones use for the retrieve all route. Since we have already verified that each user exists and has the correct roles, we may not need to be as particular when developing these tests.

// -=-=- other code omitted here -=-=-

/**
 * Get single user
 */
const getSingleUser = (user) => {
  it("should get user '" + user.username + "'", (done) => {
    request(app)
      .get("/api/v1/users/" + user.id)
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.shallowDeepEqual(user);
        done();
      });
  });
};

/**
 * Get single user check schema
 */
const getSingleUserSchemaMatch = (user) => {
  it("user '" + user.username + "' should match schema", (done) => {
    request(app)
      .get("/api/v1/users/" + user.id)
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.jsonSchema(userSchema);
        done();
      });
  });
};

// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("GET /{id}", () => {
    users.forEach((u) => {
      getSingleUser(u);
      getSingleUserSchemaMatch(u);
    })
  });
});

For these unit tests, we are once again simply checking that we can retrieve each individual user by ID, and also that the response matches the expected userSchema object we used in earlier tests.

However, these unit tests are only checking for the users that we expect the database to contain. What if we receive an ID parameter for a user that does not exist? We should also test that particular situation as well.

// -=-=- other code omitted here -=-=-

/**
 * Tries to get a user using an invalid id
 */
const getSingleUserBadId = (invalidId) => {
  it("should return 404 when requesting user with id '" + invalidId + "'", (done) => {
    request(app)
      .get("/api/v1/users/" + invalidId)
      .expect(404)
      .end((err) => {
        if (err) return done(err);
        done();
      });
  });
};

// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("GET /{id}", () => {
    users.forEach((u) => {
      getSingleUser(u);
      getSingleUserSchemaMatch(u);
    })

    getSingleUserBadId(0)
    getSingleUserBadId("test")
    getSingleUserBadId(-1)
    getSingleUserBadId(5)
  });
});

With this unit test, we can easily check that our API properly returns HTTP status code 404 for a number of invalid ID values, including 0, -1, "test", 5, and any others we can think of to try.

Create

YouTube Video

Create Route

Now that we’ve explored the routes we can use to read data from our RESTful API, let’s look at the routes we can use to modify that data. The first one we’ll cover is the create route, which allows us to add a new entry to the database. However, before we do that, let’s create some helpful utility functions that we can reuse throughout our application as we develop more advanced routes.

Success Messages

One thing we’ll want to be able to do is send some well-formatted success messages to the user. While we could include this in each route, it is a good idea to abstract this into a utility function that we can write once and use throughout our application. By doing so, it makes it easier to restructure these messages as needed in the future.

So, let’s create a new utilities folder inside of our server folder, and then a new send-success.js file with the following content:

/**
 * @file Sends JSON Success Messages
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports sendSuccess a function to send JSON Success Messages
 */

/**
 * Send JSON Success Messages
 *
 * @param {string} message - the message to send
 * @param {integer} status - the HTTP status to use
 * @param {Object} res - Express response object
 *
 * @swagger
 * components:
 *   responses:
 *     Success:
 *       description: success
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required:
 *               - message
 *               - id
 *             properties:
 *               message:
 *                 type: string
 *                 description: the description of the successful operation
 *               id:
 *                 type: integer
 *                 description: the id of the saved or created item
 *             example:
 *               message: User successfully saved!
 */
function sendSuccess(message, id, status, res) {
  res.status(status).json({
    message: message,
    id: id
  });
}
  
export default sendSuccess;

In this file, we are defining a success message from our application as a JSON object with a message attribute, as well as the id of the object that was acted upon. The code itself is very straightforward, but we are including the appropriate Open API documentation as well, which we can reuse in our routes elsewhere.

To make the Open API library aware of these new files, we need to add it to our configs/openapi.js file:

// -=-=- other code omitted here -=-=-

const options = {
  definition: {
    openapi: "3.1.0",
    info: {
      title: "Lost Communities",
      version: "0.0.1",
      description: "Kansas Lost Communities Project",
    },
    servers: [
      {
        url: url(),
      },
    ],
  },
  apis: ["./routes/*.js", "./models/*.js", "./routes/api/v1/*.js", "./utilities/*.js"],
};

Validation Error Messages

Likewise, we may also want to send a well-structured message anytime our database throws an error, or if any of our model validation steps fails. So, we can create another file handle-validation-error.js with the following content:

/**
 * @file Error handler for Sequelize Validation Errors
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports handleValidationError a handler for Sequelize validation errors
 */

/**
 * Gracefully handle Sequelize Validation Errors
 * 
 * @param {SequelizeValidationError} error - Sequelize Validation Error
 * @param {Object} res - Express response object
 * 
 * @swagger
 * components:
 *   responses:
 *     Success:
 *     ValidationError: 
 *       description: model validation error
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required:
 *               - error
 *             properties:
 *               error: 
 *                 type: string
 *                 description: the description of the error
 *               errors:
 *                 type: array
 *                 items:
 *                    type: object
 *                    required: 
 *                      - attribute
 *                      - message
 *                    properties:
 *                      attribute:
 *                        type: string
 *                        description: the attribute that caused the error
 *                      message:
 *                        type: string
 *                        description: the error associated with that attribute
 *             example:
 *               error: Validation Error
 *               errors:
 *                 - attribute: username
 *                   message: username must be unique
 */
function handleValidationError(error, res) {
  if (error.errors?.length > 0) {
    const errors = error.errors
    .map((e) => {
      return {attribute: e.path, message: e.message}
    })
    res.status(422).json({ 
      error: "Validation Error",
      errors: errors
    });
  } else {
    res.status(422).json({
      error: error.parent.message
    })
  }
}

export default handleValidationError;

Again, the code for this is not too complex. It builds upon the structure in the Sequelize ValidationError class to create a helpful JSON object that includes both an error attribute as well as an optional errors array that lists each attribute with a validation error, if possible. We also include the appropriate Open API documentation for this response type.

Trial & Error

If we look at the code in the handle-validation-error.js file, it may seem like it came from nowhere, or it may be difficult to see how this was constructed based on what little is given in the Sequelize documentation.

In fact, this code was actually constructed using a trial and error process by iteratively submitting broken models and looking at the raw errors that were produced by Sequelize until a common structure was found. For the purposes of this example, we’re leaving out some of these steps, but we encourage exploring the output to determine the best method for any given application.

Creating a New User

Now that we have created helpers for our route, we can add the code to actually create that new user when an HTTP POST request is receive4d.

In our routes/api/v1/users.js file, let’s add a new route we can use to create a new entry in the users table:

// -=-=- other code omitted here -=-=-

// Import libraries
import express from "express";
import { ValidationError } from "sequelize";

// Create Express router
const router = express.Router();

// Import models
import { User, Role } from "../../../models/models.js";

// Import logger
import logger from "../../../configs/logger.js";

// Import database
import database from "../../../configs/database.js"

// Import utilities
import handleValidationError from "../../../utilities/handle-validation-error.js";
import sendSuccess from "../../../utilities/send-success.js";

// -=-=- other code omitted here -=-=-

/**
 * Create a new user
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/users:
 *   post:
 *     summary: create user
 *     tags: [users]
 *     requestBody:
 *       description: user
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/User'
 *           example:
 *             username: newuser
 *             roles:
 *               - id: 6
 *               - id: 7
 *     responses:
 *       201:
 *         $ref: '#/components/responses/Success'
 *       422:
 *         $ref: '#/components/responses/ValidationError'         
 */
router.post("/", async function (req, res, next) {
  try {
    // Use a database transaction to roll back if any errors are thrown
    await database.transaction(async t => {
      const user = await User.create(
        // Build the user object using body attributes
        {
          username: req.body.username,
        },
        // Assign to a database transaction
        {
          transaction: t
        }
      );
  
      // If roles are included in the body
      if (req.body.roles) {
        // Find all roles listed
        const roles = await Promise.all(
          req.body.roles.map(({ id, ...next }) => {
            return Role.findByPk(id);
          }),
        );
  
        // Attach roles to user
        await user.setRoles(roles, { transaction: t });
      }
  
      // Send the success message
      sendSuccess("User saved!", user.id, 201, res);
    })
    
  } catch (error) {
    if (error instanceof ValidationError) {
      handleValidationError(error, res);
    } else {
      logger.error(error);
      res.status(500).end();
    }
  }
});

At the top of the file, we have added several additional import statements:

  • ValidationError - we import the ValidationError type from the Sequelize library
  • database - we import our Sequelize instance from configs/database.js so we can create a transaction
  • handleValidationError and sendSuccess - we import our two new utilities from the utilities folder

This route itself is quite a bit more complex that our previous routes, so let’s break down what it does piece by piece to see how it all works together.

  1. Start a database transaction
// -=-=- other code omitted here -=-=-
    await database.transaction(async t => {

      // perform database operations here

    });
// -=-=- other code omitted here -=-=-

First, since we will be updating the database using multiple steps, we should use a database transaction to ensure that we only update the database if all operations will succeed. So, we use the Sequelize Transactions feature to create a new managed database transaction. If we successfully reach the end of the block of code contained in this statement, the database transaction will be committed to the database and the changes will be stored.

  1. Create the User itself
// -=-=- other code omitted here -=-=-
      const user = await User.create(
        // Build the user object using body attributes
        {
          username: req.body.username,
        },
        // Assign to a database transaction
        {
          transaction: t
        }
      );
// -=-=- other code omitted here -=-=-

Next, we use the User model to create a new instance of the user and store it in the database. The Sequelize Create method will both build the new object in memory as well as save it to the database. This is an asynchronous process, so we must await the result before moving on. We also must give this method a reference to the current database transaction t in the second parameter.

  1. Associate Roles
// -=-=- other code omitted here -=-=-
      // If roles are included in the body
      if (req.body.roles) {
        // Find all roles listed
        const roles = await Promise.all(
          req.body.roles.map(({ id, ...next }) => {
            return Role.findByPk(id);
          }),
        );
  
        // Attach roles to user
        await user.setRoles(roles, { transaction: t });
      }
// -=-=- other code omitted here -=-=-

After that, we check to see if the roles attribute was provided as part of the body of the HTTP POST method. If it was, we need to associate those roles with the new user. Here, we are assuming that the submission includes the ID for each role at a minimum, but it may also include other data such as the name of the role. So, before doing anything else, we must first find each Role model in the database by ID using the findByPk method. Once we have a list of roles, then we can add those roles to the User object using the special setRoles method that is created as part of the Roles association on that model. If any roles are null and can’t be found, this will throw an error that we can catch later.

  1. Send Success Messages
      // Send the success message
      sendSuccess("User saved!", user.id, 201, res);

Finally, if everything is correct, we can send the success message back to the user using the sendSuccess utility method that we created earlier.

  1. Handle Exceptions
// -=-=- other code omitted here -=-=-
  } catch (error) {
    if (error instanceof ValidationError) {
      handleValidationError(error, res);
    } else {
      logger.error(error);
      res.status(500).end();
    }
  }
// -=-=- other code omitted here -=-=-

Finally, at the bottom of the file we have a catch block that will catch any exceptions thrown while trying to create our User and associate the correct Role objects. Notice that this catch block is outside the database transaction, so any database changes will not be saved if we reach this block of code.

Inside, we check to see if the error is an instance of the ValidationError class from Sequelize. If so, we can use our new handleValidationError method to process that error and send a well-structured JSON response back to the user about the error. If not, we’ll simply log the error and send back a generic HTTP 500 response code.

Testing Create

YouTube Video

Manual Testing with Open API

Before we start unit testing this route, let’s quickly do some manual testing using the Open API documentation site. It is truly a very handy way to work with our RESTful APIs as we are developing them, allowing us to test them quickly in isolation to make sure everything is working properly.

So, let’s start our server:

$ npm run dev

Once it starts, we can navigate to the /docs URL, and we should see the Open API documentation for our site, including a new POST route for the users section:

Create API Documentation Create API Documentation

If we documented our route correctly, we can see that this documentation includes not only an example for what a new submission should look like, but also examples of the success and model validation error outputs should be. To test it, we can use the Try it out button on the page to try to create a new user.

Create Example Create Example

Let’s go ahead and try to create the user that is suggested by our example input, which should look like this:

{
  "username": "newuser",
  "roles": [
    {
      "id": 6
    },
    {
      "id": 7
    }
  ]
}

This would create a user with the username newuser and assign them to the roles with IDs 6 (view_documents) and 7 (view_communities). So, we can click the Execute button to send that request to the server and see if it works.

Create Success Create Success

Excellent! We can see that it worked correctly, and we received our expected success message as part of the response. We can also scroll up and try the GET /api/v1/users API endpoint to see if that user appears in our list of all users in the system with the correct roles assigned. If we do, we should see this in the output:

  {
    "id": 6,
    "username": "newuser",
    "createdAt": "2025-02-21T18:34:54.725Z",
    "updatedAt": "2025-02-21T18:34:54.725Z",
    "roles": [
      {
        "id": 6,
        "role": "view_documents"
      },
      {
        "id": 7,
        "role": "view_communities"
      }
    ]
  }

From here, we can try a couple of different scenarios to see if our server is working properly.

Duplicate Username

First, what if we try and create a user with a duplicate username? To test this, we can simply resubmit the default example again and see what happens. This time, we get an HTTP 422 response code with a very detailed error message:

Create Failure - Duplicate Username Create Failure - Duplicate Username

This is great! It tells us exactly what the error is. This is the output created by our handleValidationError utility function from the previous page.

Missing Attributes

We can also try to submit a new user, but this time we can accidentally leave out some of the attributes, as in this example:

{
  "user": "testuser"
}

Here, we have mistakenly renamed the username attribute to just user, and we’ve left off the roles list entirely. When we submit this, we also get a helpful error message:

Create Failure - Username Null Create Failure - Username Null

Since the username attribute was not provided, it will be set to null and the database will not allow a null value for that attribute.

However, if we correct that, we do see that it will accept a new user without any listed roles! This is by design, since we may need to create users that don’t have any roles assigned.

Invalid Roles

Finally, what if we try to create a user with an invalid list of roles:

{
  "username": "baduser",
  "roles": [
    {
      "id": 6
    },
    {
      "id": 8
    }
  ]
}

In this instance, we’ll get another helpful error message:

Create Failure - Role Null Create Failure - Role Null

Since there is no role with ID 8 in the database, it finds a null value instead and tries to associate that with our user. This causes an SQL constraint error, which we can send back to our user.

Finally, we should also double-check that our user baduser was not created using GET /api/v1/users API endpoint above. This is because we don’t want to create that user unless a list of valid roles are also provided.

Unit Testing

Now that we have a good handle on how this endpoint works in practice, let’s write some unit tests to confirm that it works as expected in each of these cases. First, we should have a simple unit test that successfully creates a new user:

// -=-=- other code omitted here -=-=-

/**
 * Creates a user successfully
 */
const createUser = (user) => {
  it("should successfully create a user '" + user.username + "'", (done) => {
    request(app)
      .post("/api/v1/users/")
      .send(user)
      .expect(201)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("message");
        res.body.should.have.property("id")
        const created_id = res.body.id
        // Find user in list of all users
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            const foundUser = res.body.find(
              (u) => u.id === created_id,
            );
            foundUser.should.shallowDeepEqual(user);
            done();
          });
      });
  });
};

// -=-=- other code omitted here -=-=-

// New user structure for creating users
const new_user = {
  username: "test_user",
  roles: [
    {
      id: 6
    },
    {
      id: 7
    }
  ]
}

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("POST /", () => {
    createUser(new_user);
  })
});

This first test is very straightforward since it just confirms that we can successfully create a new user in the system. It also confirms that the user now appears in the output from the get all route, which is helpful.

While this at least confirms that the route works as expected, we should write several more unit tests to confirm that the route works correctly even if the user provides invalid input.

Missing Attributes

First, we should confirm that the user will be created even with the list of roles missing. We can do this just by creating a second new_user object that is missing the list of roles.

// -=-=- other code omitted here -=-=-

// New user structure for creating users without roles
const new_user_no_roles = {
  username: "test_user_no_roles",
}

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("POST /", () => {
    createUser(new_user);
    createUser(new_user_no_roles);
  })
});

We should also write a test to make sure the process will fail if any required attributes (in this case, just username) are missing. We can even check the output to make sure the missing attribute is listed:

// -=-=- other code omitted here -=-=-

/**
 * Fails to create user with missing required attribute
 */
const createUserFailsOnMissingRequiredAttribute = (user, attr) => {
  it("should fail when required attribute '" + attr + "' is missing", (done) => {
    // Create a copy of the user object and delete the given attribute
    const updated_user = {... user}
    delete updated_user[attr]
    request(app)
      .post("/api/v1/users/")
      .send(updated_user)
      .expect(422)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("error");
        res.body.should.have.property("errors")
        res.body.errors.should.be.an("array")
        // the error should be related to the deleted attribute
        expect(res.body.errors.some((e) => e.attribute === attr)).to.equal(true);
        done();
      });
  });
}

// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("POST /", () => {
    createUser(new_user);
    createUser(new_user_no_roles);

    createUserFailsOnMissingRequiredAttribute(new_user, "username");
  })
});

Duplicate Username

We also should write a unit test that will make sure we cannot create a user with a duplicate username.

// -=-=- other code omitted here -=-=-

/**
 * Fails to create user with a duplicate username
 */
const createUserFailsOnDuplicateUsername = (user) => {
  it("should fail on duplicate username '" + user.username + "'", (done) => {
    request(app)
      .post("/api/v1/users/")
      .send(user)
      .expect(201)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("message");
        res.body.should.have.property("id")
        const created_id = res.body.id
        // Find user in list of all users
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            const foundUser = res.body.find(
              (u) => u.id === created_id,
            );
            foundUser.should.shallowDeepEqual(user);
            // Try to create same user again
            request(app)
              .post("/api/v1/users/")
              .send(user)
              .expect(422)
              .end((err, res) => {
                if (err) return done(err);
                res.body.should.be.an("object");
                res.body.should.have.property("error");
                res.body.should.have.property("errors");
                res.body.errors.should.be.an("array");
                // the error should be related to the username attribute
                expect(
                  res.body.errors.some((e) => e.attribute === "username"),
                ).to.equal(true);
                done();
              });
          });
      });
  });
};


// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("POST /", () => {
    createUser(new_user);
    createUser(new_user_no_roles);

    createUserFailsOnMissingRequiredAttribute(new_user, "username");
    createUserFailsOnDuplicateUsername(new_user);
  })
});

This test builds upon the previous createUser test by first creating the user, and then confirming that it appears in the output, before trying to create it again. This time, it should fail, so we can borrow some of the code from the createUserFailsOnMissingRequiredAttribute to confirm that it is failing because of a duplicate username.

Invalid Roles

Finally, we should write a unit test that makes sure a user won’t be created if any invalid role IDs are used, and also that the database transaction is properly rolled back so that the user itself isn’t created.

// -=-=- other code omitted here -=-=-

/**
 * Fails to create user with bad role ID
 */
const createUserFailsOnInvalidRole = (user, role_id) => {
  it("should fail when invalid role id '" + role_id + "' is used", (done) => {
    // Create a copy of the user object
    const updated_user = { ...user };
    // Make a shallow copy of the roles array
    updated_user.roles = [... user.roles]
    // Add invalid role ID to user object
    updated_user.roles.push({
      id: role_id,
    });
    request(app)
      .post("/api/v1/users/")
      .send(updated_user)
      .expect(422)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("error");
        // User with invalid roles should not be created
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            expect(res.body.some((u) => u.username === updated_user.username)).to.equal(
              false,
            );
            done();
          });
      });
  });
};


// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("POST /", () => {
    createUser(new_user);
    createUser(new_user_no_roles);

    createUserFailsOnMissingRequiredAttribute(new_user, "username");
    createUserFailsOnDuplicateUsername(new_user);

    createUserFailsOnInvalidRole(new_user, 0)
    createUserFailsOnInvalidRole(new_user, -1)
    createUserFailsOnInvalidRole(new_user, 8)
    createUserFailsOnInvalidRole(new_user, "test")
  })
});

This test will try to create a valid user, but it appends an invalid role ID to the list of roles to assign to the user. It also confirms that the user itself is not created by querying the get all endpoint and checking for a matching username.

There we go! We have a set of unit tests that cover most of the situations we can anticipate seeing with our route to create new users. If we run all of these tests at this point, they should all pass:

    POST /
      ✔ should successfully create a user 'test_user'
      ✔ should successfully create a user 'test_user_no_roles'
      ✔ should fail when required attribute 'username' is missing
      ✔ should fail on duplicate username 'test_user'
      ✔ should fail when invalid role id '0' is used
      ✔ should fail when invalid role id '-1' is used
      ✔ should fail when invalid role id '8' is used
      ✔ should fail when invalid role id 'test' is used

Great! Now is a great time to lint, format, and then commit and push our work to GitHub before continuing.

Update

YouTube Video

Update Route

Next, let’s look at adding an additional route in our application that allows us to update a User model. This route is very similar to the route used to create a user, but there are a few key differences as well.

// -=-=- other code omitted here -=-=-

/**
 * Update a user
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/users/{id}:
 *   put:
 *     summary: update user
 *     tags: [users]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: user ID
 *     requestBody:
 *       description: user
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/User'
 *           example:
 *             username: updateduser
 *             roles:
 *               - id: 6
 *               - id: 7
 *     responses:
 *       201:
 *         $ref: '#/components/responses/Success'
 *       422:
 *         $ref: '#/components/responses/ValidationError'
 */
router.put("/:id", async function (req, res, next) {
  try {
    const user = await User.findByPk(req.params.id)

    // if the user is not found, return an HTTP 404 not found status code
    if (user === null) {
      res.status(404).end();
    } else {
      await database.transaction(async (t) => {
        await user.update(
          // Update the user object using body attributes
          {
            username: req.body.username,
          },
          // Assign to a database transaction
          {
            transaction: t,
          },
        );
  
        // If roles are included in the body
        if (req.body.roles) {
          // Find all roles listed
          const roles = await Promise.all(
            req.body.roles.map(({ id, ...next }) => {
              return Role.findByPk(id);
            }),
          );
  
          // Attach roles to user
          await user.setRoles(roles, { transaction: t });
        } else {
          // Remove all roles
          await user.setRoles([], { transaction: t });
        }
  
        // Send the success message
        sendSuccess("User saved!", user.id, 201, res);
      });
    }
  } catch (error) {
    if (error instanceof ValidationError) {
      handleValidationError(error, res);
    } else {
      logger.error(error);
      res.status(500).end();
    }
  }
});

// -=-=- other code omitted here -=-=-

As we can see, overall this route is very similar to the create route. The only major difference is that we must first find the user we want to update based on the query parameter, and then we use the update database method to update the existing values in the database. The rest of the work updating the related Roles models is exactly the same. We can also reuse the utility functions we created for the previous route.

Just like we did earlier, we can test this route using the Open API documentation website to confirm that it is working correctly before we even move on to testing it.

Unit Testing Update Route

The unit tests for the route to update a user are very similar to the ones used for creating a user. First, we need a test that will confirm we can successfully update a user entry:

// -=-=- other code omitted here -=-=-

/**
 * Update a user successfully
 */
const updateUser = (id, user) => {
  it("should successfully update user ID '" + id + "' to '" + user.username + "'", (done) => {
    request(app)
      .put("/api/v1/users/" + id)
      .send(user)
      .expect(201)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("message");
        res.body.should.have.property("id");
        expect(res.body.id).equal(id)
        // Find user in list of all users
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            const foundUser = res.body.find(
              (u) => u.id === id,
            );
            foundUser.should.shallowDeepEqual(user);
            done();
          });
      });
  });
};

// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("PUT /{id}", () => {
    updateUser(3, new_user);
  });
});

Next, we also want to check that any updated users have the correct roles attached, including instances where the roles were completely removed:

// -=-=- other code omitted here -=-=-

/**
 * Update a user and roles successfully
 */
const updateUserAndRoles = (id, user) => {
  it("should successfully update user ID '" + id + "' roles", (done) => {
    request(app)
      .put("/api/v1/users/" + id)
      .send(user)
      .expect(201)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("message");
        res.body.should.have.property("id");
        expect(res.body.id).equal(id)
        // Find user in list of all users
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            const foundUser = res.body.find(
              (u) => u.id === id,
            );
            // Handle case where user has no roles assigned
            const roles = user.roles || []
            foundUser.roles.should.be.an("array");
            foundUser.roles.should.have.lengthOf(roles.length);
            roles.forEach((role) => {
              expect(foundUser.roles.some((r) => r.id === role.id)).to.equal(true);
            })
            done();
          });
      });
  });
};


// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("PUT /{id}", () => {
    updateUser(3, new_user);
    updateUserAndRoles(3, new_user);
    updateUserAndRoles(2, new_user_no_roles);
  });
});

We also should check that the username is unchanged if an update is sent with no username attribute, but the rest of the update will succeed. For this test, we can just create a new mock object with just roles and no username included.

// -=-=- other code omitted here -=-=-

// Update user structure with only roles
const update_user_only_roles = {
  roles: [
    {
      id: 6,
    },
    {
      id: 7,
    },
  ],
};

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("PUT /{id}", () => {
    updateUser(3, new_user);
    updateUserAndRoles(3, new_user);
    updateUserAndRoles(2, new_user_no_roles);
    updateUserAndRoles(1, update_user_only_roles);
  });
});

Finally, we should include a couple of tests to handle the situation where a duplicate username is provided, or where an invalid role is provided. These are nearly identical to the tests used in the create route earlier in this example:

// -=-=- other code omitted here -=-=-

/**
 * Fails to update user with a duplicate username
 */
const updateUserFailsOnDuplicateUsername = (id, user) => {
  it("should fail on duplicate username '" + user.username + "'", (done) => {
    request(app)
      .put("/api/v1/users/" + id)
      .send(user)
      .expect(422)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("error");
        res.body.should.have.property("errors");
        res.body.errors.should.be.an("array");
        // the error should be related to the username attribute
        expect(
          res.body.errors.some((e) => e.attribute === "username"),
        ).to.equal(true);
        done();
      });
  });
};

/**
 * Fails to update user with bad role ID
 */
const updateUserFailsOnInvalidRole = (id, user, role_id) => {
  it("should fail when invalid role id '" + role_id + "' is used", (done) => {
    // Create a copy of the user object
    const updated_user = { ...user };
    // Make a shallow copy of the roles array
    updated_user.roles = [... user.roles]
    // Add invalid role ID to user object
    updated_user.roles.push({
      id: role_id,
    });
    request(app)
      .put("/api/v1/users/" + id)
      .send(updated_user)
      .expect(422)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("error");
        // User with invalid roles should not be updated
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            expect(res.body.some((u) => u.username === updated_user.username)).to.equal(
              false,
            );
            done();
          });
      });
  });
};

// -=-=- other code omitted here -=-=-

// Update user structure with duplicate username
const update_user_duplicate_username = {
  username: "admin",
};

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("PUT /{id}", () => {
    updateUser(3, new_user);
    updateUserAndRoles(3, new_user);
    updateUserAndRoles(2, new_user_no_roles);
    updateUserAndRoles(1, update_user_only_roles);

    updateUserFailsOnDuplicateUsername(2, update_user_duplicate_username);
    updateUserFailsOnInvalidRole(4, new_user, 0);
    updateUserFailsOnInvalidRole(4, new_user, -1);
    updateUserFailsOnInvalidRole(4, new_user, 8);
    updateUserFailsOnInvalidRole(4, new_user, "test");
  })
});

There we go! We have a set of unit tests that cover most of the situations we can anticipate seeing with our route to update users. If we run all of these tests at this point, they should all pass:

    PUT /{id}
      ✔ should successfully update user ID '3' to 'test_user'
      ✔ should successfully update user ID '3' roles
      ✔ should successfully update user ID '2' roles
      ✔ should successfully update user ID '1' roles
      ✔ should fail on duplicate username 'admin'
      ✔ should fail when invalid role id '0' is used
      ✔ should fail when invalid role id '-1' is used
      ✔ should fail when invalid role id '8' is used
      ✔ should fail when invalid role id 'test' is used

Great! Now is a great time to lint, format, and then commit and push our work to GitHub before continuing.

Delete

YouTube Video

Delete Route

Finally, the last route we need to add to our users routes is the delete route. This route is very simple - it will remove a user based on the given user ID if it exists in the database:

// -=-=- other code omitted here -=-=-

/**
 * Delete a user
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /api/v1/users/{id}:
 *   delete:
 *     summary: delete user
 *     tags: [users]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: user ID
 *     responses:
 *       200:
 *         $ref: '#/components/responses/Success'
 */
router.delete("/:id", async function (req, res, next) {
  try {
    const user = await User.findByPk(req.params.id)

    // if the user is not found, return an HTTP 404 not found status code
    if (user === null) {
      res.status(404).end();
    } else {
      await user.destroy();

      // Send the success message
      sendSuccess("User deleted!", req.params.id, 200, res);
    }
  } catch (error) {
    console.log(error)
    logger.error(error);
    res.status(500).end();
  }
});

// -=-=- other code omitted here -=-=-

Once again, we can test this route using the Open API documentation website. Let’s look at how we can quickly unit test it as well.

Unit Testing Delete Route

The unit tests for this route are similarly very simple. We really only have two cases - the user is found and successfully deleted, or the user cannot be found and an HTTP 404 response is returned.

// -=-=- other code omitted here -=-=-

/**
 * Delete a user successfully
 */
const deleteUser = (id) => {
  it("should successfully delete user ID '" + id, (done) => {
    request(app)
      .delete("/api/v1/users/" + id)
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        res.body.should.be.an("object");
        res.body.should.have.property("message");
        res.body.should.have.property("id")
        expect(res.body.id).to.equal(String(id))
        // Ensure user is not found in list of users
        request(app)
          .get("/api/v1/users")
          .expect(200)
          .end((err, res) => {
            if (err) return done(err);
            expect(res.body.some((u) => u.id === id)).to.equal(false);
            done();
          });
      });
  });
};

/**
 * Fail to delete a missing user
 */
const deleteUserFailsInvalidId= (id) => {
  it("should fail to delete invalid user ID '" + id, (done) => {
    request(app)
      .delete("/api/v1/users/" + id)
      .expect(404)
      .end((err) => {
        if (err) return done(err);
        done();
      });
  });
};

// -=-=- other code omitted here -=-=-

/**
 * Test /api/v1/users route
 */
describe("/api/v1/users", () => {
  // -=-=- other code omitted here -=-=-

  describe("DELETE /{id}", () => {
    deleteUser(4);
    deleteUserFailsInvalidId(0)
    deleteUserFailsInvalidId(-1)
    deleteUserFailsInvalidId(5)
    deleteUserFailsInvalidId("test")
  });
});

There we go! That will cover all of the unit tests for the users route. If we try to run all of our tests, we should see that they succeed!

DELETE /{id}
      ✔ should successfully delete user ID '4
      ✔ should fail to delete invalid user ID '0
      ✔ should fail to delete invalid user ID '-1
      ✔ should fail to delete invalid user ID '5
      ✔ should fail to delete invalid user ID 'test

All told, we write just 5 API routes (retrieve all, retrieve one, create, update, and delete) but wrote 53 different unit tests to fully test those routes.

Now is a great time to lint, format, and then commit and push our work to GitHub.

In the next example, we’ll explore how to add authentication to our RESTful API.