From c15c77475b8d2b6da01776de9b3542bba4435585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Tue, 28 Nov 2023 18:56:56 +0100 Subject: [PATCH 1/4] feat(rooms): add rooms for io events filtering --- src/app.module.ts | 2 + src/games/games.module.ts | 2 + .../gateways/game-events.gateway.spec.ts | 18 +++++++++ src/games/gateways/game-events.gateway.ts | 40 +++++++++++++++++++ src/games/gateways/game-slots.gateway.ts | 4 +- src/rooms/gateways/rooms.gateway.spec.ts | 18 +++++++++ src/rooms/gateways/rooms.gateway.ts | 17 ++++++++ src/rooms/rooms.module.ts | 7 ++++ src/websocket-event.ts | 3 +- 9 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/games/gateways/game-events.gateway.spec.ts create mode 100644 src/games/gateways/game-events.gateway.ts create mode 100644 src/rooms/gateways/rooms.gateway.spec.ts create mode 100644 src/rooms/gateways/rooms.gateway.ts create mode 100644 src/rooms/rooms.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 3f691a868..c69555fb0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -34,6 +34,7 @@ import { validateEnvironment } from './validate-environment'; import { Etf2lModule } from './etf2l/etf2l.module'; import { SteamModule } from './steam/steam.module'; import { CacheModule } from '@nestjs/cache-manager'; +import { RoomsModule } from './rooms/rooms.module'; @Module({ imports: [ @@ -100,6 +101,7 @@ import { CacheModule } from '@nestjs/cache-manager'; QueueConfigModule, Etf2lModule, SteamModule, + RoomsModule, ], controllers: [AppController], }) diff --git a/src/games/games.module.ts b/src/games/games.module.ts index d5bc2162c..5aeaa18a5 100644 --- a/src/games/games.module.ts +++ b/src/games/games.module.ts @@ -16,6 +16,7 @@ import { GameServerAssignerService } from './services/game-server-assigner.servi import { QueueConfigModule } from '@/queue-config/queue-config.module'; import { GamesConfigurationService } from './services/games-configuration.service'; import { GameSlotsGateway } from './gateways/game-slots.gateway'; +import { GameEventsGateway } from './gateways/game-events.gateway'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { GameSlotsGateway } from './gateways/game-slots.gateway'; GameServerAssignerService, GamesConfigurationService, GameSlotsGateway, + GameEventsGateway, ], exports: [GamesService, PlayerSubstitutionService], controllers: [GamesController, GamesWithSubstitutionRequestsController], diff --git a/src/games/gateways/game-events.gateway.spec.ts b/src/games/gateways/game-events.gateway.spec.ts new file mode 100644 index 000000000..96b99dc43 --- /dev/null +++ b/src/games/gateways/game-events.gateway.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GameEventsGateway } from './game-events.gateway'; + +describe('GameEventsGateway', () => { + let gateway: GameEventsGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GameEventsGateway], + }).compile(); + + gateway = module.get(GameEventsGateway); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/src/games/gateways/game-events.gateway.ts b/src/games/gateways/game-events.gateway.ts new file mode 100644 index 000000000..0ddf68d90 --- /dev/null +++ b/src/games/gateways/game-events.gateway.ts @@ -0,0 +1,40 @@ +import { WebsocketEventEmitter } from '@/shared/websocket-event-emitter'; +import { WebSocketGateway } from '@nestjs/websockets'; +import { GameEventDto } from '../dto/game-event.dto'; +import { OnModuleInit } from '@nestjs/common'; +import { Events } from '@/events/events'; +import { filter, map } from 'rxjs'; +import { isEqual } from 'lodash'; +import { WebsocketEvent } from '@/websocket-event'; + +const roomName = (gameNumber: number) => `/games/${gameNumber}/events`; + +@WebSocketGateway() +export class GameEventsGateway + extends WebsocketEventEmitter + implements OnModuleInit +{ + constructor(private readonly events: Events) { + super(); + } + + onModuleInit() { + this.events.gameChanges + .pipe( + filter( + ({ oldGame, newGame }) => !isEqual(oldGame.events, newGame.events), + ), + map(({ newGame }) => ({ + number: newGame.number, + events: newGame.events, + })), + ) + .subscribe(({ number, events }) => { + this.emit({ + room: roomName(number), + event: WebsocketEvent.gameEventsUpdated, + payload: events, + }); + }); + } +} diff --git a/src/games/gateways/game-slots.gateway.ts b/src/games/gateways/game-slots.gateway.ts index 70cc489cf..4579847c9 100644 --- a/src/games/gateways/game-slots.gateway.ts +++ b/src/games/gateways/game-slots.gateway.ts @@ -7,6 +7,8 @@ import { WebsocketEventEmitter } from '@/shared/websocket-event-emitter'; import { filter, map } from 'rxjs/operators'; import { isEqual } from 'lodash'; +const roomName = (gameNumber: number) => `/games/${gameNumber}/slots`; + @WebSocketGateway() export class GameSlotsGateway extends WebsocketEventEmitter @@ -29,7 +31,7 @@ export class GameSlotsGateway ) .subscribe(({ number, slots }) => { this.emit({ - room: `game/${number}`, + room: roomName(number), event: WebsocketEvent.gameSlotsUpdated, payload: slots, }); diff --git a/src/rooms/gateways/rooms.gateway.spec.ts b/src/rooms/gateways/rooms.gateway.spec.ts new file mode 100644 index 000000000..54cccb7f6 --- /dev/null +++ b/src/rooms/gateways/rooms.gateway.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoomsGateway } from './rooms.gateway'; + +describe('RoomsGateway', () => { + let gateway: RoomsGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RoomsGateway], + }).compile(); + + gateway = module.get(RoomsGateway); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/src/rooms/gateways/rooms.gateway.ts b/src/rooms/gateways/rooms.gateway.ts new file mode 100644 index 000000000..c00551078 --- /dev/null +++ b/src/rooms/gateways/rooms.gateway.ts @@ -0,0 +1,17 @@ +import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'; +import { Socket } from 'socket.io'; + +@WebSocketGateway() +export class RoomsGateway { + @SubscribeMessage('join') + join(client: Socket, room: string): string[] { + client.join(room); + return Array.from(client.rooms); + } + + @SubscribeMessage('leave') + leave(client: Socket, room: string): string[] { + client.leave(room); + return Array.from(client.rooms); + } +} diff --git a/src/rooms/rooms.module.ts b/src/rooms/rooms.module.ts new file mode 100644 index 000000000..cbb8228ad --- /dev/null +++ b/src/rooms/rooms.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { RoomsGateway } from './gateways/rooms.gateway'; + +@Module({ + providers: [RoomsGateway], +}) +export class RoomsModule {} diff --git a/src/websocket-event.ts b/src/websocket-event.ts index fe4b16e2a..4a18215e5 100644 --- a/src/websocket-event.ts +++ b/src/websocket-event.ts @@ -3,7 +3,8 @@ export enum WebsocketEvent { gameCreated = 'game created', gameUpdated = 'game updated', - gameSlotsUpdated = 'game slots updated', + gameSlotsUpdated = 'slots updated', + gameEventsUpdated = 'events updated', queueSlotsUpdate = 'queue slots update', queueStateUpdate = 'queue state update', From 9af96442c54899d5ff55953684bbf71b07900bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Wed, 29 Nov 2023 01:04:59 +0100 Subject: [PATCH 2/4] fix tests --- .../gateways/game-events.gateway.spec.ts | 68 ++++++++++++++++++- src/games/gateways/game-slots.gateway.spec.ts | 2 +- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/games/gateways/game-events.gateway.spec.ts b/src/games/gateways/game-events.gateway.spec.ts index 96b99dc43..842645e7c 100644 --- a/src/games/gateways/game-events.gateway.spec.ts +++ b/src/games/gateways/game-events.gateway.spec.ts @@ -1,18 +1,84 @@ import { Test, TestingModule } from '@nestjs/testing'; import { GameEventsGateway } from './game-events.gateway'; +import { Events } from '@/events/events'; +import { Socket } from 'socket.io'; +import { GameState } from '../models/game-state'; +import { Game } from '../models/game'; +import { GameEventType } from '../models/game-event-type'; +import { Types } from 'mongoose'; +import { PlayerId } from '@/players/types/player-id'; +import { waitABit } from '@/utils/wait-a-bit'; +import { WebsocketEvent } from '@/websocket-event'; describe('GameEventsGateway', () => { let gateway: GameEventsGateway; + let events: Events; + let socket: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [GameEventsGateway], + providers: [GameEventsGateway, Events], }).compile(); gateway = module.get(GameEventsGateway); + events = module.get(Events); + }); + + beforeEach(() => { + socket = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + } as unknown as jest.Mocked; + + gateway.onModuleInit(); + gateway.afterInit(socket); }); it('should be defined', () => { expect(gateway).toBeDefined(); }); + + describe('when game events change', () => { + beforeEach(async () => { + const slotId = new Types.ObjectId(); + const player1 = new Types.ObjectId() as PlayerId; + + const oldGame = { + number: 1, + state: GameState.launching, + events: [ + { + event: GameEventType.gameCreated, + at: new Date(), + }, + ], + } as Game; + + const newGame = { + number: 1, + state: GameState.launching, + events: [ + { + event: GameEventType.gameCreated, + at: new Date(), + }, + { + event: GameEventType.gameEnded, + at: new Date(), + }, + ], + } as Game; + + events.gameChanges.next({ oldGame, newGame }); + await waitABit(100); + }); + + it('should emit', () => { + expect(socket.to).toHaveBeenCalledWith('/games/1/events'); + expect(socket.emit).toHaveBeenCalledWith( + WebsocketEvent.gameEventsUpdated, + expect.any(Object), + ); + }); + }); }); diff --git a/src/games/gateways/game-slots.gateway.spec.ts b/src/games/gateways/game-slots.gateway.spec.ts index 059e8400d..b52db3702 100644 --- a/src/games/gateways/game-slots.gateway.spec.ts +++ b/src/games/gateways/game-slots.gateway.spec.ts @@ -81,7 +81,7 @@ describe('GameSlotsGateway', () => { }); it('should emit', () => { - expect(socket.to).toHaveBeenCalledWith('game/1'); + expect(socket.to).toHaveBeenCalledWith('/games/1/slots'); expect(socket.emit).toHaveBeenCalledWith( WebsocketEvent.gameSlotsUpdated, expect.any(Object), From 84a7a9d85b6a806cd65fcd72b330d598e65f552b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Wed, 29 Nov 2023 11:43:52 +0100 Subject: [PATCH 3/4] add tests --- src/rooms/gateways/rooms.gateway.spec.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/rooms/gateways/rooms.gateway.spec.ts b/src/rooms/gateways/rooms.gateway.spec.ts index 54cccb7f6..83a2ad3e4 100644 --- a/src/rooms/gateways/rooms.gateway.spec.ts +++ b/src/rooms/gateways/rooms.gateway.spec.ts @@ -1,8 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RoomsGateway } from './rooms.gateway'; +import { Socket } from 'socket.io'; describe('RoomsGateway', () => { let gateway: RoomsGateway; + let socket: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -12,7 +14,33 @@ describe('RoomsGateway', () => { gateway = module.get(RoomsGateway); }); + beforeEach(() => { + socket = { + rooms: new Set(), + join: jest.fn().mockImplementation((room) => socket.rooms.add(room)), + leave: jest.fn().mockImplementation((room) => socket.rooms.delete(room)), + } as unknown as jest.Mocked; + }); + it('should be defined', () => { expect(gateway).toBeDefined(); }); + + describe('#join()', () => { + it('should join', () => { + const ret = gateway.join(socket, 'FAKE_ROOM'); + expect(ret).toEqual(['FAKE_ROOM']); + }); + }); + + describe('#leave()', () => { + beforeEach(() => { + socket.rooms.add('FAKE_ROOM'); + }); + + it('should leave', () => { + const ret = gateway.leave(socket, 'FAKE_ROOM'); + expect(ret).toEqual([]); + }); + }); }); From 01522a3c4cabb42785d923e2a10e80d8e259b40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 8 Dec 2023 13:46:18 +0100 Subject: [PATCH 4/4] add schema validation --- src/rooms/gateways/rooms.gateway.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/rooms/gateways/rooms.gateway.ts b/src/rooms/gateways/rooms.gateway.ts index c00551078..211821437 100644 --- a/src/rooms/gateways/rooms.gateway.ts +++ b/src/rooms/gateways/rooms.gateway.ts @@ -1,16 +1,34 @@ -import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'; +import { ZodPipe } from '@/shared/pipes/zod.pipe'; +import { + ConnectedSocket, + MessageBody, + SubscribeMessage, + WebSocketGateway, +} from '@nestjs/websockets'; import { Socket } from 'socket.io'; +import { z } from 'zod'; + +const joinRoomSchema = z.string(); +const leaveRoomSchema = z.string(); @WebSocketGateway() export class RoomsGateway { @SubscribeMessage('join') - join(client: Socket, room: string): string[] { + join( + @ConnectedSocket() client: Socket, + @MessageBody(new ZodPipe(joinRoomSchema)) + room: z.infer, + ): string[] { client.join(room); return Array.from(client.rooms); } @SubscribeMessage('leave') - leave(client: Socket, room: string): string[] { + leave( + @ConnectedSocket() client: Socket, + @MessageBody(new ZodPipe(leaveRoomSchema)) + room: z.infer, + ): string[] { client.leave(room); return Array.from(client.rooms); }