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 new file mode 100644 index 0000000..c34adcc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java @@ -0,0 +1,82 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.service.QuestionService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mock-applies/{mockApplyId}/questions") +@Tag(name = "Question", description = "자소서 문항 선택 및 답변 저장 API") +public class QuestionController { + + private final QuestionService questionService; + + @Operation(summary = "문항 후보 목록 조회", description = "문항 선택 화면에서 사용할 기본 문항 후보와 선택 여부를 조회합니다.") + @GetMapping("/candidates") + public ApiResponse> getQuestionCandidates( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "문항 후보 목록 조회에 성공했습니다.", + questionService.getQuestionCandidates(userDetails.getUser(), mockApplyId) + ); + } + + @Operation(summary = "선택 문항 조회", description = "현재 모의 서류 지원에 저장된 선택 문항 목록을 조회합니다.") + @GetMapping + public ApiResponse getSelectedQuestions( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "선택 문항 조회에 성공했습니다.", + questionService.getSelectedQuestions(userDetails.getUser(), mockApplyId) + ); + } + + @Operation(summary = "선택 문항 저장", description = "사용자가 선택하거나 직접 추가한 문항을 저장하고 답변 작성 단계로 진입합니다.") + @PutMapping + public ApiResponse saveSelectedQuestions( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId, + @Valid @RequestBody QuestionSelectionSaveRequest request + ) { + return ApiResponse.onSuccess( + "선택 문항이 저장되었습니다.", + questionService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request) + ); + } + + @Operation(summary = "자소서 답변 저장/수정", description = "저장된 문항의 답변 내용을 작성하거나 수정합니다. 문항 내용은 수정하지 않습니다.") + @PutMapping("/answers") + public ApiResponse saveAnswers( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId, + @Valid @RequestBody QuestionAnswerSaveRequest request + ) { + return ApiResponse.onSuccess( + "자소서 답변이 저장되었습니다.", + questionService.saveAnswers(userDetails.getUser(), mockApplyId, request) + ); + } +} 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 new file mode 100644 index 0000000..0643cf3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java @@ -0,0 +1,22 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record QuestionAnswerSaveRequest( + @Valid + @NotEmpty(message = "저장할 답변은 1개 이상이어야 합니다.") + List answers +) { + public record AnswerItem( + @NotNull(message = "문항 ID는 필수입니다.") + Long questionId, + + @NotNull(message = "답변 내용은 필수입니다.") + String answer + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java new file mode 100644 index 0000000..6d7558a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java @@ -0,0 +1,27 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record QuestionSelectionSaveRequest( + @Valid + @NotEmpty(message = "선택 문항은 1개 이상이어야 합니다.") + @Size(max = 5, message = "문항은 최대 5개까지 선택할 수 있습니다.") + List questions +) { + public record QuestionItem( + @NotBlank(message = "문항 내용은 필수입니다.") + String content, + + @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + Integer charLimit, + + Boolean custom + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnswerResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnswerResponse.java new file mode 100644 index 0000000..abf0ecf --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnswerResponse.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +import java.util.List; + +public record QuestionAnswerResponse( + Long mockApplyId, + MockApplyStatus status, + List questions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java new file mode 100644 index 0000000..644b113 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record QuestionCandidateResponse( + String content, + int charLimit, + boolean selected +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionResponse.java new file mode 100644 index 0000000..2a12d0f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionResponse.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; + +public record QuestionResponse( + Long questionId, + String content, + int charLimit, + String answer +) { + public static QuestionResponse from(Question question) { + return new QuestionResponse( + question.getId(), + question.getContent(), + question.getLimit(), + question.getAnswer() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionSelectionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionSelectionResponse.java new file mode 100644 index 0000000..379f851 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionSelectionResponse.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +import java.util.List; + +public record QuestionSelectionResponse( + Long mockApplyId, + MockApplyStatus status, + List questions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionRepository.java index c53ff60..8ada435 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionRepository.java @@ -7,4 +7,6 @@ public interface QuestionRepository extends JpaRepository { List findAllByMockApplyId(Long mockApplyId); + List findAllByMockApplyIdOrderByIdAsc(Long mockApplyId); + void deleteAllByMockApplyId(Long mockApplyId); } 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 new file mode 100644 index 0000000..fd6ef28 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -0,0 +1,170 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuestionService { + + private static final int MIN_SELECTION_COUNT = 1; + private static final int MAX_SELECTION_COUNT = 5; + private static final int DEFAULT_CHAR_LIMIT = 1000; + + private static final List DEFAULT_CANDIDATES = List.of( + new QuestionCandidate("지원 동기와 입사 후 목표를 작성해주세요.", 700), + new QuestionCandidate("지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), + new QuestionCandidate("문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), + new QuestionCandidate("협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), + new QuestionCandidate("가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) + ); + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + + public List getQuestionCandidates(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + Set selectedContents = questionRepository.findAllByMockApplyId(mockApply.getId()).stream() + .map(Question::getContent) + .collect(Collectors.toSet()); + + return DEFAULT_CANDIDATES.stream() + .map(candidate -> new QuestionCandidateResponse( + candidate.content(), + candidate.charLimit(), + selectedContents.contains(candidate.content()) + )) + .toList(); + } + + public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() + .map(QuestionResponse::from) + .toList(); + + return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions); + } + + @Transactional + public QuestionSelectionResponse saveSelectedQuestions( + User user, + Long mockApplyId, + QuestionSelectionSaveRequest request + ) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + validateSelectionCount(request.questions().size()); + + List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); + questionRepository.deleteAll(existingQuestions); + + List questions = request.questions().stream() + .map(item -> Question.create( + mockApply, + item.content().trim(), + resolveCharLimit(item.charLimit()), + "" + )) + .toList(); + List savedQuestions = questionRepository.saveAll(questions); + mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); + + return new QuestionSelectionResponse( + mockApply.getId(), + mockApply.getStatus(), + savedQuestions.stream().map(QuestionResponse::from).toList() + ); + } + + @Transactional + public QuestionAnswerResponse saveAnswers( + User user, + Long mockApplyId, + QuestionAnswerSaveRequest request + ) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + + for (QuestionAnswerSaveRequest.AnswerItem item : request.answers()) { + Question question = questionMap.get(item.questionId()); + if (question == null) { + throw new GeneralException( + GeneralErrorCode.QUESTION_NOT_FOUND, + "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + item.questionId() + ); + } + question.updateAnswer(normalizeAnswer(item.answer())); + } + + return new QuestionAnswerResponse( + mockApply.getId(), + mockApply.getStatus(), + questions.stream().map(QuestionResponse::from).toList() + ); + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + private void validateSelectionCount(int count) { + if (count < MIN_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); + } + if (count > MAX_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다."); + } + } + + private int resolveCharLimit(Integer charLimit) { + if (charLimit == null) { + return DEFAULT_CHAR_LIMIT; + } + return charLimit; + } + + private String normalizeAnswer(String answer) { + if (StringUtils.hasText(answer)) { + return answer; + } + return ""; + } + + private record QuestionCandidate(String content, int charLimit) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index c528607..76f87d1 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -39,6 +39,10 @@ public enum GeneralErrorCode implements BaseErrorCode { JOB_POSTING_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4041", "채용 공고를 찾을 수 없습니다."), JOB_POSTING_ASYNC_TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4042", "채용 공고 비동기 작업을 찾을 수 없습니다."), + // 모의 서류 지원 에러 + MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."), + QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), + // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); 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 new file mode 100644 index 0000000..8c7aeac --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -0,0 +1,246 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; +import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class QuestionServiceTest { + + @Autowired + private QuestionService questionService; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private MockApplyRepository mockApplyRepository; + + @Autowired + private JobPostingRepository jobPostingRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private ClassificationRepository classificationRepository; + + @Autowired + private DetailClassificationRepository detailClassificationRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("선택 문항을 저장하고 지원 상태를 답변 작성 단계로 변경한다") + void saveSelectedQuestions() { + User user = saveUser("question-save@example.com"); + MockApply mockApply = saveMockApply(user); + QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("지원 동기를 작성해주세요.", 700, false), + new QuestionSelectionSaveRequest.QuestionItem("직접 추가한 문항입니다.", null, true) + )); + + QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), request); + + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(response.questions()).hasSize(2); + assertThat(response.questions().get(0).content()).isEqualTo("지원 동기를 작성해주세요."); + assertThat(response.questions().get(0).charLimit()).isEqualTo(700); + assertThat(response.questions().get(1).content()).isEqualTo("직접 추가한 문항입니다."); + assertThat(response.questions().get(1).charLimit()).isEqualTo(1000); + assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).hasSize(2); + } + + @Test + @DisplayName("문항 저장 시 기존 선택 문항을 새 선택 목록으로 교체한다") + void saveSelectedQuestionsReplacesExistingQuestions() { + User user = saveUser("question-replace@example.com"); + MockApply mockApply = saveMockApply(user); + questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("기존 문항 1", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("기존 문항 2", 500, false) + ))); + + QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("새 문항", 800, false) + ))); + + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).content()).isEqualTo("새 문항"); + assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())) + .extracting(Question::getContent) + .containsExactly("새 문항"); + } + + @Test + @DisplayName("문항 후보 목록은 이미 저장된 기본 문항을 선택 상태로 반환한다") + void getQuestionCandidatesMarksSelectedQuestion() { + User user = saveUser("question-candidates@example.com"); + MockApply mockApply = saveMockApply(user); + questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false) + ))); + + List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); + + assertThat(candidates).hasSize(5); + assertThat(candidates.get(0).selected()).isTrue(); + assertThat(candidates.get(1).selected()).isFalse(); + } + + @Test + @DisplayName("선택 문항은 1개 이상이어야 한다") + void saveSelectedQuestionsThrowsWhenEmpty() { + User user = saveUser("question-empty@example.com"); + MockApply mockApply = saveMockApply(user); + QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of()); + + assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("선택 문항은 최대 5개까지 저장할 수 있다") + void saveSelectedQuestionsThrowsWhenTooMany() { + User user = saveUser("question-too-many@example.com"); + MockApply mockApply = saveMockApply(user); + QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("문항 1", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("문항 2", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("문항 3", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("문항 4", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("문항 5", 500, false), + new QuestionSelectionSaveRequest.QuestionItem("문항 6", 500, false) + )); + + assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("다른 사용자의 지원서에는 문항을 저장할 수 없다") + void saveSelectedQuestionsThrowsWhenUserDoesNotOwnMockApply() { + User owner = saveUser("question-owner@example.com"); + User other = saveUser("question-other@example.com"); + MockApply mockApply = saveMockApply(owner); + QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("지원 동기", 700, false) + )); + + assertThatThrownBy(() -> questionService.saveSelectedQuestions(other, mockApply.getId(), request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("저장된 문항의 답변만 작성하거나 수정한다") + void saveAnswers() { + User user = saveUser("answer-save@example.com"); + MockApply mockApply = saveMockApply(user); + QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("지원 동기를 작성해주세요.", 700, false) + ))); + Long questionId = selected.questions().get(0).questionId(); + + QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( + new QuestionAnswerSaveRequest.AnswerItem(questionId, "저는 백엔드 개발 경험을 바탕으로 지원했습니다.") + ))); + + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).content()).isEqualTo("지원 동기를 작성해주세요."); + assertThat(response.questions().get(0).answer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); + assertThat(questionRepository.findById(questionId).orElseThrow().getAnswer()) + .isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); + } + + @Test + @DisplayName("해당 지원서에 속하지 않은 문항은 답변 저장에 사용할 수 없다") + void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { + User user = saveUser("answer-invalid-question@example.com"); + MockApply mockApply = saveMockApply(user); + MockApply otherMockApply = saveMockApply(user); + QuestionSelectionResponse otherSelected = questionService.saveSelectedQuestions(user, otherMockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("다른 지원서 문항", 700, false) + ))); + Long otherQuestionId = otherSelected.questions().get(0).questionId(); + + assertThatThrownBy(() -> questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( + new QuestionAnswerSaveRequest.AnswerItem(otherQuestionId, "답변") + )))) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.QUESTION_NOT_FOUND); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private MockApply saveMockApply(User user) { + JobPosting jobPosting = saveJobPosting(); + return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + } + + private JobPosting saveJobPosting() { + Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(); + return jobPostingRepository.save(JobPosting.create( + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + } + + private DetailClassification saveDetailClassification() { + Classification classification = Classification.create("테스트 대분류 " + System.nanoTime()); + MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류"); + DetailClassification detailClassification = middleClassification.addDetailClassification("테스트 소분류"); + classificationRepository.save(classification); + return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); + } +}