Skip to content

Commit

Permalink
feat(game-history): add limit and order to get game history endpoint (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Oct 3, 2023
1 parent abdf8a1 commit 6bdb566
Show file tree
Hide file tree
Showing 15 changed files with 19,919 additions and 19,694 deletions.
13 changes: 10 additions & 3 deletions src/modules/game/controllers/game.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";

import { GetGameHistoryDto } from "@/modules/game/dto/get-game-history/get-game-history.dto";
import { ApiGameIdParam } from "@/modules/game/controllers/decorators/api-game-id-param.decorator";
import { ApiGameNotFoundResponse } from "@/modules/game/controllers/decorators/api-game-not-found-response.decorator";
import { GetGameByIdPipe } from "@/modules/game/controllers/pipes/get-game-by-id.pipe";
Expand Down Expand Up @@ -67,7 +68,10 @@ export class GameController {
@Post(":id/play")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Make a game play", description: `Make a play for a game with the \`${GameStatuses.PLAYING}\` status. Body parameters fields are required or optional based on the upcoming game play.` })
private async makeGamePlay(@Param("id", GetGameByIdPipe) game: Game, @Body() makeGamePlayDto: MakeGamePlayDto): Promise<Game> {
private async makeGamePlay(
@Param("id", GetGameByIdPipe) game: Game,
@Body() makeGamePlayDto: MakeGamePlayDto,
): Promise<Game> {
return this.gameService.makeGamePlay(game, makeGamePlayDto);
}

Expand All @@ -76,7 +80,10 @@ export class GameController {
@ApiGameIdParam()
@ApiResponse({ status: HttpStatus.OK, type: [GameHistoryRecord] })
@ApiGameNotFoundResponse()
private async getGameHistory(@Param("id", GetGameByIdPipe) game: Game): Promise<GameHistoryRecord[]> {
return this.gameHistoryRecordService.getGameHistory(game._id);
private async getGameHistory(
@Param("id", GetGameByIdPipe) game: Game,
@Query() getGameHistoryDto: GetGameHistoryDto,
): Promise<GameHistoryRecord[]> {
return this.gameHistoryRecordService.getGameHistory(game._id, getGameHistoryDto);
}
}
28 changes: 28 additions & 0 deletions src/modules/game/dto/get-game-history/get-game-history.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsInt, IsOptional, Min } from "class-validator";
import { Type } from "class-transformer";

import { ApiSortOrder } from "@/shared/api/enums/api.enum";

class GetGameHistoryDto {
@ApiProperty({
description: "Number of returned game's history records. `0` means no limit",
minimum: 0,
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
public limit: number = 0;

@ApiProperty({
description: "Sorting order of returned game's history records by creation date. `asc` means from oldest to newest, `desc` means from newest to oldest",
required: false,
})
@IsOptional()
@IsEnum(ApiSortOrder)
public order: ApiSortOrder = ApiSortOrder.ASC;
}

export { GetGameHistoryDto };
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { QueryOptions } from "mongoose";

import type { GetGameHistoryDto } from "@/modules/game/dto/get-game-history/get-game-history.dto";

import { getMongooseSortValueFromApiSortOrder } from "@/shared/mongoose/helpers/mongoose.helper";

function convertGetGameHistoryDtoToMongooseQueryOptions(getGameHistoryDto: GetGameHistoryDto): QueryOptions {
return {
limit: getGameHistoryDto.limit,
sort: { createdAt: getMongooseSortValueFromApiSortOrder(getGameHistoryDto.order) },
};
}

export { convertGetGameHistoryDtoToMongooseQueryOptions };
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import type { FilterQuery, Types } from "mongoose";

import { convertGetGameHistoryDtoToMongooseQueryOptions } from "@/modules/game/helpers/game-history/game-history-record.mapper";
import type { GetGameHistoryDto } from "@/modules/game/dto/get-game-history/get-game-history.dto";
import { GameHistoryRecordVotingResults } from "@/modules/game/enums/game-history-record.enum";
import { GamePlayActions, WitchPotions } from "@/modules/game/enums/game-play.enum";
import type { GamePhases } from "@/modules/game/enums/game.enum";
Expand All @@ -14,8 +16,9 @@ import { RoleNames } from "@/modules/role/enums/role.enum";
export class GameHistoryRecordRepository {
public constructor(@InjectModel(GameHistoryRecord.name) private readonly gameHistoryRecordModel: Model<GameHistoryRecordDocument>) {}

public async getGameHistory(gameId: Types.ObjectId): Promise<GameHistoryRecord[]> {
return this.gameHistoryRecordModel.find({ gameId });
public async getGameHistory(gameId: Types.ObjectId, getGameHistoryDto: GetGameHistoryDto): Promise<GameHistoryRecord[]> {
const queryOptions = convertGetGameHistoryDtoToMongooseQueryOptions(getGameHistoryDto);
return this.gameHistoryRecordModel.find({ gameId }, undefined, queryOptions);
}

public async create(gameHistoryRecord: GameHistoryRecordToInsert): Promise<GameHistoryRecord> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import type { Types } from "mongoose";

import type { GetGameHistoryDto } from "@/modules/game/dto/get-game-history/get-game-history.dto";
import type { MakeGamePlayWithRelationsDto } from "@/modules/game/dto/make-game-play/make-game-play-with-relations.dto";
import { GameHistoryRecordVotingResults } from "@/modules/game/enums/game-history-record.enum";
import type { WitchPotions } from "@/modules/game/enums/game-play.enum";
Expand Down Expand Up @@ -96,8 +97,8 @@ export class GameHistoryRecordService {
return plainToInstance(GameHistoryRecordToInsert, gameHistoryRecordToInsert, PLAIN_TO_INSTANCE_DEFAULT_OPTIONS);
}

public async getGameHistory(gameId: Types.ObjectId): Promise<GameHistoryRecord[]> {
return this.gameHistoryRecordRepository.getGameHistory(gameId);
public async getGameHistory(gameId: Types.ObjectId, getGameHistoryDto: GetGameHistoryDto): Promise<GameHistoryRecord[]> {
return this.gameHistoryRecordRepository.getGameHistory(gameId, getGameHistoryDto);
}

private generateCurrentGameHistoryRecordDeadPlayersToInsert(baseGame: Game, newGame: Game): Player[] | undefined {
Expand Down
10 changes: 9 additions & 1 deletion src/shared/api/enums/api.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ enum ApiResources {
HEALTH = "health",
}

export { ApiResources };
enum ApiSortOrder {
ASC = "asc",
DESC = "desc",
}

export {
ApiResources,
ApiSortOrder,
};
5 changes: 1 addition & 4 deletions src/shared/misc/helpers/object.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ function toJSON<T extends object>(obj: T): Record<keyof T, unknown>;
function toJSON<T extends object>(obj: T | null): Record<keyof T, unknown> | null;

function toJSON<T extends object>(obj: T | T[] | null): Record<keyof T, unknown> | Record<keyof T, unknown>[] | null {
if (Array.isArray(obj)) {
return obj.map(item => JSON.parse(JSON.stringify(item)) as Record<keyof T, unknown>);
}
return JSON.parse(JSON.stringify(obj)) as Record<keyof T, unknown>;
return JSON.parse(JSON.stringify(obj)) as Record<keyof T, unknown> | Record<keyof T, unknown>[] | null;
}

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

function getMongooseSortValueFromApiSortOrder(sortOrder: ApiSortOrder): number {
return sortOrder === ApiSortOrder.ASC ? 1 : -1;
}

export { getMongooseSortValueFromApiSortOrder };
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { faker } from "@faker-js/faker";
import { HttpStatus } 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";
import type { TestingModule } from "@nestjs/testing";
Expand All @@ -24,6 +24,7 @@ import { Game } from "@/modules/game/schemas/game.schema";
import type { Player } from "@/modules/game/schemas/player/player.schema";
import { RoleNames, RoleSides } from "@/modules/role/enums/role.enum";

import { ApiSortOrder } from "@/shared/api/enums/api.enum";
import { toJSON } from "@/shared/misc/helpers/object.helper";

import { truncateAllCollections } from "@tests/e2e/helpers/mongoose.helper";
Expand All @@ -33,6 +34,7 @@ import { createFakeGameOptionsDto } from "@tests/factories/game/dto/create-game/
import { createFakeCreateThiefGameOptionsDto } from "@tests/factories/game/dto/create-game/create-game-options/create-roles-game-options/create-roles-game-options.dto.factory";
import { bulkCreateFakeCreateGamePlayerDto } from "@tests/factories/game/dto/create-game/create-game-player/create-game-player.dto.factory";
import { createFakeCreateGameDto, createFakeCreateGameWithPlayersDto } from "@tests/factories/game/dto/create-game/create-game.dto.factory";
import { createFakeGetGameHistoryDto } from "@tests/factories/game/dto/get-game-history/get-game-history.dto.factory";
import { createFakeMakeGamePlayDto } from "@tests/factories/game/dto/make-game-play/make-game-play.dto.factory";
import { createFakeGameAdditionalCard } from "@tests/factories/game/schemas/game-additional-card/game-additional-card.schema.factory";
import { createFakeGameHistoryRecord } from "@tests/factories/game/schemas/game-history-record/game-history-record.schema.factory";
Expand All @@ -41,7 +43,7 @@ import { createFakeGameOptions } from "@tests/factories/game/schemas/game-option
import { createFakeRolesGameOptions } from "@tests/factories/game/schemas/game-options/game-roles-options.schema.factory";
import { createFakeVotesGameOptions } from "@tests/factories/game/schemas/game-options/votes-game-options.schema.factory";
import { createFakeGamePlaySource } from "@tests/factories/game/schemas/game-play/game-play-source.schema.factory";
import { createFakeGamePlaySurvivorsVote, createFakeGamePlayCupidCharms, createFakeGamePlayLoversMeetEachOther, createFakeGamePlaySeerLooks, createFakeGamePlayThiefChoosesCard, createFakeGamePlayWerewolvesEat, createFakeGamePlayWhiteWerewolfEats } from "@tests/factories/game/schemas/game-play/game-play.schema.factory";
import { createFakeGamePlayCupidCharms, createFakeGamePlayLoversMeetEachOther, createFakeGamePlaySeerLooks, createFakeGamePlaySurvivorsVote, createFakeGamePlayThiefChoosesCard, createFakeGamePlayWerewolvesEat, createFakeGamePlayWhiteWerewolfEats } from "@tests/factories/game/schemas/game-play/game-play.schema.factory";
import { createFakeGame, createFakeGameWithCurrentPlay } from "@tests/factories/game/schemas/game.schema.factory";
import { createFakeSeenBySeerPlayerAttribute } from "@tests/factories/game/schemas/player/player-attribute/player-attribute.schema.factory";
import { createFakeSeerAlivePlayer, createFakeVillagerAlivePlayer, createFakeWerewolfAlivePlayer } from "@tests/factories/game/schemas/player/player-with-role.schema.factory";
Expand Down Expand Up @@ -975,8 +977,34 @@ describe("Game Controller", () => {
});

describe("GET /games/:id/history", () => {
afterEach(async() => {
await models.gameHistoryRecord.deleteMany();
it.each<{ query: Record<string, unknown>; test: string; errorMessage: string }>([
{
query: { limit: -1 },
test: "limit is negative",
errorMessage: "limit must not be less than 0",
},
{
query: { limit: "lol" },
test: "limit is not a number",
errorMessage: "limit must be an integer number",
},
{
query: { order: "unknown" },
test: "order is not asc nor desc",
errorMessage: "order must be one of the following values: asc, desc",
},
])("should get bad request error on getting game history when $test [#$#].", async({
query,
errorMessage,
}) => {
const response = await app.inject({
method: "GET",
url: `/games/${faker.database.mongodbObjectId()}/history`,
query: stringify(query),
});

expect(response.statusCode).toBe(HttpStatus.BAD_REQUEST);
expect(response.json<BadRequestException>().message).toContainEqual(errorMessage);
});

it("should get a bad request error when id is not mongoId.", async() => {
Expand Down Expand Up @@ -1024,9 +1052,9 @@ describe("Game Controller", () => {
const game = createFakeGameWithCurrentPlay();
const secondGame = createFakeGameWithCurrentPlay();
const gameHistoryRecords = [
createFakeGameHistoryRecord({ gameId: game._id }),
createFakeGameHistoryRecord({ gameId: game._id }),
createFakeGameHistoryRecord({ gameId: game._id }),
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2022-01-01") }),
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2023-01-01") }),
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2024-01-01") }),
];
await models.game.insertMany([game, secondGame]);
await models.gameHistoryRecord.insertMany(gameHistoryRecords);
Expand All @@ -1052,5 +1080,32 @@ describe("Game Controller", () => {
},
] as GameHistoryRecord[]);
});

it("should return last recent game history record when limit is 1 and order is desc.", async() => {
const game = createFakeGameWithCurrentPlay();
const getGameHistoryDto = createFakeGetGameHistoryDto({ limit: 1, order: ApiSortOrder.DESC });
const secondGame = createFakeGameWithCurrentPlay();
const gameHistoryRecords = [
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2022-01-01") }),
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2023-01-01") }),
createFakeGameHistoryRecord({ gameId: game._id, createdAt: new Date("2024-01-01") }),
];
await models.game.insertMany([game, secondGame]);
await models.gameHistoryRecord.insertMany(gameHistoryRecords);

const response = await app.inject({
method: "GET",
url: `/games/${game._id.toString()}/history`,
query: stringify(getGameHistoryDto),
});

expect(response.statusCode).toBe(HttpStatus.OK);
expect(response.json<GameHistoryRecord[]>()).toStrictEqual<GameHistoryRecord[]>([
{
...toJSON(gameHistoryRecords[2]),
createdAt: expect.any(String) as Date,
},
] as GameHistoryRecord[]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import type { GameHistoryRecordToInsert } from "@/modules/game/types/game-histor
import type { GameSource } from "@/modules/game/types/game.type";
import type { RoleSides } from "@/modules/role/enums/role.enum";

import { ApiSortOrder } from "@/shared/api/enums/api.enum";
import { toJSON } from "@/shared/misc/helpers/object.helper";

import { truncateAllCollections } from "@tests/e2e/helpers/mongoose.helper";
import { initNestApp } from "@tests/e2e/helpers/nest-app.helper";
import { createFakeGameHistoryRecord, createFakeGameHistoryRecordSurvivorsElectSheriffPlay, createFakeGameHistoryRecordSurvivorsVotePlay, createFakeGameHistoryRecordBigBadWolfEatPlay, createFakeGameHistoryRecordGuardProtectPlay, createFakeGameHistoryRecordPlay, createFakeGameHistoryRecordPlaySource, createFakeGameHistoryRecordPlayTarget, createFakeGameHistoryRecordPlayVoting, createFakeGameHistoryRecordWerewolvesEatPlay, createFakeGameHistoryRecordWitchUsePotionsPlay } from "@tests/factories/game/schemas/game-history-record/game-history-record.schema.factory";
import { createFakeGetGameHistoryDto } from "@tests/factories/game/dto/get-game-history/get-game-history.dto.factory";
import { createFakeGameHistoryRecord, createFakeGameHistoryRecordBigBadWolfEatPlay, createFakeGameHistoryRecordGuardProtectPlay, createFakeGameHistoryRecordPlay, createFakeGameHistoryRecordPlaySource, createFakeGameHistoryRecordPlayTarget, createFakeGameHistoryRecordPlayVoting, createFakeGameHistoryRecordSurvivorsElectSheriffPlay, createFakeGameHistoryRecordSurvivorsVotePlay, createFakeGameHistoryRecordWerewolvesEatPlay, createFakeGameHistoryRecordWitchUsePotionsPlay } from "@tests/factories/game/schemas/game-history-record/game-history-record.schema.factory";
import { createFakeAncientAlivePlayer, createFakeSeerAlivePlayer, createFakeWitchAlivePlayer } from "@tests/factories/game/schemas/player/player-with-role.schema.factory";
import { bulkCreateFakePlayers, createFakePlayer } from "@tests/factories/game/schemas/player/player.schema.factory";
import { createFakeGameHistoryRecordToInsert } from "@tests/factories/game/types/game-history-record/game-history-record.type.factory";
Expand Down Expand Up @@ -57,21 +59,51 @@ describe("Game History Record Repository", () => {
describe("getGameHistory", () => {
it("should get nothing when there are no record.", async() => {
const gameId = createFakeObjectId();
const getGameHistoryDto = createFakeGetGameHistoryDto();

await expect(repositories.gameHistoryRecord.getGameHistory(gameId)).resolves.toStrictEqual<GameHistoryRecord[]>([]);
await expect(repositories.gameHistoryRecord.getGameHistory(gameId, getGameHistoryDto)).resolves.toStrictEqual<GameHistoryRecord[]>([]);
});

it("should get records when called with gameId with records.", async() => {
it("should get all ascending records when called with gameId with records with default get game history dto.", async() => {
const gameId = createFakeObjectId();
const getGameHistoryDto = createFakeGetGameHistoryDto();
const gameHistoryRecords = [
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWerewolvesEatPlay() }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWitchUsePotionsPlay() }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWerewolvesEatPlay(), createdAt: new Date("2022-01-01") }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWitchUsePotionsPlay(), createdAt: new Date("2023-01-01") }),
];
await populate(gameHistoryRecords);
const records = await repositories.gameHistoryRecord.getGameHistory(gameId);
const records = await repositories.gameHistoryRecord.getGameHistory(gameId, getGameHistoryDto);

expect(toJSON(records)).toStrictEqual<GameHistoryRecord[]>(toJSON(gameHistoryRecords) as GameHistoryRecord[]);
});

it("should get only one record when called with get game history dto limit set to 1.", async() => {
const gameId = createFakeObjectId();
const getGameHistoryDto = createFakeGetGameHistoryDto({ limit: 1 });
const gameHistoryRecords = [
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordSurvivorsElectSheriffPlay(), createdAt: new Date("2022-01-01") }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWerewolvesEatPlay(), createdAt: new Date("2023-01-01") }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWitchUsePotionsPlay(), createdAt: new Date("2024-01-01") }),
];
await populate(gameHistoryRecords);
const records = await repositories.gameHistoryRecord.getGameHistory(gameId, getGameHistoryDto);

expect(toJSON(records)).toStrictEqual<GameHistoryRecord[]>([toJSON(gameHistoryRecords[0]) as GameHistoryRecord]);
});

it("should get records in descending order when called with get game history dto order set to DESC.", async() => {
const gameId = createFakeObjectId();
const getGameHistoryDto = createFakeGetGameHistoryDto({ order: ApiSortOrder.DESC });
const gameHistoryRecords = [
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordSurvivorsElectSheriffPlay(), createdAt: new Date("2022-01-01") }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWerewolvesEatPlay(), createdAt: new Date("2023-01-01") }),
createFakeGameHistoryRecord({ gameId, play: createFakeGameHistoryRecordWitchUsePotionsPlay(), createdAt: new Date("2024-01-01") }),
];
await populate(gameHistoryRecords);
const records = await repositories.gameHistoryRecord.getGameHistory(gameId, getGameHistoryDto);

expect(toJSON(records)).toStrictEqual<GameHistoryRecord[]>(toJSON(gameHistoryRecords.reverse()) as GameHistoryRecord[]);
});
});

describe("create", () => {
Expand Down

0 comments on commit 6bdb566

Please sign in to comment.