Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import IRedisService from '../../../../../common/service/redis/IRedisService';

/**
* In-memory mock of RedisService for use in tests.
* Simulates set/get/keys with TTL support.
*/
@Injectable()
export class RedisServiceInMemory implements OnModuleDestroy {
export class RedisServiceInMemory implements IRedisService, OnModuleDestroy {
private readonly store = new Map<
string,
{ value: string; expiresAt?: number }
Expand Down Expand Up @@ -45,6 +46,19 @@ export class RedisServiceInMemory implements OnModuleDestroy {
.map(([key]) => key);
}

async getValuesByKeyPattern(
pattern: string,
): Promise<Record<string, string | null>> {
const keys = await this.getKeys(pattern);
const result: Record<string, string | null> = {};

for (const key of keys) {
result[key] = await this.get(key);
}

return result;
}

async onModuleDestroy() {
this.store.clear();
this.isDestroyed = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ import OnlinePlayersModule from '../modules/onlinePlayers.module';
import PlayerBuilderFactory from '../../player/data/playerBuilderFactory';
import PlayerModule from '../../player/modules/player.module';
import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum';

const redisSet = jest.fn();
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => ({
set: redisSet,
keys: jest.fn(),
on: jest.fn(),
}));
});
import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus';
import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module';
import { RedisService } from '../../../common/service/redis/redis.service';

describe('OnlinePlayersService.addPlayerOnline() test suite', () => {
let service: OnlinePlayersService;

let redisService: RedisService;

const playerBuilder = PlayerBuilderFactory.getBuilder('Player');
const player1 = playerBuilder
.setUniqueIdentifier('player1')
Expand All @@ -30,14 +26,28 @@ describe('OnlinePlayersService.addPlayerOnline() test suite', () => {

const player1Resp = await playerModel.create(player1);
player1._id = player1Resp._id.toString();

redisService = (await OnlinePlayersCommonModule.getModule()).get(
RedisService,
);
});

it('Should be able to add one player to cache and set 1 as its value', async () => {
await service.addPlayerOnline(player1._id);
it('Should be able to add one player to cache', async () => {
const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${player1._id}`;
const expectedPayload = JSON.stringify({
_id: player1._id,
name: player1.name,
status: OnlinePlayerStatus.BATTLE,
});

const redisSet = jest.spyOn(redisService, 'set');

const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify({ id: player1._id, name: player1.name })}`;
await service.addPlayerOnline({
player_id: player1._id,
status: OnlinePlayerStatus.BATTLE,
});

expect(redisSet).toHaveBeenCalledTimes(1);
expect(redisSet).toHaveBeenCalledWith(expectedKey, '1', 'EX', 300);
expect(redisSet).toHaveBeenCalledWith(expectedKey, expectedPayload, 90);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@ import OnlinePlayersModule from '../modules/onlinePlayers.module';
import { ObjectId } from 'mongodb';
import PlayerBuilderFactory from '../../player/data/playerBuilderFactory';
import PlayerModule from '../../player/modules/player.module';
import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum';

const redisKeys = jest.fn();
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => ({
keys: redisKeys,
on: jest.fn(),
}));
});
import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus';
import { RedisService } from '../../../common/service/redis/redis.service';
import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module';

describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => {
let service: OnlinePlayersService;

const _id1 = new ObjectId();
const _id2 = new ObjectId();
let redisService: RedisService;

const _id1 = new ObjectId().toString();
const _id2 = new ObjectId().toString();

const playerBuilder = PlayerBuilderFactory.getBuilder('Player');
const player1 = playerBuilder
Expand All @@ -36,33 +32,39 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => {
beforeEach(async () => {
jest.clearAllMocks();
service = await OnlinePlayersModule.getOnlinePlayersService();

redisService = (await OnlinePlayersCommonModule.getModule()).get(
RedisService,
);
});

it('Should return player_ids from the cache', async () => {
await playerModel.create(player1);
await playerModel.create(player2);
const payload1 = { name: player1.name, id: _id1.toString() };
const payload2 = { name: player2.name, id: _id2.toString() };
const payload1 = {
name: player1.name,
_id: _id1,
status: OnlinePlayerStatus.UI,
};
const payload2 = {
name: player2.name,
_id: _id2,
status: OnlinePlayerStatus.BATTLE,
};

redisKeys.mockReturnValueOnce([
`${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify(payload1)}`,
`${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify(payload2)}`,
]);
jest.spyOn(redisService, 'getValuesByKeyPattern').mockResolvedValue({
[_id1]: JSON.stringify(payload1),
[_id2]: JSON.stringify(payload2),
});

const player_ids = await service.getAllOnlinePlayers();

expect(player_ids).toContainEqual(payload1);
expect(player_ids).toContainEqual(payload2);
});

it('Should return an empty array if there are no players set', async () => {
const player_ids = await service.getAllOnlinePlayers();

expect(player_ids).toHaveLength(0);
});

it('Should not return players _ids which TTL was expired', async () => {
redisKeys.mockReturnValueOnce(undefined);
it('Should not return players if there are no players', async () => {
jest.spyOn(redisService, 'getValuesByKeyPattern').mockResolvedValue({});

const player_ids = await service.getAllOnlinePlayers();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { mongooseOptions, mongoString } from '../../test_utils/const/db';
import { ModelName } from '../../../common/enum/modelName.enum';
import { PlayerSchema } from '../../../player/schemas/player.schema';
import { RedisModule } from '../../../common/service/redis/redis.module';
import { RedisServiceInMemory } from '../../common/service/redis/mocks/RedisServiceInMemory';
import { RedisService } from '../../../common/service/redis/redis.service';

export default class OnlinePlayersCommonModule {
private static module: TestingModule;
Expand All @@ -24,7 +26,10 @@ export default class OnlinePlayersCommonModule {
RedisModule,
],
providers: [OnlinePlayersService],
}).compile();
})
.overrideProvider(RedisService)
.useClass(RedisServiceInMemory)
.compile();

return OnlinePlayersCommonModule.module;
}
Expand Down
11 changes: 11 additions & 0 deletions src/common/service/redis/IRedisService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default interface IRedisService {
set(key: string, value: string, ttlS?: number): Promise<void>;

get(key: string): Promise<string>;

getKeys(pattern: string): Promise<string[]>;

getValuesByKeyPattern(
pattern: string,
): Promise<Record<string, string | null>>;
}
43 changes: 40 additions & 3 deletions src/common/service/redis/redis.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { envVars } from '../envHandler/envVars';
import IRedisService from './IRedisService';

/**
* A service for interacting directly with Redis using ioredis.
* Provides basic get/set functionality and pattern-based key scanning.
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
export class RedisService implements OnModuleDestroy, IRedisService {
private readonly client: Redis;

constructor() {
Expand Down Expand Up @@ -53,10 +54,46 @@ export class RedisService implements OnModuleDestroy {
* Retrieves all keys matching a given pattern.
*
* @param pattern - A Redis key pattern (e.g., 'online:*').
* @returns A promise resolving to an array of matching keys.
* @returns Array of matching keys.
*/
async getKeys(pattern: string) {
return this.client.keys(pattern);
let cursor = '0';
const foundKeys: string[] = [];

do {
const [nextCursor, keys] = await this.client.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100,
);
cursor = nextCursor;
foundKeys.push(...keys);
} while (cursor !== '0');

return foundKeys;
}

/**
* Retrieves all values for keys matching a given pattern
*
* @param pattern pattern of keys to find
*
* @example getValuesByKeyPattern("user:*") // Get all values, which keys starts with "user:"
*/
async getValuesByKeyPattern(pattern: string) {
const keys = await this.getKeys(pattern);
if (keys.length === 0) return {};

const values = await this.client.mget(...keys);
const result: Record<string, string | null> = {};

keys.forEach((key, index) => {
result[key] = values[index];
});

return result;
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus';
import { IsEnum, IsOptional } from 'class-validator';

export default class InformPlayerIsOnlineDto {
/**
* What players is doing or where the player is in the game.
*
* @example "BattleWait"
* @default "UI"
*/
@IsOptional()
@IsEnum(OnlinePlayerStatus)
status?: OnlinePlayerStatus;
}
11 changes: 10 additions & 1 deletion src/onlinePlayers/dto/onlinePlayer.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Expose } from 'class-transformer';
import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus';

export default class OnlinePlayerDto {
/**
Expand All @@ -7,7 +8,7 @@ export default class OnlinePlayerDto {
* @example "68189c8ce6eda712552911b9"
*/
@Expose()
id: string;
_id: string;

/**
* name of the player
Expand All @@ -16,4 +17,12 @@ export default class OnlinePlayerDto {
*/
@Expose()
name: string;

/**
* What players is doing or where the player is in the game.
*
* @example "UI"
*/
@Expose()
status: OnlinePlayerStatus;
}
19 changes: 19 additions & 0 deletions src/onlinePlayers/enum/OnlinePlayerStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Represents what players is doing or where the player is in the game.
*/
export enum OnlinePlayerStatus {
/**
* Player is in game UI
*/
UI = 'UI',

/**
* Player is waiting in battle queue, to join the battle
*/
BATTLE_WAIT = 'BattleWait',

/**
* Player is playing the battle
*/
BATTLE = 'Battle',
}
18 changes: 11 additions & 7 deletions src/onlinePlayers/onlinePlayers.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post } from '@nestjs/common';
import { OnlinePlayersService } from './onlinePlayers.service';
import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator';
import { User } from '../auth/user';
import { UniformResponse } from '../common/decorator/response/UniformResponse';
import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription';
import OnlinePlayerDto from './dto/onlinePlayer.dto';
import InformPlayerIsOnlineDto from './dto/InformPlayerIsOnline.dto';

@Controller('online-players')
export class OnlinePlayersController {
Expand All @@ -13,20 +14,23 @@ export class OnlinePlayersController {
/**
* Inform the API if player is still online
*
* @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 5 min.
* @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 1.5 min.
*
* So it is recommended to make requests to this endpoint every 4-5 min to properly track the players being online, although not often than that.
* So it is recommended to make requests to this endpoint every 1 min to properly track the players being online, although not often than that.
*/
@ApiResponseDescription({
success: {
status: 204,
},
errors: [401],
errors: [400, 401],
})
@Post('ping')
@UniformResponse()
async ping(@LoggedUser() user: User) {
return this.onlinePlayersService.addPlayerOnline(user.player_id);
async ping(@Body() body: InformPlayerIsOnlineDto, @LoggedUser() user: User) {
return this.onlinePlayersService.addPlayerOnline({
player_id: user.player_id,
status: body.status,
});
}

/**
Expand All @@ -45,7 +49,7 @@ export class OnlinePlayersController {
errors: [401],
})
@Get()
@UniformResponse()
@UniformResponse(null, OnlinePlayerDto)
async getAllOnlinePlayers() {
return this.onlinePlayersService.getAllOnlinePlayers();
}
Expand Down
Loading