diff --git a/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewController.java b/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewController.java index b100953..7168710 100644 --- a/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewController.java +++ b/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewController.java @@ -5,6 +5,11 @@ import com.example.trouble_log.domain.interview.dto.ReportResponse; import com.example.trouble_log.domain.interview.service.InterviewService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -19,20 +24,46 @@ public class InterviewController { private final InterviewService interviewService; - @Operation(summary = "답변 제출", description = "면접 질문에 대한 답변을 저장하고 AI 피드백을 반환합니다.") + @Operation( + summary = "면접 질문 답변 저장 및 AI 피드백 생성", + description = "특정 프로젝트 세션의 면접 질문에 대한 답변을 저장합니다. 답변이 비어 있으면 스킵으로 처리하고, 답변이 있으면 Azure OpenAI 피드백을 생성해 개선 제안과 주의 메시지를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "답변 저장 성공", + content = @Content(schema = @Schema(implementation = AnswerResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 본문 누락"), + @ApiResponse(responseCode = "403", description = "해당 세션의 질문이 아님"), + @ApiResponse(responseCode = "404", description = "세션, 질문 또는 사전 컨텍스트를 찾을 수 없음") + }) @PostMapping("/{sessionId}/answers/{questionId}") public ResponseEntity saveAnswer( + @Parameter(description = "프로젝트 세션 ID", example = "1", required = true) @PathVariable Long sessionId, + @Parameter(description = "답변을 저장할 면접 질문 ID", example = "1", required = true) @PathVariable Long questionId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원 ID와 답변 내용. 답변이 null 또는 공백이면 스킵으로 저장됩니다.", + required = true, + content = @Content(schema = @Schema(implementation = AnswerRequest.class)) + ) @RequestBody AnswerRequest request ) { return ResponseEntity.status(HttpStatus.CREATED) .body(interviewService.saveAnswer(sessionId, questionId, request)); } - @Operation(summary = "리포트 생성", description = "저장된 Q&A를 바탕으로 트러블슈팅 리포트를 생성합니다.") + @Operation( + summary = "트러블슈팅 리포트 생성", + description = "저장된 면접 질문과 답변, 사전 컨텍스트, 코드 평가 결과를 바탕으로 마크다운 리포트와 역량 레이더 점수를 생성합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리포트 생성 성공", + content = @Content(schema = @Schema(implementation = ReportResponse.class))), + @ApiResponse(responseCode = "404", description = "세션 또는 사전 컨텍스트를 찾을 수 없음") + }) @PostMapping("/{sessionId}/report") public ResponseEntity generateReport( + @Parameter(description = "프로젝트 세션 ID", example = "1", required = true) @PathVariable Long sessionId ) { return ResponseEntity.ok(interviewService.generateReport(sessionId)); diff --git a/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewSubmissionController.java b/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewSubmissionController.java index 8f64772..04013cb 100644 --- a/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewSubmissionController.java +++ b/src/main/java/com/example/trouble_log/domain/interview/controller/InterviewSubmissionController.java @@ -3,6 +3,13 @@ import com.example.trouble_log.domain.interview.dto.InterviewSubmitRequest; import com.example.trouble_log.domain.interview.dto.InterviewSubmitResponse; import com.example.trouble_log.domain.interview.service.InterviewSubmissionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -14,13 +21,31 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/projects") +@Tag(name = "Interview Submission", description = "면접 답변 최종 제출 및 개인정보 감지 API") public class InterviewSubmissionController { private final InterviewSubmissionService interviewSubmissionService; + @Operation( + summary = "면접 답변 최종 제출", + description = "프로젝트 세션에 생성된 면접 질문 3개에 대한 최종 답변을 저장합니다. 답변 내 개인정보가 감지되면 제출을 차단하고 감지 유형과 경고 메시지를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "최종 제출 성공", + content = @Content(schema = @Schema(implementation = InterviewSubmitResponse.class))), + @ApiResponse(responseCode = "400", description = "필수 값 누락, 질문 3개 미준비, 답변 개수 불일치, 개인정보 감지"), + @ApiResponse(responseCode = "403", description = "해당 프로젝트 세션에 접근할 수 없음"), + @ApiResponse(responseCode = "404", description = "프로젝트 세션을 찾을 수 없음") + }) @PostMapping("/{sessionId}/interview/submit") public ResponseEntity submit( + @Parameter(description = "프로젝트 세션 ID", example = "1", required = true) @PathVariable Long sessionId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원 ID와 질문별 최종 답변 3개", + required = true, + content = @Content(schema = @Schema(implementation = InterviewSubmitRequest.class)) + ) @RequestBody InterviewSubmitRequest request ) { InterviewSubmitResponse response = interviewSubmissionService.submit(sessionId, request); diff --git a/src/main/java/com/example/trouble_log/domain/interview/entity/InterviewAnswer.java b/src/main/java/com/example/trouble_log/domain/interview/entity/InterviewAnswer.java index d386ff7..f148df3 100644 --- a/src/main/java/com/example/trouble_log/domain/interview/entity/InterviewAnswer.java +++ b/src/main/java/com/example/trouble_log/domain/interview/entity/InterviewAnswer.java @@ -46,7 +46,13 @@ public InterviewAnswer(InterviewQuestion interviewQuestion, String answer) { this(interviewQuestion, answer, false, null); // 생성자 2 호출 } - public void updateAnswer(String answer) { + public void update(String answer, boolean isSkipped, String feedback) { this.answer = answer; + this.isSkipped = isSkipped; + this.feedback = feedback; + } + + public void updateAnswer(String answer) { + update(answer, answer == null || answer.isBlank(), null); } } diff --git a/src/main/java/com/example/trouble_log/domain/interview/service/InterviewService.java b/src/main/java/com/example/trouble_log/domain/interview/service/InterviewService.java index 7be9d17..dee57b0 100644 --- a/src/main/java/com/example/trouble_log/domain/interview/service/InterviewService.java +++ b/src/main/java/com/example/trouble_log/domain/interview/service/InterviewService.java @@ -64,6 +64,11 @@ public AnswerResponse saveAnswer(Long sessionId, Long questionId, AnswerRequest .orElseThrow(() -> new ResponseStatusException( HttpStatus.NOT_FOUND, "사전 컨텍스트를 찾을 수 없습니다.")); + if (request == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "답변 요청 본문은 필수입니다."); + } + // 스킵 여부 판단 boolean isSkipped = request.getAnswer() == null || request.getAnswer().isBlank(); @@ -90,8 +95,9 @@ public AnswerResponse saveAnswer(Long sessionId, Long questionId, AnswerRequest warning = feedbackResult.getWarning(); } - InterviewAnswer interviewAnswer = new InterviewAnswer( - question, + InterviewAnswer interviewAnswer = answerRepository.findByInterviewQuestionId(questionId) + .orElseGet(() -> new InterviewAnswer(question, null, true, null)); + interviewAnswer.update( isSkipped ? null : request.getAnswer(), isSkipped, feedbackJson diff --git a/src/main/java/com/example/trouble_log/domain/projectSession/controller/ProjectSessionController.java b/src/main/java/com/example/trouble_log/domain/projectSession/controller/ProjectSessionController.java index 66066d0..38ec7c4 100644 --- a/src/main/java/com/example/trouble_log/domain/projectSession/controller/ProjectSessionController.java +++ b/src/main/java/com/example/trouble_log/domain/projectSession/controller/ProjectSessionController.java @@ -5,6 +5,13 @@ import com.example.trouble_log.domain.projectSession.dto.ProjectSessionRequest; import com.example.trouble_log.domain.projectSession.dto.ProjectSessionResponse; import com.example.trouble_log.domain.projectSession.service.ProjectSessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -17,19 +24,55 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/projects") +@Tag(name = "Project Session", description = "프로젝트 코드 입력, 사전 컨텍스트 저장, AI 면접 질문 생성 API") public class ProjectSessionController { private final ProjectSessionService projectSessionService; + @Operation( + summary = "프로젝트 세션 생성", + description = "회원이 입력한 소스코드와 선택적인 GitHub URL을 저장하고, 이후 사전 컨텍스트와 면접 질문 생성에 사용할 세션 ID를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "프로젝트 세션 생성 성공", + content = @Content(schema = @Schema(implementation = ProjectSessionResponse.class))), + @ApiResponse(responseCode = "400", description = "회원 ID 또는 소스코드 누락"), + @ApiResponse(responseCode = "404", description = "회원을 찾을 수 없음") + }) @PostMapping() - public ResponseEntity create(@RequestBody ProjectSessionRequest request) { + public ResponseEntity create( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "프로젝트 세션 생성을 위한 회원 ID, 소스코드, GitHub URL", + required = true, + content = @Content(schema = @Schema(implementation = ProjectSessionRequest.class)) + ) + @RequestBody ProjectSessionRequest request + ) { ProjectSessionResponse response = projectSessionService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @Operation( + summary = "사전 컨텍스트 저장 및 면접 질문 생성", + description = "프로젝트 목적, 기술 선택 이유, 예외 처리 방식, 프로젝트 규모를 저장한 뒤 Azure OpenAI로 면접 질문 3개를 생성합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "사전 컨텍스트 저장 및 질문 생성 성공", + content = @Content(schema = @Schema(implementation = PreContextResponse.class))), + @ApiResponse(responseCode = "400", description = "필수 사전 컨텍스트 값 누락"), + @ApiResponse(responseCode = "403", description = "해당 프로젝트 세션에 접근할 수 없음"), + @ApiResponse(responseCode = "404", description = "프로젝트 세션을 찾을 수 없음"), + @ApiResponse(responseCode = "409", description = "이미 사전 컨텍스트가 저장된 세션") + }) @PostMapping("/{sessionId}/pre-context") public ResponseEntity createPreContext( + @Parameter(description = "프로젝트 세션 ID", example = "1", required = true) @PathVariable Long sessionId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "면접 질문 생성을 위한 사전 컨텍스트", + required = true, + content = @Content(schema = @Schema(implementation = PreContextRequest.class)) + ) @RequestBody PreContextRequest request ) { PreContextResponse response = projectSessionService.createPreContext(sessionId, request); diff --git a/src/main/java/com/example/trouble_log/domain/user/controller/MemberController.java b/src/main/java/com/example/trouble_log/domain/user/controller/MemberController.java index 0c06c95..470a6a2 100644 --- a/src/main/java/com/example/trouble_log/domain/user/controller/MemberController.java +++ b/src/main/java/com/example/trouble_log/domain/user/controller/MemberController.java @@ -4,6 +4,12 @@ import com.example.trouble_log.domain.user.dto.LoginResponse; import com.example.trouble_log.domain.user.dto.SignUpRequest; import com.example.trouble_log.domain.user.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -15,24 +21,64 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/members") +@Tag(name = "Member", description = "회원가입, 로그인, 로그아웃 API") public class MemberController { private final MemberService memberService; + @Operation( + summary = "로그인", + description = "이메일과 비밀번호를 검증하고, 프론트에서 이후 API 호출에 사용할 회원 ID를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "400", description = "이메일 또는 비밀번호 누락"), + @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치") + }) @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "로그인에 사용할 이메일과 비밀번호", + required = true, + content = @Content(schema = @Schema(implementation = LoginRequest.class)) + ) + @RequestBody LoginRequest request + ) { LoginResponse response = memberService.login(request); return ResponseEntity.ok(response); } + @Operation( + summary = "로그아웃", + description = "현재는 서버 세션이나 토큰을 무효화하지 않고, 프론트 로그아웃 플로우를 위한 종료 응답만 반환합니다." + ) + @ApiResponse(responseCode = "204", description = "로그아웃 처리 완료") @PostMapping("/logout") public ResponseEntity logout() { memberService.logout(); return ResponseEntity.noContent().build(); } + @Operation( + summary = "회원가입", + description = "신규 회원을 생성하고, 생성된 회원 정보를 로그인 응답 형태로 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "회원가입 성공", + content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "400", description = "이메일, 비밀번호 또는 사용자 이름 누락"), + @ApiResponse(responseCode = "409", description = "이미 사용 중인 이메일") + }) @PostMapping("/signup") - public ResponseEntity signup(@RequestBody SignUpRequest request) { + public ResponseEntity signup( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원가입에 필요한 이메일, 비밀번호, 사용자 이름", + required = true, + content = @Content(schema = @Schema(implementation = SignUpRequest.class)) + ) + @RequestBody SignUpRequest request + ) { LoginResponse response = memberService.signup(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } diff --git a/src/test/java/com/example/trouble_log/domain/interview/service/InterviewServiceTest.java b/src/test/java/com/example/trouble_log/domain/interview/service/InterviewServiceTest.java new file mode 100644 index 0000000..57d924a --- /dev/null +++ b/src/test/java/com/example/trouble_log/domain/interview/service/InterviewServiceTest.java @@ -0,0 +1,102 @@ +package com.example.trouble_log.domain.interview.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.example.trouble_log.domain.ai.dto.AnswerFeedbackResult; +import com.example.trouble_log.domain.ai.service.AzureOpenAiPromptService; +import com.example.trouble_log.domain.ai.service.RadarScoreCalculator; +import com.example.trouble_log.domain.analysis.repository.AnalysisResultRepository; +import com.example.trouble_log.domain.interview.dto.AnswerRequest; +import com.example.trouble_log.domain.interview.dto.AnswerResponse; +import com.example.trouble_log.domain.interview.entity.InterviewAnswer; +import com.example.trouble_log.domain.interview.entity.InterviewQuestion; +import com.example.trouble_log.domain.interview.repository.InterviewAnswerRepository; +import com.example.trouble_log.domain.interview.repository.InterviewQuestionRepository; +import com.example.trouble_log.domain.projectSession.entity.PreContext; +import com.example.trouble_log.domain.projectSession.entity.ProjectSession; +import com.example.trouble_log.domain.projectSession.repository.PreContextRepository; +import com.example.trouble_log.domain.projectSession.repository.ProjectSessionRepository; +import com.example.trouble_log.domain.user.entity.Member; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class InterviewServiceTest { + + private InterviewAnswerRepository answerRepository; + private InterviewService interviewService; + + @BeforeEach + void setUp() { + InterviewQuestionRepository questionRepository = mock(InterviewQuestionRepository.class); + answerRepository = mock(InterviewAnswerRepository.class); + ProjectSessionRepository sessionRepository = mock(ProjectSessionRepository.class); + PreContextRepository preContextRepository = mock(PreContextRepository.class); + AzureOpenAiPromptService promptService = mock(AzureOpenAiPromptService.class); + + interviewService = new InterviewService( + questionRepository, + answerRepository, + sessionRepository, + preContextRepository, + promptService, + new ObjectMapper(), + mock(RadarScoreCalculator.class), + mock(AnalysisResultRepository.class) + ); + + ProjectSession projectSession = projectSession(); + InterviewQuestion question = new InterviewQuestion(projectSession, "질문입니다.", 1); + ReflectionTestUtils.setField(question, "id", 1L); + PreContext preContext = new PreContext(projectSession, "목적", "기술", "예외", "small"); + + AnswerFeedbackResult feedbackResult = new AnswerFeedbackResult(); + feedbackResult.setImprovement("개선 제안"); + feedbackResult.setWarning("주의"); + + when(sessionRepository.findById(anyLong())).thenReturn(Optional.of(projectSession)); + when(questionRepository.findById(anyLong())).thenReturn(Optional.of(question)); + when(preContextRepository.findByProjectSession(projectSession)).thenReturn(Optional.of(preContext)); + when(promptService.evaluateAnswer(any(ProjectSession.class), any(PreContext.class), any(), any())) + .thenReturn(feedbackResult); + when(answerRepository.save(any(InterviewAnswer.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void saveAnswerUpdatesExistingAnswerInsteadOfCreatingDuplicate() { + InterviewAnswer existingAnswer = new InterviewAnswer(question(), null, true, null); + ReflectionTestUtils.setField(existingAnswer, "id", 10L); + when(answerRepository.findByInterviewQuestionId(1L)).thenReturn(Optional.of(existingAnswer)); + + AnswerRequest request = new AnswerRequest(); + request.setAnswer("수정된 답변입니다."); + + AnswerResponse response = interviewService.saveAnswer(1L, 1L, request); + + assertThat(response.getAnswerId()).isEqualTo(10L); + assertThat(existingAnswer.getAnswer()).isEqualTo("수정된 답변입니다."); + assertThat(existingAnswer.isSkipped()).isFalse(); + assertThat(existingAnswer.getFeedback()).isNotBlank(); + } + + private ProjectSession projectSession() { + Member member = new Member("user@example.com", "password", "user"); + ReflectionTestUtils.setField(member, "id", 1L); + + ProjectSession projectSession = new ProjectSession(member, "code", null); + ReflectionTestUtils.setField(projectSession, "id", 1L); + return projectSession; + } + + private InterviewQuestion question() { + InterviewQuestion question = new InterviewQuestion(projectSession(), "질문입니다.", 1); + ReflectionTestUtils.setField(question, "id", 1L); + return question; + } +} diff --git a/src/test/java/com/example/trouble_log/domain/interview/service/InterviewSubmissionServiceTest.java b/src/test/java/com/example/trouble_log/domain/interview/service/InterviewSubmissionServiceTest.java index 0976597..ecfe5f7 100644 --- a/src/test/java/com/example/trouble_log/domain/interview/service/InterviewSubmissionServiceTest.java +++ b/src/test/java/com/example/trouble_log/domain/interview/service/InterviewSubmissionServiceTest.java @@ -124,6 +124,35 @@ void submitAllowsBlankFinalAnswerAsUnanswered() { assertThat(response.getReportGenerationReady()).isTrue(); } + @Test + void submitUpdatesExistingSkippedAnswerAsAnswered() { + ProjectSession projectSession = projectSession(); + InterviewAnswer existingAnswer = new InterviewAnswer(question(projectSession, 1L, "질문 1", 1), null, true, null); + ReflectionTestUtils.setField(existingAnswer, "id", 10L); + + InterviewSubmitRequest request = request(List.of( + answerRequest(1L, "최종 답변입니다."), + answerRequest(2L, "JPA 변경 감지를 활용했습니다."), + answerRequest(3L, "예외는 도메인별로 구분해 처리했습니다.") + )); + List questions = List.of( + existingAnswer.getInterviewQuestion(), + question(projectSession, 2L, "질문 2", 2), + question(projectSession, 3L, "질문 3", 3) + ); + + when(projectSessionRepository.findById(anyLong())).thenReturn(Optional.of(projectSession)); + when(interviewQuestionRepository.findByProjectSessionIdOrderByQuestionSequenceAsc(anyLong())) + .thenReturn(questions); + when(interviewAnswerRepository.findByInterviewQuestionId(1L)).thenReturn(Optional.of(existingAnswer)); + + InterviewSubmitResponse response = interviewSubmissionService.submit(1L, request); + + assertThat(response.getSubmitted()).isTrue(); + assertThat(existingAnswer.getAnswer()).isEqualTo("최종 답변입니다."); + assertThat(existingAnswer.isSkipped()).isFalse(); + } + private ProjectSession projectSession() { Member member = new Member("user@example.com", "password", "user"); ReflectionTestUtils.setField(member, "id", 1L);