diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java index 97320bb..b405498 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java @@ -83,7 +83,7 @@ public ApiResponse saveSelectedQuestions( ); } - @Operation(summary = "자소서 문항 답변 저장/수정", description = "저장된 문항의 내용과 답변을 함께 작성하거나 수정합니다.") + @Operation(summary = "자소서 문항 답변 저장/수정", description = "자소서 작성 화면의 문항 목록, 글자수 제한, 답변을 함께 저장합니다.") @PatchMapping("/answers") public ApiResponse saveAnswers( @AuthenticationPrincipal UserDetailsImpl userDetails, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java index cd72556..5cf5964 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java @@ -4,21 +4,27 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import java.util.List; public record QuestionAnswerSaveRequest( @Valid - @NotEmpty(message = "저장할 답변은 1개 이상이어야 합니다.") - List answers + @NotEmpty(message = "저장할 문항은 1개 이상이어야 합니다.") + @Size(max = 5, message = "문항은 최대 5개까지 저장할 수 있습니다.") + List questions ) { - public record AnswerItem( - @NotNull(message = "문항 ID는 필수입니다.") + public record QuestionItem( Long questionId, @NotBlank(message = "문항 내용은 필수입니다.") + @Size(min = 5, message = "문항은 최소 5자 이상이어야 합니다.") String content, + @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + Integer charLimit, + @NotNull(message = "답변 내용은 필수입니다.") String answer ) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Question.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Question.java index afe027f..e210a43 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Question.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Question.java @@ -54,4 +54,10 @@ public void updateContentAndAnswer(String content, String answer) { this.content = content; this.answer = answer; } + + public void updateContentLimitAndAnswer(String content, int limit, String answer) { + this.content = content; + this.limit = limit; + this.answer = answer; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java index d351d08..500771b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -182,26 +183,60 @@ public QuestionAnswerResponse saveAnswers( QuestionAnswerSaveRequest request ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - Map questionMap = questions.stream() + validateSelectionCount(request.questions().size()); + + List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + Map questionMap = existingQuestions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); + Set requestedQuestionIds = new HashSet<>(); + List syncedQuestions = new ArrayList<>(); - for (QuestionAnswerSaveRequest.AnswerItem item : request.answers()) { - Question question = questionMap.get(item.questionId()); - if (question == null) { + for (QuestionAnswerSaveRequest.QuestionItem item : request.questions()) { + if (item.questionId() == null) { + syncedQuestions.add(Question.create( + mockApply, + item.content().trim(), + resolveCharLimit(item.charLimit()), + normalizeAnswer(item.answer()) + )); + continue; + } + + if (!requestedQuestionIds.add(item.questionId())) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "중복된 문항 ID가 포함되어 있습니다. questionId=" + item.questionId() + ); + } + + Question existingQuestion = questionMap.get(item.questionId()); + if (existingQuestion == null) { throw new GeneralException( GeneralErrorCode.QUESTION_NOT_FOUND, "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + item.questionId() ); } - question.updateContentAndAnswer(item.content().trim(), normalizeAnswer(item.answer())); + + existingQuestion.updateContentLimitAndAnswer( + item.content().trim(), + resolveCharLimit(item.charLimit()), + normalizeAnswer(item.answer()) + ); + syncedQuestions.add(existingQuestion); } + List deletedQuestions = existingQuestions.stream() + .filter(question -> !requestedQuestionIds.contains(question.getId())) + .toList(); + questionRepository.deleteAll(deletedQuestions); + List savedQuestions = questionRepository.saveAll(syncedQuestions); + mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); + return new QuestionAnswerResponse( mockApply.getId(), mockApply.getStatus(), mockApplyRepository.calculateSequence(mockApply), - questions.stream().map(this::toQuestionResponse).toList() + savedQuestions.stream().map(this::toQuestionResponse).toList() ); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java index 660e9e3..e292f19 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -320,9 +320,10 @@ void saveAnswers() { Long questionId = selected.questions().get(0).questionId(); QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.AnswerItem( + new QuestionAnswerSaveRequest.QuestionItem( questionId, "지원 동기와 입사 후 목표를 작성해주세요.", + 1000, "저는 백엔드 개발 경험을 바탕으로 지원했습니다." ) ))); @@ -333,9 +334,54 @@ void saveAnswers() { assertThat(response.questions().get(0).answer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); Question savedQuestion = questionRepository.findById(questionId).orElseThrow(); assertThat(savedQuestion.getContent()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); + assertThat(savedQuestion.getLimit()).isEqualTo(1000); assertThat(savedQuestion.getAnswer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); } + @Test + @DisplayName("답변 저장 시 문항 추가 수정 삭제와 글자수 제한을 화면 상태대로 동기화한다") + void saveAnswersSyncsQuestionList() { + User user = saveUser("answer-sync@example.com"); + MockApply mockApply = saveMockApply(user); + QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("기존 문항 1", 700, false), + new QuestionSelectionSaveRequest.QuestionItem("기존 문항 2", 800, false) + ))); + Long retainedQuestionId = selected.questions().get(0).questionId(); + Long deletedQuestionId = selected.questions().get(1).questionId(); + + QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( + new QuestionAnswerSaveRequest.QuestionItem( + retainedQuestionId, + "수정된 기존 문항", + 1000, + "수정된 기존 문항 답변입니다." + ), + new QuestionAnswerSaveRequest.QuestionItem( + null, + "새로 추가한 문항", + 700, + "새로 추가한 문항 답변입니다." + ) + ))); + + assertThat(response.questions()).hasSize(2); + assertThat(response.questions()) + .extracting("content", "charLimit", "answer") + .containsExactly( + tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), + tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") + ); + assertThat(questionRepository.findById(deletedQuestionId)).isEmpty(); + assertThat(questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId())) + .extracting(Question::getContent, Question::getLimit, Question::getAnswer) + .containsExactly( + tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), + tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") + ); + assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + } + @Test @DisplayName("답변 저장 응답은 같은 공고 기준 현재 지원 순번을 반환한다") void saveAnswersReturnsSequence() { @@ -349,7 +395,7 @@ void saveAnswersReturnsSequence() { Long questionId = selected.questions().get(0).questionId(); QuestionAnswerResponse response = questionService.saveAnswers(user, secondMockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.AnswerItem(questionId, "재지원 답변 문항입니다.", "두 번째 지원 답변입니다.") + new QuestionAnswerSaveRequest.QuestionItem(questionId, "재지원 답변 문항입니다.", 700, "두 번째 지원 답변입니다.") ))); assertThat(response.mockApplyId()).isEqualTo(secondMockApply.getId()); @@ -368,7 +414,7 @@ void saveAnswersReturnsStoredSequence() { Long questionId = selected.questions().get(0).questionId(); QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.AnswerItem(questionId, "재지원 저장 순번 문항입니다.", "네 번째 지원 답변입니다.") + new QuestionAnswerSaveRequest.QuestionItem(questionId, "재지원 저장 순번 문항입니다.", 700, "네 번째 지원 답변입니다.") ))); assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); @@ -387,7 +433,7 @@ void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { Long otherQuestionId = otherSelected.questions().get(0).questionId(); assertThatThrownBy(() -> questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.AnswerItem(otherQuestionId, "다른 지원서 문항", "답변") + new QuestionAnswerSaveRequest.QuestionItem(otherQuestionId, "다른 지원서 문항", 700, "답변") )))) .isInstanceOf(GeneralException.class) .extracting("code")