diff --git a/apps/server/package.json b/apps/server/package.json index 62263dc..a0cc75d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -85,6 +85,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "transformIgnorePatterns": [ + "node_modules/(?!.pnpm/(nanoid|uuid)|nanoid|uuid)" + ] } } diff --git a/apps/server/src/modules/room/dto/create-room-response.dto.ts b/apps/server/src/modules/room/dto/create-room-response.dto.ts new file mode 100644 index 0000000..95f1274 --- /dev/null +++ b/apps/server/src/modules/room/dto/create-room-response.dto.ts @@ -0,0 +1,4 @@ +export class CreateRoomResponseDto { + roomCode: string; + myPtId: string; +} diff --git a/apps/server/src/modules/room/room.controller.spec.ts b/apps/server/src/modules/room/room.controller.spec.ts new file mode 100644 index 0000000..0d1cb38 --- /dev/null +++ b/apps/server/src/modules/room/room.controller.spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoomController } from './room.controller'; +import { RoomService } from './room.service'; +import { CreateRoomResponseDto } from './dto/create-room-response.dto'; + +describe('RoomController', () => { + let controller: RoomController; + let service: RoomService; + + const mockRoomService = { + createQuickRoom: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RoomController], + providers: [ + { + provide: RoomService, + useValue: mockRoomService, + }, + ], + }).compile(); + + controller = module.get(RoomController); + service = module.get(RoomService); + }); + + it('Controller가 정의되어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('createQuickRoom (POST /api/room/quick)', () => { + it('Service를 호출하고 생성된 방 정보를 반환해야 한다', async () => { + const mockResponse: CreateRoomResponseDto = { + roomCode: 'ABCDEF', + myPtId: 'test-uuid-1234', + }; + + mockRoomService.createQuickRoom.mockResolvedValue(mockResponse); + + const result = await controller.createQuickRoom(); + + expect(service.createQuickRoom).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/apps/server/src/modules/room/room.controller.ts b/apps/server/src/modules/room/room.controller.ts index faab7b9..4854dbf 100644 --- a/apps/server/src/modules/room/room.controller.ts +++ b/apps/server/src/modules/room/room.controller.ts @@ -1,5 +1,12 @@ -import { Controller, Get, Param, NotFoundException } from '@nestjs/common'; +import { + Controller, + Get, + Param, + NotFoundException, + Post, +} from '@nestjs/common'; import { RoomService } from './room.service'; +import { CreateRoomResponseDto } from './dto/create-room-response.dto'; @Controller('api/room') export class RoomController { @@ -13,4 +20,9 @@ export class RoomController { } return { exists: true }; } + + @Post('quick') + async createQuickRoom(): Promise { + return await this.roomService.createQuickRoom(); + } } diff --git a/apps/server/src/modules/room/room.interface.ts b/apps/server/src/modules/room/room.interface.ts new file mode 100644 index 0000000..badd28b --- /dev/null +++ b/apps/server/src/modules/room/room.interface.ts @@ -0,0 +1,8 @@ +import { DefaultRolePolicy, HostTransferPolicy } from './room.entity'; + +export interface RoomCreationOptions { + hostTransferPolicy: HostTransferPolicy; + defaultRolePolicy: DefaultRolePolicy; + roomPassword?: string; + hostPassword?: string; +} diff --git a/apps/server/src/modules/room/room.service.spec.ts b/apps/server/src/modules/room/room.service.spec.ts new file mode 100644 index 0000000..aa48b8e --- /dev/null +++ b/apps/server/src/modules/room/room.service.spec.ts @@ -0,0 +1,185 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoomService } from './room.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DefaultRolePolicy, HostTransferPolicy, Room } from './room.entity'; +import { Repository, DataSource, QueryRunner } from 'typeorm'; +import { InternalServerErrorException } from '@nestjs/common'; +import { PtRole } from '../pt/pt.entity'; + +// 테스트 상수 +const MOCK_ROOM_CODE = 'UNI001'; +const MOCK_ROOM_ID = 100; +const MOCK_PT_ID = 'uuid-host-1'; + +const createMockQueryRunner = () => ({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, +}); + +describe('RoomService', () => { + let service: RoomService; + let repository: jest.Mocked>; + let dataSource: jest.Mocked; + let queryRunner: jest.Mocked; + + beforeEach(async () => { + // 각 테스트마다 새로운 QueryRunner Mock 생성 + const mockQueryRunnerInstance = createMockQueryRunner(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoomService, + { + provide: getRepositoryToken(Room), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: { + createQueryRunner: jest + .fn() + .mockReturnValue(mockQueryRunnerInstance), + }, + }, + ], + }).compile(); + + service = module.get(RoomService); + repository = module.get(getRepositoryToken(Room)); + dataSource = module.get(DataSource); + queryRunner = dataSource.createQueryRunner() as jest.Mocked; + + // 셋업 과정에서 생긴 호출 기록 초기화 + (dataSource.createQueryRunner as jest.Mock).mockClear(); + + // 공통 Mock 동작 + (queryRunner.manager.create as jest.Mock).mockImplementation( + (entity, data) => data, + ); + }); + + it('Service가 정의되어야 한다', () => { + expect(service).toBeDefined(); + }); + + describe('createQuickRoom', () => { + it('성공 시: 트랜잭션 내에서 Room과 방장(Pt)을 저장하고 결과를 반환한다', async () => { + // Arrange + jest + .spyOn(service as any, 'generateRoomCode') + .mockReturnValue(MOCK_ROOM_CODE); + repository.findOne.mockResolvedValue(null); // 중복 없음 + + const savedRoom = { roomId: MOCK_ROOM_ID, roomCode: MOCK_ROOM_CODE }; + const savedPt = { + ptId: MOCK_PT_ID, + roomId: MOCK_ROOM_ID, + role: PtRole.HOST, + }; + + (queryRunner.manager.save as jest.Mock) + .mockResolvedValueOnce(savedRoom) // Room 저장 성공 + .mockResolvedValueOnce(savedPt); // Pt 저장 성공 + + // Act + const result = await service.createQuickRoom(); + + // Assert + // 1. 흐름 검증 + expect(dataSource.createQueryRunner).toHaveBeenCalled(); + expect(queryRunner.connect).toHaveBeenCalled(); + expect(queryRunner.startTransaction).toHaveBeenCalled(); + expect(queryRunner.commitTransaction).toHaveBeenCalled(); + expect(queryRunner.release).toHaveBeenCalled(); + expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled(); + + // 2. 데이터 저장 검증 + expect(queryRunner.manager.save).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + roomCode: MOCK_ROOM_CODE, + hostTransferPolicy: HostTransferPolicy.AUTO_TRANSFER, + defaultRolePolicy: DefaultRolePolicy.VIEWER, + }), + ); + + expect(queryRunner.manager.save).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + role: PtRole.HOST, + code: '0000', + }), + ); + + // 3. 반환값 검증 + expect(result).toEqual({ + roomCode: MOCK_ROOM_CODE, + myPtId: MOCK_PT_ID, + }); + }); + + it('룸 코드가 중복되면 최대 3번까지 재시도하고, 성공하면 저장한다', async () => { + // Arrange + jest + .spyOn(service as any, 'generateRoomCode') + .mockReturnValueOnce('DUP001') // 1차 시도 (중복) + .mockReturnValueOnce('UNI002'); // 2차 시도 (성공) + + repository.findOne + .mockResolvedValueOnce({ roomId: 1 } as Room) // 1차 결과: 존재함 + .mockResolvedValueOnce(null); // 2차 결과: 없음 + + const mockRoom = { roomCode: 'UNI002' } as Room; + (queryRunner.manager.save as jest.Mock).mockResolvedValue(mockRoom); + + // Act + await service.createQuickRoom(); + + // Assert + expect(repository.findOne).toHaveBeenCalledTimes(2); // 재시도 확인 + expect(queryRunner.manager.create).toHaveBeenCalledWith( + Room, + expect.objectContaining({ roomCode: 'UNI002' }), + ); + expect(queryRunner.commitTransaction).toHaveBeenCalled(); + }); + + it('3번 모두 중복되면 예외를 던지고 트랜잭션은 시작되지 않는다', async () => { + // Arrange + jest.spyOn(service as any, 'generateRoomCode').mockReturnValue('DUP999'); + repository.findOne.mockResolvedValue({ roomId: 999 } as Room); // 계속 중복 + + // Act & Assert + await expect(service.createQuickRoom()).rejects.toThrow( + InternalServerErrorException, + ); + + expect(repository.findOne).toHaveBeenCalledTimes(3); + expect(dataSource.createQueryRunner).not.toHaveBeenCalled(); // 트랜잭션 시작 X + }); + + it('저장 중 DB 에러가 발생하면 롤백해야 한다', async () => { + // Arrange + repository.findOne.mockResolvedValue(null); + (queryRunner.manager.save as jest.Mock).mockRejectedValue( + new Error('DB Error'), + ); + + // Act & Assert + await expect(service.createQuickRoom()).rejects.toThrow('DB Error'); + + expect(queryRunner.startTransaction).toHaveBeenCalled(); + expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); // 롤백 확인 + expect(queryRunner.release).toHaveBeenCalled(); // 리소스 해제 확인 + }); + }); +}); diff --git a/apps/server/src/modules/room/room.service.ts b/apps/server/src/modules/room/room.service.ts index 547e761..b2e4d96 100644 --- a/apps/server/src/modules/room/room.service.ts +++ b/apps/server/src/modules/room/room.service.ts @@ -1,24 +1,122 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Redis } from 'ioredis'; -import { Room } from './room.entity'; +import { DataSource, Repository } from 'typeorm'; +import { DefaultRolePolicy, HostTransferPolicy, Room } from './room.entity'; +import { customAlphabet } from 'nanoid'; +import { Pt, PtRole } from '../pt/pt.entity'; +import { CreateRoomResponseDto } from './dto/create-room-response.dto'; +import { RoomCreationOptions } from './room.interface'; /** 방의 생명 주기 관리 */ @Injectable() export class RoomService { + private readonly logger = new Logger(RoomService.name); + constructor( - @Inject('REDIS_CLIENT') private redis: Redis, @InjectRepository(Room) private roomRepository: Repository, + private dataSource: DataSource, ) {} /** - * 방 존재 여부 확인 (GET /api/room/:roomId/exists 용) + * 방 존재 여부 확인 */ async roomExists(roomId: string): Promise { - const keys = await this.redis.keys(`room:${roomId}:*`); - return keys.length > 0; + // TODO: DB에서 해당 방 존재 여부 판단 필요 + return true; + } + + async createQuickRoom(): Promise { + const options: RoomCreationOptions = { + hostTransferPolicy: HostTransferPolicy.AUTO_TRANSFER, + defaultRolePolicy: DefaultRolePolicy.VIEWER, + }; + + return this.createRoom(options); + } + + private async createRoom( + options: RoomCreationOptions, + ): Promise { + const roomCode = await this.generateUniqueRoomCode(); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const newRoom = queryRunner.manager.create(Room, { + roomCode, + hostTransferPolicy: options.hostTransferPolicy, + defaultRolePolicy: options.defaultRolePolicy, + roomPassword: options.roomPassword, + hostPassword: options.hostPassword, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + const savedRoom = await queryRunner.manager.save(newRoom); + + const hostPt = queryRunner.manager.create(Pt, { + roomId: savedRoom.roomId, + role: PtRole.HOST, + code: '0000', + nickname: 'Host', + color: '#E0E0E0', + }); + + const savedPt = await queryRunner.manager.save(hostPt); + + await queryRunner.commitTransaction(); + + this.logger.log( + `✅ Quick Room Created: [${savedRoom.roomCode}] (ID: ${savedRoom.roomId}), Host Pt: [${savedPt.ptId}]`, + ); + + return { + roomCode: savedRoom.roomCode, + myPtId: savedPt.ptId, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error(`Failed to create room: ${error.message}`); + throw error; + } finally { + await queryRunner.release(); + } + } + + private async generateUniqueRoomCode(maxRetries = 3): Promise { + for (let i = 0; i < maxRetries; i++) { + const roomCode = this.generateRoomCode(); + + const existingRoom = await this.roomRepository.findOne({ + where: { roomCode }, + select: ['roomId'], + }); + + if (!existingRoom) { + return roomCode; + } + + this.logger.warn( + `Room code collision detected: ${roomCode}. Retrying... (${i + 1}/${maxRetries})`, + ); + } + + throw new InternalServerErrorException( + 'Failed to generate unique room code', + ); + } + + protected generateRoomCode(roomCodeLength = 6): string { + const alphabet = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const nanoid = customAlphabet(alphabet, roomCodeLength); + return nanoid(); } }