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..5955c7f --- /dev/null +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewFinalizeService.java @@ -0,0 +1,194 @@ +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.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; +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; +import java.util.concurrent.Executor; + +@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; + + @Autowired + @Qualifier("mockInterviewFinalizeExecutor") + private Executor finalizeExecutor; + + @Autowired + @Lazy + private MockInterviewFinalizeService self; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + 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); + return; + } + if (session.getStatus() != MockInterviewStatus.PROCESSING) { + log.warn("[mock-finalize] session not in PROCESSING state, skipping sessionId={} status={}", + sessionId, session.getStatus()); + return; + } + + 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..775fabb --- /dev/null +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewFinalizeServiceTest.java @@ -0,0 +1,287 @@ +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.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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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.doFinalize(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..02ddf53 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,45 @@ 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.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; 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.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.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 +51,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 +65,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 +79,205 @@ 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(); + } + + // ── 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) { + 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" + } + """; } }