diff --git a/be/gameServer/package-lock.json b/be/gameServer/package-lock.json index 877404f..c69659c 100644 --- a/be/gameServer/package-lock.json +++ b/be/gameServer/package-lock.json @@ -27,7 +27,7 @@ "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", + "@nestjs/testing": "^10.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", @@ -39,6 +39,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.1.0", @@ -1730,9 +1731,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.6", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.6.tgz", - "integrity": "sha512-aiDicKhlGibVGNYuew399H5qZZXaseOBT/BS+ERJxxCmco7ZdAqaujsNjSaSbTK9ojDPf27crLT0C4opjqJe3A==", + "version": "10.4.8", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.8.tgz", + "integrity": "sha512-VusUnVgfY6KUc0gKU7ER9QQ2QyCoO770wcAfgLhtqydezt/w07FvqT6uOtb/Tf4SMfUbxx6AJwte6UUmkewbnQ==", "dev": true, "dependencies": { "tslib": "2.7.0" @@ -3826,6 +3827,19 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz", + "integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -7624,6 +7638,21 @@ "ws": "~8.17.1" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -8730,6 +8759,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/be/gameServer/package.json b/be/gameServer/package.json index 066c415..bec6ef0 100644 --- a/be/gameServer/package.json +++ b/be/gameServer/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", + "@nestjs/testing": "^10.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", @@ -50,6 +50,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.1.0", diff --git a/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts b/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts new file mode 100644 index 0000000..70cf422 --- /dev/null +++ b/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts @@ -0,0 +1,365 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoomsGateway } from './rooms.gateway'; +import { RedisService } from '../../redis/redis.service'; +import { Logger } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { CreateRoomDto } from './dto/create-room.dto'; +import { JoinRoomDto } from './dto/join-data.dto'; +import { RoomDataDto } from './dto/room-data.dto'; + +describe('RoomsGateway', () => { + let gateway: RoomsGateway; + let redisService: RedisService; + let mockServer: Server; + let mockClient: Socket; + + beforeEach(async () => { + const redisServiceMock = { + set: jest.fn(), + get: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoomsGateway, + { provide: RedisService, useValue: redisServiceMock }, + Logger, + ], + }).compile(); + + gateway = module.get(RoomsGateway); + redisService = module.get(RedisService); + + mockServer = { + to: jest.fn().mockReturnValue({ + emit: jest.fn(), + }), + } as unknown as Server; + + mockClient = { + emit: jest.fn(), + join: jest.fn(), + to: jest.fn().mockReturnValue({ + emit: jest.fn(), + }), + data: {}, + } as unknown as Socket; + + gateway.server = mockServer; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleCreateRoom', () => { + it('새로운 방을 생성하고 Redis에 저장해야 한다.', async () => { + const createRoomDto: CreateRoomDto = { + roomName: 'Test Room', + hostNickname: 'HostUser', + }; + mockClient.data = {}; + + await gateway.handleCreateRoom(createRoomDto, mockClient); + + expect(redisService.set).toHaveBeenCalledWith( + expect.stringMatching(/^room:/), + expect.any(String), + 'roomUpdate', + ); + expect(mockClient.join).toHaveBeenCalledWith(expect.any(String)); + expect(mockClient.emit).toHaveBeenCalledWith( + 'roomCreated', + expect.objectContaining({ + roomId: expect.any(String), + roomName: createRoomDto.roomName, + hostNickname: createRoomDto.hostNickname, + players: expect.arrayContaining([createRoomDto.hostNickname]), + status: 'waiting', + }), + ); + }); + + it('Redis 오류로 인해 방 생성이 실패하면 오류를 반환해야 한다.', async () => { + jest + .spyOn(redisService, 'set') + .mockRejectedValue(new Error('Redis error')); + + await gateway.handleCreateRoom( + { roomName: 'Fail Room', hostNickname: 'Host' }, + mockClient, + ); + + expect(mockClient.emit).toHaveBeenCalledWith('error', { + message: 'Failed to create the room', + }); + }); + + it('hostNickname이 유효하지 않으면 오류를 반환해야 한다.', async () => { + const createRoomDto: CreateRoomDto = { + roomName: 'Room Name', + hostNickname: 'HostHostHostHost', + }; + + try { + await gateway.handleCreateRoom(createRoomDto, mockClient); + } catch (error) { + console.error('Validation failed error:', error); + + expect(mockClient.emit).toHaveBeenCalledWith('error', { + message: 'Validation failed.', + details: expect.any(Array), + }); + + expect(redisService.set).not.toHaveBeenCalled(); + expect(mockClient.join).not.toHaveBeenCalled(); + } + }); + }); + + describe('handleJoinRoom', () => { + it('방이 꽉 차지 않고 닉네임이 유효하면 사용자가 방에 입장할 수 있어야 한다.', async () => { + const roomId = 'testRoomId'; + const playerNickname = 'Player1'; + const roomData = { + roomId, + players: ['host'], + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; + + await gateway.handleJoinRoom(joinRoomDto, mockClient); + + expect(mockClient.join).toHaveBeenCalledWith(roomId); + expect(mockClient.data.roomId).toBe(roomId); + expect(mockClient.data.nickname).toBe(playerNickname); + expect(mockClient.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ + 'host', + playerNickname, + ]); + expect(redisService.set).toHaveBeenCalledWith( + `room:${roomId}`, + JSON.stringify({ + roomId, + players: ['host', playerNickname], + }), + 'roomUpdate', + ); + }); + + it('닉네임이 유효하지 않으면 오류를 반환해야 한다.', async () => { + const roomId = 'testRoomId'; + const playerNickname = 'testPlayertestPlayertestPlayer'; + + const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; + + try { + await gateway.handleJoinRoom(joinRoomDto, mockClient); + } catch (error) { + console.error('Validation failed error:', error); + + expect(mockClient.emit).toHaveBeenCalledWith('error', { + message: 'Validation failed.', + details: expect.any(Array), + }); + + expect(redisService.get).not.toHaveBeenCalled(); + expect(mockClient.join).not.toHaveBeenCalled(); + } + }); + + it('닉네임이 이미 사용 중이면 오류를 반환해야 한다.', async () => { + const roomId = 'testRoomId'; + const playerNickname = 'host'; + const roomData = { + roomId, + players: ['host'], + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; + + await gateway.handleJoinRoom(joinRoomDto, mockClient); + + expect(mockClient.emit).toHaveBeenCalledWith( + 'error', + 'Nickname already taken in this room', + ); + }); + + it('방이 꽉 차 있으면 오류를 반환해야 한다.', async () => { + const roomId = 'testRoomId'; + const playerNickname = 'Player1'; + const roomData = { + roomId, + players: ['host', 'Player2', 'Player3', 'Player4'], + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; + + await gateway.handleJoinRoom(joinRoomDto, mockClient); + + expect(mockClient.emit).toHaveBeenCalledWith('error', 'Room is full'); + }); + + it('Redis에서 방 데이터를 가져오는 데 실패하면 오류를 반환해야 한다.', async () => { + const roomId = 'testRoomId'; + const playerNickname = 'Player1'; + + jest + .spyOn(redisService, 'get') + .mockRejectedValueOnce(new Error('Redis error')); + + const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; + + await gateway.handleJoinRoom(joinRoomDto, mockClient); + + expect(mockClient.emit).toHaveBeenCalledWith('error', { + message: 'Failed to join the room', + }); + }); + }); + + describe('handleDisconnect', () => { + it('사용자가 방을 떠나면 방의 사용자 목록이 업데이트되어야 한다.', async () => { + const roomId = 'testRoomId'; + const nickname = 'Player1'; + const roomData: RoomDataDto = { + roomId, + roomName: 'testRoom', + players: ['host', 'Player1'], + hostNickname: 'host', + status: 'wating', + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + mockClient.data = { roomId, nickname }; + + await gateway.handleDisconnect(mockClient); + + expect(redisService.set).toHaveBeenCalledWith( + `room:${roomId}`, + JSON.stringify({ + roomId, + roomName: 'testRoom', + players: ['host'], + hostNickname: 'host', + status: 'wating', + }), + 'roomUpdate', + ); + + expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ + 'host', + ]); + }); + + it('호스트가 나가면 새로운 호스트가 지정되어야 한다.', async () => { + const roomId = 'testRoomId'; + const nickname = 'host'; + const roomData: RoomDataDto = { + roomId, + roomName: 'testRoom', + players: ['host', 'Player1'], + hostNickname: 'host', + status: 'wating', + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + mockClient.data = { roomId, nickname }; + + await gateway.handleDisconnect(mockClient); + + expect(redisService.set).toHaveBeenCalledWith( + `room:${roomId}`, + JSON.stringify({ + roomId, + roomName: 'testRoom', + players: ['Player1'], + hostNickname: 'Player1', + status: 'wating', + }), + 'roomUpdate', + ); + + expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ + 'Player1', + ]); + }); + + it('방에 사용자가 하나만 남았을 때 방을 삭제해야 한다.', async () => { + const roomId = 'testRoomId'; + const nickname = 'host'; + const roomData = { + roomId, + roomName: 'testRoom', + players: ['host'], + hostNickname: 'host', + status: 'wating', + }; + + jest + .spyOn(redisService, 'get') + .mockResolvedValueOnce(JSON.stringify(roomData)); + + mockClient.data = { roomId, nickname }; + + await gateway.handleDisconnect(mockClient); + + expect(redisService.delete).toHaveBeenCalledWith( + `room:${roomId}`, + 'roomUpdate', + ); + + expect(mockServer.to(roomId).emit).not.toHaveBeenCalled(); + }); + + it('Redis에서 방 데이터를 가져오는 데 실패하면 오류를 반환해야 한다.', async () => { + const roomId = 'testRoomId'; + const nickname = 'Player1'; + + jest + .spyOn(redisService, 'get') + .mockRejectedValueOnce(new Error('Redis error')); + + mockClient.data = { roomId, nickname }; + + await gateway.handleDisconnect(mockClient); + + expect(mockClient.emit).toHaveBeenCalledWith('error', { + message: 'Failed to handle disconnect', + }); + }); + + it('방 데이터가 없으면 로그만 기록 후 오류를 반환하지 않아야 한다.', async () => { + const roomId = 'testRoomId'; + const nickname = 'Player1'; + + jest.spyOn(redisService, 'get').mockResolvedValueOnce(null); + + mockClient.data = { roomId, nickname }; + + await gateway.handleDisconnect(mockClient); + + expect(mockClient.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/be/gameServer/src/modules/rooms/rooms.gateway.ts b/be/gameServer/src/modules/rooms/rooms.gateway.ts index bf0e7c2..ae4a576 100644 --- a/be/gameServer/src/modules/rooms/rooms.gateway.ts +++ b/be/gameServer/src/modules/rooms/rooms.gateway.ts @@ -14,7 +14,7 @@ import { RoomDataDto } from './dto/room-data.dto'; import { JoinRoomDto } from './dto/join-data.dto'; import { ErrorResponse } from './dto/error-response.dto'; import { RoomsValidationPipe } from './rooms.validation.pipe'; -import { WsExceptionsFilter } from 'src/common/filters/ws-exceptions.filter'; +import { WsExceptionsFilter } from '../../common/filters/ws-exceptions.filter'; import { isRoomFull, isNicknameTaken, @@ -114,7 +114,7 @@ export class RoomsGateway implements OnGatewayDisconnect { ); client.join(roomId); - client.data = { roomId, nickname: roomData.players }; + client.data = { roomId, nickname: playerNickname }; client.to(roomId).emit('updateUsers', roomData.players); this.logger.log(`User ${playerNickname} joined room ${roomId}`); diff --git a/be/gameServer/src/modules/rooms/rooms.module.ts b/be/gameServer/src/modules/rooms/rooms.module.ts index fe81e1f..598e0d8 100644 --- a/be/gameServer/src/modules/rooms/rooms.module.ts +++ b/be/gameServer/src/modules/rooms/rooms.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { RedisModule } from 'src/redis/redis.module'; +import { RedisModule } from '../../redis/redis.module'; import { RoomsGateway } from './rooms.gateway'; import { RoomsWebSocketOnController } from './rooms.websocket.on.controller'; import { RoomsWebSocketEmitController } from './rooms.websocket.emit.controller'; diff --git a/be/gameServer/src/modules/rooms/rooms.websocket.emit.controller.ts b/be/gameServer/src/modules/rooms/rooms.websocket.emit.controller.ts index b9ed387..2f8a084 100644 --- a/be/gameServer/src/modules/rooms/rooms.websocket.emit.controller.ts +++ b/be/gameServer/src/modules/rooms/rooms.websocket.emit.controller.ts @@ -16,7 +16,6 @@ export class RoomsWebSocketEmitController { type: RoomDataDto, }) createRoom(): RoomDataDto { - // This method does not execute any logic. It's for Swagger documentation only. return { roomId: 'example-room-id', roomName: 'example-room-name', diff --git a/be/gameServer/src/redis/redis.service.ts b/be/gameServer/src/redis/redis.service.ts index b4163dd..325f817 100644 --- a/be/gameServer/src/redis/redis.service.ts +++ b/be/gameServer/src/redis/redis.service.ts @@ -43,6 +43,10 @@ export class RedisService implements OnModuleDestroy { await this.pubClient.publish(channel, message); } + async flushAll(): Promise { + await this.redisClient.flushall(); + } + subscribeToChannel( channel: string, callback: (message: string) => void, diff --git a/be/gameServer/test/app.e2e-spec.ts b/be/gameServer/test/app.e2e-spec.ts new file mode 100644 index 0000000..adb7742 --- /dev/null +++ b/be/gameServer/test/app.e2e-spec.ts @@ -0,0 +1,114 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../src/app.module'; +import { io as Client, Socket } from 'socket.io-client'; +import { RedisService } from '../src/redis/redis.service'; + +describe('RoomsGateway (e2e)', () => { + let app: INestApplication; + let clientSocket: Socket; + let redisService: RedisService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + redisService = moduleFixture.get(RedisService); + await app.init(); + + clientSocket = Client('ws://localhost:8000/rooms', { + transports: ['websocket'], + }); + }); + + afterAll(async () => { + clientSocket.close(); + await redisService.flushAll(); + await redisService['redisClient'].quit(); + await app.close(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await redisService.flushAll(); // Redis 초기화 + }); + + describe('WebSocket Rooms Gateway', () => { + it('사용자가 방을 생성할 수 있어야 한다.', async () => { + const roomData = { + roomName: 'Test Room', + hostNickname: 'HostUser', + }; + + clientSocket.emit('createRoom', roomData); + + clientSocket.on('roomCreated', async (response) => { + expect(response).toMatchObject({ + roomId: expect.any(String), + roomName: roomData.roomName, + hostNickname: roomData.hostNickname, + players: [roomData.hostNickname], + status: 'waiting', + }); + + const redisData = await redisService.get( + `room:${response.roomId}`, + ); + expect(redisData).toBeDefined(); + const parsedData = JSON.parse(redisData); + expect(parsedData.roomName).toBe(roomData.roomName); + expect(parsedData.hostNickname).toBe(roomData.hostNickname); + }); + }); + + it('사용자가 방에 참가할 수 있어야 한다.', async () => { + const roomId = 'testRoom'; + const roomData = { + roomId, + roomName: 'Test Room', + hostNickname: 'HostUser', + players: ['HostUser'], + status: 'waiting', + }; + + redisService.set(`room:${roomId}`, JSON.stringify(roomData)); + + const joinRoomData = { + roomId, + playerNickname: 'Player1', + }; + + clientSocket.emit('joinRoom', joinRoomData); + + clientSocket.on('updateUsers', async (players) => { + expect(players).toEqual(['HostUser', 'Player1']); + + const redisData = await redisService.get(`room:${roomId}`); + const updatedRoom = JSON.parse(redisData); + expect(updatedRoom.players).toContain('Player1'); + }); + }); + + it('방이 비면 삭제되어야 한다.', async () => { + const roomId = 'testRoom'; + const roomData = { + roomId, + roomName: 'Test Room', + hostNickname: 'HostUser', + players: ['HostUser'], + status: 'waiting', + }; + + redisService.set(`room:${roomId}`, JSON.stringify(roomData)); + + clientSocket.close(); + + setTimeout(async () => { + const redisData = await redisService.get(`room:${roomId}`); + expect(redisData).toBeNull(); + }, 100); + }); + }); +});