diff --git a/build.gradle b/build.gradle index 2a5631bf..f6fed813 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,9 @@ repositories { } dependencies { + implementation 'io.projectreactor:reactor-core' + implementation 'io.projectreactor.netty:reactor-netty' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -37,7 +40,6 @@ dependencies { // activemq implementation 'org.springframework.boot:spring-boot-starter-activemq' - implementation "io.projectreactor.netty:reactor-netty:1.1.0" // 웹소켓 implementation 'org.springframework.boot:spring-boot-starter-websocket' @@ -62,6 +64,7 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation "org.mockito:mockito-inline:4.+" + testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0' // bcrypt implementation 'at.favre.lib:bcrypt:0.10.2' diff --git a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java index 9a603142..f4300237 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java @@ -1,6 +1,7 @@ package org.ezcode.codetest.infrastructure.judge0; import java.time.Duration; +import java.util.concurrent.TimeoutException; import org.ezcode.codetest.application.submission.dto.request.compile.CodeCompileRequest; import org.ezcode.codetest.application.submission.dto.response.compile.ExecutionResultResponse; @@ -15,7 +16,6 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import io.netty.handler.timeout.TimeoutException; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -55,10 +55,13 @@ public String submitAndGetToken(CodeCompileRequest request) { .filter(ex -> ex instanceof WebClientResponseException || ex instanceof TimeoutException) ) + .onErrorMap(IllegalStateException.class, + ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_TIMEOUT)) .onErrorMap(WebClientResponseException.class, ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR)) .onErrorMap(TimeoutException.class, ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_TIMEOUT)) + .block(); if (resp == null || resp.token() == null) { diff --git a/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueConsumerTest.java b/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueConsumerTest.java new file mode 100644 index 00000000..1d55bf7b --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueConsumerTest.java @@ -0,0 +1,103 @@ +package org.ezcode.codetest.infrastructure.event; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Map; + +import org.ezcode.codetest.application.submission.service.SubmissionService; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; +import org.ezcode.codetest.infrastructure.event.listener.RedisJudgeQueueConsumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisJudgeQueueConsumer 단위 테스트") +public class RedisJudgeQueueConsumerTest { + + @InjectMocks + private RedisJudgeQueueConsumer consumer; + + @Mock + private SubmissionService submissionService; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private StreamOperations streamOps; + + @Mock + private MapRecord record; + + @BeforeEach + void setUp() { + Map payload = Map.of( + "sessionKey", "session-key", + "problemId", "42", + "languageId", "7", + "userId", "100", + "sourceCode", "System.out.println(123);" + ); + RecordId recordId = RecordId.of("1-0"); + + given(record.getValue()).willReturn(payload); + given(record.getId()).willReturn(recordId); + } + + @Test + @DisplayName("정상 처리 -> processSubmissionAsync & ack 호출") + void onMessage_success() { + + // given + given(redisTemplate.opsForStream()).willReturn(streamOps); + + // when + consumer.onMessage(record); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(SubmissionMessage.class); + then(submissionService).should().processSubmissionAsync(captor.capture()); + + SubmissionMessage sent = captor.getValue(); + + assertAll( + () -> assertThat(sent.sessionKey()).isEqualTo("session-key"), + () -> assertThat(sent.problemId()).isEqualTo(42L), + () -> assertThat(sent.languageId()).isEqualTo(7L), + () -> assertThat(sent.userId()).isEqualTo(100L), + () -> assertThat(sent.sourceCode()).isEqualTo("System.out.println(123);") + ); + + then(redisTemplate.opsForStream()).should().acknowledge("judge-group", record); + } + + @Test + @DisplayName("processSubmissionAsync 에러 -> SubmissionException & ack 미호출") + void onMessage_failure() { + + // given + doThrow(new RuntimeException("oops")) + .when(submissionService).processSubmissionAsync(any()); + + // when & then + assertThatThrownBy(() -> consumer.onMessage(record)) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.REDIS_SERVER_ERROR.getMessage()); + + then(streamOps).should(never()).acknowledge(anyString(), any()); + } +} diff --git a/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueProducerTest.java b/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueProducerTest.java new file mode 100644 index 00000000..10b4a3bb --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/event/RedisJudgeQueueProducerTest.java @@ -0,0 +1,72 @@ +package org.ezcode.codetest.infrastructure.event; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Map; + +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; +import org.ezcode.codetest.infrastructure.event.publisher.RedisJudgeQueueProducer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisJudgeQueueProducer 단위 테스트") +public class RedisJudgeQueueProducerTest { + + @InjectMocks + private RedisJudgeQueueProducer producer; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private StreamOperations streamOps; + + @BeforeEach + void setUp() { + given(redisTemplate.opsForStream()).willReturn(streamOps); + } + + @Test + @DisplayName("enqueue -> judge-queue에 메시지 추가") + void enqueue_addToStream() { + + // given + SubmissionMessage msg = new SubmissionMessage( + "session-key", + 42L, + 7L, + 100L, + "System.out.println(123);" + ); + + // when + producer.enqueue(msg); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + + then(streamOps).should() + .add(eq("judge-queue"), captor.capture()); + + Map actual = captor.getValue(); + assertAll( + () -> assertThat(actual.get("sessionKey")).isEqualTo("session-key"), + () -> assertThat(actual.get("problemId")).isEqualTo("42"), + () -> assertThat(actual.get("languageId")).isEqualTo("7"), + () -> assertThat(actual.get("userId")).isEqualTo("100"), + () -> assertThat(actual.get("sourceCode")).isEqualTo("System.out.println(123);") + ); + } +} diff --git a/src/test/java/org/ezcode/codetest/infrastructure/github/GitHubClientTest.java b/src/test/java/org/ezcode/codetest/infrastructure/github/GitHubClientTest.java new file mode 100644 index 00000000..822457dd --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/github/GitHubClientTest.java @@ -0,0 +1,121 @@ +package org.ezcode.codetest.infrastructure.github; + +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.ezcode.codetest.application.submission.dto.request.github.GitHubPushRequest; +import org.ezcode.codetest.infrastructure.github.model.CommitContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GitHubClient 단위 테스트") +public class GitHubClientTest { + + @InjectMocks + GitHubClientImpl gitHubClientImpl; + + @Mock + private GitHubApiClient gitHubApiClient; + + @Mock + private GitHubContentBuilder templateBuilder; + + @Mock + private GitBlobCalculator blobCalculator; + + @Mock + private GitHubPushRequest request; + + @Mock + private CommitContext ctx; + + private String sourceCode; + + @BeforeEach + void setup() { + sourceCode = "source-code"; + } + + @Test + @DisplayName("동일한 SHA -> 얼리 리턴") + void blobUnchanged_earlyReturn() { + + // given + String sha = "abc123"; + given(request.sourceCode()).willReturn(sourceCode); + given(blobCalculator.calculateBlobSha(request.sourceCode())).willReturn(sha); + given(gitHubApiClient.fetchSourceBlobSha(request)).willReturn(Optional.of(sha)); + + // when + gitHubClientImpl.commitAndPushToRepo(request); + + // then + then(gitHubApiClient).should(never()).fetchCommitContext(any()); + then(templateBuilder).should(never()).buildGitTreeEntries(any(), any()); + then(gitHubApiClient).should(never()).createTree(any(), any(), anyList()); + then(gitHubApiClient).should(never()).commitAndPush(any(), any(), any()); + } + + @Test + @DisplayName("새 SHA == 트리 SHA -> 얼리 리턴") + void newBlobEqualsTreeBlob_earlyReturn() { + + // given + String oldSha = "old"; + String newSha = "new"; + given(request.sourceCode()).willReturn(sourceCode); + given(blobCalculator.calculateBlobSha(request.sourceCode())).willReturn(newSha); + given(gitHubApiClient.fetchSourceBlobSha(request)).willReturn(Optional.of(oldSha)); + given(gitHubApiClient.fetchCommitContext(request)).willReturn(ctx); + + List> entries = List.of(Map.of()); + given(templateBuilder.buildGitTreeEntries(request, newSha)).willReturn(entries); + + given(ctx.baseTreeSha()).willReturn("tree"); + given(gitHubApiClient.createTree(request, "tree", entries)).willReturn("tree"); + + // when + gitHubClientImpl.commitAndPushToRepo(request); + + // then + then(gitHubApiClient).should().fetchCommitContext(request); + then(templateBuilder).should().buildGitTreeEntries(request, newSha); + then(gitHubApiClient).should().createTree(request, "tree", entries); + then(gitHubApiClient).should(never()).commitAndPush(any(), any(), any()); + } + + @Test + @DisplayName("SHA 변경 & 트리 SHA 변경 시 커밋 + 푸시") + void blobAndTreeChanged_commitAndPush() { + + // given + String oldSha = "old"; + String newSha = "new"; + given(blobCalculator.calculateBlobSha(request.sourceCode())).willReturn(newSha); + given(gitHubApiClient.fetchSourceBlobSha(request)).willReturn(Optional.of(oldSha)); + given(gitHubApiClient.fetchCommitContext(request)).willReturn(ctx); + + List> entries = List.of(Map.of()); + given(templateBuilder.buildGitTreeEntries(request, newSha)).willReturn(entries); + + given(ctx.baseTreeSha()).willReturn("tree"); + given(ctx.headCommitSha()).willReturn("head"); + String newTree = "newTree"; + given(gitHubApiClient.createTree(request, "tree", entries)).willReturn(newTree); + + // when + gitHubClientImpl.commitAndPushToRepo(request); + + // then + then(gitHubApiClient).should().commitAndPush(request, "head", newTree); + } +} diff --git a/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java b/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java new file mode 100644 index 00000000..c6dcd148 --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java @@ -0,0 +1,202 @@ +package org.ezcode.codetest.infrastructure.judge0; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.ezcode.codetest.application.submission.dto.request.compile.CodeCompileRequest; +import org.ezcode.codetest.application.submission.dto.response.compile.ExecutionResultResponse; +import org.ezcode.codetest.application.submission.model.JudgeResult; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import reactor.core.publisher.Mono; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Judge0Client 단위 테스트") +public class Judge0ClientTest { + + @InjectMocks + private Judge0Client judge0Client; + + @Mock + private Judge0ResponseMapper interpreter; + + @Mock + private CodeCompileRequest request; + + @Mock + private JudgeResult result; + + private MockWebServer mockWebServer; + + @BeforeEach + void setup() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + String baseUrl = mockWebServer.url("/").toString(); + ReflectionTestUtils.setField(judge0Client, "judge0ApiUrl", baseUrl); + ReflectionTestUtils.invokeMethod(judge0Client, "init"); + + WebClient original = (WebClient)ReflectionTestUtils.getField(judge0Client, "webClient"); + WebClient testClient = Objects.requireNonNull(original).mutate() + .filter((req, next) -> + next.exchange(req) + .flatMap(resp -> resp.statusCode().is5xxServerError() + ? Mono.error(new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR)) + : Mono.just(resp)) + ) + .filter((req, next) -> + next.exchange(req) + .onErrorResume(java.util.concurrent.TimeoutException.class, + ex -> Mono.error(new SubmissionException(SubmissionExceptionCode.COMPILE_TIMEOUT))) + ) + .build(); + + ReflectionTestUtils.setField(judge0Client, "webClient", testClient); + } + + @AfterEach + void tearDown() throws Exception { + mockWebServer.shutdown(); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("성공 -> 서버가 토큰을 반환하면 그대로 리턴") + void submitAndGetToken_success() { + + // given + mockWebServer.enqueue(new MockResponse() + .setBody("{\"token\":\"token\"}") + .setHeader("Content-Type", "application/json") + ); + + // when + String token = judge0Client.submitAndGetToken(request); + + // then + assertThat(token).isEqualTo("token"); + } + + @Test + @DisplayName("성공 -> status.id >= 3 이면 interpreter 결과 리턴") + void pollUntilDone_success() { + + // given + mockWebServer.enqueue(json("{\"status\":{\"id\":1}}")); + mockWebServer.enqueue(json("{\"status\":{\"id\":3}}")); + + given(interpreter.toJudgeResult(any(ExecutionResultResponse.class))).willReturn(result); + + // when + JudgeResult judgeResult = judge0Client.pollUntilDone("token"); + + // then + assertThat(judgeResult).isSameAs(result); + ArgumentCaptor captor = + ArgumentCaptor.forClass(ExecutionResultResponse.class); + then(interpreter).should().toJudgeResult(captor.capture()); + + ExecutionResultResponse captured = captor.getValue(); + assertThat(captured.status().id()).isEqualTo(3); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("서버 에러 -> HTTP 500 응답 시 COMPILE_SERVER_ERROR 예외 발생") + void submitAndGetToken_throwServerError() { + + // given + mockWebServer.enqueue(new MockResponse() + .setResponseCode(500) + ); + + // when & then + assertThatThrownBy(() -> judge0Client.submitAndGetToken(request)) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.COMPILE_SERVER_ERROR.getMessage()); + } + + @Test + @DisplayName("타임아웃 -> 응답 지연 시 COMPILE_TIMEOUT 예외 발생") + void submitAndGetToken_throwTimeout() { + + // given + mockWebServer.enqueue(new MockResponse() + .setBody("{\"token\":\"late\"}") + .setHeader("Content-Type", "application/json") + .setBodyDelay(10, TimeUnit.SECONDS)); + + // when & then + assertThatThrownBy(() -> judge0Client.submitAndGetToken(request)) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.COMPILE_TIMEOUT.getMessage()); + } + + @Test + @DisplayName("컴파일 에러 매핑 -> BadRequest 시 ofCompileError -> interpreter 호출") + void pollUntilDone_compileErrorMapping() { + + // given + mockWebServer.enqueue(new MockResponse().setResponseCode(400)); + mockWebServer.enqueue(json("{\"status\":{\"id\":3}}")); + + ExecutionResultResponse errorResp = ExecutionResultResponse.ofCompileError(); + given(interpreter.toJudgeResult(errorResp)).willReturn(result); + + // when + JudgeResult judgeResult = judge0Client.pollUntilDone("token"); + + // then + assertThat(judgeResult).isSameAs(result); + then(interpreter).should().toJudgeResult(errorResp); + } + + @Test + @DisplayName("타임아웃 -> 60초 경과 시 COMPILE_TIMEOUT 예외 발생") + void pollUntilDone_throwTimeout() { + + // given + for (int i = 0; i < 65; i++) { + mockWebServer.enqueue(json("{\"status\":{\"id\":1}}")); + } + + // when & then + assertThatThrownBy(() -> judge0Client.pollUntilDone("token")) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.COMPILE_TIMEOUT.getMessage()); + } + + } + + private MockResponse json(String body) { + return new MockResponse() + .setBody(body) + .setHeader("Content-Type", "application/json"); + } +}