Deleting Games and Wrapping Up


We're almost done here. Just one more thing to do. We're going to add in a new endpoint that allows us to DELETE individual games in our list. This will make use of everything we have learned so far.

There are several places we could start. Working from top to bottom in the src directory seems easiest to me.

What I find is that once I've done the hard part - that is, working it all out for the first time - then the next time I need to do something very similar is significantly easier. Largely copy and paste, to be very honest.

touch __tests__/request/DeleteGameRequest.test.ts
touch src/request/DeleteGameRequest.ts

Whilst this class will be essentially identical to AddGameRequest, the concept is different, so for me, this is a different request.

However, we don't need to duplicate logic here.

We can extend from a base class.

touch src/request/GameRequest.ts 

We move the logic from AddGameRequest into GameRequest, and make GameRequest an abstract class. In other words, you can never instantiate a GameRequest directly. Only one of its concrete implementations, e.g. AddGameRequest, or DeleteGameRequest.

import {IsString, Length} from "class-validator";

export abstract class GameRequest {

  @IsString()
  @Length(1, 20)
  name!: string;

}

And:

import {GameRequest} from "./GameRequest";

export class AddGameRequest extends GameRequest {}

and:

import {GameRequest} from "./GameRequest";

export class DeleteGameRequest extends GameRequest {}

Unfortunately I don't know of a way to do this for our tests. So we do end up with duplication here.

import { DeleteGameRequest } from '../../src/request/DeleteGameRequest';
import { validate } from "class-validator";

describe("request/DeleteGameRequest", () => {

  let deleteGameRequest: DeleteGameRequest;
  const validatorOptions = {};

  beforeAll(() => {
    deleteGameRequest = new DeleteGameRequest();
  });

  it(`has the expected class properties'`, async () => {
    deleteGameRequest.name = "a game name here";
    expect(deleteGameRequest.name).toBeDefined();
  });

  describe(`'name' validation`, () => {

    it('is valid', async () => {
      for (let i = 1; i <= 20; ++i) {
        deleteGameRequest.name = "x".repeat(i);
        expect(
          await validate(deleteGameRequest, validatorOptions)
        ).toHaveLength(0);
      }
    });

    it('must have length of 1 character or greater', async () => {
      deleteGameRequest.name = '';
      expect(
        await validate(deleteGameRequest, validatorOptions)
      ).toHaveLength(1);
    });

    it('must have a length of 20 characters or fewer', async () => {
      deleteGameRequest.name = 'y'.repeat(21);
      expect(
        await validate(deleteGameRequest, validatorOptions)
      ).toHaveLength(1);
    });
  });

});

Basically the same as __tests__/request/AddGameRequest.test.ts, but with a bit of find / replace.

That's not to say these two requests couldn't diverge at some point in the future, so having both tests is valid from my point of view.

DELETE Route

We're all set up to write tests for our routes, so let's sort them out first.

We will have a test for when we have an empty list:

    it('returns an empty list when the list is empty', async () => {

      const game = "Overwatch";

      const list_of_games: string[] = [
        game
      ];

      const mockGet = jest.fn((list: string) => Promise.resolve(list_of_games));
      const mockAdd = jest.fn();
      const mockRemove = jest.fn((list: string, game: string) => {
        const index = list_of_games.indexOf(game);
        if (index === -1) {
          return false;
        }
        list_of_games.splice(index, 1);
        return true;
      });

      storage.redisStorage = jest.fn(() => {
        return {
          get: mockGet,
          add: mockAdd,
          remove: mockRemove,
        }});

      const response = await request(server)
        .delete("/codereviewvideos")
        .send({ name: game });

      expect(response.status).toEqual(200);
      expect(response.type).toEqual("application/json");
      expect(response.body).toEqual({
        games: []
      });

      expect(mockGet).toHaveBeenCalled();
      expect(mockRemove).toHaveBeenCalled();
      expect(mockAdd).not.toHaveBeenCalled();
    });

And a test for when we have a list with some games, and we delete one:

    it('returns an updated list when deleting a game', async () => {
      const game = "Overwatch";

      const list_of_games: string[] = [
        "GTA 5",
        game,
        "Diablo 3"
      ];

      const mockGet = jest.fn((list: string) => Promise.resolve(list_of_games));
      const mockAdd = jest.fn();
      const mockRemove = jest.fn((list: string, game: string) => {
        const index = list_of_games.indexOf(game);
        if (index === -1) {
          return false;
        }
        list_of_games.splice(index, 1);
        return true;
      });

      storage.redisStorage = jest.fn(() => {
        return {
          get: mockGet,
          add: mockAdd,
          remove: mockRemove,
        }});

      const response = await request(server)
        .delete("/codereviewvideos")
        .send({ name: game });

      expect(response.status).toEqual(200);
      expect(response.type).toEqual("application/json");
      expect(response.body).toEqual({
        games: list_of_games.filter(item => item !== game)
      });

      expect(mockGet).toHaveBeenCalled();
      expect(mockRemove).toHaveBeenCalled();
      expect(mockAdd).not.toHaveBeenCalled();
    });

I'm not the world's biggest fan of mutation. But here we are, writing a separate fake implementation solely for the purposes of testing. I can live with this, just about.

We start here with a route doesn't exist, so the tests immediately fail.

All good, let's implement the route:

This is pretty much the same as before, with a few small tweaks:

import {DeleteGameRequest} from "../request/DeleteGameRequest";

// ...

router.delete(`/codereviewvideos`, async (ctx: Context) => {
  try {
    const validatorOptions = {};

    const game = new DeleteGameRequest();
    game.name = ctx.request.body.name || '';

    const errors = await validate(game, validatorOptions);

    if (errors.length > 0) {
      ctx.status = 400;
      ctx.body = {
        status: 'error',
        data: errors
      };

      return ctx;
    }

    const list = 'game_list';
    const store = storage.redisStorage();

    store.remove(list, game.name);

    ctx.status = 200;
    ctx.body = {
      games: await store.get(list)
    };
  } catch (err) {
    console.error(err);
  }
});

And there we have it.

All tests passing. Sending in real requests works as expected. Redis is populated. Games can be deleted.

There are plenty of improvements we could make here. Feel free to modify this however you see fit.

To recap we have:

  1. Created a new Koa project
  2. Added TypeScript, third party typings, and created our own type definitions
  3. Set up Jest, and created tests for all the API endpoints
  4. Used Redis (through Docker) as a plug-in storage solution
  5. Created routes to handle GET, POST, and DELETE requests
  6. Added validation logic for incoming data

It may not be the world's most robust JSON API, but it's not a bad starting point.

Hack on!

Episodes