diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java index 90cf25a..6dc9551 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -32,7 +32,8 @@ public enum ErrorCode { // queue QUEUE_INSERT_FAIL(500, "대기열 등록을 실패했습니다."), - NOT_TICKETING_TIME(400, "예매 가능 시간이 아닙니다."); + NOT_TICKETING_TIME(400, "예매 가능 시간이 아닙니다."), + PRECONDITION_REQUIRED(400, "선행 조건이 수행되지 않았습니다."); private HttpStatus code; private String message; diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java index f520b0e..108b724 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java @@ -27,12 +27,9 @@ public class CheckExpiredScheduler { private final Set keys = new HashSet<>(); - // 1시간마다 티켓팅 기간인 schedule을 keys에 저장 @Scheduled(cron = "0 0 * * * *") public void checkOpenedSchedule() { - keys.clear(); - List openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore( LocalDateTime.now(), LocalDateTime.now()).stream() diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index 41be5ad..5ef1f0c 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -110,11 +110,12 @@ public void sendWaitingNumber(String key, String username, Long scheduleId) { String destination = "/topic/queue/" + scheduleId + "/" + username; MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, true); - messagingTemplate.convertAndSend(destination, response); addSelectingQueue(scheduleId, username); deleteWaitingUser(scheduleId, username); + messagingTemplate.convertAndSend(destination, response); + return; } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 85baad7..4f2ebe3 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -1,173 +1,192 @@ package org.example.siljeun.domain.seatscheduleinfo.service; -import jakarta.persistence.EntityNotFoundException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.exception.ErrorCode; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.domain.seat.entity.Seat; -import org.example.siljeun.domain.seat.repository.SeatRepository; -import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; -import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.user.entity.User; +import org.example.siljeun.domain.user.repository.UserRepository; import org.example.siljeun.global.lock.DistributedLock; import org.example.siljeun.global.util.RedisKeyProvider; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.*; @Slf4j @Service @RequiredArgsConstructor public class SeatScheduleInfoService { - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final ScheduleRepository scheduleRepository; - private final RedisTemplate redisTemplate; - - @DistributedLock(key = "'seat:' + #seatScheduleInfoId") - public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { - //예외 상황 처리 - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisTemplate; + private final WaitingQueueService waitingQueueService; + private final UserRepository userRepository; - Schedule schedule = seatScheduleInfo.getSchedule(); - if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - throw new CustomException(ErrorCode.NOT_TICKETING_TIME); - } + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") + public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { - if (!seatScheduleInfo.isAvailable()) { - throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); - } + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); - String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); - if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { - throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); - } - - //DB 상태 변경 - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); + //대기열을 거쳐서 요청했는지 검증 (정상적인 요청인지 검증) + boolean hasPassed = waitingQueueService.hasPassedWaitingQueue(scheduleId, user.getUsername()); + if (!hasPassed) { + throw new CustomException(ErrorCode.PRECONDITION_REQUIRED); + } - //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) - redisTemplate.opsForValue() - .set(redisSelectedKey, seatScheduleInfoId.toString()); - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); + //예외 상황 처리 + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); - //TTL 관리를 위한 키 생성 - String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); - redisTemplate.opsForValue().set(redisLockKey, userId.toString()); + Schedule schedule = seatScheduleInfo.getSchedule(); + if (schedule.getTicketingStartTime().isAfter(LocalDateTime.now())) { + throw new CustomException(ErrorCode.NOT_TICKETING_TIME); + } - //Redis 상태 변경 - updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + if (!seatScheduleInfo.isAvailable()) { + throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); + } - //TTL 적용 - applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); + String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); + if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { + throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); } - public Map getSeatStatusMap(Long scheduleId) { + //DB 상태 변경 + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) + redisTemplate.opsForValue() + .set(redisSelectedKey, seatScheduleInfoId.toString()); + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - if(seatScheduleInfos.isEmpty()){ - throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); - } + //TTL 관리를 위한 키 생성 + String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + redisTemplate.opsForValue().set(redisLockKey, userId.toString()); - List fieldKeys = seatScheduleInfos.stream() - .map(info -> info.getId().toString()) - .toList(); + //Redis 상태 변경 + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); + //TTL 적용 + applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); - Map seatStatusMap = new HashMap<>(); - for (int i = 0; i < seatScheduleInfos.size(); i++) { - SeatScheduleInfo info = seatScheduleInfos.get(i); - Object redisStatusObj = redisStatuses.get(i); + //좌석 선택 queue에서 데이터 삭제 + waitingQueueService.addSelectingQueue(scheduleId, user.getUsername()); + } - String status = redisStatusObj != null - ? redisStatusObj.toString() - : seatScheduleInfos.get(i).getStatus().name(); + public Map getSeatStatusMap(Long scheduleId) { - seatStatusMap.put(info.getId().toString(), status); - } + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); - return seatStatusMap; + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule( + schedule); + if (seatScheduleInfos.isEmpty()) { + throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); } - public void forceSeatScheduleInfoInRedis(Long scheduleId){ - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + List fieldKeys = seatScheduleInfos.stream() + .map(info -> info.getId().toString()) + .toList(); - List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + List redisStatuses = redisTemplate.opsForHash() + .multiGet(redisKey, new ArrayList<>(fieldKeys)); - String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); - Map seatStatusMap = new HashMap<>(); + Map seatStatusMap = new HashMap<>(); + for (int i = 0; i < seatScheduleInfos.size(); i++) { + SeatScheduleInfo info = seatScheduleInfos.get(i); + Object redisStatusObj = redisStatuses.get(i); - for (SeatScheduleInfo seat : seatInfos) { - seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); - } + String status = redisStatusObj != null + ? redisStatusObj.toString() + : seatScheduleInfos.get(i).getStatus().name(); - redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); - } - @Transactional - public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); - - Long scheduleId = seatScheduleInfo.getSchedule().getId(); - updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + seatStatusMap.put(info.getId().toString(), status); } - public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - String fieldKey = seatScheduleInfoId.toString(); - redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus.name()); - } + return seatStatusMap; + } + + public void forceSeatScheduleInfoInRedis(Long scheduleId) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + + List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ - String member = seatScheduleInfoId.toString(); - - String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); - String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); - String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); - - Duration ttl = null; - long nowMillis = System.currentTimeMillis(); - - redisTemplate.opsForZSet().remove(zsetSelectedKey, member); - redisTemplate.opsForZSet().remove(zsetHoldKey, member); - - switch(seatStatus){ - case SELECTED: - ttl = Duration.ofMinutes(5); - redisTemplate.expire(seatLockkey, ttl); - redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); - break; - case HOLD: - ttl = Duration.ofMinutes(60); - redisTemplate.expire(seatLockkey, ttl); - redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis+ttl.toMillis()); - break; - default: - redisTemplate.persist(seatLockkey); - break; - } + String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); + Map seatStatusMap = new HashMap<>(); + + for (SeatScheduleInfo seat : seatInfos) { + seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); } - public SeatScheduleInfo findById(Long seatScheduleInfoId){ - return seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); + } + + @Transactional + public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus) { + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + } + + public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, + SeatStatus seatStatus) { + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + String fieldKey = seatScheduleInfoId.toString(); + redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus.name()); + } + + public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus) { + String member = seatScheduleInfoId.toString(); + + String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); + String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); + + Duration ttl = null; + long nowMillis = System.currentTimeMillis(); + + redisTemplate.opsForZSet().remove(zsetSelectedKey, member); + redisTemplate.opsForZSet().remove(zsetHoldKey, member); + + switch (seatStatus) { + case SELECTED: + ttl = Duration.ofMinutes(5); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis + ttl.toMillis()); + break; + case HOLD: + ttl = Duration.ofMinutes(60); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis + ttl.toMillis()); + break; + default: + redisTemplate.persist(seatLockkey); + break; } + } + + public SeatScheduleInfo findById(Long seatScheduleInfoId) { + return seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + } } diff --git a/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java new file mode 100644 index 0000000..53bd68c --- /dev/null +++ b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java @@ -0,0 +1,63 @@ +package org.example.siljeun.domain.reservation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +class WaitingQueueServiceTest { + + private final WaitingQueueService waitingQueueService; + private final StringRedisTemplate redisTemplate; + + @Autowired + WaitingQueueServiceTest(WaitingQueueService waitingQueueService, + StringRedisTemplate redisTemplate) { + this.waitingQueueService = waitingQueueService; + this.redisTemplate = redisTemplate; + } + + @Test + @Transactional + void _1001명_대기시_1001번째_유저가_대기0순위() { + // given + ZSetOperations zSet = redisTemplate.opsForZSet(); + + for (int i = 1; i <= 1001; i++) { + waitingQueueService.addWaitingQueue(1L, "user" + i); + } + + // when + Long rank = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1001"); + + // then + assertThat(rank).isEqualTo(0); + } + + @Test + @Transactional + void selectingQueue에서_1명나가면_1002번이_대기0순위() { + // given + ZSetOperations zSet = redisTemplate.opsForZSet(); + waitingQueueService.addWaitingQueue(1L, "user1002"); + + // when + waitingQueueService.deleteSelectingUser(1L, "user1000"); + + Long rank1001AtWaiting = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, + "user1001"); + Long rank1001AtSelecting = zSet.rank(waitingQueueService.prefixKeyForSelecingQueue + 1L, + "user1001"); + Long rank1002 = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1002"); + + // then + assertThat(rank1001AtWaiting).isEqualTo(null); + assertThat(rank1001AtSelecting).isEqualTo(999); + assertThat(rank1002).isEqualTo(0); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java index e85fc3c..4ef6d98 100644 --- a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java +++ b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java @@ -1,13 +1,21 @@ package org.example.siljeun.global.queueing; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Type; import java.net.URI; +import java.net.URISyntaxException; import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; import org.example.siljeun.domain.concert.repository.ConcertRepository; +import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.venue.entity.Venue; @@ -20,16 +28,17 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@ActiveProfiles("test") +//@ActiveProfiles("test") public class WebSocketTest { @LocalServerPort @@ -44,6 +53,9 @@ public class WebSocketTest { @Autowired private ConcertRepository concertRepository; + @Autowired + private WaitingQueueService waitingQueueService; + @Autowired private JwtUtil jwtUtil; @@ -71,11 +83,10 @@ void socket_connection_test() throws Exception { stompClient.setMessageConverter(new MappingJackson2MessageConverter()); URI uri = new URI( - "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId() + "&token=" - + validToken); + "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId()); WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); - webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); + webSocketHttpHeaders.add("Authorization", validToken); StompHeaders stompHeaders = new StompHeaders(); @@ -86,4 +97,54 @@ void socket_connection_test() throws Exception { assertTrue(session.isConnected()); } + + @Test + @Transactional + void 대기번호_응답_성공() + throws URISyntaxException, ExecutionException, InterruptedException, TimeoutException { + // given + // 소켓 연결 + WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + URI uri = new URI( + "ws://localhost:" + port + "/ws?scheduleId=1"); + WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); + webSocketHttpHeaders.add("Authorization", validToken); + + StompHeaders stompHeaders = new StompHeaders(); + StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, + new StompSessionHandlerAdapter() { + } + ).get(5, TimeUnit.SECONDS); + + // 메시지 수신 대기용 변수 + CompletableFuture completableFuture = new CompletableFuture<>(); + + String destination = "/topic/queue/1/user1002"; + session.subscribe(destination, new StompFrameHandler() { + + @Override + public Type getPayloadType(StompHeaders headers) { + return MyQueueInfoResponse.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + completableFuture.complete((MyQueueInfoResponse) payload); + } + }); + + // key 설정 + String key = waitingQueueService.prefixKeyForWaitingQueue + 1L; + + // when + waitingQueueService.sendWaitingNumber(key, "user1002", 1L); + + // then + MyQueueInfoResponse response = completableFuture.get(5, TimeUnit.SECONDS); // 메시지 수신 대기 + assertThat(response.username()).isEqualTo("user1002"); + assertThat(response.scheduleId()).isEqualTo(1); + assertThat(response.rank()).isGreaterThanOrEqualTo(1); + } }