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..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 @@ -20,7 +20,6 @@ public class ChatService { private final RoomService roomService; - private final TimerService timerService; private final MessageSender messageSender; private final ApplicationEventPublisher eventPublisher; @@ -41,7 +40,12 @@ 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..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 @@ -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..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 @@ -16,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 { @@ -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..467251be --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/ChatServiceTests.java @@ -0,0 +1,212 @@ +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +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.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 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 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 { + + 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; + 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); + given(room.getCurrentQuestion()).willReturn(question); + given(question.getAnswer()).willReturn("정답"); + + // when + 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( + 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; + } +} 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..f9ef482c --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java @@ -0,0 +1,237 @@ +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.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.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 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.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) +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); + } + } +}