Skip to content

Commit

Permalink
feat(game): cancel playing game with DELETE endpoint (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Mar 18, 2023
1 parent b4de0e0 commit 16eef75
Show file tree
Hide file tree
Showing 15 changed files with 4,380 additions and 3,257 deletions.
11 changes: 6 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Cache npm dependencies 🥡
uses: actions/cache/restore@v3
- name: Install project dependencies 📦
run: npm ci --ignore-scripts
- name: Save npm dependencies in cache 🥡
uses: actions/cache/save@v3
id: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-npm-v3-${{ hashFiles('package-lock.json') }}
- name: Install project dependencies 📦
run: npm ci --ignore-scripts
if: steps.cache-node-modules.outputs.cache-hit != 'true'
release:
name: Release 🏷️
runs-on: ubuntu-latest
Expand All @@ -37,6 +36,8 @@ jobs:
steps:
- name: Checkout GitHub repository 📡
uses: actions/checkout@v3
with:
persist-credentials: false
- name: Setup NodeJS v18 ⚙️
uses: actions/setup-node@v3
with:
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# 🐺 Werewolves Assistant API Versioning Changelog

## [1.4.0](https://github.com/antoinezanardi/werewolves-assistant-api-next/compare/v1.3.0...v1.4.0) (2023-03-17)


### 🔁 CI

* **build:** parallel jobs and cache for faster build ([#25](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/25)) ([0f84af0](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/0f84af05a6591bd684a501f581addc4ebfc0803d))
* **pipeline:** concurrent pipelines are canceled ([#27](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/27)) ([3c5e3ce](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/3c5e3ce80136f41017ff01eabe9eb1658db07a6a))
* **sonarcloud:** code quality and security scan ([#28](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/28)) ([052447d](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/052447db18a935b640ba0a9ba8df99cf689873ff))


### 🚀 Features

* **eslint:** override controller files for eslint rules ([#34](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/34)) ([b4de0e0](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/b4de0e035940b0dec3b5bfdd8cb597bb0414cea5))
* **game:** get a game by id route ([#29](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/29)) ([b636d5a](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/b636d5a6c22ba150bb41a51be8b9b7cb34efec27))


### 🐛 Bug Fixes

* **pipeline:** always save tests coverage ([#31](https://github.com/antoinezanardi/werewolves-assistant-api-next/issues/31)) ([39417fa](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/39417fa452d3061095ea21288fbfa3245d5f9614))
* **release:** good use of restore cache for deploying ([9eb4e0f](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/9eb4e0f99a6ec4d3d85bb5784311a543768e5fea))
* **release:** good use of restore cache for deploying ([f2e8595](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/f2e85958ab4a74696d6e2d4c493b2100c2662d87))
* **release:** good use of restore cache for deploying.. last time ([e6eff61](https://github.com/antoinezanardi/werewolves-assistant-api-next/commit/e6eff61feaecae7feebc8bcb039b51168e9bdb8c))

## [1.3.0](https://github.com/antoinezanardi/werewolves-assistant-api-next/compare/v1.2.0...v1.3.0) (2023-03-15)


Expand Down
1 change: 0 additions & 1 deletion config/jest/jest-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const config: Config = {
resetMocks: true,
restoreMocks: true,
clearMocks: true,
detectLeaks: true,
setupFiles: ["<rootDir>/tests/unit/unit-setup.ts"],
coverageReporters: ["clover", "json", "lcov", "text", "text-summary"],
collectCoverageFrom: [
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "werewolves-assistant-api-next",
"version": "1.3.0",
"version": "1.4.0",
"description": "About Werewolves Assistant API provides over HTTP requests a way of manage Werewolves games to help the game master.",
"scripts": {
"create-branch": "./scripts/create-git-branch.sh",
Expand All @@ -24,8 +24,8 @@
"test:stryker": "NODE_ENV=test rimraf tests/stryker/coverage && stryker run config/stryker/stryker.conf.json",
"test:stryker:force": "NODE_ENV=test rimraf tests/stryker/incremental.json tests/stryker/coverage && stryker run config/stryker/stryker.conf.json --force",
"test:unit": "NODE_ENV=test jest --config config/jest/jest-unit.ts",
"test:unit:watch": "NODE_ENV=test jest --watch --detectOpenHandles --config config/jest/jest-unit.ts",
"test:unit:cov": "NODE_ENV=test rimraf tests/unit/coverage && jest --coverage --config config/jest/jest-unit.ts --logHeapUsage",
"test:unit:watch": "NODE_ENV=test jest --watch --config config/jest/jest-unit.ts",
"test:unit:cov": "NODE_ENV=test rimraf tests/unit/coverage && jest --coverage --config config/jest/jest-unit.ts",
"test:unit:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --config tests/unit/jest-unit.ts",
"test:e2e": "NODE_ENV=test jest --config config/jest/jest-e2e.ts",
"test:e2e:watch": "NODE_ENV=test jest --watch --config config/jest/jest-e2e.ts",
Expand Down
17 changes: 15 additions & 2 deletions src/modules/game/game.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Body, Controller, Get, HttpStatus, Param, Post } from "@nestjs/common";
import { ApiResponse, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Delete, Get, HttpStatus, Param, Post } from "@nestjs/common";
import { ApiOperation, ApiParam, 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 { GAME_STATUSES } from "./enums/game.enum";
import { GameService } from "./game.service";
import { Game } from "./schemas/game.schema";

Expand Down Expand Up @@ -31,4 +32,16 @@ export class GameController {
public async createGame(@Body() createGameDto: CreateGameDto): Promise<Game> {
return this.gameService.createGame(createGameDto);
}

@Delete(":id")
@ApiOperation({ summary: "Cancel a playing game" })
@ApiParam({ name: "id", description: "Game's Id. Must be a valid MongoId" })
@ApiResponse({ status: HttpStatus.OK, type: Game, description: `Game's status will be set to ${GAME_STATUSES.CANCELED}` })
public async cancelGame(@Param("id", ValidateMongoId) id: string): Promise<Game> {
try {
return await this.gameService.cancelGameById(id);
} catch (err) {
throw getControllerRouteError(err);
}
}
}
10 changes: 7 additions & 3 deletions src/modules/game/game.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import type { FilterQuery } from "mongoose";
import type { FilterQuery, QueryOptions } from "mongoose";
import { Model } from "mongoose";
import type { CreateGameDto } from "./dto/create-game/create-game.dto";
import type { GameDocument } from "./schemas/game.schema";
Expand All @@ -10,14 +10,18 @@ import { Game } from "./schemas/game.schema";
export class GameRepository {
public constructor(@InjectModel(Game.name) private readonly gameModel: Model<GameDocument>) {}
public async find(filter: FilterQuery<GameDocument> = {}): Promise<Game[]> {
return this.gameModel.find(filter).exec();
return this.gameModel.find(filter);
}

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

public async create(game: CreateGameDto): Promise<Game> {
return this.gameModel.create(game);
}

public async updateOne(filter: FilterQuery<GameDocument>, game: Partial<Game>, options: QueryOptions = {}): Promise<Game | null> {
return this.gameModel.findOneAndUpdate(filter, game, { new: true, ...options });
}
}
19 changes: 18 additions & 1 deletion src/modules/game/game.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Injectable } from "@nestjs/common";
import { API_RESOURCES } from "../../shared/api/enums/api.enum";
import { ResourceNotFoundError } from "../../shared/error/types/error.type";
import { BAD_RESOURCE_MUTATION_REASONS } from "../../shared/error/enums/bad-resource-mutation-error.enum";
import { BadResourceMutationError } from "../../shared/error/types/bad-resource-mutation-error.type";
import { ResourceNotFoundError } from "../../shared/error/types/resource-not-found-error.type";
import type { CreateGameDto } from "./dto/create-game/create-game.dto";
import { GAME_STATUSES } from "./enums/game.enum";
import { GameRepository } from "./game.repository";
import type { Game } from "./schemas/game.schema";

Expand All @@ -23,4 +26,18 @@ export class GameService {
public async createGame(game: CreateGameDto): Promise<Game> {
return this.gameRepository.create(game);
}

public async cancelGameById(id: string): Promise<Game> {
const game = await this.gameRepository.findOne({ id });
if (game === null) {
throw new ResourceNotFoundError(API_RESOURCES.GAMES, id);
} else if (game.status !== GAME_STATUSES.PLAYING) {
throw new BadResourceMutationError(API_RESOURCES.GAMES, game._id, BAD_RESOURCE_MUTATION_REASONS.GAME_NOT_PLAYING);
}
const updatedGame = await this.gameRepository.updateOne({ id }, { status: GAME_STATUSES.CANCELED });
if (updatedGame === null) {
throw new ResourceNotFoundError(API_RESOURCES.GAMES, id);
}
return updatedGame;
}
}
5 changes: 5 additions & 0 deletions src/shared/error/enums/bad-resource-mutation-error.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
enum BAD_RESOURCE_MUTATION_REASONS {
GAME_NOT_PLAYING = "game-not-playing",
}

export { BAD_RESOURCE_MUTATION_REASONS };
15 changes: 12 additions & 3 deletions src/shared/error/helpers/error.helper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { NotFoundException } from "@nestjs/common";
import { ResourceNotFoundError } from "../types/error.type";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { BAD_RESOURCE_MUTATION_REASONS } from "../enums/bad-resource-mutation-error.enum";
import { BadResourceMutationError } from "../types/bad-resource-mutation-error.type";
import { ResourceNotFoundError } from "../types/resource-not-found-error.type";

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

export { getControllerRouteError };
function getBadResourceMutationReasonMessage(reason: BAD_RESOURCE_MUTATION_REASONS): string {
const reasonMessages: Record<BAD_RESOURCE_MUTATION_REASONS, string> = { [BAD_RESOURCE_MUTATION_REASONS.GAME_NOT_PLAYING]: `Game doesn't have status with value "playing"` };
return reasonMessages[reason];
}

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

class BadResourceMutationError extends Error {
public constructor(resource: API_RESOURCES, id: string, reason: BAD_RESOURCE_MUTATION_REASONS) {
const resourceSingularForm = getResourceSingularForm(resource);
const reasonMessage = getBadResourceMutationReasonMessage(reason);
const message = `Bad mutation for ${upperFirst(resourceSingularForm)} with id "${id}" : ${reasonMessage}`;
super(message);
}
}

export { BadResourceMutationError };
File renamed without changes.
50 changes: 49 additions & 1 deletion tests/e2e/specs/modules/game/game.module.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe("Game Module", () => {
});

describe("GET /game/:id", () => {
it("should get a bad request error when id is not uuid.", async() => {
it("should get a bad request error when id is not mongoId.", async() => {
const response = await app.inject({
method: "GET",
url: "/games/123",
Expand Down Expand Up @@ -314,4 +314,52 @@ describe("Game Module", () => {
expect(response.json<Game>().options).toMatchObject<GameOptions>(options);
});
});

describe("DELETE /game/:id", () => {
it("should get a bad request error when id is not mongoId.", async() => {
const response = await app.inject({
method: "DELETE",
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: "DELETE",
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 bad request error when game doesn't have playing status.", async() => {
const game = createFakeGame({ status: GAME_STATUSES.CANCELED });
await model.create(game);
const response = await app.inject({
method: "DELETE",
url: `/games/${game._id}`,
});
expect(response.statusCode).toBe(HttpStatus.BAD_REQUEST);
expect(response.json<BadRequestException>().message).toBe(`Bad mutation for Game with id "${game._id}" : Game doesn't have status with value "playing"`);
});

it("should update game status to canceled when called.", async() => {
const game = createFakeGame({ status: GAME_STATUSES.PLAYING });
await model.create(game);
const response = await app.inject({
method: "DELETE",
url: `/games/${game._id}`,
});
expect(response.statusCode).toBe(HttpStatus.OK);
expect(response.json<Game>()).toStrictEqual({
...game,
status: GAME_STATUSES.CANCELED,
createdAt: expect.any(String) as Date,
updatedAt: expect.any(String) as Date,
});
});
});
});

0 comments on commit 16eef75

Please sign in to comment.