diff --git a/src/__tests__/common/interface/data/interface/IGetAllQueryBuilder.ts b/src/__tests__/common/interface/data/interface/IGetAllQueryBuilder.ts new file mode 100644 index 000000000..0d6b595d4 --- /dev/null +++ b/src/__tests__/common/interface/data/interface/IGetAllQueryBuilder.ts @@ -0,0 +1,40 @@ +import { IGetAllQuery } from '../../../../../common/interface/IGetAllQuery'; + +export default class IGetAllQueryBuilder { + private readonly base: Partial = { + filter: {}, + select: [], + limit: 0, + sort: {}, + skip: 0, + }; + + build(): IGetAllQuery { + return { ...this.base } as IGetAllQuery; + } + + setFilter(filter: object) { + this.base.filter = filter; + return this; + } + + setSelect(select: string[]) { + this.base.select = select; + return this; + } + + setLimit(limit: number) { + this.base.limit = limit; + return this; + } + + setSort(sort: Record) { + this.base.sort = sort; + return this; + } + + setSkip(skip: number) { + this.base.skip = skip; + return this; + } +} diff --git a/src/__tests__/common/interface/data/interfaceBuilderFactory.ts b/src/__tests__/common/interface/data/interfaceBuilderFactory.ts new file mode 100644 index 000000000..35979f7a6 --- /dev/null +++ b/src/__tests__/common/interface/data/interfaceBuilderFactory.ts @@ -0,0 +1,18 @@ +import IGetAllQueryBuilder from './interface/IGetAllQueryBuilder'; + +type BuilderName = 'IGetAllQuery'; + +type BuilderMap = { + IGetAllQuery: IGetAllQueryBuilder; +}; + +export default class InterfaceBuilderFactory { + static getBuilder(builderName: T): BuilderMap[T] { + switch (builderName) { + case 'IGetAllQuery': + return new IGetAllQueryBuilder() as BuilderMap[T]; + default: + throw new Error(`Unknown builder name: ${builderName}`); + } + } +} diff --git a/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts new file mode 100644 index 000000000..fc03fd53e --- /dev/null +++ b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts @@ -0,0 +1,52 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; + +/** + * In-memory mock of RedisService for use in tests. + * Simulates set/get/keys with TTL support. + */ +@Injectable() +export class RedisServiceInMemory implements OnModuleDestroy { + private readonly store = new Map< + string, + { value: string; expiresAt?: number } + >(); + + private isDestroyed = false; + + async set(key: string, value: string, ttlS?: number): Promise { + const expiresAt = ttlS ? Date.now() + ttlS * 1000 : undefined; + this.store.set(key, { value, expiresAt }); + } + + async get(key: string): Promise { + const entry = this.store.get(key); + if (!entry) return null; + + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + + return entry.value; + } + + async getKeys(pattern: string): Promise { + const regex = new RegExp('^' + pattern.replace('*', '.*') + '$'); + const now = Date.now(); + + return Array.from(this.store.entries()) + .filter(([key, { expiresAt }]) => { + if (expiresAt && now > expiresAt) { + this.store.delete(key); + return false; + } + return regex.test(key); + }) + .map(([key]) => key); + } + + async onModuleDestroy() { + this.store.clear(); + this.isDestroyed = true; + } +} diff --git a/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanLeaderboard.test.ts b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanLeaderboard.test.ts new file mode 100644 index 000000000..3fba6bb82 --- /dev/null +++ b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanLeaderboard.test.ts @@ -0,0 +1,52 @@ +import { LeaderboardService } from '../../../leaderboard/leaderboard.service'; +import ClanBuilderFactory from '../../clan/data/clanBuilderFactory'; +import ClanModule from '../../clan/modules/clan.module'; +import LeaderboardModule from '../modules/leaderboard.module'; +import InterfaceBuilderFactory from '../../common/interface/data/interfaceBuilderFactory'; +import { Clan } from '../../../clan/clan.schema'; + +describe('LeaderboardService.getClanLeaderboard() test suite', () => { + let service: LeaderboardService; + const queryBuilder = InterfaceBuilderFactory.getBuilder('IGetAllQuery'); + + const clanBuilder = ClanBuilderFactory.getBuilder('Clan'); + const clan1 = clanBuilder.setName('clan-1').setPoints(100).build(); + const clan2 = clanBuilder.setName('clan-2').setPoints(50).build(); + const clan3 = clanBuilder.setName('clan-3').setPoints(10).build(); + const clanModel = ClanModule.getClanModel(); + + beforeEach(async () => { + service = await LeaderboardModule.getLeaderboardService(); + + await clanModel.create(clan1); + await clanModel.create(clan2); + await clanModel.create(clan3); + }); + + it('Should return leading clans in valid order', async () => { + const query = queryBuilder.setLimit(10).setSkip(0).build(); + + const leaders = (await service.getClanLeaderboard(query)) as Clan[]; + + const clanNames = leaders.map((leader) => leader.name); + + expect(clanNames).toEqual([clan1.name, clan2.name, clan3.name]); + }); + + it('Should return requested amount of leading clans', async () => { + const query = queryBuilder.setLimit(2).setSkip(0).build(); + + const leaders = await service.getClanLeaderboard(query); + + expect(leaders).toHaveLength(2); + }); + + it('Should be able to skip leading clans', async () => { + const query = queryBuilder.setLimit(10).setSkip(2).build(); + + const leaders = (await service.getClanLeaderboard(query)) as Clan[]; + + expect(leaders).toHaveLength(1); + expect(leaders[0].name).toBe(clan3.name); + }); +}); diff --git a/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanPosition.test.ts b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanPosition.test.ts new file mode 100644 index 000000000..b51ad491a --- /dev/null +++ b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getClanPosition.test.ts @@ -0,0 +1,41 @@ +import { LeaderboardService } from '../../../leaderboard/leaderboard.service'; +import ClanBuilderFactory from '../../clan/data/clanBuilderFactory'; +import ClanModule from '../../clan/modules/clan.module'; +import LeaderboardModule from '../modules/leaderboard.module'; +import { getNonExisting_id } from '../../test_utils/util/getNonExisting_id'; + +describe('LeaderboardService.getClanPosition() test suite', () => { + let service: LeaderboardService; + + const clanBuilder = ClanBuilderFactory.getBuilder('Clan'); + const clan1 = clanBuilder.setName('clan-1').setPoints(100).build(); + const clan2 = clanBuilder.setName('clan-2').setPoints(50).build(); + const clan3 = clanBuilder.setName('clan-3').setPoints(10).build(); + const clanModel = ClanModule.getClanModel(); + + beforeEach(async () => { + service = await LeaderboardModule.getLeaderboardService(); + + const createClan1 = await clanModel.create(clan1); + clan1._id = createClan1._id.toString(); + const createClan2 = await clanModel.create(clan2); + clan2._id = createClan2._id.toString(); + const createClan3 = await clanModel.create(clan3); + clan3._id = createClan3._id.toString(); + }); + + it('Should return valid order number of the requested clan', async () => { + const { position } = await service.getClanPosition(clan2._id); + + expect(position).toBe(2); + }); + + it('Should throw ServiceError NOT_FOUND if clan with the _id can not be found', async () => { + try { + await service.getClanPosition(getNonExisting_id()); + fail('getClanPosition() did not throw'); + } catch (e) { + expect(e).toBeSE_NOT_FOUND(); + } + }); +}); diff --git a/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getPlayerLeaderboard.test.ts b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getPlayerLeaderboard.test.ts new file mode 100644 index 000000000..338cc87b9 --- /dev/null +++ b/src/__tests__/leaderboard/LeaderboardService/LeaderboardService.getPlayerLeaderboard.test.ts @@ -0,0 +1,87 @@ +import { LeaderboardService } from '../../../leaderboard/leaderboard.service'; +import ClanBuilderFactory from '../../clan/data/clanBuilderFactory'; +import ClanModule from '../../clan/modules/clan.module'; +import LeaderboardModule from '../modules/leaderboard.module'; +import InterfaceBuilderFactory from '../../common/interface/data/interfaceBuilderFactory'; +import PlayerBuilderFactory from '../../player/data/playerBuilderFactory'; +import PlayerModule from '../../player/modules/player.module'; +import { Player } from '../../../player/schemas/player.schema'; +import LoggedUser from '../../test_utils/const/loggedUser'; + +describe('LeaderboardService.getPlayerLeaderboard() test suite', () => { + let service: LeaderboardService; + const queryBuilder = InterfaceBuilderFactory.getBuilder('IGetAllQuery'); + + const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); + const player1 = playerBuilder + .setName('player-1') + .setUniqueIdentifier('player-1') + .setPoints(100) + .build(); + const player2 = playerBuilder + .setName('player-2') + .setUniqueIdentifier('player-2') + .setPoints(50) + .build(); + const player3 = playerBuilder + .setName('player-3') + .setUniqueIdentifier('player-3') + .setPoints(10) + .build(); + const playerModel = PlayerModule.getPlayerModel(); + + const clanBuilder = ClanBuilderFactory.getBuilder('Clan'); + const clan = clanBuilder.setName('clan').setPoints(100).build(); + const clanModel = ClanModule.getClanModel(); + + beforeEach(async () => { + service = await LeaderboardModule.getLeaderboardService(); + + //Remove pre-created default player + await playerModel.deleteOne({ name: LoggedUser.getPlayer().name }); + + const createdClan = await clanModel.create(clan); + clan._id = createdClan._id; + + player1.clan_id = clan._id; + player2.clan_id = clan._id; + player3.clan_id = clan._id; + + await playerModel.create(player1); + await playerModel.create(player2); + await playerModel.create(player3); + }); + + it('Should return leading players in valid order', async () => { + const query = queryBuilder.setLimit(10).build(); + + const leaders = (await service.getPlayerLeaderboard(query)) as Player[]; + + const playerNames = leaders.map((leader) => leader.name); + + expect(playerNames).toEqual([player1.name, player2.name, player3.name]); + }); + + it('Should return clanLogo data if player is in a clan', async () => { + const query = queryBuilder.setLimit(10).build(); + + const leaders = (await service.getPlayerLeaderboard(query)) as any[]; + + expect(leaders[0].clanLogo).toEqual(clan.clanLogo); + }); + + it('Should return requested amount of leading players', async () => { + const query = queryBuilder.setLimit(2).setSkip(0).build(); + const leaders = await service.getPlayerLeaderboard(query); + + expect(leaders).toHaveLength(2); + }); + + it('Should be able to skip leading players', async () => { + const query = queryBuilder.setLimit(10).setSkip(2).build(); + const leaders = (await service.getPlayerLeaderboard(query)) as Player[]; + + expect(leaders).toHaveLength(1); + expect(leaders[0].name).toBe(player3.name); + }); +}); diff --git a/src/__tests__/leaderboard/modules/leaderboard.module.ts b/src/__tests__/leaderboard/modules/leaderboard.module.ts new file mode 100644 index 000000000..95d6e3cc7 --- /dev/null +++ b/src/__tests__/leaderboard/modules/leaderboard.module.ts @@ -0,0 +1,11 @@ +import { LeaderboardService } from '../../../leaderboard/leaderboard.service'; +import LeaderboardCommonModule from './leaderboardCommon'; + +export default class LeaderboardModule { + private constructor() {} + + static async getLeaderboardService() { + const module = await LeaderboardCommonModule.getModule(); + return module.resolve(LeaderboardService); + } +} diff --git a/src/__tests__/leaderboard/modules/leaderboardCommon.ts b/src/__tests__/leaderboard/modules/leaderboardCommon.ts new file mode 100644 index 000000000..0053606cc --- /dev/null +++ b/src/__tests__/leaderboard/modules/leaderboardCommon.ts @@ -0,0 +1,35 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MongooseModule } from '@nestjs/mongoose'; +import { RequestHelperModule } from '../../../requestHelper/requestHelper.module'; +import { mongooseOptions, mongoString } from '../../test_utils/const/db'; +import { RedisServiceInMemory } from '../../common/service/redis/mocks/RedisServiceInMemory'; +import { PlayerModule } from '../../../player/player.module'; +import { ClanModule } from '../../../clan/clan.module'; +import { RedisModule } from '../../../common/service/redis/redis.module'; +import { LeaderboardService } from '../../../leaderboard/leaderboard.service'; +import { RedisService } from '../../../common/service/redis/redis.service'; + +export default class LeaderboardCommonModule { + private constructor() {} + + private static module: TestingModule; + + static async getModule() { + if (!LeaderboardCommonModule.module) + LeaderboardCommonModule.module = await Test.createTestingModule({ + imports: [ + MongooseModule.forRoot(mongoString, mongooseOptions), + PlayerModule, + ClanModule, + RedisModule, + RequestHelperModule, + ], + providers: [LeaderboardService], + }) + .overrideProvider(RedisService) + .useClass(RedisServiceInMemory) + .compile(); + + return LeaderboardCommonModule.module; + } +}