diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ce73e9b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Tests + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + cache: gradle + - name: Install Redis + run: | + sudo apt-get update + sudo apt-get install -y redis-server + - name: Run tests + run: ./gradlew test + - name: Verify Redis integration tests ran + run: | + python3 - <<'PYTHON' + import xml.etree.ElementTree as ET + report = 'build/test-results/test/TEST-com.person.repit.interview.service.InterviewResultRedisTest.xml' + suite = ET.parse(report).getroot() + assert int(suite.attrib['tests']) > 0, 'Redis integration tests did not run' + assert int(suite.attrib['skipped']) == 0, 'Redis integration tests were skipped' + PYTHON + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-reports + path: | + build/reports/tests/test/ + build/test-results/test/ diff --git a/src/main/java/com/person/repit/interview/dto/request/ChatAnswerRequest.java b/src/main/java/com/person/repit/interview/dto/request/ChatAnswerRequest.java index 5564637..4512582 100644 --- a/src/main/java/com/person/repit/interview/dto/request/ChatAnswerRequest.java +++ b/src/main/java/com/person/repit/interview/dto/request/ChatAnswerRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.*; @Getter @@ -14,6 +15,7 @@ public class ChatAnswerRequest { private Long questionId; @NotNull + @PositiveOrZero private Integer responseTime; @NotBlank diff --git a/src/main/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequest.java b/src/main/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequest.java index 8668f44..f2add70 100644 --- a/src/main/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequest.java +++ b/src/main/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequest.java @@ -52,11 +52,11 @@ public class ChatInterviewPrepareRequest { private Company company; @Valid - private List personas; + private List<@NotNull Persona> personas; @Valid @NotEmpty - private List questions; + private List<@NotNull Question> questions; @Getter @Setter diff --git a/src/main/java/com/person/repit/interview/dto/request/ChatWebSocketAnswerMessageRequest.java b/src/main/java/com/person/repit/interview/dto/request/ChatWebSocketAnswerMessageRequest.java index b982859..eeba105 100644 --- a/src/main/java/com/person/repit/interview/dto/request/ChatWebSocketAnswerMessageRequest.java +++ b/src/main/java/com/person/repit/interview/dto/request/ChatWebSocketAnswerMessageRequest.java @@ -3,6 +3,7 @@ import com.person.repit.common.type.MessageType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.Getter; import lombok.Setter; @@ -16,6 +17,7 @@ public class ChatWebSocketAnswerMessageRequest { private Long questionId; @NotNull + @PositiveOrZero private Integer responseTime; @NotBlank diff --git a/src/main/java/com/person/repit/interview/service/impl/AiQuestionClientImpl.java b/src/main/java/com/person/repit/interview/service/impl/AiQuestionClientImpl.java index cd433e7..7dc913c 100644 --- a/src/main/java/com/person/repit/interview/service/impl/AiQuestionClientImpl.java +++ b/src/main/java/com/person/repit/interview/service/impl/AiQuestionClientImpl.java @@ -105,7 +105,10 @@ private FollowQuestionAiResponse parseResponse(String body) { FollowQuestionAiResponse result = objectMapper.readValue(json, FollowQuestionAiResponse.class); - if (result.getRequired() == null) { + if (result == null || result.getRequired() == null + || (Boolean.TRUE.equals(result.getRequired()) + && (!StringUtils.hasText(result.getContent()) + || !StringUtils.hasText(result.getExpectedAnswer())))) { result = FollowQuestionAiResponse.notRequired(); } diff --git a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java index 98b4216..590aa11 100644 --- a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java +++ b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java @@ -65,6 +65,7 @@ public class ChatInterviewServiceImpl implements ChatInterviewService { @Override public Mono prepareInterview(ChatInterviewPrepareRequest request) { return Mono.defer(() -> { + validateQuestionIds(request); validatePersonas(request); log.debug("sessionId={}, interviewId={}", request.getSessionId(), request.getInterviewId()); String key = createKey(request.getSessionId()); @@ -402,6 +403,15 @@ private String firstText(String... values) { return null; } + private void validateQuestionIds(ChatInterviewPrepareRequest request) { + var ids = new HashSet(); + for (ChatInterviewPrepareRequest.Question question : request.getQuestions()) { + if (!ids.add(question.getId())) { + throw new InvalidRequestException("질문 ID가 중복되었습니다."); + } + } + } + private void validatePersonas(ChatInterviewPrepareRequest request) { if (request.getPersonas() == null) { return; diff --git a/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java b/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java index f009648..3fe8367 100644 --- a/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java +++ b/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java @@ -93,6 +93,9 @@ private Mono processMessage(String sessionId, String payload) { return Mono.just(error("메시지 형식이 올바르지 않습니다.")); } + if (request == null) { + return Mono.just(error("메시지 형식이 올바르지 않습니다.")); + } if (request.getType() == null) { return Mono.just(error("메시지 타입이 필요합니다.")); } @@ -119,6 +122,9 @@ private Mono processAnswer(String sessionId, String payload) { if (message.getResponseTime() == null) { return Mono.just(error("답변 응답 시간이 존재하지 않습니다.")); } + if (message.getResponseTime() < 0) { + return Mono.just(error("답변 응답 시간은 0 이상이어야 합니다.")); + } if (message.getContent() == null || message.getContent().isBlank()) { return Mono.just(error("답변 내용이 존재하지 않습니다.")); } diff --git a/src/test/java/com/person/repit/interview/dto/request/ChatAnswerRequestTest.java b/src/test/java/com/person/repit/interview/dto/request/ChatAnswerRequestTest.java new file mode 100644 index 0000000..6f4f00a --- /dev/null +++ b/src/test/java/com/person/repit/interview/dto/request/ChatAnswerRequestTest.java @@ -0,0 +1,56 @@ +package com.person.repit.interview.dto.request; + +import com.person.repit.common.exception.handler.GlobalExceptionHandler; +import com.person.repit.interview.controller.ChatInterviewController; +import com.person.repit.interview.dto.response.ChatProgressResponse; +import com.person.repit.interview.service.ChatInterviewService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ChatAnswerRequestTest { + private ChatInterviewService service; + private WebTestClient client; + + @BeforeEach + void setUp() { + service = mock(ChatInterviewService.class); + when(service.submitAnswer(eq("s"), any())).thenReturn(Mono.just(ChatProgressResponse.end())); + client = WebTestClient.bindToController(new ChatInterviewController(service)) + .controllerAdvice(new GlobalExceptionHandler()).build(); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "-2147483648", "null"}) + void 음수이거나_없는_응답_시간은_서비스_호출_전에_거절한다(String responseTime) { + client.post().uri("/chat/interviews/s/answers").contentType(MediaType.APPLICATION_JSON) + .bodyValue(answer(responseTime)).exchange() + .expectStatus().isBadRequest().expectBody() + .jsonPath("$.code").isEqualTo("VALIDATION_FAILED") + .jsonPath("$.errors.responseTime").exists(); + + verify(service, never()).submitAnswer(any(), any()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, Integer.MAX_VALUE}) + void 음수가_아닌_응답_시간은_허용한다(int responseTime) { + client.post().uri("/chat/interviews/s/answers").contentType(MediaType.APPLICATION_JSON) + .bodyValue(answer(Integer.toString(responseTime))).exchange().expectStatus().isOk(); + + verify(service).submitAnswer(eq("s"), any()); + } + + private String answer(String responseTime) { + return """ + {"questionId":1,"responseTime":%s,"content":"답변"} + """.formatted(responseTime); + } +} diff --git a/src/test/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequestTest.java b/src/test/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequestTest.java index 0a63a9c..79bf4f7 100644 --- a/src/test/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequestTest.java +++ b/src/test/java/com/person/repit/interview/dto/request/ChatInterviewPrepareRequestTest.java @@ -1,5 +1,7 @@ package com.person.repit.interview.dto.request; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.person.repit.common.exception.handler.GlobalExceptionHandler; import com.person.repit.interview.controller.ChatInterviewController; import com.person.repit.interview.service.ChatInterviewService; @@ -60,6 +62,42 @@ void setUp() { verify(service, never()).prepareInterview(any()); } + @ParameterizedTest + @MethodSource("nullListElements") + void 배열_안의_null은_서비스_호출_전에_400으로_거절한다( + String field, String elements, String errorField + ) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode body = (ObjectNode) mapper.readTree(request("")); + body.set(field, mapper.readTree(elements)); + + client.post().uri("/chat/interviews").contentType(MediaType.APPLICATION_JSON) + .bodyValue(body.toString()).exchange().expectStatus().isBadRequest().expectBody() + .jsonPath("$.code").isEqualTo("VALIDATION_FAILED") + .jsonPath("$.errors['" + errorField + "']").exists(); + verify(service, never()).prepareInterview(any()); + } + + @Test + void 면접관_목록이_null인_기존_요청도_허용한다() { + client.post().uri("/chat/interviews").contentType(MediaType.APPLICATION_JSON) + .bodyValue(request(",\"personas\":null")).exchange().expectStatus().isOk(); + verify(service).prepareInterview(any()); + } + + private static Stream nullListElements() { + String question = "{\"id\":1,\"question\":\"질문\",\"personaId\":3}"; + String persona = """ + {"personaId":3,"role":"HR","type":"FRIENDLY","tone":"GENTLE","level":"NORMAL"} + """; + return Stream.of( + Arguments.of("questions", "[null]", "questions[0]"), + Arguments.of("questions", "[" + question + ",null]", "questions[1]"), + Arguments.of("personas", "[null]", "personas[0]"), + Arguments.of("personas", "[" + persona + ",null]", "personas[1]") + ); + } + private static Stream invalidCompanies() { return Stream.of( Arguments.of("{}", "displayName"), diff --git a/src/test/java/com/person/repit/interview/service/impl/AiQuestionClientResponseTest.java b/src/test/java/com/person/repit/interview/service/impl/AiQuestionClientResponseTest.java new file mode 100644 index 0000000..9533b86 --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/AiQuestionClientResponseTest.java @@ -0,0 +1,85 @@ +package com.person.repit.interview.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.dto.request.FollowQuestionAiRequest; +import com.person.repit.interview.service.AiRequestConcurrencyLimiter; +import com.person.repit.interview.type.InterviewTone; +import com.person.repit.interview.type.InterviewType; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiQuestionClientResponseTest { + @ParameterizedTest + @ValueSource(strings = { + "null", "{}", "{\"required\":true}", + "{\"required\":true,\"content\":null,\"expectedAnswer\":\"답안\"}", + "{\"required\":true,\"content\":\"\",\"expectedAnswer\":\"답안\"}", + "{\"required\":true,\"content\":\" \",\"expectedAnswer\":\"답안\"}", + "{\"required\":true,\"content\":\"질문\"}", + "{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":null}", + "{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":\"\"}", + "{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":\" \\n\\t\"}" + }) + void 불완전한_AI_응답은_꼬리질문을_생성하지_않는다(String text) throws Exception { + StepVerifier.create(client(text).decideFollowQuestion(request())) + .assertNext(response -> { + assertThat(response.getRequired()).isFalse(); + assertThat(response.getContent()).isNull(); + assertThat(response.getExpectedAnswer()).isNull(); + }) + .verifyComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 정상_AI_응답은_판단과_내용을_유지한다(boolean required) throws Exception { + String text = """ + {"score":2,"required":%s,"intention":"선택 근거 확인", + "content":"왜 선택했나요?","spokenText":"왜 선택했나요?","expectedAnswer":"선택 근거"} + """.formatted(required); + + StepVerifier.create(client(text).decideFollowQuestion(request())) + .assertNext(response -> { + assertThat(response.getRequired()).isEqualTo(required); + assertThat(response.getScore()).isEqualTo(2); + assertThat(response.getContent()).isEqualTo("왜 선택했나요?"); + assertThat(response.getExpectedAnswer()).isEqualTo("선택 근거"); + assertThat(response.getIntention()).isEqualTo("선택 근거 확인"); + assertThat(response.getSpokenText()).isEqualTo("왜 선택했나요?"); + }) + .verifyComplete(); + } + + private AiQuestionClientImpl client(String text) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String body = mapper.writeValueAsString(Map.of("content", List.of(Map.of("type", "text", "text", text)))); + WebClient webClient = WebClient.builder().baseUrl("http://localhost") + .exchangeFunction(request -> Mono.just(ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(body).build())) + .build(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + return new AiQuestionClientImpl(mapper, "stub", 800, webClient, + new RepitMetrics(registry), new AiRequestConcurrencyLimiter(1, registry)); + } + + private FollowQuestionAiRequest request() { + return FollowQuestionAiRequest.builder().sessionId("s").interviewId(1L).userId(2L).personaId(3L) + .personality(InterviewType.FRIENDLY).tone(InterviewTone.GENTLE) + .questionId(4L).questionContent("질문").expectedAnswer("답안").answerContent("답변") + .responseTime(10).build(); + } +} diff --git a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java index ce6a2b6..36a7318 100644 --- a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java +++ b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java @@ -1,5 +1,6 @@ package com.person.repit.interview.service.impl; +import com.person.repit.common.exception.InvalidRequestException; import com.person.repit.common.metrics.RepitMetrics; import com.person.repit.interview.domain.ChatInterviewSession; import com.person.repit.interview.domain.ChatQuestion; @@ -29,10 +30,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveValueOperations; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import java.time.Duration; import java.util.ArrayList; @@ -47,6 +50,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @SuppressWarnings("unchecked") @@ -224,6 +228,29 @@ void setUp() { assertThat(captor.getValue().getCompanyBrief()).isNull(); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 중복_질문_ID는_면접관_목록_유무와_관계없이_저장_전에_거절한다(boolean withPersonas) { + var request = prepareRequest(); + request.getQuestions().get(1).setId(request.getQuestions().getFirst().getId()); + if (withPersonas) { + var persona = new ChatInterviewPrepareRequest.Persona(); + persona.setPersonaId(3L); + persona.setRole(InterviewRole.HR); + persona.setType(InterviewType.FRIENDLY); + persona.setTone(InterviewTone.GENTLE); + persona.setLevel(InterviewLevel.NORMAL); + request.setPersonas(List.of(persona)); + } + + StepVerifier.create(service.prepareInterview(request)) + .expectErrorSatisfies(error -> assertThat(error) + .isInstanceOf(InvalidRequestException.class) + .hasMessage("질문 ID가 중복되었습니다.")) + .verify(); + verifyNoInteractions(redisTemplate, ttsScheduler, aiQuestionClient); + } + private ChatInterviewPrepareRequest prepareRequest() { ChatInterviewPrepareRequest request = new ChatInterviewPrepareRequest(); request.setSessionId("session-id"); diff --git a/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java b/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java new file mode 100644 index 0000000..bfe1371 --- /dev/null +++ b/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java @@ -0,0 +1,117 @@ +package com.person.repit.interview.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.dto.request.ChatAnswerRequest; +import com.person.repit.interview.dto.response.ChatProgressResponse; +import com.person.repit.interview.dto.response.ChatQuestionResponse; +import com.person.repit.interview.service.ChatInterviewService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.socket.CloseStatus; +import org.springframework.web.reactive.socket.HandshakeInfo; +import org.springframework.web.reactive.socket.WebSocketMessage; +import org.springframework.web.reactive.socket.WebSocketSession; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ReactiveChatInterviewWebSocketHandlerTest { + private ChatInterviewService service; + private WebSocketSession session; + private ReactiveChatInterviewWebSocketHandler handler; + private final List outbound = new ArrayList<>(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + service = mock(ChatInterviewService.class); + session = mock(WebSocketSession.class); + handler = new ReactiveChatInterviewWebSocketHandler( + new ObjectMapper(), service, new RepitMetrics(new SimpleMeterRegistry())); + when(session.getHandshakeInfo()).thenReturn(new HandshakeInfo( + URI.create("ws://localhost/chat?sessionId=s"), new HttpHeaders(), Mono.empty(), null)); + when(service.getCurrentQuestion("s")).thenReturn(Mono.just( + ChatQuestionResponse.builder().questionId(1L).content("질문").build())); + when(service.submitAnswer(eq("s"), any())).thenReturn(Mono.just( + ChatProgressResponse.next(ChatQuestion.builder().questionId(2L).content("다음 질문").build()))); + when(session.textMessage(anyString())).thenAnswer(call -> textMessage(call.getArgument(0))); + when(session.send(any())).thenAnswer(call -> Flux.from( + (Publisher) call.getArgument(0)) + .doOnNext(message -> outbound.add(message.getPayloadAsText())).then()); + when(session.close(any())).thenReturn(Mono.empty()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "{", "{}"}) + void 잘못된_메시지_다음에도_정상_답변을_처리한다(String payload) { + when(session.receive()).thenReturn(Flux.just(textMessage(payload), textMessage(answer(0)))); + + StepVerifier.create(handler.handle(session)).verifyComplete(); + + assertThat(outbound).hasSize(3); + assertThat(outbound.get(1)).contains("\"type\":\"ERROR\""); + assertThat(outbound.get(2)).contains("\"type\":\"QUESTION\"", "다음 질문"); + verify(service).submitAnswer(eq("s"), any()); + verify(session).close(CloseStatus.NORMAL); + } + + @ParameterizedTest + @ValueSource(ints = {-1, Integer.MIN_VALUE}) + void 음수_응답_시간은_거절하고_이후_정상_답변만_전달한다(int responseTime) { + when(session.receive()).thenReturn(Flux.just( + textMessage(answer(responseTime)), textMessage(answer(10)))); + + StepVerifier.create(handler.handle(session)).verifyComplete(); + + assertThat(outbound).hasSize(3); + assertThat(outbound.get(1)).contains("\"type\":\"ERROR\"", "0 이상"); + ArgumentCaptor captured = ArgumentCaptor.forClass(ChatAnswerRequest.class); + verify(service).submitAnswer(eq("s"), captured.capture()); + assertThat(captured.getValue().getResponseTime()).isEqualTo(10); + verify(session).close(CloseStatus.NORMAL); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, Integer.MAX_VALUE}) + void 음수가_아닌_응답_시간은_그대로_전달한다(int responseTime) { + when(session.receive()).thenReturn(Flux.just(textMessage(answer(responseTime)))); + + StepVerifier.create(handler.handle(session)).verifyComplete(); + + assertThat(outbound).hasSize(2); + assertThat(outbound.get(1)).contains("\"type\":\"QUESTION\""); + ArgumentCaptor captured = ArgumentCaptor.forClass(ChatAnswerRequest.class); + verify(service).submitAnswer(eq("s"), captured.capture()); + assertThat(captured.getValue().getResponseTime()).isEqualTo(responseTime); + } + + private String answer(int responseTime) { + return """ + {"type":"ANSWER","questionId":1,"responseTime":%d,"content":"답변"} + """.formatted(responseTime); + } + + private WebSocketMessage textMessage(String payload) { + return new WebSocketMessage(WebSocketMessage.Type.TEXT, + DefaultDataBufferFactory.sharedInstance.wrap(payload.getBytes(StandardCharsets.UTF_8))); + } +}