diff --git a/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java index aeb9c819..4402b887 100644 --- a/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java +++ b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java @@ -1,12 +1,14 @@ package com.debatetimer.config.sharing; import com.debatetimer.config.CorsProperties; +import com.debatetimer.event.sharing.RoomSubscribeInterceptor; import java.time.Duration; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; @@ -26,12 +28,18 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final CorsProperties corsProperties; private final WebSocketAuthMemberResolver webSocketAuthMemberResolver; + private final RoomSubscribeInterceptor roomSubscribeInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(webSocketAuthMemberResolver); } + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(roomSubscribeInterceptor); + } + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/room", "/chairman") diff --git a/src/main/java/com/debatetimer/controller/sharing/SharingWebSocketController.java b/src/main/java/com/debatetimer/controller/sharing/SharingWebSocketController.java index 92ca689d..7400da30 100644 --- a/src/main/java/com/debatetimer/controller/sharing/SharingWebSocketController.java +++ b/src/main/java/com/debatetimer/controller/sharing/SharingWebSocketController.java @@ -26,6 +26,6 @@ public SharingResponse share( @DestinationVariable(value = "roomId") long roomId, @Valid @Payload SharingRequest request ) { - return sharingService.share(request); + return sharingService.share(roomId, request); } } diff --git a/src/main/java/com/debatetimer/domain/sharing/TimerEvent.java b/src/main/java/com/debatetimer/domain/sharing/TimerEvent.java index e32a9a20..c5e1f1f9 100644 --- a/src/main/java/com/debatetimer/domain/sharing/TimerEvent.java +++ b/src/main/java/com/debatetimer/domain/sharing/TimerEvent.java @@ -18,6 +18,9 @@ public TimerEvent( @Nullable TimerEventData timerEventData ) { eventType.validateEventData(timerEventData); + if (eventType.isSync()) { + timerEventData.validateSyncable(); + } this.eventType = eventType; this.timerEventData = timerEventData; } diff --git a/src/main/java/com/debatetimer/domain/sharing/TimerEventData.java b/src/main/java/com/debatetimer/domain/sharing/TimerEventData.java index 85de556b..df1f2e09 100644 --- a/src/main/java/com/debatetimer/domain/sharing/TimerEventData.java +++ b/src/main/java/com/debatetimer/domain/sharing/TimerEventData.java @@ -21,17 +21,32 @@ public class TimerEventData { private final long remainingTime; + @Nullable + private final Boolean isRunning; + + @Nullable + private final Long prosRemainingTime; + + @Nullable + private final Long consRemainingTime; + public TimerEventData( CustomizeBoxType timerType, int sequence, @Nullable Stance currentTeam, - long remainingTime + long remainingTime, + @Nullable Boolean isRunning, + @Nullable Long prosRemainingTime, + @Nullable Long consRemainingTime ) { validateCurrentTeam(timerType, currentTeam); this.timerType = timerType; this.sequence = sequence; this.currentTeam = currentTeam; this.remainingTime = remainingTime; + this.isRunning = isRunning; + this.prosRemainingTime = prosRemainingTime; + this.consRemainingTime = consRemainingTime; } private void validateCurrentTeam(CustomizeBoxType timerType, Stance currentTeam) { @@ -43,4 +58,14 @@ private void validateCurrentTeam(CustomizeBoxType timerType, Stance currentTeam) throw new DTClientErrorException(ClientErrorCode.INVALID_NORMAL_TIMER_EVENT_DATA); } } + + public void validateSyncable() { + if (isRunning == null) { + throw new DTClientErrorException(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA); + } + + if (timerType.isTimeBased() && (prosRemainingTime == null || consRemainingTime == null)) { + throw new DTClientErrorException(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA); + } + } } diff --git a/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java b/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java index ca1a5427..c7db505d 100644 --- a/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java +++ b/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java @@ -15,6 +15,7 @@ public enum TimerEventType { PLAY(Objects::nonNull), RESET(Objects::nonNull), TEAM_SWITCH(Objects::nonNull), + SYNC(Objects::nonNull), FINISHED(Objects::isNull), ; @@ -25,4 +26,8 @@ public void validateEventData(Object eventData) { throw new DTClientErrorException(ClientErrorCode.INVALID_TIMER_EVENT); } } + + public boolean isSync() { + return this == SYNC; + } } diff --git a/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java index dab8299a..0ad3b563 100644 --- a/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java +++ b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java @@ -11,6 +11,9 @@ public record SharingRequest( @NotNull TimerEventType eventType, + @Nullable + Long version, + @Valid @Nullable TimerEventInfoRequest data diff --git a/src/main/java/com/debatetimer/dto/sharing/request/TimerEventInfoRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/TimerEventInfoRequest.java index 6a4c591e..e2b35559 100644 --- a/src/main/java/com/debatetimer/dto/sharing/request/TimerEventInfoRequest.java +++ b/src/main/java/com/debatetimer/dto/sharing/request/TimerEventInfoRequest.java @@ -14,7 +14,16 @@ public record TimerEventInfoRequest( Stance currentTeam, int sequence, - long remainingTime + long remainingTime, + + @Nullable + Boolean isRunning, + + @Nullable + Long prosRemainingTime, + + @Nullable + Long consRemainingTime ) { public TimerEventData toTimerEventInfo() { @@ -22,7 +31,10 @@ public TimerEventData toTimerEventInfo() { timerType, sequence, currentTeam, - remainingTime + remainingTime, + isRunning, + prosRemainingTime, + consRemainingTime ); } } diff --git a/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java index 2973fb94..88298fab 100644 --- a/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java +++ b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java @@ -6,11 +6,14 @@ public record SharingResponse( TimerEventType eventType, + @Nullable + Long version, + @Nullable TimerEventDataResponse data ) { public SharingResponse(TimerEventType eventType) { - this(eventType, null); + this(eventType, null, null); } } diff --git a/src/main/java/com/debatetimer/dto/sharing/response/TimerEventDataResponse.java b/src/main/java/com/debatetimer/dto/sharing/response/TimerEventDataResponse.java index 5fde093a..8aa02b05 100644 --- a/src/main/java/com/debatetimer/dto/sharing/response/TimerEventDataResponse.java +++ b/src/main/java/com/debatetimer/dto/sharing/response/TimerEventDataResponse.java @@ -15,7 +15,16 @@ public record TimerEventDataResponse( Stance currentTeam, int sequence, - long remainingTime + long remainingTime, + + @Nullable + Boolean isRunning, + + @Nullable + Long prosRemainingTime, + + @Nullable + Long consRemainingTime ) { public TimerEventDataResponse(TimerEventData timerEventInfo) { @@ -23,7 +32,10 @@ public TimerEventDataResponse(TimerEventData timerEventInfo) { timerEventInfo.getTimerType(), timerEventInfo.getCurrentTeam(), timerEventInfo.getSequence(), - timerEventInfo.getRemainingTime() + timerEventInfo.getRemainingTime(), + timerEventInfo.getIsRunning(), + timerEventInfo.getProsRemainingTime(), + timerEventInfo.getConsRemainingTime() ); } } diff --git a/src/main/java/com/debatetimer/event/sharing/RoomSubscribeInterceptor.java b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeInterceptor.java new file mode 100644 index 00000000..78bff8e9 --- /dev/null +++ b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeInterceptor.java @@ -0,0 +1,89 @@ +package com.debatetimer.event.sharing; + +import com.debatetimer.domain.sharing.TimerEventType; +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.sharing.SharingRoomRegistry; +import org.springframework.context.annotation.Lazy; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.broker.SimpleBrokerMessageHandler; +import org.springframework.messaging.support.ExecutorChannelInterceptor; +import org.springframework.stereotype.Component; + +/** + * simple broker가 청중의 룸 구독을 등록한 뒤에 후속 메시지를 보낸다. + * SessionSubscribeEvent는 구독 등록 전에 발행될 수 있어, 그 시점에 룸으로 보내면 새 청중이 받지 못할 수 있다. + */ +@Component +public class RoomSubscribeInterceptor implements ExecutorChannelInterceptor { + + private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/"; + private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/"; + + private final SimpMessagingTemplate messagingTemplate; + private final SharingRoomRegistry sharingRoomRegistry; + + public RoomSubscribeInterceptor( + @Lazy SimpMessagingTemplate messagingTemplate, + SharingRoomRegistry sharingRoomRegistry + ) { + this.messagingTemplate = messagingTemplate; + this.sharingRoomRegistry = sharingRoomRegistry; + } + + @Override + public void afterMessageHandled(Message message, MessageChannel channel, MessageHandler handler, Exception ex) { + if (ex != null || !(handler instanceof SimpleBrokerMessageHandler)) { + return; + } + + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(message); + String destination = accessor.getDestination(); + if (accessor.getMessageType() != SimpMessageType.SUBSCRIBE || destination == null) { + return; + } + + if (destination.startsWith(CHAIRMAN_CHANNEL_PREFIX)) { + handleChairmanSubscribe(destination); + return; + } + + if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) { + handleAudienceSubscribe(destination); + } + } + + /** + * 사회자의 구독은 공유 시작을 뜻하므로, 이전에 종료된 룸이라도 다시 진행 상태로 되돌린다. + */ + private void handleChairmanSubscribe(String destination) { + long roomId = parseRoomId(destination, CHAIRMAN_CHANNEL_PREFIX); + sharingRoomRegistry.reopen(roomId); + } + + private void handleAudienceSubscribe(String destination) { + long roomId = parseRoomId(destination, AUDIENCE_SUBSCRIBE_PREFIX); + if (sharingRoomRegistry.isFinished(roomId)) { + messagingTemplate.convertAndSend(AUDIENCE_SUBSCRIBE_PREFIX + roomId, + new SharingResponse(TimerEventType.FINISHED)); + return; + } + messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId)); + } + + private long parseRoomId(String destination, String prefix) { + try { + String parsedRoomId = destination.substring(prefix.length()); + return Long.parseLong(parsedRoomId); + } catch (NumberFormatException exception) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ROOM_ID); + } + } +} diff --git a/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java deleted file mode 100644 index f2e4b47a..00000000 --- a/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.debatetimer.event.sharing; - -import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; -import com.debatetimer.exception.custom.DTClientErrorException; -import com.debatetimer.exception.errorcode.ClientErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionSubscribeEvent; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RoomSubscribeListener { - - private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/"; - private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/"; - - private final SimpMessagingTemplate messagingTemplate; - - @EventListener - public void handleSubscribeEvent(SessionSubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - if (destination == null) { - return; - } - - if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) { - long roomId = parseRoomId(destination); - messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId)); - } - } - - private long parseRoomId(String destination) { - try { - String parsedRoomId = destination.substring(AUDIENCE_SUBSCRIBE_PREFIX.length()); - return Long.parseLong(parsedRoomId); - } catch (NumberFormatException exception) { - throw new DTClientErrorException(ClientErrorCode.INVALID_ROOM_ID); - } - } -} - diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 6079ba2f..51140d16 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -81,6 +81,7 @@ public enum ClientErrorCode implements ResponseErrorCode { INVALID_NORMAL_TIMER_EVENT_DATA(HttpStatus.BAD_REQUEST, "잘못된 일반 타이머 이벤트 데이터입니다"), INVALID_TIME_BASED_TIMER_EVENT_DATA(HttpStatus.BAD_REQUEST, "잘못된 자유토론 타이머 이벤트 데이터입니다"), INVALID_TIMER_EVENT(HttpStatus.BAD_REQUEST, "잘못된 타이머 이벤트 데이터입니다"), + INVALID_SYNC_TIMER_EVENT_DATA(HttpStatus.BAD_REQUEST, "잘못된 동기화 타이머 이벤트 데이터입니다"), ; private final HttpStatus status; diff --git a/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java new file mode 100644 index 00000000..86b10d07 --- /dev/null +++ b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java @@ -0,0 +1,20 @@ +package com.debatetimer.scheduler; + +import com.debatetimer.service.sharing.SharingRoomRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SharingRoomCleanupScheduler { + + private static final long INTERVAL_MILLIS = 60 * 60 * 1000L; + + private final SharingRoomRegistry sharingRoomRegistry; + + @Scheduled(fixedRate = INTERVAL_MILLIS) + public void cleanupExpiredRooms() { + sharingRoomRegistry.removeExpired(); + } +} diff --git a/src/main/java/com/debatetimer/service/sharing/SharingRoomRegistry.java b/src/main/java/com/debatetimer/service/sharing/SharingRoomRegistry.java new file mode 100644 index 00000000..3c2abce5 --- /dev/null +++ b/src/main/java/com/debatetimer/service/sharing/SharingRoomRegistry.java @@ -0,0 +1,53 @@ +package com.debatetimer.service.sharing; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class SharingRoomRegistry { + + static final Duration FINISHED_TTL = Duration.ofDays(1); + + private final Map finishedRoomExpirations = new ConcurrentHashMap<>(); + private final Clock clock; + + public SharingRoomRegistry() { + this(Clock.systemDefaultZone()); + } + + SharingRoomRegistry(Clock clock) { + this.clock = clock; + } + + public void markFinished(long roomId) { + finishedRoomExpirations.put(roomId, clock.instant().plus(FINISHED_TTL)); + } + + public void reopen(long roomId) { + finishedRoomExpirations.remove(roomId); + } + + public boolean isFinished(long roomId) { + Instant expiration = finishedRoomExpirations.get(roomId); + if (expiration == null) { + return false; + } + if (isExpired(expiration)) { + finishedRoomExpirations.remove(roomId, expiration); + return false; + } + return true; + } + + public void removeExpired() { + finishedRoomExpirations.values().removeIf(this::isExpired); + } + + private boolean isExpired(Instant expiration) { + return !clock.instant().isBefore(expiration); + } +} diff --git a/src/main/java/com/debatetimer/service/sharing/SharingService.java b/src/main/java/com/debatetimer/service/sharing/SharingService.java index 863981af..54efd843 100644 --- a/src/main/java/com/debatetimer/service/sharing/SharingService.java +++ b/src/main/java/com/debatetimer/service/sharing/SharingService.java @@ -1,6 +1,7 @@ package com.debatetimer.service.sharing; import com.debatetimer.domain.sharing.TimerEvent; +import com.debatetimer.domain.sharing.TimerEventType; import com.debatetimer.dto.sharing.request.SharingRequest; import com.debatetimer.dto.sharing.response.SharingResponse; import com.debatetimer.dto.sharing.response.TimerEventDataResponse; @@ -12,13 +13,22 @@ @RequiredArgsConstructor public class SharingService { - public SharingResponse share(SharingRequest request) { + private final SharingRoomRegistry sharingRoomRegistry; + + public SharingResponse share(long roomId, SharingRequest request) { TimerEvent timerEvent = request.toTimerEvent(); - return Optional.ofNullable(timerEvent.getTimerEventData()) - .map(eventData -> new SharingResponse( - request.eventType(), - new TimerEventDataResponse(eventData) - )) - .orElse(new SharingResponse(request.eventType(), null)); + updateRoomStatus(roomId, timerEvent.getEventType()); + TimerEventDataResponse data = Optional.ofNullable(timerEvent.getTimerEventData()) + .map(TimerEventDataResponse::new) + .orElse(null); + return new SharingResponse(request.eventType(), request.version(), data); + } + + private void updateRoomStatus(long roomId, TimerEventType eventType) { + if (eventType == TimerEventType.FINISHED) { + sharingRoomRegistry.markFinished(roomId); + return; + } + sharingRoomRegistry.reopen(roomId); } } diff --git a/src/test/java/com/debatetimer/controller/sharing/SharingWebSocketControllerTest.java b/src/test/java/com/debatetimer/controller/sharing/SharingWebSocketControllerTest.java index 88775492..c713552d 100644 --- a/src/test/java/com/debatetimer/controller/sharing/SharingWebSocketControllerTest.java +++ b/src/test/java/com/debatetimer/controller/sharing/SharingWebSocketControllerTest.java @@ -7,41 +7,60 @@ import com.debatetimer.BaseStompTest; import com.debatetimer.MessageFrameHandler; import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.customize.Stance; import com.debatetimer.domain.member.Member; import com.debatetimer.domain.sharing.TimerEventType; +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; import com.debatetimer.dto.sharing.request.SharingRequest; import com.debatetimer.dto.sharing.request.TimerEventInfoRequest; import com.debatetimer.dto.sharing.response.SharingResponse; +import com.debatetimer.service.sharing.SharingRoomRegistry; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.simp.stomp.StompHeaders; class SharingWebSocketControllerTest extends BaseStompTest { + private static final long ROOM_ID = 1L; + + @Autowired + private SharingRoomRegistry sharingRoomRegistry; + + @AfterEach + void reopenRoom() { + sharingRoomRegistry.reopen(ROOM_ID); + } + @Nested class Share { @Test void 사회자가_발생시킨_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { - long roomId = 1L; MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); Member member = memberGenerator.generate("example@email.com"); - StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + roomId, member); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); SharingRequest request = new SharingRequest( TimerEventType.NEXT, + null, new TimerEventInfoRequest( CustomizeBoxType.NORMAL, null, 2, - 30 + 30, + null, + null, + null ) ); - stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 + stompSession.subscribe("/room/" + ROOM_ID, handler); //청중의 구독 stompSession.send(headers, request); //사회자의 이벤트 발생 @@ -50,30 +69,68 @@ class Share { assertAll( () -> assertThat(response.eventType()).isEqualTo(request.eventType()), + () -> assertThat(response.version()).isNull(), () -> assertThat(response.data()).isNotNull(), () -> assertThat(response.data().timerType()).isEqualTo(request.data().timerType()), () -> assertThat(response.data().sequence()).isEqualTo(request.data().sequence()), () -> assertThat(response.data().currentTeam()).isEqualTo(request.data().currentTeam()), - () -> assertThat(response.data().remainingTime()).isEqualTo(request.data().remainingTime()) + () -> assertThat(response.data().remainingTime()).isEqualTo(request.data().remainingTime()), + () -> assertThat(response.data().isRunning()).isNull(), + () -> assertThat(response.data().prosRemainingTime()).isNull(), + () -> assertThat(response.data().consRemainingTime()).isNull() ); } @Test - void 사회자가_아니면_이벤트를_발행할_수_없다() throws ExecutionException, InterruptedException, TimeoutException { - long roomId = 1L; + void 사회자가_발생시킨_이벤트의_버전과_재생_여부를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.PLAY, + 1758500000789L, + new TimerEventInfoRequest( + CustomizeBoxType.NORMAL, + null, + 2, + 142, + true, + null, + null + ) + ); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + SharingResponse response = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + + assertAll( + () -> assertThat(response.eventType()).isEqualTo(TimerEventType.PLAY), + () -> assertThat(response.version()).isEqualTo(request.version()), + () -> assertThat(response.data().isRunning()).isTrue() + ); + } + + @Test + void 사회자가_아니면_이벤트를_발행할_수_없다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); SharingRequest request = new SharingRequest( TimerEventType.NEXT, + null, new TimerEventInfoRequest( CustomizeBoxType.NORMAL, null, 2, - 30 + 30, + null, + null, + null ) ); - stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 - stompSession.send("/app/event/" + roomId, request); //사회자의 이벤트 발생 + stompSession.subscribe("/room/" + ROOM_ID, handler); //청중의 구독 + stompSession.send("/app/event/" + ROOM_ID, request); //사회자의 이벤트 발생 assertThatThrownBy(() -> handler.getCompletableFuture() .get(2L, TimeUnit.SECONDS)) @@ -82,12 +139,11 @@ class Share { @Test void 사회자가_발생시킨_토론_종료_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { - long roomId = 1L; MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); Member member = memberGenerator.generate("example@email.com"); - StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + roomId, member); - SharingRequest request = new SharingRequest(TimerEventType.FINISHED, null); - stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest(TimerEventType.FINISHED, 1758500001000L, null); + stompSession.subscribe("/room/" + ROOM_ID, handler); //청중의 구독 stompSession.send(headers, request); //사회자의 이벤트 발생 @@ -96,27 +152,79 @@ class Share { assertAll( () -> assertThat(response.eventType()).isEqualTo(request.eventType()), + () -> assertThat(response.version()).isEqualTo(request.version()), () -> assertThat(response.data()).isNull() ); } + @Test + void 사회자가_토론을_종료하면_룸이_종료_상태가_된다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, new SharingRequest(TimerEventType.FINISHED, null, null)); + handler.getCompletableFuture().get(3L, TimeUnit.SECONDS); + + assertThat(sharingRoomRegistry.isFinished(ROOM_ID)).isTrue(); + } + + @Test + void 종료된_룸에서_사회자가_다른_이벤트를_발생시키면_룸이_다시_진행_상태가_된다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler chairmanHandler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + MessageFrameHandler audienceHandler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.SYNC, + 1758500000123L, + new TimerEventInfoRequest( + CustomizeBoxType.NORMAL, + null, + 0, + 180, + false, + null, + null + ) + ); + stompSession.subscribe("/chairman/" + ROOM_ID, chairmanHandler); + stompSession.subscribe("/room/" + ROOM_ID, audienceHandler); + chairmanHandler.getCompletableFuture().get(3L, TimeUnit.SECONDS); // 청중 구독 처리 완료 대기 + sharingRoomRegistry.markFinished(ROOM_ID); + + stompSession.send(headers, request); + + SharingResponse response = audienceHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertAll( + () -> assertThat(response.eventType()).isEqualTo(TimerEventType.SYNC), + () -> assertThat(sharingRoomRegistry.isFinished(ROOM_ID)).isFalse() + ); + } + @ParameterizedTest @NullSource void 타이머_타입은_빈_값일_수_없다(CustomizeBoxType boxType) { - long roomId = 1L; MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); Member member = memberGenerator.generate("example@email.com"); - StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + roomId, member); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); SharingRequest request = new SharingRequest( TimerEventType.NEXT, + null, new TimerEventInfoRequest( boxType, null, 2, - 30 + 30, + null, + null, + null ) ); - stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 + stompSession.subscribe("/room/" + ROOM_ID, handler); //청중의 구독 stompSession.send(headers, request); //사회자의 이벤트 발생 @@ -125,4 +233,154 @@ class Share { .isInstanceOf(TimeoutException.class); } } + + @Nested + class ShareSync { + + @Test + void 사회자가_발생시킨_일반_타이머_동기화_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.SYNC, + 1758500000123L, + new TimerEventInfoRequest( + CustomizeBoxType.NORMAL, + null, + 2, + 142, + true, + null, + null + ) + ); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + SharingResponse response = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + + assertAll( + () -> assertThat(response.eventType()).isEqualTo(TimerEventType.SYNC), + () -> assertThat(response.version()).isEqualTo(request.version()), + () -> assertThat(response.data().timerType()).isEqualTo(CustomizeBoxType.NORMAL), + () -> assertThat(response.data().sequence()).isEqualTo(request.data().sequence()), + () -> assertThat(response.data().currentTeam()).isNull(), + () -> assertThat(response.data().remainingTime()).isEqualTo(request.data().remainingTime()), + () -> assertThat(response.data().isRunning()).isTrue(), + () -> assertThat(response.data().prosRemainingTime()).isNull(), + () -> assertThat(response.data().consRemainingTime()).isNull() + ); + } + + @Test + void 사회자가_발생시킨_자유토론_동기화_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.SYNC, + 1758500000456L, + new TimerEventInfoRequest( + CustomizeBoxType.TIME_BASED, + Stance.CONS, + 4, + 20, + false, + 45L, + 38L + ) + ); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + SharingResponse response = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + + assertAll( + () -> assertThat(response.eventType()).isEqualTo(TimerEventType.SYNC), + () -> assertThat(response.version()).isEqualTo(request.version()), + () -> assertThat(response.data().timerType()).isEqualTo(CustomizeBoxType.TIME_BASED), + () -> assertThat(response.data().sequence()).isEqualTo(request.data().sequence()), + () -> assertThat(response.data().currentTeam()).isEqualTo(Stance.CONS), + () -> assertThat(response.data().remainingTime()).isEqualTo(request.data().remainingTime()), + () -> assertThat(response.data().isRunning()).isFalse(), + () -> assertThat(response.data().prosRemainingTime()).isEqualTo(45L), + () -> assertThat(response.data().consRemainingTime()).isEqualTo(38L) + ); + } + + @Test + void 동기화_이벤트는_데이터가_없으면_공유되지_않는다() { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest(TimerEventType.SYNC, 1758500000123L, null); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + assertThatThrownBy(() -> handler.getCompletableFuture() + .get(2L, TimeUnit.SECONDS)) + .isInstanceOf(TimeoutException.class); + } + + @Test + void 동기화_이벤트는_재생_여부가_없으면_공유되지_않는다() { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.SYNC, + 1758500000123L, + new TimerEventInfoRequest( + CustomizeBoxType.NORMAL, + null, + 2, + 142, + null, + null, + null + ) + ); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + assertThatThrownBy(() -> handler.getCompletableFuture() + .get(2L, TimeUnit.SECONDS)) + .isInstanceOf(TimeoutException.class); + } + + @ParameterizedTest + @CsvSource(value = {"null, 38", "45, null", "null, null"}, nullValues = "null") + void 자유토론_동기화_이벤트는_양_팀의_남은_시간이_없으면_공유되지_않는다(Long prosRemainingTime, Long consRemainingTime) { + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest request = new SharingRequest( + TimerEventType.SYNC, + 1758500000456L, + new TimerEventInfoRequest( + CustomizeBoxType.TIME_BASED, + Stance.CONS, + 4, + 20, + false, + prosRemainingTime, + consRemainingTime + ) + ); + stompSession.subscribe("/room/" + ROOM_ID, handler); + + stompSession.send(headers, request); + + assertThatThrownBy(() -> handler.getCompletableFuture() + .get(2L, TimeUnit.SECONDS)) + .isInstanceOf(TimeoutException.class); + } + } } diff --git a/src/test/java/com/debatetimer/domain/sharing/TimerEventDataTest.java b/src/test/java/com/debatetimer/domain/sharing/TimerEventDataTest.java index 1811b00e..76e4f941 100644 --- a/src/test/java/com/debatetimer/domain/sharing/TimerEventDataTest.java +++ b/src/test/java/com/debatetimer/domain/sharing/TimerEventDataTest.java @@ -1,5 +1,6 @@ package com.debatetimer.domain.sharing; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.debatetimer.domain.customize.CustomizeBoxType; @@ -8,6 +9,8 @@ import com.debatetimer.exception.errorcode.ClientErrorCode; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; class TimerEventDataTest { @@ -20,7 +23,10 @@ class ValidateCurrentTeam { CustomizeBoxType.NORMAL, 2, Stance.CONS, - 30L + 30L, + null, + null, + null )).isInstanceOf(DTClientErrorException.class) .hasMessage(ClientErrorCode.INVALID_NORMAL_TIMER_EVENT_DATA.getMessage()); } @@ -31,9 +37,83 @@ class ValidateCurrentTeam { CustomizeBoxType.TIME_BASED, 2, null, - 30L + 30L, + null, + null, + null )).isInstanceOf(DTClientErrorException.class) .hasMessage(ClientErrorCode.INVALID_TIME_BASED_TIMER_EVENT_DATA.getMessage()); } } + + @Nested + class ValidateSyncable { + + @Test + void 동기화_데이터는_재생_여부가_입력되어야_한다() { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.NORMAL, + 2, + null, + 30L, + null, + null, + null + ); + + assertThatThrownBy(timerEventData::validateSyncable) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA.getMessage()); + } + + @Test + void 일반_타이머는_재생_여부만_있으면_동기화할_수_있다() { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.NORMAL, + 2, + null, + 30L, + true, + null, + null + ); + + assertThatCode(timerEventData::validateSyncable) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @CsvSource(value = {"null, 38", "45, null", "null, null"}, nullValues = "null") + void 자유토론_타이머는_양_팀의_남은_시간이_입력되어야_한다(Long prosRemainingTime, Long consRemainingTime) { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.TIME_BASED, + 4, + Stance.CONS, + 20L, + false, + prosRemainingTime, + consRemainingTime + ); + + assertThatThrownBy(timerEventData::validateSyncable) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA.getMessage()); + } + + @Test + void 자유토론_타이머는_재생_여부와_양_팀의_남은_시간이_있으면_동기화할_수_있다() { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.TIME_BASED, + 4, + Stance.CONS, + 20L, + false, + 45L, + 38L + ); + + assertThatCode(timerEventData::validateSyncable) + .doesNotThrowAnyException(); + } + } } diff --git a/src/test/java/com/debatetimer/domain/sharing/TimerEventTest.java b/src/test/java/com/debatetimer/domain/sharing/TimerEventTest.java new file mode 100644 index 00000000..296fa08d --- /dev/null +++ b/src/test/java/com/debatetimer/domain/sharing/TimerEventTest.java @@ -0,0 +1,88 @@ +package com.debatetimer.domain.sharing; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class TimerEventTest { + + @Nested + class Validate { + + @Test + void 동기화_이벤트는_재생_여부가_없으면_생성할_수_없다() { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.NORMAL, + 2, + null, + 142L, + null, + null, + null + ); + + assertThatThrownBy(() -> new TimerEvent(TimerEventType.SYNC, timerEventData)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA.getMessage()); + } + + @Test + void 자유토론_동기화_이벤트는_양_팀의_남은_시간이_없으면_생성할_수_없다() { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.TIME_BASED, + 4, + Stance.CONS, + 20L, + false, + 45L, + null + ); + + assertThatThrownBy(() -> new TimerEvent(TimerEventType.SYNC, timerEventData)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_SYNC_TIMER_EVENT_DATA.getMessage()); + } + + @Test + void 동기화_이벤트는_데이터가_없으면_생성할_수_없다() { + assertThatThrownBy(() -> new TimerEvent(TimerEventType.SYNC)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TIMER_EVENT.getMessage()); + } + + @EnumSource( + value = TimerEventType.class, + names = { + "NEXT", + "BEFORE", + "STOP", + "PLAY", + "RESET", + "TEAM_SWITCH", + } + ) + @ParameterizedTest + void 동기화_이벤트가_아니면_새_필드가_없어도_생성할_수_있다(TimerEventType eventType) { + TimerEventData timerEventData = new TimerEventData( + CustomizeBoxType.TIME_BASED, + 4, + Stance.CONS, + 20L, + null, + null, + null + ); + + assertThatCode(() -> new TimerEvent(eventType, timerEventData)) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java b/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java index b080c112..954e4d7a 100644 --- a/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java +++ b/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java @@ -1,11 +1,13 @@ package com.debatetimer.domain.sharing; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.debatetimer.domain.customize.CustomizeBoxType; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -23,6 +25,7 @@ class ValidateData { "PLAY", "RESET", "TEAM_SWITCH", + "SYNC", } ) @ParameterizedTest @@ -40,11 +43,29 @@ class ValidateData { CustomizeBoxType.NORMAL, 2, null, - 30L + 30L, + null, + null, + null ); assertThatThrownBy(() -> eventType.validateEventData(timerEventData)) .isInstanceOf(DTClientErrorException.class) .hasMessage(ClientErrorCode.INVALID_TIMER_EVENT.getMessage()); } } + + @Nested + class IsSync { + + @Test + void 동기화_이벤트인지_판단한다() { + assertThat(TimerEventType.SYNC.isSync()).isTrue(); + } + + @EnumSource(value = TimerEventType.class, names = {"SYNC"}, mode = EnumSource.Mode.EXCLUDE) + @ParameterizedTest + void 동기화_이벤트가_아니면_거짓을_반환한다(TimerEventType eventType) { + assertThat(eventType.isSync()).isFalse(); + } + } } diff --git a/src/test/java/com/debatetimer/event/sharing/RoomSubscribeInterceptorTest.java b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeInterceptorTest.java new file mode 100644 index 00000000..d56e94d3 --- /dev/null +++ b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeInterceptorTest.java @@ -0,0 +1,221 @@ +package com.debatetimer.event.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.BaseStompTest; +import com.debatetimer.MessageFrameHandler; +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.sharing.TimerEventType; +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import com.debatetimer.dto.sharing.request.SharingRequest; +import com.debatetimer.dto.sharing.request.TimerEventInfoRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +import com.debatetimer.service.sharing.SharingRoomRegistry; +import java.lang.reflect.Type; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; + +class RoomSubscribeInterceptorTest extends BaseStompTest { + + private static final long ROOM_ID = 1L; + + @Autowired + private SharingRoomRegistry sharingRoomRegistry; + + @AfterEach + void reopenRoom() { + sharingRoomRegistry.reopen(ROOM_ID); + } + + @Nested + class AfterMessageHandled { + + @Test + void 새로운_청중이_공유되면_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler handler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + stompSession.subscribe("/chairman/" + ROOM_ID, handler); + + stompSession.subscribe("/room/" + ROOM_ID, new MessageFrameHandler<>(SharingResponse.class)); + + ChairmanSharingRequest sharingRequest = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(sharingRequest.roomId()).isEqualTo(ROOM_ID); + } + + @Test + void 새로운_청중이_구독해도_진행_중인_룸에는_서버가_이벤트를_보내지_않는다() { + MessageFrameHandler audienceHandler = new MessageFrameHandler<>(SharingResponse.class); + + stompSession.subscribe("/room/" + ROOM_ID, audienceHandler); + + assertThatThrownBy(() -> audienceHandler.getCompletableFuture() + .get(2L, TimeUnit.SECONDS)) + .isInstanceOf(TimeoutException.class); + } + + @Test + void 종료된_룸에_청중이_구독하면_토론_종료_이벤트를_보낸다() throws ExecutionException, InterruptedException, TimeoutException { + sharingRoomRegistry.markFinished(ROOM_ID); + MessageFrameHandler audienceHandler = new MessageFrameHandler<>(SharingResponse.class); + + stompSession.subscribe("/room/" + ROOM_ID, audienceHandler); + + SharingResponse response = audienceHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertAll( + () -> assertThat(response.eventType()).isEqualTo(TimerEventType.FINISHED), + () -> assertThat(response.version()).isNull(), + () -> assertThat(response.data()).isNull() + ); + } + + @Test + void 종료된_룸에_청중이_구독하면_사회자에게_정보공유_트리거를_발송하지_않는다() throws InterruptedException { + QueueFrameHandler chairmanHandler = new QueueFrameHandler<>( + ChairmanSharingRequest.class); + stompSession.subscribe("/chairman/" + ROOM_ID, chairmanHandler); + stompSession.subscribe("/room/" + ROOM_ID, new MessageFrameHandler<>(SharingResponse.class)); + assertThat(chairmanHandler.poll(3L)).isNotNull(); // 사회자 구독 처리 완료 대기 + sharingRoomRegistry.markFinished(ROOM_ID); + + stompSession.subscribe("/room/" + ROOM_ID, new MessageFrameHandler<>(SharingResponse.class)); + + assertThat(chairmanHandler.poll(2L)).isNull(); + } + + @Test + void 사회자가_구독하면_종료된_룸을_다시_진행_상태로_되돌린다() throws InterruptedException { + sharingRoomRegistry.markFinished(ROOM_ID); + + stompSession.subscribe("/chairman/" + ROOM_ID, new MessageFrameHandler<>(ChairmanSharingRequest.class)); + + assertThat(awaitReopened(3L)).isTrue(); + } + + @Test + void 종료된_룸에_사회자가_다시_구독하면_새로운_청중에_대해_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { + sharingRoomRegistry.markFinished(ROOM_ID); + MessageFrameHandler chairmanHandler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + stompSession.subscribe("/chairman/" + ROOM_ID, chairmanHandler); + assertThat(awaitReopened(3L)).isTrue(); + + stompSession.subscribe("/room/" + ROOM_ID, new MessageFrameHandler<>(SharingResponse.class)); + + ChairmanSharingRequest sharingRequest = chairmanHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(sharingRequest.roomId()).isEqualTo(ROOM_ID); + } + + @Test + void 사회자가_토론을_종료한_뒤_입장한_청중은_서버로부터_토론_종료_이벤트를_받는다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler existingAudienceHandler = new MessageFrameHandler<>( + SharingResponse.class); + MessageFrameHandler newAudienceHandler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + stompSession.subscribe("/room/" + ROOM_ID, existingAudienceHandler); + stompSession.send(headers, new SharingRequest(TimerEventType.FINISHED, 100L, null)); + SharingResponse chairmanFinished = existingAudienceHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + + stompSession.subscribe("/room/" + ROOM_ID, newAudienceHandler); + + SharingResponse serverFinished = newAudienceHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertAll( + () -> assertThat(chairmanFinished.version()).isEqualTo(100L), + () -> assertThat(serverFinished.eventType()).isEqualTo(TimerEventType.FINISHED), + () -> assertThat(serverFinished.version()).isNull(), + () -> assertThat(serverFinished.data()).isNull() + ); + } + + @Test + void 종료된_룸을_사회자가_다시_진행하면_새로운_청중에_대해_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { + MessageFrameHandler firstChairmanHandler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + MessageFrameHandler secondChairmanHandler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + MessageFrameHandler existingAudienceHandler = new MessageFrameHandler<>( + SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateChairmanTokenHeader("/app/event/" + ROOM_ID, member); + SharingRequest playRequest = new SharingRequest( + TimerEventType.PLAY, + 200L, + new TimerEventInfoRequest( + CustomizeBoxType.NORMAL, + null, + 0, + 180, + true, + null, + null + ) + ); + stompSession.subscribe("/chairman/" + ROOM_ID, firstChairmanHandler); + stompSession.subscribe("/room/" + ROOM_ID, existingAudienceHandler); + firstChairmanHandler.getCompletableFuture().get(3L, TimeUnit.SECONDS); // 기존 청중 구독 처리 완료 대기 + sharingRoomRegistry.markFinished(ROOM_ID); + stompSession.send(headers, playRequest); + existingAudienceHandler.getCompletableFuture().get(3L, TimeUnit.SECONDS); // 사회자 이벤트 처리 완료 대기 + stompSession.subscribe("/chairman/" + ROOM_ID, secondChairmanHandler); + + stompSession.subscribe("/room/" + ROOM_ID, new MessageFrameHandler<>(SharingResponse.class)); + + ChairmanSharingRequest sharingRequest = secondChairmanHandler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(sharingRequest.roomId()).isEqualTo(ROOM_ID); + } + } + + private boolean awaitReopened(long timeoutSeconds) throws InterruptedException { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); + while (System.currentTimeMillis() < deadline) { + if (!sharingRoomRegistry.isFinished(ROOM_ID)) { + return true; + } + Thread.sleep(50L); + } + return !sharingRoomRegistry.isFinished(ROOM_ID); + } + + private static class QueueFrameHandler implements StompFrameHandler { + + private final BlockingQueue messages = new LinkedBlockingQueue<>(); + private final Class tClass; + + QueueFrameHandler(Class tClass) { + this.tClass = tClass; + } + + @Override + public Type getPayloadType(StompHeaders headers) { + return tClass; + } + + @Override + @SuppressWarnings("unchecked") + public void handleFrame(StompHeaders headers, Object payload) { + messages.add((T) payload); + } + + T poll(long timeoutSeconds) throws InterruptedException { + return messages.poll(timeoutSeconds, TimeUnit.SECONDS); + } + } +} diff --git a/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java deleted file mode 100644 index b8e2bba7..00000000 --- a/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.debatetimer.event.sharing; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.debatetimer.BaseStompTest; -import com.debatetimer.MessageFrameHandler; -import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class RoomSubscribeListenerTest extends BaseStompTest { - - @Nested - class SubscribeListener { - - @Test - void 새로운_청중이_공유되면_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { - long roomId = 1L; - MessageFrameHandler handler = new MessageFrameHandler<>( - ChairmanSharingRequest.class); - stompSession.subscribe("/chairman/" + roomId, handler); - - stompSession.subscribe("/room/" + roomId, handler); - - ChairmanSharingRequest sharingRequest = handler.getCompletableFuture() - .get(3L, TimeUnit.SECONDS); - assertThat(sharingRequest).isNotNull(); - } - } -} diff --git a/src/test/java/com/debatetimer/service/sharing/SharingRoomRegistryTest.java b/src/test/java/com/debatetimer/service/sharing/SharingRoomRegistryTest.java new file mode 100644 index 00000000..484f93ea --- /dev/null +++ b/src/test/java/com/debatetimer/service/sharing/SharingRoomRegistryTest.java @@ -0,0 +1,140 @@ +package com.debatetimer.service.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SharingRoomRegistryTest { + + private MutableClock clock; + private SharingRoomRegistry sharingRoomRegistry; + + @BeforeEach + void setUp() { + clock = new MutableClock(Instant.parse("2026-09-22T10:00:00Z")); + sharingRoomRegistry = new SharingRoomRegistry(clock); + } + + @Nested + class IsFinished { + + @Test + void 종료_처리된_룸은_종료_상태이다() { + sharingRoomRegistry.markFinished(1L); + + assertThat(sharingRoomRegistry.isFinished(1L)).isTrue(); + } + + @Test + void 종료_처리되지_않은_룸은_종료_상태가_아니다() { + sharingRoomRegistry.markFinished(1L); + + assertThat(sharingRoomRegistry.isFinished(2L)).isFalse(); + } + + @Test + void 종료_유지_시간_직전까지는_종료_상태이다() { + sharingRoomRegistry.markFinished(1L); + + clock.advance(SharingRoomRegistry.FINISHED_TTL.minusSeconds(1)); + + assertThat(sharingRoomRegistry.isFinished(1L)).isTrue(); + } + + @Test + void 종료_유지_시간이_지나면_종료_상태가_아니다() { + sharingRoomRegistry.markFinished(1L); + + clock.advance(SharingRoomRegistry.FINISHED_TTL); + + assertThat(sharingRoomRegistry.isFinished(1L)).isFalse(); + } + + @Test + void 다시_종료_처리하면_종료_유지_시간이_갱신된다() { + sharingRoomRegistry.markFinished(1L); + clock.advance(SharingRoomRegistry.FINISHED_TTL.minusSeconds(1)); + + sharingRoomRegistry.markFinished(1L); + clock.advance(Duration.ofSeconds(1)); + + assertThat(sharingRoomRegistry.isFinished(1L)).isTrue(); + } + } + + @Nested + class Reopen { + + @Test + void 종료된_룸을_다시_열면_종료_상태가_아니다() { + sharingRoomRegistry.markFinished(1L); + + sharingRoomRegistry.reopen(1L); + + assertThat(sharingRoomRegistry.isFinished(1L)).isFalse(); + } + + @Test + void 종료되지_않은_룸을_다시_열어도_예외가_발생하지_않는다() { + sharingRoomRegistry.reopen(1L); + + assertThat(sharingRoomRegistry.isFinished(1L)).isFalse(); + } + } + + @Nested + class RemoveExpired { + + @Test + void 종료_유지_시간이_지난_룸만_제거한다() { + sharingRoomRegistry.markFinished(1L); + clock.advance(Duration.ofHours(1)); + sharingRoomRegistry.markFinished(2L); + clock.advance(SharingRoomRegistry.FINISHED_TTL.minusMinutes(30)); + + sharingRoomRegistry.removeExpired(); + clock.advance(Duration.ofHours(-1)); // 제거되지 않았다면 다시 종료 상태로 보이는 시점 + + assertAll( + () -> assertThat(sharingRoomRegistry.isFinished(1L)).isFalse(), + () -> assertThat(sharingRoomRegistry.isFinished(2L)).isTrue() + ); + } + } + + private static class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +}