From 68f4e449c3a2957e4c5be5d5fd6611a62280980e Mon Sep 17 00:00:00 2001 From: silver-eunjoo Date: Wed, 30 Jul 2025 14:38:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?:wrench:=20chore=20:=20dev=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20rebase=20=ED=9B=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/game/app/ChatService.java | 13 +- .../backend/domain/game/app/GameService.java | 9 ++ .../io/f1/backend/domain/game/model/Room.java | 11 ++ .../domain/game/app/ChatServiceTests.java | 118 ++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java index 985e5476..e282b113 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java @@ -10,8 +10,12 @@ import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.global.lock.DistributedLock; +import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -20,7 +24,6 @@ public class ChatService { private final RoomService roomService; - private final TimerService timerService; private final MessageSender messageSender; private final ApplicationEventPublisher eventPublisher; @@ -41,10 +44,16 @@ public void chat(Long roomId, UserPrincipal userPrincipal, ChatMessage chatMessa String answer = currentQuestion.getAnswer(); - if (answer.equals(chatMessage.message())) { + if (!answer.equals(chatMessage.message())) { + return; + } + + // false -> true + if(room.compareAndSetAnsweredFlag(false, true)) { eventPublisher.publishEvent( new GameCorrectAnswerEvent( room, userPrincipal.getUserId(), chatMessage, answer)); + } } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 21d8f7d3..dcbd20d1 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -123,6 +123,7 @@ public void onCorrectAnswer(GameCorrectAnswerEvent event) { ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER)); timerService.cancelTimer(room); + room.compareAndSetAnsweredFlag(true, false); if (!timerService.validateCurrentRound(room)) { gameEnd(room); @@ -142,6 +143,12 @@ public void onCorrectAnswer(GameCorrectAnswerEvent event) { @EventListener public void onTimeout(GameTimeoutEvent event) { Room room = event.room(); + + // false -> true 여야 하는데 실패했을 때 => 이미 정답 처리가 된 경우 (onCorrectAnswer 로직 실행 중) + if(!room.compareAndSetAnsweredFlag(false, true)) { + return; + } + log.debug(room.getId() + "번 방 타임아웃! 현재 라운드 : " + room.getCurrentRound()); String destination = getDestination(room.getId()); @@ -167,6 +174,8 @@ public void onTimeout(GameTimeoutEvent event) { destination, MessageType.QUESTION_START, toQuestionStartResponse(room, CONTINUE_DELAY)); + + room.compareAndSetAnsweredFlag(true, false); } public void gameEnd(Room room) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index bf0ad3a3..efb3490b 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -5,6 +5,7 @@ import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; +import java.util.concurrent.atomic.AtomicBoolean; import lombok.Getter; import java.time.LocalDateTime; @@ -42,6 +43,8 @@ public class Room { private ScheduledFuture timer; + private final AtomicBoolean answered = new AtomicBoolean(false); + public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { this.id = id; this.roomSetting = roomSetting; @@ -194,4 +197,12 @@ public boolean isPlayerInState(Long userId, ConnectionState state) { public boolean isPasswordIncorrect(String password) { return roomSetting.locked() && !roomSetting.password().equals(password); } + + public boolean compareAndSetAnsweredFlag(boolean expected, boolean newValue) { + return answered.compareAndSet(expected, newValue); + } + + public AtomicBoolean getAnsweredFlag() { + return this.answered; + } } diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java new file mode 100644 index 00000000..22a30ab8 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java @@ -0,0 +1,118 @@ +package io.f1.backend.domain.game.app; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import io.f1.backend.domain.game.dto.ChatMessage; +import io.f1.backend.domain.game.event.GameCorrectAnswerEvent; +import io.f1.backend.domain.game.model.GameSetting; +import io.f1.backend.domain.game.model.Player; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.model.RoomSetting; +import io.f1.backend.domain.game.websocket.MessageSender; +import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.user.entity.User; +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.LocalDateTime; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class ChatServiceTests { + + private ChatService chatService; + + @Mock private RoomService roomService; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private MessageSender messageSender; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. + + chatService = new ChatService(roomService, messageSender, eventPublisher); + + SecurityContextHolder.clearContext(); + } + + + @Test + @DisplayName("정답이 아닐 때 이벤트가 발행되지 않는다.") + void noEventWhenIncorrect() throws Exception { + + // given + Long roomId = 1L; + String sessionId = "session123"; + ChatMessage wrongMessage = new ChatMessage("nick", "오답", Instant.now()); + + Room room = mock(Room.class); + Question question = mock(Question.class); + + given(roomService.findRoom(roomId)).willReturn(room); + given(room.isPlaying()).willReturn(true); + given(room.getCurrentQuestion()).willReturn(question); + given(question.getAnswer()).willReturn("정답"); + + // when + chatService.chat(roomId, sessionId, wrongMessage); + + // then + verify(eventPublisher, never()).publishEvent(any(GameCorrectAnswerEvent.class)); + } + + + } + + private Room createRoom( + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { + RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); + GameSetting gameSetting = new GameSetting(quizId, 10, 60); + Player host = new Player(playerId, "nickname"); + + return new Room(roomId, roomSetting, gameSetting, host); + } + + private User createUser(int i) { + Long userId = i + 1L; + String provider = "provider +" + i; + String providerId = "providerId" + i; + LocalDateTime lastLogin = LocalDateTime.now(); + + User user = + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); + + try { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, userId); + } catch (Exception e) { + throw new RuntimeException("ID 설정 실패", e); + } + + return user; + } + +} \ No newline at end of file From e7db315b4555129ca3dcf4ab2b50de823afef902 Mon Sep 17 00:00:00 2001 From: silver-eunjoo Date: Wed, 30 Jul 2025 21:00:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?:white=5Fcheck=5Fmark:=20test=20:=20?= =?UTF-8?q?=EC=A0=95=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/app/ChatServiceTests.java | 105 +++++++- .../domain/game/app/GameFlowTests.java | 234 ++++++++++++++++++ 2 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java index 22a30ab8..e553ad8d 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java @@ -1,10 +1,14 @@ package io.f1.backend.domain.game.app; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import io.f1.backend.domain.game.dto.ChatMessage; @@ -13,17 +17,25 @@ import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.model.RoomSetting; +import io.f1.backend.domain.game.model.RoomState; import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; import java.lang.reflect.Field; import java.time.Instant; import java.time.LocalDateTime; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; @@ -56,11 +68,12 @@ void noEventWhenIncorrect() throws Exception { // given Long roomId = 1L; - String sessionId = "session123"; - ChatMessage wrongMessage = new ChatMessage("nick", "오답", Instant.now()); + ChatMessage wrongMessage = new ChatMessage("뜨거운제티", "오답", Instant.now()); Room room = mock(Room.class); Question question = mock(Question.class); + User user = createUser(1); + UserPrincipal userPrincipal = new UserPrincipal(user, Collections.emptyMap()); given(roomService.findRoom(roomId)).willReturn(room); given(room.isPlaying()).willReturn(true); @@ -68,13 +81,95 @@ void noEventWhenIncorrect() throws Exception { given(question.getAnswer()).willReturn("정답"); // when - chatService.chat(roomId, sessionId, wrongMessage); + chatService.chat(roomId, userPrincipal, wrongMessage); // then verify(eventPublisher, never()).publishEvent(any(GameCorrectAnswerEvent.class)); } - + @Test + @DisplayName("정답일 때 GameCorrectAnswerEvent가 발행된다. ") + void EventPublishedWhenCorrect() throws Exception { + + // given + Long roomId = 1L; + ChatMessage answer = new ChatMessage("뜨거운제티", "정답", Instant.now()); + + Room room = mock(Room.class); + Question question = mock(Question.class); + User user = createUser(1); + UserPrincipal userPrincipal = new UserPrincipal(user, Collections.emptyMap()); + + given(roomService.findRoom(roomId)).willReturn(room); + given(room.isPlaying()).willReturn(true); + given(room.getCurrentQuestion()).willReturn(question); + given(question.getAnswer()).willReturn("정답"); + given(room.compareAndSetAnsweredFlag(false, true)).willReturn(true); + + // when + chatService.chat(roomId, userPrincipal, answer); + + // then + verify(eventPublisher, times(1)).publishEvent(any(GameCorrectAnswerEvent.class)); + + } + + @Test + @DisplayName("동시에 여러 명의 사용자가 채팅을 보냈을 때, 한 명만 정답인정") + void onlyOneCorrectPlayerWhenConcurrentCorrectAnswers() throws Exception { + + // given + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + String correctAnswer = "정답"; + + Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + Question question = mock(Question.class); + room.updateRoomState(RoomState.PLAYING); + room.increaseCurrentRound(); + room.updateQuestions(Collections.singletonList(question)); + + // room이 실제 객체이므로, RoomService만 mock으로 대체 + given(roomService.findRoom(roomId)).willReturn(room); + given(question.getAnswer()).willReturn(correctAnswer); + + int userCount = 8; + ExecutorService executor = Executors.newFixedThreadPool(userCount); + + CountDownLatch countDownLatch = new CountDownLatch(userCount); + + ChatMessage msg = new ChatMessage("닉네임", correctAnswer, Instant.now()); + + for (int i = 0; i < userCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + User user = createUser(idx); + UserPrincipal principal = new UserPrincipal(user, Collections.emptyMap()); + chatService.chat(roomId, principal, msg); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + + + // then: 이벤트는 단 1번만 발행돼야 함 + ArgumentCaptor captor = ArgumentCaptor.forClass(GameCorrectAnswerEvent.class); + verify(eventPublisher, times(1)).publishEvent(captor.capture()); + + GameCorrectAnswerEvent event = captor.getValue(); + log.info("정답 인정된 유저 ID : {}", event.userId()); + assertThat(event.userId()).isBetween(0L, 7L); + + verify(eventPublisher, times(1)).publishEvent(any(GameCorrectAnswerEvent.class)); + assertThat(room.getAnswered().get()).isTrue(); } private Room createRoom( @@ -90,7 +185,7 @@ private Room createRoom( return new Room(roomId, roomSetting, gameSetting, host); } - + private User createUser(int i) { Long userId = i + 1L; String provider = "provider +" + i; diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java new file mode 100644 index 00000000..4bfe4fff --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java @@ -0,0 +1,234 @@ +package io.f1.backend.domain.game.app; + +import static io.f1.backend.domain.game.dto.MessageType.QUESTION_RESULT; +import static io.f1.backend.domain.game.dto.MessageType.QUESTION_START; +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import io.f1.backend.domain.game.dto.ChatMessage; +import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.dto.RoomEventType; +import io.f1.backend.domain.game.dto.SystemNoticeMessage; +import io.f1.backend.domain.game.event.GameCorrectAnswerEvent; +import io.f1.backend.domain.game.event.GameTimeoutEvent; +import io.f1.backend.domain.game.model.GameSetting; +import io.f1.backend.domain.game.model.Player; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.model.RoomSetting; +import io.f1.backend.domain.game.model.RoomState; +import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.game.store.UserRoomRepository; +import io.f1.backend.domain.game.websocket.DisconnectTaskManager; +import io.f1.backend.domain.game.websocket.MessageSender; +import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.stat.app.StatService; +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.entity.User; +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + + +@Slf4j +@ExtendWith(MockitoExtension.class) +class GameFlowTests { + + private ChatService chatService; + private GameService gameService; + private TestRoomService testRoomService; + + @Mock private QuizService quizService; + @Mock private StatService statService; + @Mock private TimerService timerService; + @Mock private MessageSender messageSender; + @Mock private DisconnectTaskManager disconnectTaskManager; + + private Room room; + private Question question; + + @BeforeEach + void setUp() { + question = mock(Question.class); + + ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { + @Override + public void publishEvent(Object event) { + if (event instanceof GameCorrectAnswerEvent e) { + gameService.onCorrectAnswer(e); + } else if (event instanceof GameTimeoutEvent e) { + gameService.onTimeout(e); + } + } + }; + + testRoomService = new TestRoomService(); + chatService = new ChatService(testRoomService, messageSender, eventPublisher); + gameService = new GameService(statService, quizService, testRoomService, timerService, messageSender, null, eventPublisher); + } + + @Test + @DisplayName("정답 채팅이 타임아웃보다 먼저 도착하면 정답으로 인정된다") + void correctChatBeforeTimeout_shouldPreferChat() throws Exception { + + // given + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + String correctAnswer = "정답"; + + room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + when(question.getAnswer()).thenReturn(correctAnswer); + + room.updateRoomState(RoomState.PLAYING); + room.increaseCurrentRound(); + room.updateQuestions(Collections.singletonList(question)); + room.getAnswered().set(false); + + testRoomService.register(room); // Room 등록 + + User user = createUser(1); + UserPrincipal principal = new UserPrincipal(user, Collections.emptyMap()); + ChatMessage answer = new ChatMessage("뜨거운제티", correctAnswer, Instant.now()); + + CountDownLatch latch = new CountDownLatch(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + + // when + // 채팅으로 정답 -> AtomicBoolean (false -> true) + executor.submit(() -> { + try { + chatService.chat(roomId, principal, answer); + log.info("채팅으로 정답! 현재 시간 : {}", Instant.now()); + } finally { + latch.countDown(); + } + }); + + // 그 찰나에 timeout 발생 -> AtomicBoolean compareAndSet 때문에 return! 실행 안됨. + executor.submit(() -> { + try { + Thread.sleep(100); // 살짝 늦게 타임아웃 발생 + gameService.onTimeout(new GameTimeoutEvent(room)); + log.info("그 찰나에 타임아웃 발생! 현재 시간 : {}", Instant.now()); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + + boolean done = latch.await(3, java.util.concurrent.TimeUnit.SECONDS); + executor.shutdown(); + executor.awaitTermination(1, TimeUnit.SECONDS); + assertThat(done).isTrue(); + + // then + + verify(messageSender, atMostOnce()).sendBroadcast( + eq(getDestination(roomId)), + eq(QUESTION_RESULT), + any() + ); + verify(messageSender, atMostOnce()).sendBroadcast( + eq(getDestination(roomId)), + eq(QUESTION_START), + any() + ); + + ArgumentCaptor noticeCaptor = ArgumentCaptor.forClass(SystemNoticeMessage.class); + + verify(messageSender, atMost(1)).sendBroadcast( + eq(getDestination(roomId)), + eq(MessageType.SYSTEM_NOTICE), + noticeCaptor.capture() + ); + + // SYSTEM_NOTICE가 TIMEOUT 내용이었는지 확인 + if (!noticeCaptor.getAllValues().isEmpty()) { + SystemNoticeMessage message = noticeCaptor.getValue(); + assertThat(message.getMessage()).isNotEqualTo(RoomEventType.TIMEOUT); + } + + } + + + private Room createRoom( + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { + RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); + GameSetting gameSetting = new GameSetting(quizId, 10, 60); + Player host = new Player(playerId, "nickname"); + + return new Room(roomId, roomSetting, gameSetting, host); + } + + private User createUser(int i) { + Long userId = i + 1L; + String provider = "provider" + i; + String providerId = "providerId" + i; + LocalDateTime lastLogin = LocalDateTime.now(); + + User user = User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); + + try { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, userId); + } catch (Exception e) { + throw new RuntimeException("ID 설정 실패", e); + } + + return user; + } + + // 내부 클래스: 테스트용 RoomService + static class TestRoomService extends RoomService { + private final Map rooms = new ConcurrentHashMap<>(); + + public TestRoomService() { + super(null, null, null, null, null, null); + } + + @Override + public Room findRoom(Long roomId) { + return rooms.get(roomId); + } + + public void register(Room room) { + rooms.put(room.getId(), room); + } + } +} From efe42f50ef61b464247f4edaaac4ff532e129d85 Mon Sep 17 00:00:00 2001 From: github-actions <> Date: Wed, 30 Jul 2025 12:01:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20Java=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/game/app/ChatService.java | 7 +- .../backend/domain/game/app/GameService.java | 2 +- .../io/f1/backend/domain/game/model/Room.java | 2 +- .../domain/game/app/ChatServiceTests.java | 73 +++++---- .../domain/game/app/GameFlowTests.java | 153 +++++++++--------- 5 files changed, 117 insertions(+), 120 deletions(-) diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java index e282b113..653f12d7 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java @@ -10,12 +10,8 @@ import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.user.dto.UserPrincipal; -import io.f1.backend.global.lock.DistributedLock; -import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -49,11 +45,10 @@ public void chat(Long roomId, UserPrincipal userPrincipal, ChatMessage chatMessa } // false -> true - if(room.compareAndSetAnsweredFlag(false, true)) { + if (room.compareAndSetAnsweredFlag(false, true)) { eventPublisher.publishEvent( new GameCorrectAnswerEvent( room, userPrincipal.getUserId(), chatMessage, answer)); - } } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index dcbd20d1..cf0e72e9 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -145,7 +145,7 @@ public void onTimeout(GameTimeoutEvent event) { Room room = event.room(); // false -> true 여야 하는데 실패했을 때 => 이미 정답 처리가 된 경우 (onCorrectAnswer 로직 실행 중) - if(!room.compareAndSetAnsweredFlag(false, true)) { + if (!room.compareAndSetAnsweredFlag(false, true)) { return; } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index efb3490b..395fcd03 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -5,7 +5,6 @@ import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; -import java.util.concurrent.atomic.AtomicBoolean; import lombok.Getter; import java.time.LocalDateTime; @@ -17,6 +16,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; @Getter public class Room { diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java index e553ad8d..467251be 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java @@ -4,10 +4,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.only; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -22,15 +20,9 @@ import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; -import java.lang.reflect.Field; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; + import lombok.extern.slf4j.Slf4j; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,6 +34,14 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.context.SecurityContextHolder; +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + @Slf4j @ExtendWith(MockitoExtension.class) class ChatServiceTests { @@ -61,7 +61,6 @@ void setUp() { SecurityContextHolder.clearContext(); } - @Test @DisplayName("정답이 아닐 때 이벤트가 발행되지 않는다.") void noEventWhenIncorrect() throws Exception { @@ -111,7 +110,6 @@ void EventPublishedWhenCorrect() throws Exception { // then verify(eventPublisher, times(1)).publishEvent(any(GameCorrectAnswerEvent.class)); - } @Test @@ -146,22 +144,24 @@ void onlyOneCorrectPlayerWhenConcurrentCorrectAnswers() throws Exception { for (int i = 0; i < userCount; i++) { final int idx = i; - executor.submit(() -> { - try { - User user = createUser(idx); - UserPrincipal principal = new UserPrincipal(user, Collections.emptyMap()); - chatService.chat(roomId, principal, msg); - } finally { - countDownLatch.countDown(); - } - }); + executor.submit( + () -> { + try { + User user = createUser(idx); + UserPrincipal principal = + new UserPrincipal(user, Collections.emptyMap()); + chatService.chat(roomId, principal, msg); + } finally { + countDownLatch.countDown(); + } + }); } countDownLatch.await(); - // then: 이벤트는 단 1번만 발행돼야 함 - ArgumentCaptor captor = ArgumentCaptor.forClass(GameCorrectAnswerEvent.class); + ArgumentCaptor captor = + ArgumentCaptor.forClass(GameCorrectAnswerEvent.class); verify(eventPublisher, times(1)).publishEvent(captor.capture()); GameCorrectAnswerEvent event = captor.getValue(); @@ -173,19 +173,19 @@ void onlyOneCorrectPlayerWhenConcurrentCorrectAnswers() throws Exception { } private Room createRoom( - Long roomId, - Long playerId, - Long quizId, - String password, - int maxUserCount, - boolean locked) { + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); GameSetting gameSetting = new GameSetting(quizId, 10, 60); Player host = new Player(playerId, "nickname"); return new Room(roomId, roomSetting, gameSetting, host); } - + private User createUser(int i) { Long userId = i + 1L; String provider = "provider +" + i; @@ -193,11 +193,11 @@ private User createUser(int i) { LocalDateTime lastLogin = LocalDateTime.now(); User user = - User.builder() - .provider(provider) - .providerId(providerId) - .lastLogin(lastLogin) - .build(); + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); try { Field idField = User.class.getDeclaredField("id"); @@ -209,5 +209,4 @@ private User createUser(int i) { return user; } - -} \ No newline at end of file +} diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java index 4bfe4fff..f9ef482c 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java @@ -3,9 +3,9 @@ import static io.f1.backend.domain.game.dto.MessageType.QUESTION_RESULT; import static io.f1.backend.domain.game.dto.MessageType.QUESTION_START; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; import io.f1.backend.domain.game.dto.ChatMessage; @@ -19,8 +19,6 @@ import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.model.RoomSetting; import io.f1.backend.domain.game.model.RoomState; -import io.f1.backend.domain.game.store.RoomRepository; -import io.f1.backend.domain.game.store.UserRoomRepository; import io.f1.backend.domain.game.websocket.DisconnectTaskManager; import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.question.entity.Question; @@ -28,28 +26,28 @@ import io.f1.backend.domain.stat.app.StatService; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; -import java.lang.reflect.Field; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; @Slf4j @ExtendWith(MockitoExtension.class) @@ -72,20 +70,29 @@ class GameFlowTests { void setUp() { question = mock(Question.class); - ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { - @Override - public void publishEvent(Object event) { - if (event instanceof GameCorrectAnswerEvent e) { - gameService.onCorrectAnswer(e); - } else if (event instanceof GameTimeoutEvent e) { - gameService.onTimeout(e); - } - } - }; + ApplicationEventPublisher eventPublisher = + new ApplicationEventPublisher() { + @Override + public void publishEvent(Object event) { + if (event instanceof GameCorrectAnswerEvent e) { + gameService.onCorrectAnswer(e); + } else if (event instanceof GameTimeoutEvent e) { + gameService.onTimeout(e); + } + } + }; testRoomService = new TestRoomService(); chatService = new ChatService(testRoomService, messageSender, eventPublisher); - gameService = new GameService(statService, quizService, testRoomService, timerService, messageSender, null, eventPublisher); + gameService = + new GameService( + statService, + quizService, + testRoomService, + timerService, + messageSender, + null, + eventPublisher); } @Test @@ -120,27 +127,29 @@ void correctChatBeforeTimeout_shouldPreferChat() throws Exception { // when // 채팅으로 정답 -> AtomicBoolean (false -> true) - executor.submit(() -> { - try { - chatService.chat(roomId, principal, answer); - log.info("채팅으로 정답! 현재 시간 : {}", Instant.now()); - } finally { - latch.countDown(); - } - }); + executor.submit( + () -> { + try { + chatService.chat(roomId, principal, answer); + log.info("채팅으로 정답! 현재 시간 : {}", Instant.now()); + } finally { + latch.countDown(); + } + }); // 그 찰나에 timeout 발생 -> AtomicBoolean compareAndSet 때문에 return! 실행 안됨. - executor.submit(() -> { - try { - Thread.sleep(100); // 살짝 늦게 타임아웃 발생 - gameService.onTimeout(new GameTimeoutEvent(room)); - log.info("그 찰나에 타임아웃 발생! 현재 시간 : {}", Instant.now()); - } catch (InterruptedException e) { - e.printStackTrace(); - } finally { - latch.countDown(); - } - }); + executor.submit( + () -> { + try { + Thread.sleep(100); // 살짝 늦게 타임아웃 발생 + gameService.onTimeout(new GameTimeoutEvent(room)); + log.info("그 찰나에 타임아웃 발생! 현재 시간 : {}", Instant.now()); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); boolean done = latch.await(3, java.util.concurrent.TimeUnit.SECONDS); executor.shutdown(); @@ -149,41 +158,34 @@ void correctChatBeforeTimeout_shouldPreferChat() throws Exception { // then - verify(messageSender, atMostOnce()).sendBroadcast( - eq(getDestination(roomId)), - eq(QUESTION_RESULT), - any() - ); - verify(messageSender, atMostOnce()).sendBroadcast( - eq(getDestination(roomId)), - eq(QUESTION_START), - any() - ); - - ArgumentCaptor noticeCaptor = ArgumentCaptor.forClass(SystemNoticeMessage.class); - - verify(messageSender, atMost(1)).sendBroadcast( - eq(getDestination(roomId)), - eq(MessageType.SYSTEM_NOTICE), - noticeCaptor.capture() - ); + verify(messageSender, atMostOnce()) + .sendBroadcast(eq(getDestination(roomId)), eq(QUESTION_RESULT), any()); + verify(messageSender, atMostOnce()) + .sendBroadcast(eq(getDestination(roomId)), eq(QUESTION_START), any()); + + ArgumentCaptor noticeCaptor = + ArgumentCaptor.forClass(SystemNoticeMessage.class); + + verify(messageSender, atMost(1)) + .sendBroadcast( + eq(getDestination(roomId)), + eq(MessageType.SYSTEM_NOTICE), + noticeCaptor.capture()); // SYSTEM_NOTICE가 TIMEOUT 내용이었는지 확인 if (!noticeCaptor.getAllValues().isEmpty()) { SystemNoticeMessage message = noticeCaptor.getValue(); assertThat(message.getMessage()).isNotEqualTo(RoomEventType.TIMEOUT); } - } - private Room createRoom( - Long roomId, - Long playerId, - Long quizId, - String password, - int maxUserCount, - boolean locked) { + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); GameSetting gameSetting = new GameSetting(quizId, 10, 60); Player host = new Player(playerId, "nickname"); @@ -197,11 +199,12 @@ private User createUser(int i) { String providerId = "providerId" + i; LocalDateTime lastLogin = LocalDateTime.now(); - User user = User.builder() - .provider(provider) - .providerId(providerId) - .lastLogin(lastLogin) - .build(); + User user = + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); try { Field idField = User.class.getDeclaredField("id");