From c6a0865b86972cbf8ca68882f22819725bbbe714 Mon Sep 17 00:00:00 2001 From: yong203 Date: Fri, 19 Jun 2026 19:18:32 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat:=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=EC=84=9C=20=EB=AC=B8=ED=95=AD=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/README.md | 2 +- docs/api/cover-letters.md | 37 ++++ docs/status.md | 4 +- .../controller/CoverLetterController.java | 15 ++ .../coverletter/dto/SaveQuestionsRequest.java | 32 ++++ .../dto/SaveQuestionsResponse.java | 44 +++++ .../coverletter/entity/CoverLetter.java | 4 + .../entity/CoverLetterQuestion.java | 73 ++++++++ .../CoverLetterQuestionRepository.java | 14 ++ .../service/CoverLetterService.java | 116 +++++++++++++ .../service/SaveQuestionInput.java | 8 + .../service/SaveQuestionsResult.java | 12 ++ .../controller/CoverLetterControllerTest.java | 158 ++++++++++++++++++ .../CoverLetterQuestionRepositoryTest.java | 77 +++++++++ .../service/CoverLetterServiceTest.java | 145 ++++++++++++++++ 15 files changed, 738 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsRequest.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsResponse.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/entity/CoverLetterQuestion.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepository.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionInput.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionsResult.java create mode 100644 src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepositoryTest.java diff --git a/docs/api/README.md b/docs/api/README.md index 47d7499..0b2a75d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -59,7 +59,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-008 | POST | `/cover-letters` | Implemented | REQ-003 | 자기소개서 초안 생성 | | API-009 | PUT | `/cover-letters/{coverLetterId}/basic-info` | Implemented | REQ-004 | 등록 step1 저장 | | API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Implemented | REQ-004 | 등록 step2 저장 | -| API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Planned | REQ-004 | 등록 step3 저장 | +| API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Implemented | REQ-004 | 등록 step3 저장 | | API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 | | API-013 | DELETE | `/cover-letters/{coverLetterId}` | Implemented | REQ-003 | 자기소개서 soft delete | | API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Planned | REQ-005 | 첨삭 Job 생성 | diff --git a/docs/api/cover-letters.md b/docs/api/cover-letters.md index 7124150..207f8d7 100644 --- a/docs/api/cover-letters.md +++ b/docs/api/cover-letters.md @@ -285,6 +285,43 @@ Response: } ``` +Validation Error Response: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "요청 값이 올바르지 않습니다.", + "details": [ + { + "field": "questions[0].question", + "reason": "질문을 입력해야 합니다." + }, + { + "field": "questions[0].maxAnswerLength", + "reason": "최대 답변 글자 수는 100자 이상 5000자 이하여야 합니다." + }, + { + "field": "questions[0].originalAnswer", + "reason": "답변을 입력해야 합니다." + } + ] + } +} +``` + +Conflict Response: + +```json +{ + "error": { + "code": "COVER_LETTER_NOT_DRAFT", + "message": "제출 이후에는 원본 자기소개서를 수정할 수 없습니다.", + "details": [] + } +} +``` + ### 자기소개서 상세 조회 등록 step4 확인 화면과 AI 첨삭 페이지 상단 정보에서 사용한다. diff --git a/docs/status.md b/docs/status.md index ea60bf4..5c2e00e 100644 --- a/docs/status.md +++ b/docs/status.md @@ -21,7 +21,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | REQ-001 | 공통 예외 응답 기반 | Verified | High | 공통 에러 응답 | [#2](https://github.com/Rewrite-Team/Rewrite-BE/issues/2), [PR #3](https://github.com/Rewrite-Team/Rewrite-BE/pull/3) | `GlobalExceptionHandlerTest`, `./gradlew test` | `BusinessException`, `ErrorCode`, `GlobalExceptionHandler`, `ErrorResponse` 구현됨 | | REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 | | REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 | -| REQ-004 | 자기소개서 등록 step 저장 | In Progress | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장 계약 구현됨. API-011은 후속 slice 후보 기준으로 분리 진행 | +| REQ-004 | 자기소개서 등록 step 저장 | Implemented | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34), [#36](https://github.com/Rewrite-Team/Rewrite-BE/issues/36) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterQuestionRepositoryTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장, API-011 등록 step3 질문과 답변 저장 계약 구현됨 | | REQ-005 | 자기소개서 제출과 LLM Job 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 | | REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 | | REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 | @@ -59,7 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| | 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test | -| 등록 step3 저장 | REQ-004 | API-011 | questions replace, order 재부여, 글자 수 검증, service/web test | +| 자기소개서 제출과 LLM Job skeleton | REQ-005 | API-014, API-015 | 필수 step 데이터 검증, cover letter 상태 전환, LLM job persistence skeleton, service/web/repository test | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java index 1fac8eb..797aff1 100644 --- a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java +++ b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java @@ -7,9 +7,12 @@ import com.daon.rewrite.coverletter.dto.SaveBasicInfoResponse; import com.daon.rewrite.coverletter.dto.SavePreferencesRequest; import com.daon.rewrite.coverletter.dto.SavePreferencesResponse; +import com.daon.rewrite.coverletter.dto.SaveQuestionsRequest; +import com.daon.rewrite.coverletter.dto.SaveQuestionsResponse; import com.daon.rewrite.coverletter.entity.CoverLetter; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; import com.daon.rewrite.coverletter.service.CoverLetterService; +import com.daon.rewrite.coverletter.service.SaveQuestionsResult; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; @@ -76,4 +79,16 @@ public SavePreferencesResponse savePreferences( ); return SavePreferencesResponse.from(result); } + + @PutMapping("/cover-letters/{coverLetterId}/questions") + public SaveQuestionsResponse saveQuestions( + @PathVariable String coverLetterId, + @RequestBody SaveQuestionsRequest request + ) { + SaveQuestionsResult result = coverLetterService.saveQuestions( + coverLetterId, + request.toInputs() + ); + return SaveQuestionsResponse.from(result); + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsRequest.java b/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsRequest.java new file mode 100644 index 0000000..f1796d4 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsRequest.java @@ -0,0 +1,32 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.service.SaveQuestionInput; + +import java.util.List; + +public record SaveQuestionsRequest( + List questions +) { + + public List toInputs() { + if (questions == null) { + return null; + } + return questions.stream() + .map(question -> question == null + ? null + : new SaveQuestionInput( + question.question(), + question.maxAnswerLength(), + question.originalAnswer() + )) + .toList(); + } + + public record QuestionRequest( + String question, + Integer maxAnswerLength, + String originalAnswer + ) { + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsResponse.java new file mode 100644 index 0000000..2df3fd1 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/SaveQuestionsResponse.java @@ -0,0 +1,44 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import com.daon.rewrite.coverletter.service.SaveQuestionsResult; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +public record SaveQuestionsResponse( + String coverLetterId, + List questions, + LocalDateTime updatedAt +) { + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static SaveQuestionsResponse from(SaveQuestionsResult result) { + return new SaveQuestionsResponse( + result.coverLetter().getId(), + result.questions().stream() + .map(QuestionResponse::from) + .toList(), + LocalDateTime.ofInstant(result.coverLetter().getUpdatedAt(), API_ZONE) + ); + } + + public record QuestionResponse( + String id, + int order, + String question, + int maxAnswerLength, + String originalAnswer + ) { + private static QuestionResponse from(CoverLetterQuestion question) { + return new QuestionResponse( + question.getId(), + question.getQuestionOrder(), + question.getQuestion(), + question.getMaxAnswerLength(), + question.getOriginalAnswer() + ); + } + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java index a032d99..6e24d31 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -85,6 +85,10 @@ public void fillPreferences(String preferences, Instant now) { this.updatedAt = now; } + public void touch(Instant now) { + this.updatedAt = now; + } + public void markDeleted(Instant now) { this.deletedAt = now; this.updatedAt = now; diff --git a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetterQuestion.java b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetterQuestion.java new file mode 100644 index 0000000..dfac922 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetterQuestion.java @@ -0,0 +1,73 @@ +package com.daon.rewrite.coverletter.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "cover_letter_questions") +public class CoverLetterQuestion { + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "cover_letter_id", nullable = false) + private CoverLetter coverLetter; + + @Column(name = "question_order", nullable = false) + private int questionOrder; + + @Column(name = "question", nullable = false, length = 300) + private String question; + + @Column(name = "max_answer_length", nullable = false) + private int maxAnswerLength; + + @Column(name = "original_answer", nullable = false, columnDefinition = "text") + private String originalAnswer; + + private CoverLetterQuestion( + String id, + CoverLetter coverLetter, + int questionOrder, + String question, + int maxAnswerLength, + String originalAnswer + ) { + this.id = id; + this.coverLetter = coverLetter; + this.questionOrder = questionOrder; + this.question = question; + this.maxAnswerLength = maxAnswerLength; + this.originalAnswer = originalAnswer; + } + + public static CoverLetterQuestion create( + String id, + CoverLetter coverLetter, + int questionOrder, + String question, + int maxAnswerLength, + String originalAnswer + ) { + return new CoverLetterQuestion( + id, + coverLetter, + questionOrder, + question, + maxAnswerLength, + originalAnswer + ); + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepository.java b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepository.java new file mode 100644 index 0000000..9005bed --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepository.java @@ -0,0 +1,14 @@ +package com.daon.rewrite.coverletter.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CoverLetterQuestionRepository extends JpaRepository { + + List findByCoverLetterIdOrderByQuestionOrderAsc(String coverLetterId); + + void deleteByCoverLetter(CoverLetter coverLetter); +} diff --git a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java index 0284a42..e3757a0 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -3,7 +3,9 @@ import com.daon.rewrite.auth.CurrentUser; import com.daon.rewrite.auth.CurrentUserProvider; import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; import com.daon.rewrite.coverletter.repository.CoverLetterRepository; import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; @@ -35,9 +37,15 @@ public class CoverLetterService { private static final int MAX_POSITION_TITLE_LENGTH = 30; private static final int MAX_JOB_POSTING_URL_LENGTH = 500; private static final int MAX_PREFERENCES_LENGTH = 3000; + private static final String COVER_LETTER_QUESTION_ID_PREFIX = "clq"; + private static final int MAX_QUESTION_LENGTH = 300; + private static final int MIN_MAX_ANSWER_LENGTH = 100; + private static final int MAX_MAX_ANSWER_LENGTH = 5000; + private static final int MAX_ORIGINAL_ANSWER_LENGTH = 5000; private final CurrentUserProvider currentUserProvider; private final CoverLetterRepository coverLetterRepository; + private final CoverLetterQuestionRepository coverLetterQuestionRepository; private final IdGenerator idGenerator; private final Clock clock; @@ -137,6 +145,39 @@ public CoverLetter savePreferences(String coverLetterId, String preferences) { return coverLetter; } + @Transactional + public SaveQuestionsResult saveQuestions(String coverLetterId, List questions) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + if (coverLetter.getStatus() != CoverLetterStatus.DRAFT) { + throw new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT); + } + + List normalizedQuestions = validateAndNormalizeQuestions(questions); + // 해당 자기소개서에 기존에 저장돼 있던 문항들을 전부 삭제 + coverLetterQuestionRepository.deleteByCoverLetter(coverLetter); + + List savedQuestions = new ArrayList<>(); + for (int index = 0; index < normalizedQuestions.size(); index++) { + NormalizedQuestionInput question = normalizedQuestions.get(index); + savedQuestions.add(CoverLetterQuestion.create( + idGenerator.generate(COVER_LETTER_QUESTION_ID_PREFIX), + coverLetter, + index + 1, + question.question(), + question.maxAnswerLength(), + question.originalAnswer() + )); + } + savedQuestions = coverLetterQuestionRepository.saveAll(savedQuestions); + + coverLetter.touch(Instant.now(clock)); + return new SaveQuestionsResult(coverLetter, savedQuestions); + } + private void validateListQuery(int page, int size) { if (page < 1 || size < 1 || size > MAX_LIST_SIZE) { throw new BusinessException(ErrorCode.VALIDATION_ERROR); @@ -225,6 +266,74 @@ private String validateAndNormalizePreferences(String preferences) { return normalizedPreferences; } + private List validateAndNormalizeQuestions(List questions) { + List details = new ArrayList<>(); + if (questions == null || questions.isEmpty()) { + details.add(new ErrorResponse.ErrorDetail( + "questions", + "질문과 답변을 1개 이상 입력해야 합니다." + )); + throw new BusinessException(ErrorCode.VALIDATION_ERROR, details); + } + + List normalizedQuestions = new ArrayList<>(); + for (int index = 0; index < questions.size(); index++) { + SaveQuestionInput question = questions.get(index); + if (question == null) { + details.add(new ErrorResponse.ErrorDetail( + "questions[" + index + "]", + "문항 정보를 입력해야 합니다." + )); + continue; + } + + String normalizedQuestion = normalizeRequiredText( + "questions[" + index + "].question", + question.question(), + MAX_QUESTION_LENGTH, + "질문을 입력해야 합니다.", + "질문은 최대 300자까지 입력할 수 있습니다.", + details + ); + validateMaxAnswerLength(index, question.maxAnswerLength(), details); + String normalizedOriginalAnswer = normalizeRequiredText( + "questions[" + index + "].originalAnswer", + question.originalAnswer(), + MAX_ORIGINAL_ANSWER_LENGTH, + "답변을 입력해야 합니다.", + "답변은 최대 5000자까지 입력할 수 있습니다.", + details + ); + + normalizedQuestions.add(new NormalizedQuestionInput( + normalizedQuestion, + question.maxAnswerLength(), + normalizedOriginalAnswer + )); + } + + if (!details.isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, details); + } + + return normalizedQuestions; + } + + private void validateMaxAnswerLength( + int index, + Integer maxAnswerLength, + List details + ) { + if (maxAnswerLength == null + || maxAnswerLength < MIN_MAX_ANSWER_LENGTH + || maxAnswerLength > MAX_MAX_ANSWER_LENGTH) { + details.add(new ErrorResponse.ErrorDetail( + "questions[" + index + "].maxAnswerLength", + "최대 답변 글자 수는 100자 이상 5000자 이하여야 합니다." + )); + } + } + private String normalizeOptionalJobPostingUrl( String value, List details @@ -275,4 +384,11 @@ private record BasicInfoInput( String jobPostingUrl ) { } + + private record NormalizedQuestionInput( + String question, + Integer maxAnswerLength, + String originalAnswer + ) { + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionInput.java b/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionInput.java new file mode 100644 index 0000000..4a5e663 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionInput.java @@ -0,0 +1,8 @@ +package com.daon.rewrite.coverletter.service; + +public record SaveQuestionInput( + String question, + Integer maxAnswerLength, + String originalAnswer +) { +} diff --git a/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionsResult.java b/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionsResult.java new file mode 100644 index 0000000..003b7af --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/service/SaveQuestionsResult.java @@ -0,0 +1,12 @@ +package com.daon.rewrite.coverletter.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; + +import java.util.List; + +public record SaveQuestionsResult( + CoverLetter coverLetter, + List questions +) { +} diff --git a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java index 5cba80e..b538c49 100644 --- a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java @@ -1,7 +1,10 @@ package com.daon.rewrite.coverletter.controller; import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.service.SaveQuestionInput; +import com.daon.rewrite.coverletter.service.SaveQuestionsResult; import com.daon.rewrite.coverletter.service.CoverLetterService; import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; @@ -362,4 +365,159 @@ void savePreferencesReturnsCoverLetterNotDraft() throws Exception { .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT")); } + + @Test + void saveQuestionsReturnsSavedQuestions() throws Exception { + CoverLetter updated = CoverLetter.draft( + "cl_questions", + "user_1", + Instant.parse("2026-06-20T05:00:00Z") + ); + updated.touch(Instant.parse("2026-06-20T05:10:00Z")); + List questions = List.of( + CoverLetterQuestion.create( + "clq_1", + updated, + 1, + "지원 동기를 작성해주세요.", + 1000, + "제가 지원한 이유는..." + ), + CoverLetterQuestion.create( + "clq_2", + updated, + 2, + "직무 관련 경험을 작성해주세요.", + 1500, + "저는 프로젝트에서..." + ) + ); + given(coverLetterService.saveQuestions( + "cl_questions", + List.of( + new SaveQuestionInput(" 지원 동기를 작성해주세요. ", 1000, " 제가 지원한 이유는... "), + new SaveQuestionInput(" 직무 관련 경험을 작성해주세요. ", 1500, " 저는 프로젝트에서... ") + ) + )).willReturn(new SaveQuestionsResult(updated, questions)); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/questions", "cl_questions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "questions": [ + { + "question": " 지원 동기를 작성해주세요. ", + "maxAnswerLength": 1000, + "originalAnswer": " 제가 지원한 이유는... " + }, + { + "question": " 직무 관련 경험을 작성해주세요. ", + "maxAnswerLength": 1500, + "originalAnswer": " 저는 프로젝트에서... " + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coverLetterId").value("cl_questions")) + .andExpect(jsonPath("$.questions[0].id").value("clq_1")) + .andExpect(jsonPath("$.questions[0].order").value(1)) + .andExpect(jsonPath("$.questions[0].question").value("지원 동기를 작성해주세요.")) + .andExpect(jsonPath("$.questions[0].maxAnswerLength").value(1000)) + .andExpect(jsonPath("$.questions[0].originalAnswer").value("제가 지원한 이유는...")) + .andExpect(jsonPath("$.questions[1].id").value("clq_2")) + .andExpect(jsonPath("$.questions[1].order").value(2)) + .andExpect(jsonPath("$.updatedAt").value("2026-06-20T14:10:00")); + } + + @Test + void saveQuestionsReturnsValidationDetails() throws Exception { + given(coverLetterService.saveQuestions( + "cl_questions", + List.of(new SaveQuestionInput(" ", 99, " ")) + )).willThrow(new BusinessException( + ErrorCode.VALIDATION_ERROR, + List.of( + new com.daon.rewrite.global.response.ErrorResponse.ErrorDetail( + "questions[0].question", + "질문을 입력해야 합니다." + ), + new com.daon.rewrite.global.response.ErrorResponse.ErrorDetail( + "questions[0].maxAnswerLength", + "최대 답변 글자 수는 100자 이상 5000자 이하여야 합니다." + ), + new com.daon.rewrite.global.response.ErrorResponse.ErrorDetail( + "questions[0].originalAnswer", + "답변을 입력해야 합니다." + ) + ) + )); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/questions", "cl_questions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "questions": [ + { + "question": " ", + "maxAnswerLength": 99, + "originalAnswer": " " + } + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.error.details[0].field").value("questions[0].question")) + .andExpect(jsonPath("$.error.details[1].field").value("questions[0].maxAnswerLength")) + .andExpect(jsonPath("$.error.details[2].field").value("questions[0].originalAnswer")); + } + + @Test + void saveQuestionsReturnsNotFound() throws Exception { + given(coverLetterService.saveQuestions( + "cl_missing", + List.of(new SaveQuestionInput("질문", 1000, "답변")) + )).willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/questions", "cl_missing") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "questions": [ + { + "question": "질문", + "maxAnswerLength": 1000, + "originalAnswer": "답변" + } + ] + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } + + @Test + void saveQuestionsReturnsCoverLetterNotDraft() throws Exception { + given(coverLetterService.saveQuestions( + "cl_reviewing", + List.of(new SaveQuestionInput("질문", 1000, "답변")) + )).willThrow(new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT)); + + mockMvc.perform(put("/cover-letters/{coverLetterId}/questions", "cl_reviewing") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "questions": [ + { + "question": "질문", + "maxAnswerLength": 1000, + "originalAnswer": "답변" + } + ] + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT")); + } } diff --git a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepositoryTest.java b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepositoryTest.java new file mode 100644 index 0000000..643e181 --- /dev/null +++ b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterQuestionRepositoryTest.java @@ -0,0 +1,77 @@ +package com.daon.rewrite.coverletter.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class CoverLetterQuestionRepositoryTest { + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository questionRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void saveAndFindByCoverLetterIdOrdersByQuestionOrder() { + CoverLetter coverLetter = coverLetterRepository.save( + CoverLetter.draft("cl_questions", "user_1", Instant.parse("2026-06-20T01:00:00Z")) + ); + questionRepository.saveAll(List.of( + CoverLetterQuestion.create("clq_2", coverLetter, 2, "두 번째 질문", 1500, "두 번째 답변"), + CoverLetterQuestion.create("clq_1", coverLetter, 1, "첫 번째 질문", 1000, "첫 번째 답변") + )); + entityManager.flush(); + entityManager.clear(); + + List questions = questionRepository + .findByCoverLetterIdOrderByQuestionOrderAsc("cl_questions"); + + assertThat(questions).extracting(CoverLetterQuestion::getId) + .containsExactly("clq_1", "clq_2"); + assertThat(questions).extracting(CoverLetterQuestion::getQuestion) + .containsExactly("첫 번째 질문", "두 번째 질문"); + assertThat(questions).extracting(CoverLetterQuestion::getMaxAnswerLength) + .containsExactly(1000, 1500); + assertThat(questions).extracting(CoverLetterQuestion::getOriginalAnswer) + .containsExactly("첫 번째 답변", "두 번째 답변"); + } + + @Test + void deleteByCoverLetterRemovesOnlyThatCoverLetterQuestions() { + CoverLetter target = coverLetterRepository.save( + CoverLetter.draft("cl_target", "user_1", Instant.parse("2026-06-20T01:00:00Z")) + ); + CoverLetter other = coverLetterRepository.save( + CoverLetter.draft("cl_other", "user_1", Instant.parse("2026-06-20T01:00:00Z")) + ); + questionRepository.saveAll(List.of( + CoverLetterQuestion.create("clq_target", target, 1, "대상 질문", 1000, "대상 답변"), + CoverLetterQuestion.create("clq_other", other, 1, "다른 질문", 1000, "다른 답변") + )); + entityManager.flush(); + + questionRepository.deleteByCoverLetter(target); + entityManager.flush(); + entityManager.clear(); + + assertThat(questionRepository.findByCoverLetterIdOrderByQuestionOrderAsc("cl_target")).isEmpty(); + assertThat(questionRepository.findByCoverLetterIdOrderByQuestionOrderAsc("cl_other")) + .extracting(CoverLetterQuestion::getId) + .containsExactly("clq_other"); + } +} diff --git a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java index b893227..84544ae 100644 --- a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java @@ -3,8 +3,10 @@ import com.daon.rewrite.auth.CurrentUser; import com.daon.rewrite.auth.CurrentUserProvider; import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; @@ -36,6 +38,9 @@ class CoverLetterServiceTest { @Autowired private CoverLetterRepository repository; + @Autowired + private CoverLetterQuestionRepository questionRepository; + @MockitoBean private CurrentUserProvider currentUserProvider; @@ -47,6 +52,7 @@ class CoverLetterServiceTest { @AfterEach void cleanUp() { + questionRepository.deleteAll(); repository.deleteAll(); } @@ -393,6 +399,145 @@ void savePreferencesRejectsTooLongPreferences() { }); } + @Test + void saveQuestionsReplacesQuestionsWithServerAssignedOrderAndUpdatesTimestamp() { + Instant createdAt = Instant.parse("2026-06-20T01:00:00Z"); + Instant updatedAt = Instant.parse("2026-06-20T05:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("clq")).willReturn("clq_1", "clq_2"); + given(clock.instant()).willReturn(updatedAt); + CoverLetter coverLetter = repository.save(CoverLetter.draft("cl_questions", "user_1", createdAt)); + questionRepository.save(CoverLetterQuestion.create( + "clq_old", + coverLetter, + 1, + "기존 질문", + 1000, + "기존 답변" + )); + + SaveQuestionsResult result = service.saveQuestions( + "cl_questions", + List.of( + new SaveQuestionInput(" 지원 동기를 작성해주세요. ", 1000, " 제가 지원한 이유는... "), + new SaveQuestionInput(" 직무 관련 경험을 작성해주세요. ", 1500, " 저는 프로젝트에서... ") + ) + ); + + assertThat(result.coverLetter().getUpdatedAt()).isEqualTo(updatedAt); + assertThat(result.questions()).extracting(CoverLetterQuestion::getId) + .containsExactly("clq_1", "clq_2"); + assertThat(result.questions()).extracting(CoverLetterQuestion::getQuestionOrder) + .containsExactly(1, 2); + assertThat(result.questions()).extracting(CoverLetterQuestion::getQuestion) + .containsExactly("지원 동기를 작성해주세요.", "직무 관련 경험을 작성해주세요."); + assertThat(result.questions()).extracting(CoverLetterQuestion::getOriginalAnswer) + .containsExactly("제가 지원한 이유는...", "저는 프로젝트에서..."); + assertThat(repository.findById("cl_questions")).hasValueSatisfying(saved -> + assertThat(saved.getUpdatedAt()).isEqualTo(updatedAt) + ); + assertThat(questionRepository.findByCoverLetterIdOrderByQuestionOrderAsc("cl_questions")) + .extracting(CoverLetterQuestion::getId) + .containsExactly("clq_1", "clq_2"); + assertThat(questionRepository.findById("clq_old")).isEmpty(); + } + + @Test + void saveQuestionsThrowsNotFoundWhenCoverLetterIsMissingOtherOwnerOrAlreadyDeleted() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + CoverLetter deleted = CoverLetter.draft("cl_deleted", "user_1", now); + deleted.markDeleted(now.plusSeconds(60)); + repository.saveAll(List.of( + CoverLetter.draft("cl_other", "user_2", now), + deleted + )); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + List questions = List.of( + new SaveQuestionInput("질문", 1000, "답변") + ); + + assertThatThrownBy(() -> service.saveQuestions("cl_missing", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.saveQuestions("cl_other", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.saveQuestions("cl_deleted", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void saveQuestionsThrowsCoverLetterNotDraftWhenStatusIsNotDraft() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.saveAll(List.of( + coverLetterWithStatus("cl_reviewing", CoverLetterStatus.REVIEWING, now), + coverLetterWithStatus("cl_reviewed", CoverLetterStatus.REVIEWED, now), + coverLetterWithStatus("cl_failed", CoverLetterStatus.REVIEW_FAILED, now) + )); + List questions = List.of( + new SaveQuestionInput("질문", 1000, "답변") + ); + + assertThatThrownBy(() -> service.saveQuestions("cl_reviewing", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + assertThatThrownBy(() -> service.saveQuestions("cl_reviewed", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + assertThatThrownBy(() -> service.saveQuestions("cl_failed", questions)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COVER_LETTER_NOT_DRAFT); + } + + @Test + void saveQuestionsThrowsValidationErrorWithDetails() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(CoverLetter.draft("cl_questions", "user_1", now)); + + assertThatThrownBy(() -> service.saveQuestions( + "cl_questions", + List.of(new SaveQuestionInput(" ", 99, " ")) + )) + .isInstanceOf(BusinessException.class) + .satisfies(error -> { + BusinessException businessException = (BusinessException) error; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(businessException.getDetails()) + .extracting("field") + .containsExactly( + "questions[0].question", + "questions[0].maxAnswerLength", + "questions[0].originalAnswer" + ); + }); + } + + @Test + void saveQuestionsRequiresAtLeastOneQuestion() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(CoverLetter.draft("cl_questions", "user_1", now)); + + assertThatThrownBy(() -> service.saveQuestions("cl_questions", List.of())) + .isInstanceOf(BusinessException.class) + .satisfies(error -> { + BusinessException businessException = (BusinessException) error; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(businessException.getDetails()) + .extracting("field") + .containsExactly("questions"); + }); + } + private CoverLetter draft(String id, String ownerId, String title, Instant now) { CoverLetter coverLetter = CoverLetter.draft(id, ownerId, now); coverLetter.fillBasicInfo(title, "Rewrite Corp", "백엔드 개발자", null, now);