diff --git a/build.gradle b/build.gradle index e9805311..2a5631bf 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation "org.mockito:mockito-inline:4.+" // bcrypt implementation 'at.favre.lib:bcrypt:0.10.2' diff --git a/src/test/java/org/ezcode/codetest/application/language/service/LanguageServiceTest.java b/src/test/java/org/ezcode/codetest/application/language/LanguageServiceTest.java similarity index 99% rename from src/test/java/org/ezcode/codetest/application/language/service/LanguageServiceTest.java rename to src/test/java/org/ezcode/codetest/application/language/LanguageServiceTest.java index d40e4680..2d0befb7 100644 --- a/src/test/java/org/ezcode/codetest/application/language/service/LanguageServiceTest.java +++ b/src/test/java/org/ezcode/codetest/application/language/LanguageServiceTest.java @@ -1,4 +1,4 @@ -package org.ezcode.codetest.application.language.service; +package org.ezcode.codetest.application.language; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/org/ezcode/codetest/application/submission/GitHubPushServiceTest.java b/src/test/java/org/ezcode/codetest/application/submission/GitHubPushServiceTest.java new file mode 100644 index 00000000..4000b5cd --- /dev/null +++ b/src/test/java/org/ezcode/codetest/application/submission/GitHubPushServiceTest.java @@ -0,0 +1,169 @@ +package org.ezcode.codetest.application.submission; + +import static org.mockito.BDDMockito.*; + +import org.ezcode.codetest.application.submission.dto.event.GitPushStatusEvent; +import org.ezcode.codetest.application.submission.dto.request.github.GitHubPushRequest; +import org.ezcode.codetest.application.submission.model.SubmissionContext; +import org.ezcode.codetest.application.submission.port.ExceptionNotifier; +import org.ezcode.codetest.application.submission.port.GitHubClient; +import org.ezcode.codetest.application.submission.port.SubmissionEventService; +import org.ezcode.codetest.application.submission.service.GitHubPushService; +import org.ezcode.codetest.common.security.util.AESUtil; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.user.model.entity.UserGithubInfo; +import org.ezcode.codetest.domain.user.service.UserGithubService; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("깃허브 푸시 서비스 테스트") +public class GitHubPushServiceTest { + + @InjectMocks + private GitHubPushService gitHubPushService; + + @Mock + private GitHubClient gitHubClient; + + @Mock + private UserGithubService userGithubService; + + @Mock + private ExceptionNotifier exceptionNotifier; + + @Mock + private SubmissionEventService eventService; + + @Mock + private AESUtil aesUtil; + + @Mock + private UserGithubInfo info; + + @Mock + private SubmissionContext ctx; + + @Mock + private GitHubPushRequest req; + + private Long userId; + private String sessionKey; + private String encryptedToken; + private String decryptedToken; + + @BeforeEach + void setUp() { + userId = 1L; + sessionKey = "session-key"; + encryptedToken = "encrypted-token"; + decryptedToken = "decrypted-token"; + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("GitPushStatus 비활성 -> 아무 동작도 하지 않음") + void noPushWhenDisabled() { + + // given + given(ctx.isGitPushStatus()).willReturn(false); + + // when + gitHubPushService.pushSolutionToRepo(ctx); + + // then + then(eventService).should(never()).publishGitPushStatus(any(GitPushStatusEvent.class)); + then(userGithubService).should(never()).getUserGithubInfoById(anyLong()); + then(gitHubClient).should(never()).commitAndPushToRepo(any()); + then(exceptionNotifier).should(never()).notifyException(anyString(), any()); + } + + @Test + @DisplayName("오답 제출 -> 아무 동작도 하지 않음") + void noPushWhenNotPassed() { + + // given + given(ctx.isGitPushStatus()).willReturn(true); + given(ctx.isPassed()).willReturn(false); + + // when + gitHubPushService.pushSolutionToRepo(ctx); + + // then + then(eventService).should(never()).publishGitPushStatus(any(GitPushStatusEvent.class)); + then(userGithubService).should(never()).getUserGithubInfoById(anyLong()); + then(gitHubClient).should(never()).commitAndPushToRepo(any()); + then(exceptionNotifier).should(never()).notifyException(anyString(), any()); + } + + @Test + @DisplayName("활성화 & 정답 제출 -> 메서드 실행 및 시작 & 성공 이벤트 발행") + void pushSuccess() throws Exception { + + // given + given(ctx.isGitPushStatus()).willReturn(true); + given(ctx.isPassed()).willReturn(true); + + given(ctx.getSessionKey()).willReturn(sessionKey); + given(ctx.getUserId()).willReturn(userId); + + given(userGithubService.getUserGithubInfoById(userId)).willReturn(info); + given(info.getGithubAccessToken()).willReturn(encryptedToken); + given(aesUtil.decrypt(encryptedToken)).willReturn(decryptedToken); + + // when & then + try (var ms = mockStatic(GitHubPushRequest.class)) { + ms.when(() -> GitHubPushRequest.of(ctx, info, decryptedToken)).thenReturn(req); + + gitHubPushService.pushSolutionToRepo(ctx); + + then(eventService).should().publishGitPushStatus(GitPushStatusEvent.started(sessionKey)); + then(gitHubClient).should().commitAndPushToRepo(req); + then(eventService).should().publishGitPushStatus(GitPushStatusEvent.succeeded(sessionKey)); + then(exceptionNotifier).should(never()).notifyException(anyString(), any()); + } + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("Git Push 실패 -> 알림 호출 & 실패 이벤트 발행") + void pushFailure() throws Exception { + + // given + given(ctx.isGitPushStatus()).willReturn(true); + given(ctx.isPassed()).willReturn(true); + given(ctx.getSessionKey()).willReturn(sessionKey); + given(ctx.getUserId()).willReturn(userId); + given(userGithubService.getUserGithubInfoById(userId)).willReturn(info); + given(info.getGithubAccessToken()).willReturn(encryptedToken); + given(aesUtil.decrypt(encryptedToken)).willReturn(decryptedToken); + + willThrow(SubmissionException.class).given(gitHubClient).commitAndPushToRepo(req); + + // when & then + try (var ms = mockStatic(GitHubPushRequest.class)) { + ms.when(() -> GitHubPushRequest.of(ctx, info, decryptedToken)) + .thenReturn(req); + + gitHubPushService.pushSolutionToRepo(ctx); + + then(eventService).should().publishGitPushStatus(GitPushStatusEvent.started(sessionKey)); + then(exceptionNotifier).should().notifyException(eq("commitAndPush"), any(SubmissionException.class)); + then(eventService).should().publishGitPushStatus(GitPushStatusEvent.failed(sessionKey)); + } + } + } +} diff --git a/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceAsyncTest.java b/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceAsyncTest.java new file mode 100644 index 00000000..682c69d6 --- /dev/null +++ b/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceAsyncTest.java @@ -0,0 +1,152 @@ +package org.ezcode.codetest.application.submission; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; +import org.ezcode.codetest.application.submission.model.JudgeResult; +import org.ezcode.codetest.application.submission.model.SubmissionContext; +import org.ezcode.codetest.application.submission.port.ExceptionNotifier; +import org.ezcode.codetest.application.submission.port.JudgeClient; +import org.ezcode.codetest.application.submission.port.SubmissionEventService; +import org.ezcode.codetest.application.submission.service.JudgementService; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; +import org.ezcode.codetest.domain.submission.service.SubmissionDomainService; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +@DisplayName("채점 서비스 비동기 로직 테스트") +public class JudgementServiceAsyncTest { + + @InjectMocks + private JudgementService judgementService; + + @Mock + private SubmissionDomainService submissionDomainService; + + @Mock + private SubmissionEventService submissionEventService; + + @Mock + private JudgeClient judgeClient; + + @Mock + private ExceptionNotifier exceptionNotifier; + + @Mock + private SubmissionContext ctx; + + @Mock + private JudgeResult judgeResult; + + @Mock + private CountDownLatch latch; + private int testcaseCount; + private String sessionKey; + private AtomicBoolean notified; + private final Executor directExecutor = Runnable::run; + + @BeforeEach + void setup() { + testcaseCount = 5; + sessionKey = "session-key"; + notified = new AtomicBoolean(false); + ReflectionTestUtils.setField(judgementService, "judgeTestcaseExecutor", directExecutor); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("runTestcases -> TestcaseEvaludatedEvent를 테스트케이스 개수만큼 발행") + void runTestcasesPublishesEvaluations() throws InterruptedException { + + // given + given(ctx.getTestcaseCount()).willReturn(testcaseCount); + given(ctx.latch()).willReturn(latch); + doReturn(true).when(latch).await(anyLong(), any(TimeUnit.class)); + given(judgeClient.submitAndGetToken(any())).willReturn("t1", "t2", "t3", "t4", "t5"); + given(judgeClient.pollUntilDone(anyString())).willReturn(judgeResult); + + given(submissionDomainService.handleEvaluationAndUpdateStats(any(), eq(ctx))) + .willReturn(true) + .willReturn(false) + .willReturn(true) + .willReturn(false) + .willReturn(true); + + // when + judgementService.runTestcases(ctx); + + // then + then(submissionEventService).should(times(testcaseCount)) + .publishTestcaseUpdate(any(TestcaseEvaluatedEvent.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("타임아웃 -> TESTCASE_TIMEOUT 예외 발생") + void timeoutThrowsSubmissionException() throws InterruptedException { + + // given + given(ctx.getTestcaseCount()).willReturn(testcaseCount); + given(ctx.latch()).willReturn(latch); + doReturn(false).when(latch).await(anyLong(), any(TimeUnit.class)); + + // when & then + assertThatThrownBy(() -> judgementService.runTestcases(ctx)) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.TESTCASE_TIMEOUT.getMessage()); + } + + @Test + @DisplayName("judgeClient 예외 발생 -> 알림 호출 1회 & 에러 이벤트 발행") + void runTestcaseAsyncExceptionNotifies() throws InterruptedException { + + // given + given(ctx.getSessionKey()).willReturn(sessionKey); + given(ctx.getTestcaseCount()).willReturn(testcaseCount); + given(ctx.latch()).willReturn(latch); + doReturn(true).when(latch).await(anyLong(), any(TimeUnit.class)); + + given(ctx.notified()).willReturn(notified); + doNothing().when(ctx).countDown(); + given(judgeClient.submitAndGetToken(any())) + .willThrow(new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR)); + + // when + judgementService.runTestcases(ctx); + + // then + then(submissionEventService).should(times(1)).publishSubmissionError( + argThat(ev -> + ev.sessionKey().equals(ctx.getSessionKey()) && + ev.code().equals(SubmissionExceptionCode.COMPILE_SERVER_ERROR) + ) + ); + then(exceptionNotifier).should(times(1)).notifyException( + eq("runTestcaseAsync"), any(SubmissionException.class) + ); + then(ctx).should(times(5)).countDown(); + } + } +} diff --git a/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceEventTest.java b/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceEventTest.java new file mode 100644 index 00000000..ee599247 --- /dev/null +++ b/src/test/java/org/ezcode/codetest/application/submission/JudgementServiceEventTest.java @@ -0,0 +1,106 @@ +package org.ezcode.codetest.application.submission; + +import static org.mockito.BDDMockito.*; + +import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; +import org.ezcode.codetest.application.submission.model.SubmissionContext; +import org.ezcode.codetest.application.submission.port.ProblemEventService; +import org.ezcode.codetest.application.submission.port.SubmissionEventService; +import org.ezcode.codetest.application.submission.service.JudgementService; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; +import org.ezcode.codetest.domain.submission.model.SubmissionResult; +import org.ezcode.codetest.domain.submission.service.SubmissionDomainService; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("채점 서비스 이벤트 발행 테스트") +public class JudgementServiceEventTest { + + @InjectMocks + private JudgementService judgementService; + + @Mock + private SubmissionDomainService submissionDomainService; + + @Mock + private SubmissionEventService submissionEventService; + + @Mock + private ProblemEventService problemEventService; + + @Mock + private SubmissionContext ctx; + + @Mock + private SubmissionResult result; + + private String sessionKey; + + @BeforeEach + void setUp() { + sessionKey = "session-key"; + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("테스트 케이스 목록 초기화 이벤트 발행") + void publishInitTestcasesEvent() { + + // given + given(ctx.getSessionKey()).willReturn(sessionKey); + + // when + judgementService.publishInitTestcases(ctx); + + // then + then(submissionEventService).should().publishInitTestcases(any(TestcaseListInitializedEvent.class)); + } + + @Test + @DisplayName("채점 -> 최종 채점 결과 이벤트 발행") + void publishFinalResultEvent() { + + // given + given(submissionDomainService.finalizeSubmission(ctx)).willReturn(result); + + // when + judgementService.finalizeAndPublish(ctx); + + // then + then(submissionEventService).should().publishFinalResult(any(SubmissionJudgingFinishedEvent.class)); + then(problemEventService).should().publishProblemSolveEvent(result); + } + + @Test + @DisplayName("예외 발생 -> 에러 이벤트 발행") + void publishErrorAndNotify() { + + // given + SubmissionExceptionCode code = SubmissionExceptionCode.TESTCASE_TIMEOUT; + SubmissionException se = new SubmissionException(code); + + // when + judgementService.publishSubmissionError(sessionKey, se); + + // then + then(submissionEventService).should().publishSubmissionError( + argThat(ev -> + ev.sessionKey().equals(sessionKey) && + ev.code().equals(code) + ) + ); + } + } +} diff --git a/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java b/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java new file mode 100644 index 00000000..ad65bc3b --- /dev/null +++ b/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java @@ -0,0 +1,295 @@ +package org.ezcode.codetest.application.submission; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.ezcode.codetest.application.submission.dto.request.review.CodeReviewRequest; +import org.ezcode.codetest.application.submission.dto.request.submission.CodeSubmitRequest; +import org.ezcode.codetest.application.submission.dto.response.review.CodeReviewResponse; +import org.ezcode.codetest.application.submission.dto.response.submission.GroupedSubmissionResponse; +import org.ezcode.codetest.application.submission.model.ReviewResult; +import org.ezcode.codetest.application.submission.model.SubmissionContext; +import org.ezcode.codetest.application.submission.port.ExceptionNotifier; +import org.ezcode.codetest.application.submission.port.LockManager; +import org.ezcode.codetest.application.submission.port.QueueProducer; +import org.ezcode.codetest.application.submission.port.ReviewClient; +import org.ezcode.codetest.application.submission.service.GitHubPushService; +import org.ezcode.codetest.application.submission.service.JudgementService; +import org.ezcode.codetest.application.submission.service.SubmissionService; +import org.ezcode.codetest.domain.language.exception.LanguageException; +import org.ezcode.codetest.domain.language.exception.code.LanguageExceptionCode; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.language.service.LanguageDomainService; +import org.ezcode.codetest.domain.problem.model.ProblemInfo; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.problem.service.ProblemDomainService; +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; +import org.ezcode.codetest.domain.submission.model.entity.Submission; +import org.ezcode.codetest.domain.submission.service.SubmissionDomainService; +import org.ezcode.codetest.domain.user.model.entity.AuthUser; +import org.ezcode.codetest.domain.user.model.entity.User; +import org.ezcode.codetest.domain.user.service.UserDomainService; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("제출 서비스 테스트") +public class SubmissionServiceTest { + + @InjectMocks + private SubmissionService submissionService; + + @Mock + private ReviewClient reviewClient; + + @Mock + private UserDomainService userDomainService; + + @Mock + private ProblemDomainService problemDomainService; + + @Mock + private LanguageDomainService languageDomainService; + + @Mock + private SubmissionDomainService submissionDomainService; + + @Mock + private QueueProducer queueProducer; + + @Mock + private ExceptionNotifier exceptionNotifier; + + @Mock + private LockManager lockManager; + + @Mock + private JudgementService judgementService; + + @Mock + private GitHubPushService gitHubPushService; + + @Mock + private AuthUser authUser; + + @Mock + private User user; + + @Mock + private Language language; + + @Mock + private ProblemInfo info; + + @Mock + private CodeSubmitRequest request; + + @Mock + private SubmissionMessage msg; + + private Long userId; + private Long problemId; + private Long languageId; + private String sourceCode; + private String sessionKey; + private ReviewResult reviewResult; + + @BeforeEach + void setup() { + userId = 1L; + problemId = 100L; + languageId = 2L; + sourceCode = "TEST"; + sessionKey = userId + "_"; + reviewResult = new ReviewResult("good"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("락 획득 성공 -> 메세지 큐에 전송하고 sessionKey 반환") + void enqueueCodeSubmissionSucceeds() { + + // given + given(authUser.getId()).willReturn(userId); + given(request.languageId()).willReturn(languageId); + given(request.sourceCode()).willReturn(sourceCode); + given(lockManager.tryLock("submission", authUser.getId(), problemId)) + .willReturn(true); + + // when + String result = submissionService.enqueueCodeSubmission(problemId, request, authUser); + + then(queueProducer).should() + .enqueue(argThat(msg -> + msg.sessionKey().startsWith(sessionKey) && + msg.problemId().equals(problemId) && + msg.languageId().equals(languageId) && + msg.sourceCode().equals(sourceCode) + )); + assertThat(result).startsWith(sessionKey); + } + + @Test + @DisplayName("정상 흐름 -> 모든 서비스 호출 및 락 해제") + void runsThroughAllStepsAndReleasesLock() throws InterruptedException { + + // given + given(msg.userId()).willReturn(userId); + given(msg.languageId()).willReturn(languageId); + given(msg.problemId()).willReturn(problemId); + given(userDomainService.getUserById(userId)).willReturn(user); + given(languageDomainService.getLanguage(languageId)).willReturn(language); + given(problemDomainService.getProblemInfo(problemId)).willReturn(info); + + // when + submissionService.processSubmissionAsync(msg); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(SubmissionContext.class); + then(judgementService).should().publishInitTestcases(captor.capture()); + SubmissionContext ctx = captor.getValue(); + + then(judgementService).should().runTestcases(ctx); + then(judgementService).should().finalizeAndPublish(ctx); + then(gitHubPushService).should().pushSolutionToRepo(ctx); + + then(lockManager).should().releaseLock("submission", userId, problemId); + } + + @Test + @DisplayName("제출 내역을 문제 기준으로 그룹핑해서 반환") + void returnsGroupedSubmissions() { + + // given + given(authUser.getId()).willReturn(userId); + given(userDomainService.getUserById(userId)).willReturn(user); + given(user.getId()).willReturn(userId); + + Submission s1 = mock(Submission.class); + Submission s2 = mock(Submission.class); + Submission s3 = mock(Submission.class); + + LocalDateTime now = LocalDateTime.now().withNano(0); + given(s1.getCreatedAt()).willReturn(now); + given(s2.getCreatedAt()).willReturn(now); + given(s3.getCreatedAt()).willReturn(now); + + Problem p42 = mock(Problem.class); + given(p42.getId()).willReturn(42L); + given(s1.getProblem()).willReturn(p42); + given(s2.getProblem()).willReturn(p42); + + Problem p99 = mock(Problem.class); + given(p99.getId()).willReturn(99L); + given(s3.getProblem()).willReturn(p99); + + given(submissionDomainService.getSubmissions(userId)).willReturn(List.of(s1, s2, s3)); + + // when + List groups = submissionService.getSubmissions(authUser); + + // then + then(submissionDomainService).should().getSubmissions(userId); + assertThat(groups).hasSize(2); + GroupedSubmissionResponse group42 = groups.stream() + .filter(g -> g.getProblemId() == 42L) + .findFirst().orElseThrow(); + assertThat(group42.getSubmissions()).hasSize(2); + + GroupedSubmissionResponse group99 = groups.stream() + .filter(g -> g.getProblemId() == 99L) + .findFirst().orElseThrow(); + assertThat(group99.getSubmissions()).hasSize(1); + } + + @Test + @DisplayName("정상 호출 -> 토큰 차감, 리뷰 요청 & 응답 반환") + void returnsReviewResponse() { + + // given + given(authUser.getId()).willReturn(userId); + given(userDomainService.getUserById(authUser.getId())).willReturn(user); + willDoNothing().given(userDomainService).decreaseReviewToken(user); + + Problem problem = mock(Problem.class); + given(problemDomainService.getProblem(problemId)).willReturn(problem); + + CodeReviewRequest req = mock(CodeReviewRequest.class); + given(req.languageId()).willReturn(languageId); + given(languageDomainService.getLanguage(languageId)).willReturn(language); + given(reviewClient.requestReview(any())).willReturn(reviewResult); + + // when + CodeReviewResponse resp = submissionService.getCodeReview(problemId, req, authUser); + + // then + then(userDomainService).should().getUserById(userId); + then(userDomainService).should().decreaseReviewToken(user); + assertThat(resp.reviewContent()).isEqualTo(reviewResult.reviewContent()); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("락 획득 실패 -> ALREADY_JUDGING 예외") + void enqueueCodeSubmissionFailed() { + + // given + given(authUser.getId()).willReturn(userId); + given(lockManager.tryLock("submission", authUser.getId(), problemId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> + submissionService.enqueueCodeSubmission(problemId, request, authUser) + ) + .isInstanceOf(SubmissionException.class) + .hasMessage(SubmissionExceptionCode.ALREADY_JUDGING.getMessage()); + } + + @Test + @DisplayName("예외 -> 에러 이벤트 발행 & 락 해제") + void processFailureNotifiesAndReleasesLock() { + + // given + given(msg.userId()).willReturn(userId); + given(msg.languageId()).willReturn(languageId); + given(msg.problemId()).willReturn(problemId); + given(msg.sessionKey()).willReturn(sessionKey); + given(userDomainService.getUserById(userId)).willReturn(user); + given(languageDomainService.getLanguage(languageId)) + .willThrow(new LanguageException(LanguageExceptionCode.LANGUAGE_NOT_FOUND)); + + // when + submissionService.processSubmissionAsync(msg); + + // then + then(judgementService).should().publishSubmissionError( + eq(sessionKey), + any(LanguageException.class) + ); + then(exceptionNotifier).should().notifyException( + eq("submitCodeStream"), any(LanguageException.class) + ); + then(lockManager).should().releaseLock("submission", userId, problemId); + } + } +}