Skip to content

Commit ae38f1b

Browse files
authored
[Test] 테스트 커버리지 향상 #361
* test: Backend 도메인 서비스 및 유틸 테스트 추가 - rating.util: 티어 계산, 레이팅 델타 계산 테스트 - nickname.util: 닉네임 생성, 게스트 패턴 검증 테스트 - battle.util: 배틀 룸 ID 생성, 토픽 셔플 테스트 - battleResult.service: 승패 판정, 결과 빌드 테스트 - battleTimeline.service: 타임라인 빌드, 변환 테스트 - battleGuest.service: 게스트 생성, 닉네임 생성 테스트 - inviteAccess.guard: 비공개 배틀 접근 제어 테스트 * test: Backend Adapter 및 도메인 서비스 테스트 추가 - Adapter Out 테스트: battleIdentifier, battlePrivacyCheck, battleBroadcaster, guestCheck, battleReferenceGenerator, battleTimer - 도메인 서비스 테스트: battleDiscussion, battleVote - ESLint any 타입 warning 수정 (as unknown as Type 패턴 사용) * test: Backend 인프라 서비스 테스트 추가 - GeminiService 테스트: API 키 초기화, 헬스체크 - MetricsService, MetricsController, HttpMetricsMiddleware 테스트 - RedisRepository 테스트: CRUD, 해시, 리스트 연산 * test: Backend OAuth 전략 테스트 추가 - JwtStrategy 테스트: payload 검증 - RefreshStrategy 테스트: 토큰 갱신 검증 - GithubStrategy, KakaoStrategy 테스트: 프로필 변환 검증 * test: Frontend 공통 모듈 테스트 추가 - API 테스트: fetchJson, responseGuard, errorMessageGuard, battleApi - Hook 테스트: useForm, useInfiniteScroll, useModal - Store 테스트: toastStore - Util 테스트: parseQueryString, formatTime, calculateTimeDiff * test: Backend usecase 및 adapter 테스트 추가 - battleRepository.adapter.spec.ts 추가 (DB 영속성 어댑터) - battleStateRepository.adapter.spec.ts 추가 (상태 관리 어댑터) - 7개 usecase 테스트 추가: - createGuest.usecase.spec.ts - battleCreation.usecase.spec.ts - battleParticipation.usecase.spec.ts - battlePhaseTransition.usecase.spec.ts - battleQuery.usecase.spec.ts - battleInteraction.usecase.spec.ts - battleTermination.usecase.spec.ts - battle.mapper.spec.ts 추가 - jwt-auth.guard.spec.ts 추가 - prisma.service.spec.ts 추가 - battles.gateway.spec.ts 확장 (추가 테스트 케이스) Backend 커버리지: 60.52% → 84.73% * test: Backend DTO 테스트 추가 - battleResult.dto.spec.ts: fromEntity, fromPrismaBattle 변환 테스트 - closedBattleResponse.dto.spec.ts: of, fromFinished 메서드 테스트 - discussionVoteResult.dto.spec.ts: 투표 결과 DTO 변환 테스트 - generateReference.dto.spec.ts: 유효성 검증 및 응답 매핑 테스트 * test: Backend 도메인 서비스 테스트 보강 - battleChat.service.spec.ts: TEAM 스코프 예외 처리 테스트 - battleSkip.service.spec.ts: shouldSkipPhase 분기 테스트 - battleTeamSwitch.service.spec.ts: applyTeamSwitch, rebuildTeamUsers 테스트 * test: Backend 유스케이스 테스트 보강 - battlePhaseTransition.usecase.spec.ts: 콜백 함수 테스트 (onAttacked, onDefensed, resetDiscussions) - createGuest.usecase.spec.ts: 게스트 생성 콜백 검증 테스트 * test: Backend 인프라 테스트 보강 - gemini.service.spec.ts: 재시도 로직, 에러 분류, API 키 비활성화 테스트 * test: Frontend 스토어 테스트 보강 - authStore.test.ts: updateGuestTeam, getOAuthUser 게스트 사용자 처리 테스트 * test: Frontend 컴포넌트 테스트 추가 및 보강 - ChatInput.test.tsx: 입력 핸들링 및 전송 기능 테스트 - CodeHeader.test.tsx: 뷰 전환 및 탭 선택 테스트 - SoundSettingsPopover.test.tsx: BGM 재생/일시정지 분기 테스트 보강 * fix: API 테스트에서 URL 체크 유연하게 수정 - VITE_API_URL 환경변수 유무에 관계없이 테스트 통과하도록 수정 - expect.stringContaining 사용 * test: 글로벌 예외 필터 테스트 추가 - CustomGlobalExceptionFilter 테스트 - HTTP 예외 처리 (404, 400, 500) - Validation 에러 details 분리 - 예상치 못한 에러 처리 - 응답 형식 (timestamp, path) 검증 - ErrorResponse 클래스 테스트 - 생성자 속성 검증 - 다양한 HTTP 상태 코드 * fix: dev 브랜치 rebase 후 테스트 수정 - battleStateRepository.adapter.spec: Redis 의존성 mock 추가 - battleTimer.adapter.spec: Redis 기반 구현으로 전체 재작성 - battleCreation.usecase.spec: timer 제거, phaseTransitionUseCase 추가 - battleTermination.usecase.spec: clearCache mock, UserRatingUpdate 타입 확장 - battlePhaseTransition.usecase.spec: timer.schedule 시그니처 변경 - gemini.service.spec: RedisRepository 의존성 mock 추가 - jwt-refresh.strategy.spec: RefreshStrategy → RefreshGuard 변경 * test: battleTier 서비스 엣지케이스 테스트 추가 - 참가자 목록에 없는 유저 처리 - rating이 null인 유저 처리 (0으로 기본값) - 레이팅 최소값 0 유지 (음수 방지) - 레이팅/티어 변화 없을 때 업데이트 스킵 - 티어 경계값 승급 테스트 (BRONZE→SILVER) - 4위 이하 MVP 보너스 없음 확인 - getBattleResultForTeam null 반환 처리 Branches 커버리지: 68.75% → 93.75%
1 parent cb30c47 commit ae38f1b

File tree

58 files changed

+7486
-243
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+7486
-243
lines changed

backend/src/battles/adapters/in/battles.gateway.spec.ts

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,349 @@ describe('BattlesGateway - Discussion Events', () => {
346346
expect(mockServer.emit).toHaveBeenCalledWith('battle:all:updated', payload)
347347
})
348348
})
349+
350+
describe('handleConnection', () => {
351+
it('소켓 연결 시 userId를 저장한다', () => {
352+
const mockClientNew = {
353+
id: 'client-new',
354+
emit: jest.fn(),
355+
data: {},
356+
handshake: {
357+
auth: {
358+
userId: 'new-user',
359+
},
360+
},
361+
disconnect: jest.fn(),
362+
}
363+
364+
gateway.handleConnection(mockClientNew as any)
365+
366+
expect(mockClientNew.data.userId).toBe('new-user')
367+
})
368+
369+
it('userId가 없으면 연결을 끊는다', () => {
370+
const mockClientNoAuth = {
371+
id: 'client-no-auth',
372+
emit: jest.fn(),
373+
data: {},
374+
handshake: {
375+
auth: {},
376+
},
377+
disconnect: jest.fn(),
378+
}
379+
380+
gateway.handleConnection(mockClientNoAuth as any)
381+
382+
expect(mockClientNoAuth.disconnect).toHaveBeenCalled()
383+
})
384+
})
385+
386+
describe('handleDisconnect', () => {
387+
let participationUseCase: jest.Mocked<BattleParticipationUseCase>
388+
389+
beforeEach(() => {
390+
participationUseCase = gateway['participationUseCase'] as jest.Mocked<BattleParticipationUseCase>
391+
})
392+
393+
it('배틀에 참여 중이면 leave를 호출한다', async () => {
394+
const mockClientWithBattle = {
395+
id: 'client-with-battle',
396+
emit: jest.fn(),
397+
data: {
398+
userId: 'user-in-battle',
399+
battleId: 'battle-1',
400+
},
401+
disconnect: jest.fn(),
402+
}
403+
404+
participationUseCase.leave.mockResolvedValue({} as any)
405+
gateway['userIdToSocketMap'].set('user-in-battle', mockClientWithBattle as any)
406+
407+
await gateway.handleDisconnect(mockClientWithBattle as any)
408+
409+
expect(participationUseCase.leave).toHaveBeenCalledWith('user-in-battle', 'battle-1')
410+
expect(mockClientWithBattle.disconnect).toHaveBeenCalled()
411+
})
412+
413+
it('userId만 있고 battleId가 없으면 leave를 호출하지 않는다', async () => {
414+
const mockClientNoBattle = {
415+
id: 'client-no-battle',
416+
emit: jest.fn(),
417+
data: {
418+
userId: 'user-no-battle',
419+
},
420+
disconnect: jest.fn(),
421+
}
422+
423+
gateway['userIdToSocketMap'].set('user-no-battle', mockClientNoBattle as any)
424+
425+
await gateway.handleDisconnect(mockClientNoBattle as any)
426+
427+
expect(participationUseCase.leave).not.toHaveBeenCalled()
428+
expect(mockClientNoBattle.disconnect).toHaveBeenCalled()
429+
})
430+
})
431+
432+
describe('joinBattle', () => {
433+
let participationUseCase: jest.Mocked<BattleParticipationUseCase>
434+
let queryUseCase: jest.Mocked<BattleQueryUseCase>
435+
436+
beforeEach(() => {
437+
participationUseCase = gateway['participationUseCase'] as jest.Mocked<BattleParticipationUseCase>
438+
queryUseCase = gateway['queryUseCase'] as jest.Mocked<BattleQueryUseCase>
439+
})
440+
441+
it('공개 배틀에 참가한다', async () => {
442+
const dto = {
443+
battleId: 'battle-1',
444+
team: BATTLE_TEAM.A,
445+
nickname: '테스터',
446+
}
447+
448+
queryUseCase.isPrivateBattle.mockResolvedValue(false)
449+
participationUseCase.join.mockResolvedValue({
450+
battleState: {
451+
battleId: 'battle-1',
452+
round: 1,
453+
phase: 'PENDING',
454+
phaseCount: 1,
455+
startedAt: null,
456+
expiredAt: null,
457+
topics: ['topic1'],
458+
totalRounds: 1,
459+
participants: new Map([['user-1', BATTLE_TEAM.A]]),
460+
userInfoMap: new Map([['user-1', '테스터']]),
461+
teamVotes: new Map(),
462+
skipState: new Set(),
463+
teamA: { roomId: 'battle:battle-1:A', users: ['user-1'], chats: [], attacks: [], defenses: [] },
464+
teamB: { roomId: 'battle:battle-1:B', users: [], chats: [], attacks: [], defenses: [] },
465+
all: { roomId: 'battle:battle-1', chats: [], attacks: [], defenses: [] },
466+
opinionHistory: [],
467+
} as any,
468+
team: BATTLE_TEAM.A,
469+
})
470+
471+
const joinMockClient = {
472+
...mockClient,
473+
join: jest.fn().mockResolvedValue(undefined),
474+
}
475+
476+
await gateway.joinBattle(dto as any, joinMockClient)
477+
478+
expect(queryUseCase.isPrivateBattle).toHaveBeenCalledWith('battle-1')
479+
expect(participationUseCase.join).toHaveBeenCalled()
480+
expect(joinMockClient.emit).toHaveBeenCalledWith('battle:joined', expect.any(Object))
481+
})
482+
483+
it('비공개 배틀에 초대 코드 없이 접근하면 에러를 emit한다', async () => {
484+
const dto = {
485+
battleId: 'battle-private',
486+
team: BATTLE_TEAM.A,
487+
nickname: '테스터',
488+
}
489+
490+
queryUseCase.isPrivateBattle.mockResolvedValue(true)
491+
492+
const joinMockClient = {
493+
...mockClient,
494+
handshake: {
495+
auth: { userId: 'user-1' },
496+
headers: { cookie: '' },
497+
},
498+
join: jest.fn().mockResolvedValue(undefined),
499+
}
500+
501+
await gateway.joinBattle(dto as any, joinMockClient)
502+
503+
expect(joinMockClient.emit).toHaveBeenCalledWith('battle:join:error', {
504+
message: '비공개 배틀에 접근하려면 초대 코드가 필요합니다.',
505+
})
506+
})
507+
})
508+
509+
describe('handleLeave', () => {
510+
let participationUseCase: jest.Mocked<BattleParticipationUseCase>
511+
512+
beforeEach(() => {
513+
participationUseCase = gateway['participationUseCase'] as jest.Mocked<BattleParticipationUseCase>
514+
})
515+
516+
it('배틀에서 나간다', async () => {
517+
const dto = { battleId: 'battle-1' }
518+
participationUseCase.leave.mockResolvedValue({} as any)
519+
520+
await gateway.handleLeave(dto, mockClient)
521+
522+
expect(participationUseCase.leave).toHaveBeenCalledWith('user-1', 'battle-1')
523+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
524+
expect(mockServer.emit).toHaveBeenCalledWith('battle:leaved', expect.any(Object))
525+
})
526+
})
527+
528+
describe('handleStart', () => {
529+
let creationUseCase: jest.Mocked<BattleCreationUseCase>
530+
531+
beforeEach(() => {
532+
creationUseCase = gateway['creationUseCase'] as jest.Mocked<BattleCreationUseCase>
533+
})
534+
535+
it('배틀을 시작한다', async () => {
536+
const dto = { battleId: 'battle-1' }
537+
creationUseCase.start.mockResolvedValue(undefined)
538+
539+
await gateway.handleStart(dto as any)
540+
541+
expect(creationUseCase.start).toHaveBeenCalledWith('battle-1')
542+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
543+
expect(mockServer.emit).toHaveBeenCalledWith('battle:started')
544+
})
545+
})
546+
547+
describe('handlePhaseSkip', () => {
548+
let phaseTransitionUseCase: jest.Mocked<BattlePhaseTransitionUseCase>
549+
550+
beforeEach(() => {
551+
phaseTransitionUseCase = gateway['phaseTransitionUseCase'] as jest.Mocked<BattlePhaseTransitionUseCase>
552+
})
553+
554+
it('스킵 요청을 처리한다', async () => {
555+
const dto = { skip: true, battleId: 'battle-1' }
556+
phaseTransitionUseCase.handlePhaseSkip.mockResolvedValue(3)
557+
558+
await gateway.handlePhaseSkip(dto, mockClient)
559+
560+
expect(phaseTransitionUseCase.handlePhaseSkip).toHaveBeenCalledWith('battle-1', 'user-1', true)
561+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
562+
expect(mockServer.emit).toHaveBeenCalledWith('battle:user:skipped', { totalSkips: 3 })
563+
})
564+
})
565+
566+
describe('handleChat', () => {
567+
it('채팅을 전송한다', async () => {
568+
const dto = {
569+
battleId: 'battle-1',
570+
scope: 'all',
571+
team: BATTLE_TEAM.A,
572+
text: '안녕하세요',
573+
}
574+
575+
interactionUseCase.sendChat.mockResolvedValue({
576+
battleId: 'battle-1',
577+
scope: 'all',
578+
messageId: 'msg-1',
579+
team: BATTLE_TEAM.A,
580+
sender: { userId: 'user-1', nickname: '테스터' },
581+
text: '안녕하세요',
582+
createdAt: new Date(),
583+
})
584+
585+
const chatMockServer = {
586+
to: jest.fn().mockReturnValue({
587+
except: jest.fn().mockReturnValue({
588+
emit: jest.fn(),
589+
}),
590+
}),
591+
emit: jest.fn(),
592+
}
593+
gateway.server = chatMockServer as any
594+
595+
await gateway.handleChat(dto as any, mockClient)
596+
597+
expect(interactionUseCase.sendChat).toHaveBeenCalled()
598+
})
599+
})
600+
601+
describe('handleTeamVote', () => {
602+
it('팀 투표를 처리한다', async () => {
603+
const dto = {
604+
battleId: 'battle-1',
605+
team: BATTLE_TEAM.B,
606+
}
607+
608+
interactionUseCase.switchTeam.mockResolvedValue(undefined)
609+
610+
await gateway.handleTeamVote(dto as any, mockClient)
611+
612+
expect(interactionUseCase.switchTeam).toHaveBeenCalledWith('battle-1', 'user-1', BATTLE_TEAM.B)
613+
})
614+
})
615+
616+
describe('phaseUpdate', () => {
617+
it('페이즈 업데이트를 브로드캐스트한다', () => {
618+
const payload = { battleId: 'battle-1', phase: 'ATTACK', phaseCount: 1, startedAt: Date.now(), expiredAt: Date.now() + 60000 }
619+
620+
gateway.phaseUpdate(payload as any)
621+
622+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
623+
expect(mockServer.emit).toHaveBeenCalledWith('battle:phase:updated', payload)
624+
})
625+
})
626+
627+
describe('roundUpdate', () => {
628+
it('라운드 업데이트를 브로드캐스트한다', () => {
629+
const payload = { battleId: 'battle-1', round: 2, topic: 'topic2' }
630+
631+
gateway.roundUpdate(payload as any)
632+
633+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
634+
expect(mockServer.emit).toHaveBeenCalledWith('battle:round:updated', payload)
635+
})
636+
})
637+
638+
describe('onAttacked', () => {
639+
it('공격 결과를 브로드캐스트한다', () => {
640+
const payload = { battleId: 'battle-1', aTeam: null, bTeam: null }
641+
642+
gateway.onAttacked(payload as any)
643+
644+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
645+
expect(mockServer.emit).toHaveBeenCalledWith('battle:attacked', payload)
646+
})
647+
})
648+
649+
describe('onDefensed', () => {
650+
it('반론 결과를 브로드캐스트한다', () => {
651+
const payload = { battleId: 'battle-1', aTeam: null, bTeam: null }
652+
653+
gateway.onDefensed(payload as any)
654+
655+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
656+
expect(mockServer.emit).toHaveBeenCalledWith('battle:defensed', payload)
657+
})
658+
})
659+
660+
describe('closeBattle', () => {
661+
it('배틀 종료를 브로드캐스트하고 소켓을 끊는다', () => {
662+
const payload = { battleId: 'battle-1' }
663+
const mockIn = jest.fn().mockReturnValue({ disconnectSockets: jest.fn() })
664+
gateway.server = { ...mockServer, in: mockIn }
665+
666+
gateway.closeBattle(payload as any)
667+
668+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
669+
expect(mockServer.emit).toHaveBeenCalledWith('battle:closed', payload)
670+
expect(mockIn).toHaveBeenCalledTimes(3)
671+
})
672+
})
673+
674+
describe('skipPhase', () => {
675+
it('스킵 이벤트를 브로드캐스트한다', () => {
676+
const payload = { battleId: 'battle-1' }
677+
678+
gateway.skipPhase(payload)
679+
680+
expect(mockServer.to).toHaveBeenCalledWith('battle:battle-1')
681+
expect(mockServer.emit).toHaveBeenCalledWith('battle:phase:skipped')
682+
})
683+
})
684+
685+
describe('afterInit', () => {
686+
it('broadcaster에 서버를 설정한다', () => {
687+
const broadcasterAdapter = gateway['broadcaster'] as jest.Mocked<BattleBroadcasterAdapter>
688+
689+
gateway.afterInit(mockServer)
690+
691+
expect(broadcasterAdapter.setServer).toHaveBeenCalledWith(mockServer)
692+
})
693+
})
349694
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BattleIdentifierAdapter } from './battleIdentifier.adapter'
2+
3+
describe('BattleIdentifierAdapter', () => {
4+
let adapter: BattleIdentifierAdapter
5+
6+
beforeEach(() => {
7+
adapter = new BattleIdentifierAdapter()
8+
})
9+
10+
describe('generateId', () => {
11+
it('UUID v7 문자열을 반환한다', () => {
12+
const id = adapter.generateId()
13+
expect(typeof id).toBe('string')
14+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
15+
})
16+
17+
it('호출마다 고유한 ID를 생성한다', () => {
18+
const ids = new Set(Array.from({ length: 100 }, () => adapter.generateId()))
19+
expect(ids.size).toBe(100)
20+
})
21+
})
22+
23+
describe('generateInviteCode', () => {
24+
it('문자열을 반환한다', () => {
25+
const code = adapter.generateInviteCode()
26+
expect(typeof code).toBe('string')
27+
expect(code.length).toBeGreaterThan(0)
28+
})
29+
30+
it('타임스탬프 기반 코드를 반환한다', () => {
31+
const code = adapter.generateInviteCode()
32+
const parsed = Number(code)
33+
expect(Number.isNaN(parsed)).toBe(false)
34+
expect(parsed).toBeGreaterThan(0)
35+
})
36+
})
37+
})

0 commit comments

Comments
 (0)