Skip to content

Commit

Permalink
feat(game): get a game by id route (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Mar 17, 2023
1 parent 052447d commit b636d5a
Show file tree
Hide file tree
Showing 18 changed files with 3,502 additions and 2,549 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { decoratorFilesOverride } = require("./config/eslint/rules/overrides/deco
const { dtoFilesOverride } = require("./config/eslint/rules/overrides/dto-files");
const { eslintConfigFilesOverride } = require("./config/eslint/rules/overrides/eslint-config-files");
const { factoryFilesOverride } = require("./config/eslint/rules/overrides/factory-files");
const { pipeFilesOverride } = require("./config/eslint/rules/overrides/pipe-files");
const { schemaFilesOverride } = require("./config/eslint/rules/overrides/schema-files");
const { testFilesOverride } = require("./config/eslint/rules/overrides/test-files");
const { standardRules } = require("./config/eslint/rules/standard");
Expand Down Expand Up @@ -40,5 +41,6 @@ module.exports = {
decoratorFilesOverride,
schemaFilesOverride,
constantFilesOverride,
pipeFilesOverride,
],
};
8 changes: 8 additions & 0 deletions config/eslint/rules/overrides/pipe-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const { OFF } = require("../../constants");

const pipeFilesOverride = Object.freeze({
files: ["*.pipe.ts"],
rules: { "class-methods-use-this": OFF },
});

module.exports = { pipeFilesOverride };
13 changes: 10 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@
"@nestjs/platform-fastify": "^9.3.10",
"@nestjs/swagger": "^6.2.1",
"@nestjs/terminus": "^9.2.1",
"@types/lodash": "^4.14.191",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"lodash": "^4.17.21",
"mongoose": "^6.10.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0"
Expand Down
5 changes: 5 additions & 0 deletions src/_shared/api/enums/api.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
enum API_RESOURCES {
GAMES = "games",
}

export { API_RESOURCES };
8 changes: 8 additions & 0 deletions src/_shared/api/helpers/api.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { API_RESOURCES } from "../enums/api.enum";

function getResourceSingularForm(resource: API_RESOURCES): string {
const resourceSingularForms: Record<API_RESOURCES, string> = { [API_RESOURCES.GAMES]: "game" };
return resourceSingularForms[resource];
}

export { getResourceSingularForm };
15 changes: 15 additions & 0 deletions src/_shared/api/pipes/validate-mongo-id.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { PipeTransform } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose";

@Injectable()
class ValidateMongoId implements PipeTransform<string> {
public transform(value: string): string {
if (Types.ObjectId.isValid(value)) {
return value;
}
throw new BadRequestException("Validation failed (Mongo ObjectId is expected)");
}
}

export { ValidateMongoId };
11 changes: 11 additions & 0 deletions src/_shared/error/helpers/error.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NotFoundException } from "@nestjs/common";
import { ResourceNotFoundError } from "../types/error.type";

function getControllerRouteError(err: unknown): unknown {
if (err instanceof ResourceNotFoundError) {
return new NotFoundException(err.message);
}
return err;
}

export { getControllerRouteError };
13 changes: 13 additions & 0 deletions src/_shared/error/types/error.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { upperFirst } from "lodash";
import type { API_RESOURCES } from "../../api/enums/api.enum";
import { getResourceSingularForm } from "../../api/helpers/api.helper";

class ResourceNotFoundError extends Error {
public constructor(resource: API_RESOURCES, id: string) {
const resourceSingularForm = getResourceSingularForm(resource);
const message = `${upperFirst(resourceSingularForm)} with id "${id}" not found`;
super(message);
}
}

export { ResourceNotFoundError };
17 changes: 15 additions & 2 deletions src/game/game.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Body, Controller, Get, HttpStatus, Post } from "@nestjs/common";
import { Body, Controller, Get, HttpStatus, Param, Post } from "@nestjs/common";
import { ApiResponse, ApiTags } from "@nestjs/swagger";
import { API_RESOURCES } from "../_shared/api/enums/api.enum";
import { ValidateMongoId } from "../_shared/api/pipes/validate-mongo-id.pipe";
import { getControllerRouteError } from "../_shared/error/helpers/error.helper";
import { CreateGameDto } from "./dto/create-game/create-game.dto";
import { GameService } from "./game.service";
import { Game } from "./schemas/game.schema";

@ApiTags("🎲 Games")
@Controller("games")
@Controller(API_RESOURCES.GAMES)
export class GameController {
public constructor(private readonly gameService: GameService) {}
@Get()
Expand All @@ -14,6 +17,16 @@ export class GameController {
return this.gameService.getGames();
}

@Get(":id")
@ApiResponse({ status: HttpStatus.OK, type: Game })
public async getGame(@Param("id", ValidateMongoId) id: string): Promise<Game> {
try {
return await this.gameService.getGameById(id);
} catch (err) {
throw getControllerRouteError(err);
}
}

@Post()
public async createGame(@Body() createGameDto: CreateGameDto): Promise<Game> {
return this.gameService.createGame(createGameDto);
Expand Down
4 changes: 4 additions & 0 deletions src/game/game.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export class GameRepository {
return this.gameModel.find(filter).exec();
}

public async findOne(filter: FilterQuery<GameDocument>): Promise<Game | null> {
return this.gameModel.findOne(filter).exec();
}

public async create(game: CreateGameDto): Promise<Game> {
return this.gameModel.create(game);
}
Expand Down
10 changes: 10 additions & 0 deletions src/game/game.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Injectable } from "@nestjs/common";
import { API_RESOURCES } from "../_shared/api/enums/api.enum";
import { ResourceNotFoundError } from "../_shared/error/types/error.type";
import type { CreateGameDto } from "./dto/create-game/create-game.dto";
import { GameRepository } from "./game.repository";
import type { Game } from "./schemas/game.schema";
Expand All @@ -10,6 +12,14 @@ export class GameService {
return this.gameRepository.find();
}

public async getGameById(id: string): Promise<Game> {
const game = await this.gameRepository.findOne({ id });
if (game === null) {
throw new ResourceNotFoundError(API_RESOURCES.GAMES, id);
}
return game;
}

public async createGame(game: CreateGameDto): Promise<Game> {
return this.gameRepository.create(game);
}
Expand Down
40 changes: 38 additions & 2 deletions tests/e2e/specs/game/game.module.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { faker } from "@faker-js/faker";
import type { BadRequestException } from "@nestjs/common";
import type { BadRequestException, NotFoundException } from "@nestjs/common";
import { HttpStatus } from "@nestjs/common";
import { getModelToken } from "@nestjs/mongoose";
import type { NestFastifyApplication } from "@nestjs/platform-fastify";
Expand All @@ -18,7 +18,7 @@ import { ROLE_NAMES, ROLE_SIDES } from "../../../../src/role/enums/role.enum";
import { E2eTestModule } from "../../../../src/test/e2e-test.module";
import { bulkCreateFakeCreateGamePlayerDto } from "../../../factories/game/dto/create-game/create-game-player/create-game-player.dto.factory";
import { createFakeCreateGameDto } from "../../../factories/game/dto/create-game/create-game.dto.factory";
import { bulkCreateFakeGames } from "../../../factories/game/schemas/game.schema.factory";
import { bulkCreateFakeGames, createFakeGame } from "../../../factories/game/schemas/game.schema.factory";
import { initNestApp } from "../../helpers/nest-app.helper";

describe("Game Module", () => {
Expand Down Expand Up @@ -67,6 +67,42 @@ describe("Game Module", () => {
});
});

describe("GET /game/:id", () => {
it("should get a bad request error when id is not uuid.", async() => {
const response = await app.inject({
method: "GET",
url: "/games/123",
});
expect(response.statusCode).toBe(HttpStatus.BAD_REQUEST);
expect(response.json<BadRequestException>().message).toBe("Validation failed (Mongo ObjectId is expected)");
});

it("should get a not found error when id doesn't exist in base.", async() => {
const unknownId = faker.database.mongodbObjectId();
const response = await app.inject({
method: "GET",
url: `/games/${unknownId}`,
});
expect(response.statusCode).toBe(HttpStatus.NOT_FOUND);
expect(response.json<NotFoundException>().message).toBe(`Game with id "${unknownId}" not found`);
});

it("should get a game when id exists in base.", async() => {
const game = createFakeGame();
await model.create(game);
const response = await app.inject({
method: "GET",
url: `/games/${game._id}`,
});
expect(response.statusCode).toBe(HttpStatus.OK);
expect(response.json<Game>()).toStrictEqual({
...game,
createdAt: expect.any(String) as Date,
updatedAt: expect.any(String) as Date,
});
});
});

describe("POST /games", () => {
it.each<{ payload: CreateGameDto; test: string; errorMessage: string }>([
{
Expand Down
5,830 changes: 3,288 additions & 2,542 deletions tests/stryker/incremental.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions tests/unit/specs/_shared/api/helpers/api.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { API_RESOURCES } from "../../../../../../src/_shared/api/enums/api.enum";
import { getResourceSingularForm } from "../../../../../../src/_shared/api/helpers/api.helper";

describe("API Helper", () => {
describe("getResourceSingularForm", () => {
it.each<{ resource: API_RESOURCES; singular: string }>([{ resource: API_RESOURCES.GAMES, singular: "game" }])("should return $singular when called with $resource [#$#].", ({ resource, singular }) => {
expect(getResourceSingularForm(resource)).toBe(singular);
});
});
});
17 changes: 17 additions & 0 deletions tests/unit/specs/_shared/api/pipes/validate-mongo-id.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { faker } from "@faker-js/faker";
import { ValidateMongoId } from "../../../../../../src/_shared/api/pipes/validate-mongo-id.pipe";

describe("Validate MongoId Pipe", () => {
const pipe = new ValidateMongoId();

describe("transform", () => {
it("should return the value as is when value is a correct MongoId.", () => {
const validObjectId = faker.database.mongodbObjectId();
expect(pipe.transform(validObjectId)).toBe(validObjectId);
});

it("should throw an error when value is a incorrect MongoId.", () => {
expect(() => pipe.transform("123")).toThrow("Validation failed (Mongo ObjectId is expected)");
});
});
});
21 changes: 21 additions & 0 deletions tests/unit/specs/_shared/error/helpers/error.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NotFoundException } from "@nestjs/common";
import { API_RESOURCES } from "../../../../../../src/_shared/api/enums/api.enum";
import { getControllerRouteError } from "../../../../../../src/_shared/error/helpers/error.helper";
import { ResourceNotFoundError } from "../../../../../../src/_shared/error/types/error.type";

describe("Error Helper", () => {
describe("getControllerRouteError", () => {
it("should return the error as is when it doesn't have to be transformed.", () => {
const error = new Error("123");
expect(getControllerRouteError(error)).toStrictEqual(error);
});

it("should return a NotFoundExceptionError when error is ResourceNotFoundError.", () => {
const id = "123";
const error = new ResourceNotFoundError(API_RESOURCES.GAMES, id);
const result = getControllerRouteError(error);
expect(result instanceof NotFoundException).toBe(true);
expect((result as NotFoundException).message).toBe(`Game with id "${id}" not found`);
});
});
});
25 changes: 25 additions & 0 deletions tests/unit/specs/game/game.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import { when } from "jest-when";
import { GameRepository } from "../../../../src/game/game.repository";
import { GameService } from "../../../../src/game/game.service";
import { createFakeCreateGameDto } from "../../../factories/game/dto/create-game/create-game.dto.factory";
import { createFakeGame } from "../../../factories/game/schemas/game.schema.factory";

describe("Game Service", () => {
let service: GameService;
let repository: GameRepository;

const gameRepositoryMock = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};

Expand All @@ -32,7 +35,29 @@ describe("Game Service", () => {
await service.getGames();
expect(repository.find).toHaveBeenCalledWith();
});
});

describe("getGameById", () => {
const existingId = "good-id";
const existingGame = createFakeGame();
const unknownId = "bad-id";

beforeEach(() => {
when(gameRepositoryMock.findOne).calledWith({ id: existingId }).mockResolvedValue(existingGame);
when(gameRepositoryMock.findOne).calledWith({ id: unknownId }).mockResolvedValue(null);
});

it("should return a game when called with existing id.", async() => {
const result = await service.getGameById(existingId);
expect(result).toStrictEqual(existingGame);
});

it("should throw an error when called with unknown id.", async() => {
await expect(service.getGameById(unknownId)).rejects.toThrow(`Game with id "${unknownId}" not found`);
});
});

describe("createGame", () => {
it("should create game when called.", async() => {
const toCreateGame = createFakeCreateGameDto();
await service.createGame(toCreateGame);
Expand Down

0 comments on commit b636d5a

Please sign in to comment.