From 86686a3ecdce24897e850c4d87736028766c5da1 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Wed, 13 May 2026 00:51:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?DP-481:=20=EB=AA=A8=EC=9D=98=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20finalize=20AI=20=ED=98=B8=EC=B6=9C=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20504=20Tim?= =?UTF-8?q?eout=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MockInterviewStatus에 PROCESSING 상태 추가 - MockInterviewFinalizeService: @Async + @TransactionalEventListener(AFTER_COMMIT)로 finalizeMockInterview AI 호출 비동기 처리 (히스토리·포인트 적립 포함) - MockInterviewService: finalizeSession 제거, 마지막 문항/조기종료 시 PROCESSING 상태 저장 후 MockInterviewFinalizeEvent 발행으로 즉시 응답 반환 - AsyncConfig: mockInterviewFinalizeExecutor 스레드풀 설정 - 테스트: MockInterviewFinalizeServiceTest(9개), MockInterviewServiceTest(5개) 작성 --- .../job/entity/MockInterviewStatus.java | 1 + .../job/event/MockInterviewFinalizeEvent.java | 5 + .../service/MockInterviewFinalizeService.java | 180 +++++++++++ .../job/service/MockInterviewService.java | 131 ++------ .../devpick/global/config/AsyncConfig.java | 24 ++ .../MockInterviewFinalizeServiceTest.java | 288 ++++++++++++++++++ .../job/service/MockInterviewServiceTest.java | 180 ++++++++--- 7 files changed, 667 insertions(+), 142 deletions(-) create mode 100644 src/main/java/com/devpick/domain/job/event/MockInterviewFinalizeEvent.java create mode 100644 src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java create mode 100644 src/main/java/com/devpick/global/config/AsyncConfig.java create mode 100644 src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java diff --git a/src/main/java/com/devpick/domain/job/entity/MockInterviewStatus.java b/src/main/java/com/devpick/domain/job/entity/MockInterviewStatus.java index 42055b9..bf7cd2f 100644 --- a/src/main/java/com/devpick/domain/job/entity/MockInterviewStatus.java +++ b/src/main/java/com/devpick/domain/job/entity/MockInterviewStatus.java @@ -2,6 +2,7 @@ public enum MockInterviewStatus { IN_PROGRESS, + PROCESSING, COMPLETED, EARLY_FINISHED } diff --git a/src/main/java/com/devpick/domain/job/event/MockInterviewFinalizeEvent.java b/src/main/java/com/devpick/domain/job/event/MockInterviewFinalizeEvent.java new file mode 100644 index 0000000..261801b --- /dev/null +++ b/src/main/java/com/devpick/domain/job/event/MockInterviewFinalizeEvent.java @@ -0,0 +1,5 @@ +package com.devpick.domain.job.event; + +import java.util.UUID; + +public record MockInterviewFinalizeEvent(UUID sessionId, boolean early) {} diff --git a/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java b/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java new file mode 100644 index 0000000..defa131 --- /dev/null +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java @@ -0,0 +1,180 @@ +package com.devpick.domain.job.service; + +import com.devpick.domain.job.client.JobAiClient; +import com.devpick.domain.job.dto.MockInterviewModels.QuestionPlanResponse; +import com.devpick.domain.job.entity.MockInterviewSession; +import com.devpick.domain.job.entity.MockInterviewStatus; +import com.devpick.domain.job.entity.MockInterviewTurn; +import com.devpick.domain.job.event.MockInterviewFinalizeEvent; +import com.devpick.domain.job.repository.MockInterviewSessionRepository; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; +import com.devpick.domain.report.entity.History; +import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.repository.UserRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MockInterviewFinalizeService { + + private final MockInterviewSessionRepository sessionRepository; + private final JobAiClient jobAiClient; + private final HistoryRepository historyRepository; + private final UserRepository userRepository; + private final PointService pointService; + private final ObjectMapper objectMapper; + private final MockInterviewPlanner planner; + + @Async("mockInterviewFinalizeExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional + public void onFinalizeEvent(MockInterviewFinalizeEvent event) { + UUID sessionId = event.sessionId(); + MockInterviewSession session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) { + log.warn("[mock-finalize] session not found sessionId={}", sessionId); + return; + } + if (session.getStatus() != MockInterviewStatus.PROCESSING) { + log.warn("[mock-finalize] session not in PROCESSING state, skipping sessionId={} status={}", + sessionId, session.getStatus()); + return; + } + + boolean early = event.early(); + QuestionPlanResponse plan = readPlan(session); + Map finalRequest = buildFinalizePayload(session, plan, early); + Map finalResult; + try { + finalResult = jobAiClient.finalizeMockInterview(finalRequest); + } catch (Exception e) { + log.warn("[mock-finalize] AI call failed sessionId={} err={}", sessionId, e.toString()); + finalResult = fallbackFinalResult(plan, session, early); + } + + finalResult.put("earlyFinished", early); + finalResult.put("answeredCount", session.getAnsweredCount()); + finalResult.put("totalQuestions", MockInterviewPlanner.TOTAL_QUESTIONS); + finalResult.put("coverageFactor", coverageFactor(session.getAnsweredCount(), early)); + session.setResultJson(writeJson(finalResult)); + session.setStatus(early ? MockInterviewStatus.EARLY_FINISHED : MockInterviewStatus.COMPLETED); + sessionRepository.save(session); + log.info("[mock-finalize] completed sessionId={} early={}", sessionId, early); + + userRepository.findByIdAndIsActiveTrue(session.getUserId()).ifPresent(user -> { + historyRepository.save(History.builder() + .user(user) + .actionType("mock_interview_completed") + .jobPosting(session.getJobPosting()) + .build()); + pointService.earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, sessionId); + }); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private Map buildFinalizePayload(MockInterviewSession session, + QuestionPlanResponse plan, + boolean early) { + Map body = new HashMap<>(); + body.put("session_id", session.getId() != null ? session.getId().toString() : null); + body.put("model_key", session.getModelKey()); + body.put("job_title", session.getJobTitle()); + body.put("company_name", session.getCompanyName()); + body.put("job_category", session.getJobCategory()); + body.put("answered_count", session.getAnsweredCount()); + body.put("total_questions", MockInterviewPlanner.TOTAL_QUESTIONS); + body.put("early_finished", early); + body.put("plan", plan); + body.put("turns", session.getTurns().stream().map(turn -> { + Map m = new HashMap<>(); + m.put("orderNo", turn.getOrderNo()); + m.put("questionNo", turn.getQuestionNo()); + m.put("phase", turn.getPhase().name()); + m.put("type", turn.getType().name()); + m.put("content", turn.getContent()); + if (turn.getRating() != null) { + m.put("rating", turn.getRating().name()); + } + return m; + }).toList()); + return body; + } + + private Map fallbackFinalResult(QuestionPlanResponse plan, + MockInterviewSession session, + boolean early) { + Map m = new HashMap<>(); + Map scores = new HashMap<>(); + scores.put("framework", null); + scores.put("design", null); + scores.put("problemSolving", null); + scores.put("csInfra", null); + scores.put("communication", null); + m.put("scores", scores); + m.put("overallScore", null); + m.put("summary", "AI 결과 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."); + m.put("strengths", List.of()); + m.put("improvements", List.of()); + m.put("actionItems", List.of()); + m.put("uncoveredKeywords", plan.jdGapKeywords()); + m.put("perQuestion", List.of()); + m.put("notice", "fallback"); + return m; + } + + private double coverageFactor(int answered, boolean early) { + if (!early) return 1.0; + double ratio = (double) answered / MockInterviewPlanner.TOTAL_QUESTIONS; + if (ratio <= 0) return 0.0; + if (ratio >= 1.0) return 1.0; + return Math.round(ratio * 100.0) / 100.0; + } + + private QuestionPlanResponse readPlan(MockInterviewSession session) { + try { + return objectMapper.readValue(session.getPlanJson(), new TypeReference() {}); + } catch (Exception e) { + log.warn("[mock-finalize] plan parse failed sessionId={}", session.getId()); + com.devpick.domain.job.entity.JobPostingCategory category = + session.getJobCategory() != null + ? parseCategory(session.getJobCategory()) + : com.devpick.domain.job.entity.JobPostingCategory.FRONTEND; + return planner.plan(category, session.getJobTitle(), session.getCompanyName(), + List.of(), List.of(), List.of()); + } + } + + private com.devpick.domain.job.entity.JobPostingCategory parseCategory(String value) { + try { + return com.devpick.domain.job.entity.JobPostingCategory.valueOf( + value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + return com.devpick.domain.job.entity.JobPostingCategory.FRONTEND; + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java index 3797ce4..d427472 100644 --- a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java @@ -20,15 +20,11 @@ import com.devpick.domain.job.entity.MockInterviewStatus; import com.devpick.domain.job.entity.MockInterviewTurn; import com.devpick.domain.job.entity.MockInterviewTurnType; +import com.devpick.domain.job.event.MockInterviewFinalizeEvent; import com.devpick.domain.job.repository.JobPostingRepository; import com.devpick.domain.job.repository.MockInterviewSessionRepository; -import com.devpick.domain.point.entity.PointAction; -import com.devpick.domain.point.service.PointService; -import com.devpick.domain.report.entity.History; -import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.resume.repository.MasterResumeRepository; import com.devpick.domain.resume.service.ResumeCryptoService; -import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; import com.fasterxml.jackson.core.type.TypeReference; @@ -36,6 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,7 +49,6 @@ @RequiredArgsConstructor public class MockInterviewService { - /** 완료/진행 중 합산 저장 한도 */ public static final int HISTORY_LIMIT = 20; private final MockInterviewSessionRepository sessionRepository; @@ -63,13 +59,12 @@ public class MockInterviewService { private final MockInterviewModelRegistry modelRegistry; private final JobAiClient jobAiClient; private final ObjectMapper objectMapper; - private final HistoryRepository historyRepository; - private final UserRepository userRepository; - private final PointService pointService; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public HistoryListResponse listForUser(UUID userId) { - List sessions = sessionRepository.findAllByUserIdWithJobOrderByUpdatedAtDesc(userId); + List sessions = + sessionRepository.findAllByUserIdWithJobOrderByUpdatedAtDesc(userId); List items = sessions.stream().map(this::toListItem).toList(); return new HistoryListResponse(items, HISTORY_LIMIT); } @@ -116,8 +111,7 @@ public SessionDetailResponse startFromJob(UUID userId, UUID jobId, StartFromJobR @Transactional public SessionDetailResponse startFromJd(UUID userId, StartFromJdRequest request) { - if (request == null - || request.jobTitle() == null || request.jobTitle().isBlank()) { + if (request == null || request.jobTitle() == null || request.jobTitle().isBlank()) { throw new DevpickException(ErrorCode.INVALID_INPUT); } String resumeJson = loadResumeJson(userId); @@ -161,7 +155,8 @@ public AnswerOutcome submitAnswer(UUID userId, UUID sessionId, AnswerRequest req throw new DevpickException(ErrorCode.INVALID_INPUT); } QuestionPlanResponse plan = readPlan(session); - QuestionPlanItem question = findQuestion(plan, request.questionNo() > 0 ? request.questionNo() : session.getCurrentQuestionIndex()); + QuestionPlanItem question = findQuestion( + plan, request.questionNo() > 0 ? request.questionNo() : session.getCurrentQuestionIndex()); boolean isFollowUpAnswer = lastTurnIsFollowUpQuestion(session, question.questionNo()); boolean isRetryAnswer = lastTurnIsRetryRequest(session, question.questionNo()); MockInterviewTurnType answerType = isFollowUpAnswer @@ -224,7 +219,8 @@ public AnswerOutcome submitAnswer(UUID userId, UUID sessionId, AnswerRequest req int nextNo = question.questionNo() + 1; if (nextNo > MockInterviewPlanner.TOTAL_QUESTIONS) { sessionCompleted = true; - finalizeSession(session, plan, false); + session.setStatus(MockInterviewStatus.PROCESSING); + eventPublisher.publishEvent(new MockInterviewFinalizeEvent(session.getId(), false)); } else { QuestionPlanItem nextQuestion = findQuestion(plan, nextNo); String prompt = nextQuestionPrompt != null && !nextQuestionPrompt.isBlank() @@ -265,7 +261,8 @@ public SessionDetailResponse passQuestion(UUID userId, UUID sessionId, int quest int nextNo = question.questionNo() + 1; if (nextNo > MockInterviewPlanner.TOTAL_QUESTIONS) { - finalizeSession(session, plan, false); + session.setStatus(MockInterviewStatus.PROCESSING); + eventPublisher.publishEvent(new MockInterviewFinalizeEvent(session.getId(), false)); } else { QuestionPlanItem nextQuestion = findQuestion(plan, nextNo); session.setCurrentQuestionIndex(nextNo); @@ -289,8 +286,8 @@ public SessionDetailResponse finishEarly(UUID userId, UUID sessionId) { if (session.getStatus() != MockInterviewStatus.IN_PROGRESS) { return toDetail(session); } - QuestionPlanResponse plan = readPlan(session); - finalizeSession(session, plan, true); + session.setStatus(MockInterviewStatus.PROCESSING); + eventPublisher.publishEvent(new MockInterviewFinalizeEvent(session.getId(), true)); return toDetail(sessionRepository.save(session)); } @@ -311,7 +308,7 @@ public void deleteMany(UUID userId, List sessionIds) { UUID sid = UUID.fromString(raw); sessionRepository.findByIdAndUserId(sid, userId).ifPresent(sessionRepository::delete); } catch (IllegalArgumentException ignored) { - // skip + // skip invalid UUID } } } @@ -362,9 +359,12 @@ private void applyHistoryLimit(UUID userId) { long total = sessionRepository.countByUserId(userId); long over = total - (HISTORY_LIMIT - 1); if (over <= 0) return; - List oldestCompleted = sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.COMPLETED); - List oldestEarly = sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.EARLY_FINISHED); - List oldestInProgress = sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.IN_PROGRESS); + List oldestCompleted = + sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.COMPLETED); + List oldestEarly = + sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.EARLY_FINISHED); + List oldestInProgress = + sessionRepository.findOldestByUserIdAndStatus(userId, MockInterviewStatus.IN_PROGRESS); List queue = new ArrayList<>(); queue.addAll(oldestCompleted); queue.addAll(oldestEarly); @@ -376,40 +376,6 @@ private void applyHistoryLimit(UUID userId) { } } - private void finalizeSession(MockInterviewSession session, QuestionPlanResponse plan, boolean early) { - session.setStatus(early ? MockInterviewStatus.EARLY_FINISHED : MockInterviewStatus.COMPLETED); - Map finalRequest = buildFinalizePayload(session, plan, early); - Map finalResult; - try { - finalResult = jobAiClient.finalizeMockInterview(finalRequest); - } catch (Exception e) { - log.warn("[mock-interview] finalize failed sessionId={} err={}", session.getId(), e.toString()); - finalResult = fallbackFinalResult(plan, session, early); - } - finalResult.put("earlyFinished", early); - finalResult.put("answeredCount", session.getAnsweredCount()); - finalResult.put("totalQuestions", MockInterviewPlanner.TOTAL_QUESTIONS); - finalResult.put("coverageFactor", coverageFactor(session.getAnsweredCount(), early)); - session.setResultJson(writeJson(finalResult)); - - userRepository.findByIdAndIsActiveTrue(session.getUserId()).ifPresent(user -> { - historyRepository.save(History.builder() - .user(user) - .actionType("mock_interview_completed") - .jobPosting(session.getJobPosting()) - .build()); - pointService.earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, session.getId()); - }); - } - - private double coverageFactor(int answered, boolean early) { - if (!early) return 1.0; - double ratio = (double) answered / MockInterviewPlanner.TOTAL_QUESTIONS; - if (ratio <= 0) return 0.0; - if (ratio >= 1.0) return 1.0; - return Math.round(ratio * 100.0) / 100.0; - } - private Map buildTurnPayload( MockInterviewSession session, QuestionPlanResponse plan, @@ -451,32 +417,6 @@ private Map turnToMap(MockInterviewTurn turn) { return m; } - private Map buildFinalizePayload(MockInterviewSession session, QuestionPlanResponse plan, boolean early) { - Map body = new HashMap<>(); - body.put("session_id", session.getId() != null ? session.getId().toString() : null); - body.put("model_key", session.getModelKey()); - body.put("job_title", session.getJobTitle()); - body.put("company_name", session.getCompanyName()); - body.put("job_category", session.getJobCategory()); - body.put("answered_count", session.getAnsweredCount()); - body.put("total_questions", MockInterviewPlanner.TOTAL_QUESTIONS); - body.put("early_finished", early); - body.put("plan", plan); - body.put("turns", session.getTurns().stream().map(turn -> { - Map m = new HashMap<>(); - m.put("orderNo", turn.getOrderNo()); - m.put("questionNo", turn.getQuestionNo()); - m.put("phase", turn.getPhase().name()); - m.put("type", turn.getType().name()); - m.put("content", turn.getContent()); - if (turn.getRating() != null) { - m.put("rating", turn.getRating().name()); - } - return m; - }).toList()); - return body; - } - private Map fallbackEvalResult() { Map m = new HashMap<>(); m.put("rating", MockInterviewRating.OK.name()); @@ -485,26 +425,6 @@ private Map fallbackEvalResult() { return m; } - private Map fallbackFinalResult(QuestionPlanResponse plan, MockInterviewSession session, boolean early) { - Map m = new HashMap<>(); - Map scores = new HashMap<>(); - scores.put("framework", null); - scores.put("design", null); - scores.put("problemSolving", null); - scores.put("csInfra", null); - scores.put("communication", null); - m.put("scores", scores); - m.put("overallScore", null); - m.put("summary", "AI 결과 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."); - m.put("strengths", List.of()); - m.put("improvements", List.of()); - m.put("actionItems", List.of()); - m.put("uncoveredKeywords", plan.jdGapKeywords()); - m.put("perQuestion", List.of()); - m.put("notice", "fallback"); - return m; - } - private QuestionPlanResponse buildPlan( JobPostingCategory category, String jobTitle, @@ -514,7 +434,8 @@ private QuestionPlanResponse buildPlan( String resumeJson ) { List resumeSkills = extractResumeSkills(resumeJson); - QuestionPlanResponse base = planner.plan(category, jobTitle, companyName, required, preferred, resumeSkills); + QuestionPlanResponse base = planner.plan(category, jobTitle, companyName, + required, preferred, resumeSkills); Map aiBody = new HashMap<>(); aiBody.put("job_title", nullSafe(jobTitle)); @@ -664,7 +585,8 @@ private QuestionPlanResponse readPlan(MockInterviewSession session) { JobPostingCategory category = session.getJobCategory() != null ? parseCategory(session.getJobCategory()) : JobPostingCategory.FRONTEND; - return planner.plan(category, session.getJobTitle(), session.getCompanyName(), List.of(), List.of(), List.of()); + return planner.plan(category, session.getJobTitle(), session.getCompanyName(), + List.of(), List.of(), List.of()); } } @@ -731,7 +653,8 @@ private TurnResponse toTurn(MockInterviewTurn turn) { Map meta = Map.of(); if (turn.getMetadataJson() != null && !turn.getMetadataJson().isBlank()) { try { - meta = objectMapper.readValue(turn.getMetadataJson(), new TypeReference>() {}); + meta = objectMapper.readValue(turn.getMetadataJson(), + new TypeReference>() {}); } catch (Exception e) { meta = Map.of(); } diff --git a/src/main/java/com/devpick/global/config/AsyncConfig.java b/src/main/java/com/devpick/global/config/AsyncConfig.java new file mode 100644 index 0000000..3bfdee0 --- /dev/null +++ b/src/main/java/com/devpick/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.devpick.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean("mockInterviewFinalizeExecutor") + public Executor mockInterviewFinalizeExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("mock-finalize-"); + executor.initialize(); + return executor; + } +} diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java new file mode 100644 index 0000000..77e82af --- /dev/null +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java @@ -0,0 +1,288 @@ +package com.devpick.domain.job.service; + +import com.devpick.domain.job.client.JobAiClient; +import com.devpick.domain.job.entity.MockInterviewMode; +import com.devpick.domain.job.entity.MockInterviewPhase; +import com.devpick.domain.job.entity.MockInterviewRating; +import com.devpick.domain.job.entity.MockInterviewSession; +import com.devpick.domain.job.entity.MockInterviewStatus; +import com.devpick.domain.job.entity.MockInterviewTurn; +import com.devpick.domain.job.entity.MockInterviewTurnType; +import com.devpick.domain.job.event.MockInterviewFinalizeEvent; +import com.devpick.domain.job.repository.MockInterviewSessionRepository; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; +import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class MockInterviewFinalizeServiceTest { + + @InjectMocks private MockInterviewFinalizeService finalizeService; + @Mock private MockInterviewSessionRepository sessionRepository; + @Mock private JobAiClient jobAiClient; + @Mock private HistoryRepository historyRepository; + @Mock private UserRepository userRepository; + @Mock private PointService pointService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private MockInterviewPlanner planner; + + private static final String MINIMAL_PLAN_JSON = """ + { + "questions": [], + "coreCsTopics": [], + "extendedCsTopics": [], + "jdGapKeywords": [], + "domainLabel": "Backend" + } + """; + + /** userFound=true 시 getJobPosting()까지 스텁 포함, false 시 제외 */ + private MockInterviewSession buildProcessingSession(UUID sessionId, UUID userId, + boolean early, boolean stubJobPosting) { + MockInterviewSession session = mock(MockInterviewSession.class); + given(session.getId()).willReturn(sessionId); + given(session.getUserId()).willReturn(userId); + given(session.getStatus()).willReturn(MockInterviewStatus.PROCESSING); + given(session.getPlanJson()).willReturn(MINIMAL_PLAN_JSON); + given(session.getCompanyName()).willReturn("카카오"); + given(session.getJobTitle()).willReturn("백엔드 개발자"); + given(session.getJobCategory()).willReturn("BACKEND"); + given(session.getModelKey()).willReturn("default"); + given(session.getAnsweredCount()).willReturn(early ? 8 : 15); + given(session.getTurns()).willReturn(new ArrayList<>()); + if (stubJobPosting) { + given(session.getJobPosting()).willReturn(null); + } + return session; + } + + private Map aiResult() { + Map result = new HashMap<>(); + result.put("overallScore", 80); + result.put("perQuestion", new ArrayList<>()); + result.put("strengths", new ArrayList<>()); + result.put("improvements", new ArrayList<>()); + result.put("actionItems", new ArrayList<>()); + result.put("uncoveredKeywords", new ArrayList<>()); + Map scores = new HashMap<>(); + scores.put("framework", 75); + result.put("scores", scores); + return result; + } + + @Test + @DisplayName("AI 호출 성공 시 COMPLETED 상태로 저장되고 히스토리·포인트가 기록된다") + void onFinalizeEvent_aiSuccess_savesCompletedResult() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + MockInterviewSession session = buildProcessingSession(sessionId, userId, false, true); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(session).should().setStatus(MockInterviewStatus.COMPLETED); + then(session).should().setResultJson(any()); + then(sessionRepository).should().save(session); + then(historyRepository).should().save(argThat(h -> + "mock_interview_completed".equals(h.getActionType()))); + then(pointService).should().earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, sessionId); + } + + @Test + @DisplayName("조기 종료 시 EARLY_FINISHED 상태로 저장된다") + void onFinalizeEvent_aiSuccess_early_savesEarlyFinishedResult() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + MockInterviewSession session = buildProcessingSession(sessionId, userId, true, true); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, true)); + + then(session).should().setStatus(MockInterviewStatus.EARLY_FINISHED); + then(sessionRepository).should().save(session); + } + + @Test + @DisplayName("AI 호출 실패 시 fallback 결과로 저장되고 히스토리·포인트는 정상 기록된다") + void onFinalizeEvent_aiFails_savesFallbackResult() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + MockInterviewSession session = buildProcessingSession(sessionId, userId, false, true); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("timeout")); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(session).should().setStatus(MockInterviewStatus.COMPLETED); + then(session).should().setResultJson(argThat(json -> json != null && json.contains("fallback"))); + then(sessionRepository).should().save(session); + then(historyRepository).should().save(any()); + then(pointService).should().earn(eq(user), eq(PointAction.MOCK_INTERVIEW_COMPLETE), eq(sessionId)); + } + + @Test + @DisplayName("세션을 찾을 수 없으면 아무것도 저장하지 않는다") + void onFinalizeEvent_sessionNotFound_skips() { + UUID sessionId = UUID.randomUUID(); + given(sessionRepository.findById(sessionId)).willReturn(Optional.empty()); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(sessionRepository).should(never()).save(any()); + then(historyRepository).should(never()).save(any()); + then(pointService).should(never()).earn(any(), any(), any()); + } + + @Test + @DisplayName("PROCESSING 상태가 아닌 세션은 중복 실행을 방지하여 건너뛴다") + void onFinalizeEvent_sessionNotProcessing_skips() { + UUID sessionId = UUID.randomUUID(); + MockInterviewSession session = mock(MockInterviewSession.class); + given(session.getStatus()).willReturn(MockInterviewStatus.COMPLETED); + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(jobAiClient).should(never()).finalizeMockInterview(any()); + then(sessionRepository).should(never()).save(any()); + then(historyRepository).should(never()).save(any()); + } + + @Test + @DisplayName("rating이 있는 turn을 포함한 세션도 정상 처리된다") + void onFinalizeEvent_withRatedTurns_buildsPayloadAndSaves() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + + MockInterviewTurn turnWithRating = mock(MockInterviewTurn.class); + given(turnWithRating.getOrderNo()).willReturn(0); + given(turnWithRating.getQuestionNo()).willReturn(1); + given(turnWithRating.getPhase()).willReturn(MockInterviewPhase.WARM_UP); + given(turnWithRating.getType()).willReturn(MockInterviewTurnType.ANSWER); + given(turnWithRating.getContent()).willReturn("답변 내용"); + given(turnWithRating.getRating()).willReturn(MockInterviewRating.GOOD); + + MockInterviewTurn turnNoRating = mock(MockInterviewTurn.class); + given(turnNoRating.getOrderNo()).willReturn(1); + given(turnNoRating.getQuestionNo()).willReturn(1); + given(turnNoRating.getPhase()).willReturn(MockInterviewPhase.WARM_UP); + given(turnNoRating.getType()).willReturn(MockInterviewTurnType.QUESTION); + given(turnNoRating.getContent()).willReturn("질문 내용"); + given(turnNoRating.getRating()).willReturn(null); + + MockInterviewSession session = buildProcessingSession(sessionId, userId, false, true); + given(session.getTurns()).willReturn(new ArrayList<>(List.of(turnWithRating, turnNoRating))); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(session).should().setStatus(MockInterviewStatus.COMPLETED); + then(sessionRepository).should().save(session); + } + + @Test + @DisplayName("planJson 파싱 실패 시 플래너 fallback plan을 사용한다") + void onFinalizeEvent_invalidPlanJson_usesFallbackPlan() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + + MockInterviewSession session = buildProcessingSession(sessionId, userId, false, true); + given(session.getPlanJson()).willReturn("invalid-json{{{"); + given(session.getJobCategory()).willReturn(null); + + com.devpick.domain.job.dto.MockInterviewModels.QuestionPlanResponse fallbackPlan = + new com.devpick.domain.job.dto.MockInterviewModels.QuestionPlanResponse( + List.of(), List.of(), List.of(), List.of(), "Backend"); + given(planner.plan(any(), any(), any(), any(), any(), any())).willReturn(fallbackPlan); + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(planner).should().plan(any(), any(), any(), any(), any(), any()); + then(session).should().setStatus(MockInterviewStatus.COMPLETED); + } + + @Test + @DisplayName("조기 종료 시 answeredCount가 0이면 coverageFactor가 0.0이다") + void onFinalizeEvent_earlyWithZeroAnswers_coverageFactorZero() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + User user = mock(User.class); + + MockInterviewSession session = buildProcessingSession(sessionId, userId, true, true); + given(session.getAnsweredCount()).willReturn(0); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, true)); + + then(session).should().setResultJson(argThat(json -> + json != null && json.contains("\"coverageFactor\":0.0"))); + then(session).should().setStatus(MockInterviewStatus.EARLY_FINISHED); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 히스토리·포인트는 기록되지 않는다") + void onFinalizeEvent_userNotFound_skipsHistoryAndPoint() { + UUID sessionId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + // jobPosting은 user가 없으면 호출되지 않으므로 스텁 제외 + MockInterviewSession session = buildProcessingSession(sessionId, userId, false, false); + + given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); + + finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + + then(session).should().setStatus(MockInterviewStatus.COMPLETED); + then(sessionRepository).should().save(session); + then(historyRepository).should(never()).save(any()); + then(pointService).should(never()).earn(any(), any(), any()); + } +} diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java index cdaf8dd..f2ad137 100644 --- a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java @@ -1,36 +1,40 @@ package com.devpick.domain.job.service; +import com.devpick.domain.job.client.JobAiClient; +import com.devpick.domain.job.dto.MockInterviewModels.AnswerOutcome; +import com.devpick.domain.job.dto.MockInterviewModels.AnswerRequest; import com.devpick.domain.job.entity.MockInterviewMode; import com.devpick.domain.job.entity.MockInterviewPhase; import com.devpick.domain.job.entity.MockInterviewSession; import com.devpick.domain.job.entity.MockInterviewStatus; +import com.devpick.domain.job.event.MockInterviewFinalizeEvent; import com.devpick.domain.job.repository.JobPostingRepository; import com.devpick.domain.job.repository.MockInterviewSessionRepository; -import com.devpick.domain.point.entity.PointAction; -import com.devpick.domain.point.service.PointService; -import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.resume.repository.MasterResumeRepository; import com.devpick.domain.resume.service.ResumeCryptoService; -import com.devpick.domain.user.entity.User; -import com.devpick.domain.user.repository.UserRepository; import com.fasterxml.jackson.databind.ObjectMapper; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class MockInterviewServiceTest { @@ -42,11 +46,9 @@ class MockInterviewServiceTest { @Mock private ResumeCryptoService resumeCryptoService; @Mock private MockInterviewPlanner planner; @Mock private MockInterviewModelRegistry modelRegistry; - @Mock private com.devpick.domain.job.client.JobAiClient jobAiClient; + @Mock private JobAiClient jobAiClient; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private HistoryRepository historyRepository; - @Mock private UserRepository userRepository; - @Mock private PointService pointService; + @Mock private ApplicationEventPublisher eventPublisher; private static final String MINIMAL_PLAN_JSON = """ { @@ -58,16 +60,10 @@ class MockInterviewServiceTest { } """; - @Test - @DisplayName("모의면접 조기 종료 시 activity 히스토리 및 포인트가 기록된다") - void finishEarly_recordsHistoryAndPoint() { - UUID userId = UUID.randomUUID(); - UUID sessionId = UUID.randomUUID(); - User user = mock(User.class); - + /** MockInterviewService에서 getUserId()는 더 이상 사용하지 않으므로 스텁 제외 */ + private MockInterviewSession buildInProgressSession(UUID sessionId) { MockInterviewSession session = mock(MockInterviewSession.class); given(session.getId()).willReturn(sessionId); - given(session.getUserId()).willReturn(userId); given(session.getStatus()).willReturn(MockInterviewStatus.IN_PROGRESS); given(session.getPlanJson()).willReturn(MINIMAL_PLAN_JSON); given(session.getJobPosting()).willReturn(null); @@ -78,59 +74,167 @@ void finishEarly_recordsHistoryAndPoint() { given(session.getMode()).willReturn(MockInterviewMode.FULL); given(session.getModelKey()).willReturn("default"); given(session.getPhase()).willReturn(MockInterviewPhase.WARM_UP); - given(session.getAnsweredCount()).willReturn(3); - given(session.getCurrentQuestionIndex()).willReturn(2); + given(session.getAnsweredCount()).willReturn(14); + given(session.getCurrentQuestionIndex()).willReturn(MockInterviewPlanner.TOTAL_QUESTIONS); given(session.getTurns()).willReturn(new ArrayList<>()); given(session.getResultJson()).willReturn(null); given(session.getCreatedAt()).willReturn(null); given(session.getUpdatedAt()).willReturn(null); + return session; + } + + // ── finishEarly ────────────────────────────────────────────────────── + + @Test + @DisplayName("여기서 마치기 — 세션을 PROCESSING으로 변경하고 FinalizeEvent(early=true)를 발행한다") + void finishEarly_setsProcessingAndPublishesEvent() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + MockInterviewSession session = buildInProgressSession(sessionId); given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); - given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("AI unavailable")); - given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); given(sessionRepository.save(session)).willReturn(session); mockInterviewService.finishEarly(userId, sessionId); - then(historyRepository).should().save(argThat(h -> - "mock_interview_completed".equals(h.getActionType()))); - then(pointService).should().earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, sessionId); + then(session).should().setStatus(MockInterviewStatus.PROCESSING); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockInterviewFinalizeEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().sessionId()).isEqualTo(sessionId); + assertThat(captor.getValue().early()).isTrue(); } @Test - @DisplayName("사용자가 없으면 모의면접 완료 히스토리가 기록되지 않는다") - void finishEarly_userNotFound_doesNotRecordHistory() { + @DisplayName("여기서 마치기 — 이미 PROCESSING 상태면 이벤트를 발행하지 않는다") + void finishEarly_alreadyProcessing_doesNotPublishEvent() { UUID userId = UUID.randomUUID(); UUID sessionId = UUID.randomUUID(); - MockInterviewSession session = mock(MockInterviewSession.class); given(session.getId()).willReturn(sessionId); - given(session.getUserId()).willReturn(userId); - given(session.getStatus()).willReturn(MockInterviewStatus.IN_PROGRESS); + given(session.getStatus()).willReturn(MockInterviewStatus.PROCESSING); given(session.getPlanJson()).willReturn(MINIMAL_PLAN_JSON); given(session.getJobPosting()).willReturn(null); - given(session.getCompanyName()).willReturn("카카오"); given(session.getJobTitle()).willReturn("백엔드 개발자"); + given(session.getCompanyName()).willReturn("카카오"); given(session.getJobCategory()).willReturn("BACKEND"); given(session.getRawJdText()).willReturn(""); given(session.getMode()).willReturn(MockInterviewMode.FULL); given(session.getModelKey()).willReturn("default"); given(session.getPhase()).willReturn(MockInterviewPhase.WARM_UP); - given(session.getAnsweredCount()).willReturn(3); - given(session.getCurrentQuestionIndex()).willReturn(2); + given(session.getAnsweredCount()).willReturn(5); + given(session.getCurrentQuestionIndex()).willReturn(6); given(session.getTurns()).willReturn(new ArrayList<>()); given(session.getResultJson()).willReturn(null); given(session.getCreatedAt()).willReturn(null); given(session.getUpdatedAt()).willReturn(null); + given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); + + mockInterviewService.finishEarly(userId, sessionId); + + then(eventPublisher).should(never()).publishEvent(any()); + } + // ── submitAnswer (마지막 문항) ──────────────────────────────────────── + + @Test + @DisplayName("마지막 문항 답변 제출 시 PROCESSING 상태로 변경하고 FinalizeEvent(early=false)를 발행한다") + void submitAnswer_lastQuestion_setsProcessingAndPublishesEvent() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + MockInterviewSession session = buildInProgressSession(sessionId); + given(session.getPlanJson()).willReturn(buildPlanJsonWithQuestion(MockInterviewPlanner.TOTAL_QUESTIONS)); + + // rating 없이 decision=next만 반환 → getTurns().get() 호출 방지 + Map evalResult = new HashMap<>(); + evalResult.put("decision", "next"); + given(jobAiClient.evaluateMockTurn(any())).willReturn(evalResult); given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); - given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("AI unavailable")); - given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); given(sessionRepository.save(session)).willReturn(session); - mockInterviewService.finishEarly(userId, sessionId); + AnswerOutcome outcome = mockInterviewService.submitAnswer( + userId, sessionId, + new AnswerRequest(MockInterviewPlanner.TOTAL_QUESTIONS, "마지막 답변 내용")); + + assertThat(outcome.sessionCompleted()).isTrue(); + then(session).should().setStatus(MockInterviewStatus.PROCESSING); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockInterviewFinalizeEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().sessionId()).isEqualTo(sessionId); + assertThat(captor.getValue().early()).isFalse(); + } + + @Test + @DisplayName("마지막 문항이 아니면 PROCESSING 상태로 변경하지 않고 이벤트를 발행하지 않는다") + void submitAnswer_notLastQuestion_doesNotPublishEvent() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + MockInterviewSession session = buildInProgressSession(sessionId); + given(session.getPlanJson()).willReturn(buildPlanJsonWithTwoQuestions()); + given(session.getCurrentQuestionIndex()).willReturn(1); + given(session.getAnsweredCount()).willReturn(0); + + Map evalResult = new HashMap<>(); + evalResult.put("decision", "next"); + given(jobAiClient.evaluateMockTurn(any())).willReturn(evalResult); + given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); + given(sessionRepository.save(session)).willReturn(session); + + mockInterviewService.submitAnswer(userId, sessionId, new AnswerRequest(1, "중간 답변")); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + // ── passQuestion (마지막 문항) ──────────────────────────────────────── + + @Test + @DisplayName("마지막 문항 패스 시 PROCESSING 상태로 변경하고 FinalizeEvent를 발행한다") + void passQuestion_lastQuestion_setsProcessingAndPublishesEvent() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + MockInterviewSession session = buildInProgressSession(sessionId); + given(session.getPlanJson()).willReturn(buildPlanJsonWithQuestion(MockInterviewPlanner.TOTAL_QUESTIONS)); + + given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); + given(sessionRepository.save(session)).willReturn(session); + + mockInterviewService.passQuestion(userId, sessionId, MockInterviewPlanner.TOTAL_QUESTIONS); + + then(session).should().setStatus(MockInterviewStatus.PROCESSING); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockInterviewFinalizeEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().early()).isFalse(); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private String buildPlanJsonWithQuestion(int questionNo) { + return """ + { + "questions": [{"questionNo":%d,"phase":"BEHAVIORAL","topic":"행동","prompt":"질문","keywords":[]}], + "coreCsTopics": [], + "extendedCsTopics": [], + "jdGapKeywords": [], + "domainLabel": "Backend" + } + """.formatted(questionNo); + } - then(historyRepository).should(org.mockito.Mockito.never()).save(any()); - then(pointService).should(org.mockito.Mockito.never()).earn(any(), any(), any()); + private String buildPlanJsonWithTwoQuestions() { + return """ + { + "questions": [ + {"questionNo":1,"phase":"WARM_UP","topic":"자기소개","prompt":"자기소개 해주세요","keywords":[]}, + {"questionNo":2,"phase":"PROJECT","topic":"프로젝트","prompt":"프로젝트 설명","keywords":[]} + ], + "coreCsTopics": [], + "extendedCsTopics": [], + "jdGapKeywords": [], + "domainLabel": "Backend" + } + """; } } From d079421c2de2e6d808377c8b464e1b547a7e8a37 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Wed, 13 May 2026 01:24:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?DP-481:=20Spring=206.1=20@Async+@Transactio?= =?UTF-8?q?nalEventListener=20=EC=A0=9C=ED=95=9C=20=EC=9A=B0=ED=9A=8C=20?= =?UTF-8?q?=E2=80=94=20Executor=20=EC=A7=81=EC=A0=91=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MockInterviewFinalizeService.java | 22 +++++++++++++++---- .../MockInterviewFinalizeServiceTest.java | 19 ++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java b/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java index defa131..5955c7f 100644 --- a/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java @@ -15,7 +15,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; @@ -27,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executor; @Slf4j @Service @@ -41,11 +44,23 @@ public class MockInterviewFinalizeService { private final ObjectMapper objectMapper; private final MockInterviewPlanner planner; - @Async("mockInterviewFinalizeExecutor") + @Autowired + @Qualifier("mockInterviewFinalizeExecutor") + private Executor finalizeExecutor; + + @Autowired + @Lazy + private MockInterviewFinalizeService self; + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional public void onFinalizeEvent(MockInterviewFinalizeEvent event) { UUID sessionId = event.sessionId(); + boolean early = event.early(); + finalizeExecutor.execute(() -> self.doFinalize(sessionId, early)); + } + + @Transactional + public void doFinalize(UUID sessionId, boolean early) { MockInterviewSession session = sessionRepository.findById(sessionId).orElse(null); if (session == null) { log.warn("[mock-finalize] session not found sessionId={}", sessionId); @@ -57,7 +72,6 @@ public void onFinalizeEvent(MockInterviewFinalizeEvent event) { return; } - boolean early = event.early(); QuestionPlanResponse plan = readPlan(session); Map finalRequest = buildFinalizePayload(session, plan, early); Map finalResult; diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java index 77e82af..775fabb 100644 --- a/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java @@ -8,7 +8,6 @@ import com.devpick.domain.job.entity.MockInterviewStatus; import com.devpick.domain.job.entity.MockInterviewTurn; import com.devpick.domain.job.entity.MockInterviewTurnType; -import com.devpick.domain.job.event.MockInterviewFinalizeEvent; import com.devpick.domain.job.repository.MockInterviewSessionRepository; import com.devpick.domain.point.entity.PointAction; import com.devpick.domain.point.service.PointService; @@ -107,7 +106,7 @@ void onFinalizeEvent_aiSuccess_savesCompletedResult() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(session).should().setStatus(MockInterviewStatus.COMPLETED); then(session).should().setResultJson(any()); @@ -129,7 +128,7 @@ void onFinalizeEvent_aiSuccess_early_savesEarlyFinishedResult() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, true)); + finalizeService.doFinalize(sessionId, true); then(session).should().setStatus(MockInterviewStatus.EARLY_FINISHED); then(sessionRepository).should().save(session); @@ -147,7 +146,7 @@ void onFinalizeEvent_aiFails_savesFallbackResult() { given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("timeout")); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(session).should().setStatus(MockInterviewStatus.COMPLETED); then(session).should().setResultJson(argThat(json -> json != null && json.contains("fallback"))); @@ -162,7 +161,7 @@ void onFinalizeEvent_sessionNotFound_skips() { UUID sessionId = UUID.randomUUID(); given(sessionRepository.findById(sessionId)).willReturn(Optional.empty()); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(sessionRepository).should(never()).save(any()); then(historyRepository).should(never()).save(any()); @@ -177,7 +176,7 @@ void onFinalizeEvent_sessionNotProcessing_skips() { given(session.getStatus()).willReturn(MockInterviewStatus.COMPLETED); given(sessionRepository.findById(sessionId)).willReturn(Optional.of(session)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(jobAiClient).should(never()).finalizeMockInterview(any()); then(sessionRepository).should(never()).save(any()); @@ -214,7 +213,7 @@ void onFinalizeEvent_withRatedTurns_buildsPayloadAndSaves() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(session).should().setStatus(MockInterviewStatus.COMPLETED); then(sessionRepository).should().save(session); @@ -239,7 +238,7 @@ void onFinalizeEvent_invalidPlanJson_usesFallbackPlan() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(planner).should().plan(any(), any(), any(), any(), any(), any()); then(session).should().setStatus(MockInterviewStatus.COMPLETED); @@ -259,7 +258,7 @@ void onFinalizeEvent_earlyWithZeroAnswers_coverageFactorZero() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, true)); + finalizeService.doFinalize(sessionId, true); then(session).should().setResultJson(argThat(json -> json != null && json.contains("\"coverageFactor\":0.0"))); @@ -278,7 +277,7 @@ void onFinalizeEvent_userNotFound_skipsHistoryAndPoint() { given(jobAiClient.finalizeMockInterview(any())).willReturn(aiResult()); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); - finalizeService.onFinalizeEvent(new MockInterviewFinalizeEvent(sessionId, false)); + finalizeService.doFinalize(sessionId, false); then(session).should().setStatus(MockInterviewStatus.COMPLETED); then(sessionRepository).should().save(session); From ea23f958f06e077e0ae281e7f42f6e5f50ae8e45 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Wed, 13 May 2026 01:36:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?DP-481:=20MockInterviewServiceTest=20?= =?UTF-8?q?=E2=80=94=20listForUser,=20startFromJd=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EC=99=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/service/MockInterviewServiceTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java index f2ad137..02ddf53 100644 --- a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java @@ -3,6 +3,9 @@ import com.devpick.domain.job.client.JobAiClient; import com.devpick.domain.job.dto.MockInterviewModels.AnswerOutcome; import com.devpick.domain.job.dto.MockInterviewModels.AnswerRequest; +import com.devpick.domain.job.dto.MockInterviewModels.HistoryListResponse; +import com.devpick.domain.job.dto.MockInterviewModels.StartFromJdRequest; +import com.devpick.global.common.exception.DevpickException; import com.devpick.domain.job.entity.MockInterviewMode; import com.devpick.domain.job.entity.MockInterviewPhase; import com.devpick.domain.job.entity.MockInterviewSession; @@ -25,11 +28,13 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -209,6 +214,44 @@ void passQuestion_lastQuestion_setsProcessingAndPublishesEvent() { assertThat(captor.getValue().early()).isFalse(); } + // ── listForUser ────────────────────────────────────────────────────── + + @Test + @DisplayName("listForUser — 세션 없음 시 빈 목록을 반환한다") + void listForUser_noSessions_returnsEmptyList() { + UUID userId = UUID.randomUUID(); + given(sessionRepository.findAllByUserIdWithJobOrderByUpdatedAtDesc(userId)).willReturn(List.of()); + + HistoryListResponse result = mockInterviewService.listForUser(userId); + + assertThat(result.sessions()).isEmpty(); + } + + // ── startFromJd validation ──────────────────────────────────────────── + + @Test + @DisplayName("startFromJd — null request 시 DevpickException을 던진다") + void startFromJd_nullRequest_throwsException() { + assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), null)) + .isInstanceOf(DevpickException.class); + } + + @Test + @DisplayName("startFromJd — jobTitle null 시 DevpickException을 던진다") + void startFromJd_nullTitle_throwsException() { + StartFromJdRequest req = new StartFromJdRequest("카카오", null, "BACKEND", "", null, null, null); + assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), req)) + .isInstanceOf(DevpickException.class); + } + + @Test + @DisplayName("startFromJd — jobTitle 공백 시 DevpickException을 던진다") + void startFromJd_blankTitle_throwsException() { + StartFromJdRequest req = new StartFromJdRequest("카카오", " ", "BACKEND", "", null, null, null); + assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), req)) + .isInstanceOf(DevpickException.class); + } + // ── helpers ────────────────────────────────────────────────────────── private String buildPlanJsonWithQuestion(int questionNo) {