From d2ea21f5f51a80a5b0a562334481f50cb9f27aa9 Mon Sep 17 00:00:00 2001 From: yong203 Date: Sun, 21 Jun 2026 03:46:52 +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=EC=A0=9C=EC=B6=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B5=9C=EC=B4=88=20=EC=B2=A8=EC=82=AD=20Job=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= 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 | 16 +- docs/decisions/cover-letters.md | 13 +- docs/status.md | 4 +- .../controller/CoverLetterController.java | 8 + .../dto/SubmitCoverLetterResponse.java | 21 ++ .../coverletter/entity/CoverLetter.java | 8 + .../repository/CoverLetterRepository.java | 18 ++ .../service/CoverLetterService.java | 120 +++++++++++ .../service/SubmitCoverLetterResult.java | 10 + .../rewrite/global/exception/ErrorCode.java | 6 + .../llmjob/repository/LlmJobRepository.java | 11 + .../controller/CoverLetterControllerTest.java | 90 ++++++++ .../repository/CoverLetterRepositoryTest.java | 62 ++++++ .../service/CoverLetterServiceTest.java | 203 ++++++++++++++++++ .../repository/LlmJobRepositoryTest.java | 20 ++ 16 files changed, 598 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/daon/rewrite/coverletter/dto/SubmitCoverLetterResponse.java create mode 100644 src/main/java/com/daon/rewrite/coverletter/service/SubmitCoverLetterResult.java diff --git a/docs/api/README.md b/docs/api/README.md index a6a4d8f..fddd641 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -62,7 +62,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | 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 생성 | +| API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Implemented | REQ-005 | 첨삭 Job 생성 | | API-015 | GET | `/llm-jobs/{jobId}` | Implemented | REQ-005 | LLM Job 상태 조회 | | API-016 | GET | `/llm-jobs/{jobId}/stream` | Planned | REQ-005 | SSE 스트리밍 | | API-017 | GET | `/cover-letters/{coverLetterId}/review-versions` | Planned | REQ-006 | 첨삭 버전 목록 | diff --git a/docs/api/cover-letters.md b/docs/api/cover-letters.md index 207f8d7..c7f86a7 100644 --- a/docs/api/cover-letters.md +++ b/docs/api/cover-letters.md @@ -410,6 +410,12 @@ Response: POST /cover-letters/{coverLetterId}/submit ``` +Success Status: + +```text +200 OK +``` + Request: ```json @@ -422,7 +428,8 @@ Response: { "coverLetterId": "cl_01HZ...", "status": "REVIEWING", - "jobId": "job_01HZ..." + "jobId": "job_01HZ...", + "latestReviewVersionId": null } ``` @@ -453,7 +460,7 @@ Validation Error Response: { "error": { "code": "VALIDATION_ERROR", - "message": "자기소개서 제출에 필요한 정보가 누락되었습니다.", + "message": "입력값이 올바르지 않습니다.", "details": [ { "field": "preferences", @@ -468,13 +475,16 @@ Validation Error Response: } ``` +다른 종류의 LLM Job이 이미 해당 자기소개서에서 `PENDING` 또는 `PROCESSING` 상태이면 `409 Conflict`와 `LLM_JOB_ALREADY_RUNNING`을 반환한다. 동일한 최초 첨삭 Job에 대한 중복 submit은 예외로 처리하지 않고 기존 Job을 반환한다. + Already Reviewing Response: ```json { "coverLetterId": "cl_01HZ...", "status": "REVIEWING", - "jobId": "job_existing_01HZ..." + "jobId": "job_existing_01HZ...", + "latestReviewVersionId": null } ``` diff --git a/docs/decisions/cover-letters.md b/docs/decisions/cover-letters.md index 172b24f..652730f 100644 --- a/docs/decisions/cover-letters.md +++ b/docs/decisions/cover-letters.md @@ -1024,7 +1024,7 @@ jobPostingUrl: " https://example.com/jobs/1 " -> "https://example.com/jobs/1" 자기소개서 제출 API 호출 시 필수 step 데이터가 누락되어 있으면 `VALIDATION_ERROR`를 반환한다. -응답의 `details`에는 누락된 `field`, 사용자를 돌려보낼 `step`, 사용자 안내용 `reason`을 포함한다. +응답의 `details`에는 누락된 `field`와 사용자 안내용 `reason`을 포함한다. 프론트엔드는 `field`를 등록 step과 매핑한다. 적용 API: @@ -1046,16 +1046,14 @@ Response: { "error": { "code": "VALIDATION_ERROR", - "message": "자기소개서 제출에 필요한 정보가 누락되었습니다.", + "message": "입력값이 올바르지 않습니다.", "details": [ { "field": "preferences", - "step": "STEP2", "reason": "채용 우대사항을 입력해야 합니다." }, { "field": "questions", - "step": "STEP3", "reason": "질문과 답변을 1개 이상 입력해야 합니다." } ] @@ -1069,7 +1067,7 @@ Response: - 등록 완료 버튼을 누르면 AI 첨삭이 시작된다. - AI 첨삭에는 회사명, 직무명, 우대사항, 질문/답변 같은 입력이 필요하다. -- step별 입력 화면이 있으므로 누락된 step을 프론트엔드가 알 수 있어야 한다. +- step별 입력 화면이 있으므로 누락된 field를 프론트엔드가 해당 step과 연결할 수 있어야 한다. ### 고려한 대안 @@ -1083,7 +1081,7 @@ Response: ### 선택 이유 -제출은 AI 첨삭을 시작하기 직전의 최종 검증 단계다. 이 단계에서 누락된 정보를 구조적으로 내려주면 프론트엔드가 사용자를 정확한 step으로 돌려보내고, 어떤 값을 보완해야 하는지 바로 안내할 수 있다. +제출은 AI 첨삭을 시작하기 직전의 최종 검증 단계다. 이 단계에서 누락된 정보를 구조적으로 내려주면 프론트엔드가 field를 기준으로 사용자를 정확한 step으로 돌려보내고, 어떤 값을 보완해야 하는지 바로 안내할 수 있다. ### 트레이드오프 @@ -1093,7 +1091,7 @@ Response: - 제출 실패 원인이 명확해진다. - 단점 - - 서버가 step/field 매핑 정보를 관리해야 한다. + - 서버가 누락 field와 사용자 안내 문구를 관리해야 한다. - 필수 입력 정책이 바뀌면 에러 details 정책도 함께 갱신해야 한다. @@ -1611,4 +1609,3 @@ MVP에서 삭제는 사용자 화면에서 해당 자기소개서를 제거하 - 운영자 복구나 감사 조회는 별도 내부 도구나 DB 대응에 의존해야 한다. - 향후 사용자-facing 복구 기능을 만들면 접근 정책을 재검토해야 한다. - diff --git a/docs/status.md b/docs/status.md index db4a103..9a42ef8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,7 +22,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | 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 저장 | 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 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38) | `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-015 LLM Job 상태 조회 skeleton 구현됨. API-014 submit, API-016 SSE, 실제 LLM provider 호출은 후속 이슈로 분리 | +| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 | | 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는 별도 이슈로 분리 | | REQ-008 | 실제 인증 경계 | Planned | Medium | API-001 - API-006 | - | - | 카카오 OAuth, cookie, CSRF | @@ -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 | -| 자기소개서 제출 API | REQ-005 | API-014 | 필수 step 데이터 검증, cover letter 상태 전환, COVER_LETTER_REVIEW Job 생성, service/web/repository test | +| 자기소개서 삭제 시 진행 Job 취소 | REQ-003, REQ-005 | API-013 내부 동작 | soft delete transaction에서 연결된 PENDING/PROCESSING Job을 CANCELED로 전환하고 repository/service 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 797aff1..1e77f27 100644 --- a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java +++ b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java @@ -9,10 +9,12 @@ 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.dto.SubmitCoverLetterResponse; 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 com.daon.rewrite.coverletter.service.SubmitCoverLetterResult; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; @@ -91,4 +93,10 @@ public SaveQuestionsResponse saveQuestions( ); return SaveQuestionsResponse.from(result); } + + @PostMapping("/cover-letters/{coverLetterId}/submit") + public SubmitCoverLetterResponse submit(@PathVariable String coverLetterId) { + SubmitCoverLetterResult result = coverLetterService.submit(coverLetterId); + return SubmitCoverLetterResponse.from(result); + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/SubmitCoverLetterResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/SubmitCoverLetterResponse.java new file mode 100644 index 0000000..f82a59e --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/SubmitCoverLetterResponse.java @@ -0,0 +1,21 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.service.SubmitCoverLetterResult; + +public record SubmitCoverLetterResponse( + String coverLetterId, + CoverLetterStatus status, + String jobId, + String latestReviewVersionId +) { + + public static SubmitCoverLetterResponse from(SubmitCoverLetterResult result) { + return new SubmitCoverLetterResponse( + result.coverLetter().getId(), + result.coverLetter().getStatus(), + result.job() == null ? null : result.job().getId(), + result.coverLetter().getLatestReviewVersionId() + ); + } +} 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 6e24d31..9673123 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -94,6 +94,14 @@ public void markDeleted(Instant now) { this.updatedAt = now; } + public void startReview(Instant now) { + this.status = CoverLetterStatus.REVIEWING; + if (this.submittedAt == null) { + this.submittedAt = now; + } + this.updatedAt = now; + } + public void setLatestReviewVersionId(String latestReviewVersionId) { this.latestReviewVersionId = latestReviewVersionId; } diff --git a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java index 1f84e4b..fddd743 100644 --- a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java +++ b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java @@ -5,6 +5,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; import java.util.Optional; @@ -19,4 +24,17 @@ Page findByOwnerIdAndStatusAndDeletedAtIsNull( ); Optional findByIdAndOwnerIdAndDeletedAtIsNull(String id, String ownerId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select coverLetter + from CoverLetter coverLetter + where coverLetter.id = :id + and coverLetter.ownerId = :ownerId + and coverLetter.deletedAt is null + """) + Optional findActiveByIdAndOwnerIdForUpdate( + @Param("id") String id, + @Param("ownerId") String ownerId + ); } 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 e3757a0..8b31867 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -11,6 +11,11 @@ import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.response.ErrorResponse; import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobTargetType; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -42,10 +47,16 @@ public class CoverLetterService { 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 static final String LLM_JOB_ID_PREFIX = "job"; + private static final List RUNNING_JOB_STATUSES = List.of( + LlmJobStatus.PENDING, + LlmJobStatus.PROCESSING + ); private final CurrentUserProvider currentUserProvider; private final CoverLetterRepository coverLetterRepository; private final CoverLetterQuestionRepository coverLetterQuestionRepository; + private final LlmJobRepository llmJobRepository; private final IdGenerator idGenerator; private final Clock clock; @@ -178,6 +189,115 @@ public SaveQuestionsResult saveQuestions(String coverLetterId, List new BusinessException(ErrorCode.NOT_FOUND)); + + // 이미 최초 AI 첨삭이 완료된 자소서에 submit 이 다시 호출된다면 새로운 첨삭 job 생성 없이 coverLetter 반환 + if (coverLetter.getStatus() == CoverLetterStatus.REVIEWED) { + if (coverLetter.getLatestReviewVersionId() == null) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + return new SubmitCoverLetterResult(coverLetter, null); + } + + LlmJob runningJob = findRunningJob(coverLetter.getId()); + // 최소첨삭 진행 중인 자소서가 있는 경우 + if (coverLetter.getStatus() == CoverLetterStatus.REVIEWING) { + // 실제 진행중인 COVER_LETTER_REVIEW Job이 없는것은 모순, 예외처리 + if (runningJob == null || runningJob.getType() != LlmJobType.COVER_LETTER_REVIEW) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + // 기존 첨삭중인 job 반환 + return new SubmitCoverLetterResult(coverLetter, runningJob); + } + // 자소서 status 가 REVIEWING 이 아닌데 첨삭 진행중인 job 이 있는것은 모순, 추가 job 생성 방지 + if (runningJob != null) { + throw new BusinessException(ErrorCode.LLM_JOB_ALREADY_RUNNING); + } + + List questions = coverLetterQuestionRepository + .findByCoverLetterIdOrderByQuestionOrderAsc(coverLetter.getId()); + validateSubmit(coverLetter, questions); + + Instant now = Instant.now(clock); + LlmJob job = llmJobRepository.save(LlmJob.pendingReview( + idGenerator.generate(LLM_JOB_ID_PREFIX), + coverLetter.getId(), + now, + questions.size() + )); + coverLetter.startReview(now); + + return new SubmitCoverLetterResult(coverLetter, job); + } + + private LlmJob findRunningJob(String coverLetterId) { + return llmJobRepository + .findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc( + LlmJobTargetType.COVER_LETTER, + coverLetterId, + RUNNING_JOB_STATUSES + ) + .orElse(null); + } + + private void validateSubmit(CoverLetter coverLetter, List questions) { + List details = new ArrayList<>(); + addMissingDetail("title", coverLetter.getTitle(), "자기소개서 제목을 입력해야 합니다.", details); + addMissingDetail("companyName", coverLetter.getCompanyName(), "회사명을 입력해야 합니다.", details); + addMissingDetail("positionTitle", coverLetter.getPositionTitle(), "직무명을 입력해야 합니다.", details); + addMissingDetail("preferences", coverLetter.getPreferences(), "채용 우대사항을 입력해야 합니다.", details); + + if (questions.isEmpty()) { + details.add(new ErrorResponse.ErrorDetail( + "questions", + "질문과 답변을 1개 이상 입력해야 합니다." + )); + } else { + for (int index = 0; index < questions.size(); index++) { + CoverLetterQuestion question = questions.get(index); + addMissingDetail( + "questions[" + index + "].question", + question.getQuestion(), + "질문을 입력해야 합니다.", + details + ); + if (question.getMaxAnswerLength() < MIN_MAX_ANSWER_LENGTH + || question.getMaxAnswerLength() > MAX_MAX_ANSWER_LENGTH) { + details.add(new ErrorResponse.ErrorDetail( + "questions[" + index + "].maxAnswerLength", + "최대 답변 글자 수는 100자 이상 5000자 이하여야 합니다." + )); + } + addMissingDetail( + "questions[" + index + "].originalAnswer", + question.getOriginalAnswer(), + "답변을 입력해야 합니다.", + details + ); + } + } + + if (!details.isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, details); + } + } + + private void addMissingDetail( + String field, + String value, + String reason, + List details + ) { + if (value == null || value.isBlank()) { + details.add(new ErrorResponse.ErrorDetail(field, reason)); + } + } + private void validateListQuery(int page, int size) { if (page < 1 || size < 1 || size > MAX_LIST_SIZE) { throw new BusinessException(ErrorCode.VALIDATION_ERROR); diff --git a/src/main/java/com/daon/rewrite/coverletter/service/SubmitCoverLetterResult.java b/src/main/java/com/daon/rewrite/coverletter/service/SubmitCoverLetterResult.java new file mode 100644 index 0000000..ff1b460 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/service/SubmitCoverLetterResult.java @@ -0,0 +1,10 @@ +package com.daon.rewrite.coverletter.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.llmjob.entity.LlmJob; + +public record SubmitCoverLetterResult( + CoverLetter coverLetter, + LlmJob job +) { +} diff --git a/src/main/java/com/daon/rewrite/global/exception/ErrorCode.java b/src/main/java/com/daon/rewrite/global/exception/ErrorCode.java index d3373b6..1ba5998 100644 --- a/src/main/java/com/daon/rewrite/global/exception/ErrorCode.java +++ b/src/main/java/com/daon/rewrite/global/exception/ErrorCode.java @@ -32,6 +32,12 @@ public enum ErrorCode { "제출된 자기소개서의 원본 정보는 수정할 수 없습니다." ), + LLM_JOB_ALREADY_RUNNING( + HttpStatus.CONFLICT, + "LLM_JOB_ALREADY_RUNNING", + "이미 진행 중인 LLM 작업이 있습니다." + ), + INTERNAL_ERROR( HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", diff --git a/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java index 999ec3e..6b1e3fe 100644 --- a/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java +++ b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java @@ -1,7 +1,18 @@ package com.daon.rewrite.llmjob.repository; import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobTargetType; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.Optional; + public interface LlmJobRepository extends JpaRepository { + + Optional findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc( + LlmJobTargetType targetType, + String targetId, + Collection statuses + ); } 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 b538c49..94851b6 100644 --- a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java @@ -5,9 +5,12 @@ 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.SubmitCoverLetterResult; import com.daon.rewrite.coverletter.service.CoverLetterService; import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.response.ErrorResponse; +import com.daon.rewrite.llmjob.entity.LlmJob; import org.junit.jupiter.api.Test; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -17,11 +20,13 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.util.List; import static org.mockito.BDDMockito.given; +import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -520,4 +525,89 @@ void saveQuestionsReturnsCoverLetterNotDraft() throws Exception { .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT")); } + + @Test + void submitReturnsPendingReviewJob() throws Exception { + Instant submittedAt = Instant.parse("2026-06-20T05:10:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_submit", "user_1", submittedAt.minusSeconds(60)); + coverLetter.startReview(submittedAt); + LlmJob job = LlmJob.pendingReview("job_1", coverLetter.getId(), submittedAt, 2); + given(coverLetterService.submit("cl_submit")) + .willReturn(new SubmitCoverLetterResult(coverLetter, job)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/submit", "cl_submit") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coverLetterId").value("cl_submit")) + .andExpect(jsonPath("$.status").value("REVIEWING")) + .andExpect(jsonPath("$.jobId").value("job_1")) + .andExpect(jsonPath("$.latestReviewVersionId").value(nullValue())); + } + + @Test + void submitReturnsLatestReviewVersionWhenAlreadyReviewed() throws Exception { + CoverLetter coverLetter = CoverLetter.draft( + "cl_reviewed", + "user_1", + Instant.parse("2026-06-20T05:00:00Z") + ); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEWED); + coverLetter.setLatestReviewVersionId("rv_1"); + given(coverLetterService.submit("cl_reviewed")) + .willReturn(new SubmitCoverLetterResult(coverLetter, null)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/submit", "cl_reviewed") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coverLetterId").value("cl_reviewed")) + .andExpect(jsonPath("$.status").value("REVIEWED")) + .andExpect(jsonPath("$.jobId").value(nullValue())) + .andExpect(jsonPath("$.latestReviewVersionId").value("rv_1")); + } + + @Test + void submitReturnsValidationDetails() throws Exception { + given(coverLetterService.submit("cl_incomplete")).willThrow(new BusinessException( + ErrorCode.VALIDATION_ERROR, + List.of( + new ErrorResponse.ErrorDetail("preferences", "채용 우대사항을 입력해야 합니다."), + new ErrorResponse.ErrorDetail("questions", "질문과 답변을 1개 이상 입력해야 합니다.") + ) + )); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/submit", "cl_incomplete") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.error.details[0].field").value("preferences")) + .andExpect(jsonPath("$.error.details[0].reason").value("채용 우대사항을 입력해야 합니다.")) + .andExpect(jsonPath("$.error.details[1].field").value("questions")); + } + + @Test + void submitReturnsNotFound() throws Exception { + given(coverLetterService.submit("cl_missing")) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/submit", "cl_missing") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } + + @Test + void submitReturnsLlmJobAlreadyRunning() throws Exception { + given(coverLetterService.submit("cl_busy")) + .willThrow(new BusinessException(ErrorCode.LLM_JOB_ALREADY_RUNNING)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/submit", "cl_busy") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("LLM_JOB_ALREADY_RUNNING")); + } } diff --git a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java index 8efa7a8..a63a1dc 100644 --- a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java @@ -7,12 +7,24 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -27,6 +39,9 @@ class CoverLetterRepositoryTest { @Autowired private EntityManager entityManager; + @Autowired + private PlatformTransactionManager transactionManager; + @Test void saveAndFindDraftRoundTripsThroughJpa() { Instant now = Instant.parse("2026-06-20T05:00:00Z"); @@ -134,6 +149,53 @@ void findActiveByIdAndOwnerReturnsOnlyCurrentOwnerUndeletedCoverLetter() { assertThat(repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_other", "user_1")).isEmpty(); } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void findActiveForUpdateBlocksConcurrentTransaction() throws Exception { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transaction.executeWithoutResult(status -> repository.save( + CoverLetter.draft("cl_locked", "user_1", Instant.parse("2026-06-20T01:00:00Z")) + )); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch firstLocked = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + + try { + Future first = executor.submit(() -> transaction.executeWithoutResult(status -> { + repository.findActiveByIdAndOwnerIdForUpdate("cl_locked", "user_1").orElseThrow(); + firstLocked.countDown(); + await(releaseFirst); + })); + assertThat(firstLocked.await(2, TimeUnit.SECONDS)).isTrue(); + + Future second = executor.submit(() -> transaction.executeWithoutResult(status -> + repository.findActiveByIdAndOwnerIdForUpdate("cl_locked", "user_1").orElseThrow() + )); + + assertThatThrownBy(() -> second.get(200, TimeUnit.MILLISECONDS)) + .isInstanceOf(TimeoutException.class); + + releaseFirst.countDown(); + first.get(2, TimeUnit.SECONDS); + second.get(2, TimeUnit.SECONDS); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + transaction.executeWithoutResult(status -> repository.deleteAll()); + } + } + + private void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + private CoverLetter draft(String id, String ownerId, String title, String createdAt) { CoverLetter coverLetter = CoverLetter.draft(id, ownerId, Instant.parse(createdAt)); coverLetter.fillBasicInfo(title, "Rewrite Corp", "백엔드 개발자", null, Instant.parse(createdAt)); 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 84544ae..7eaa76d 100644 --- a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java @@ -10,6 +10,10 @@ import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +45,9 @@ class CoverLetterServiceTest { @Autowired private CoverLetterQuestionRepository questionRepository; + @Autowired + private LlmJobRepository llmJobRepository; + @MockitoBean private CurrentUserProvider currentUserProvider; @@ -52,6 +59,7 @@ class CoverLetterServiceTest { @AfterEach void cleanUp() { + llmJobRepository.deleteAll(); questionRepository.deleteAll(); repository.deleteAll(); } @@ -538,6 +546,177 @@ void saveQuestionsRequiresAtLeastOneQuestion() { }); } + @Test + void submitCreatesPendingReviewJobAndMarksDraftReviewing() { + Instant createdAt = Instant.parse("2026-06-20T01:00:00Z"); + Instant submittedAt = Instant.parse("2026-06-20T05:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("job")).willReturn("job_1"); + given(clock.instant()).willReturn(submittedAt); + saveCompleteCoverLetter("cl_submit", "user_1", createdAt, 2); + + SubmitCoverLetterResult result = service.submit("cl_submit"); + + assertThat(result.coverLetter().getStatus()).isEqualTo(CoverLetterStatus.REVIEWING); + assertThat(result.coverLetter().getSubmittedAt()).isEqualTo(submittedAt); + assertThat(result.coverLetter().getUpdatedAt()).isEqualTo(submittedAt); + assertThat(result.job()).isNotNull(); + assertThat(result.job().getId()).isEqualTo("job_1"); + assertThat(result.job().getType()).isEqualTo(LlmJobType.COVER_LETTER_REVIEW); + assertThat(result.job().getStatus()).isEqualTo(LlmJobStatus.PENDING); + assertThat(result.job().getTargetId()).isEqualTo("cl_submit"); + assertThat(result.job().getProgressTotal()).isEqualTo(2); + assertThat(llmJobRepository.count()).isEqualTo(1); + } + + @Test + void submitReturnsValidationDetailsWithoutChangingDraft() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(CoverLetter.draft("cl_incomplete", "user_1", now)); + + assertThatThrownBy(() -> service.submit("cl_incomplete")) + .isInstanceOf(BusinessException.class) + .satisfies(error -> { + BusinessException businessException = (BusinessException) error; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(businessException.getDetails()) + .extracting("field") + .containsExactly("title", "companyName", "positionTitle", "preferences", "questions"); + }); + + assertThat(repository.findById("cl_incomplete")).hasValueSatisfying(coverLetter -> { + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.DRAFT); + assertThat(coverLetter.getSubmittedAt()).isNull(); + }); + assertThat(llmJobRepository.count()).isZero(); + } + + @Test + void submitReturnsExistingJobWhenCoverLetterIsReviewing() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_reviewing", "user_1", now, 1); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEWING); + repository.saveAndFlush(coverLetter); + LlmJob existingJob = llmJobRepository.save(LlmJob.pendingReview( + "job_existing", + coverLetter.getId(), + now.plusSeconds(60), + 1 + )); + + SubmitCoverLetterResult result = service.submit("cl_reviewing"); + + assertThat(result.coverLetter().getStatus()).isEqualTo(CoverLetterStatus.REVIEWING); + assertThat(result.job().getId()).isEqualTo(existingJob.getId()); + assertThat(llmJobRepository.count()).isEqualTo(1); + } + + @Test + void submitReturnsLatestReviewVersionWhenCoverLetterIsReviewed() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_reviewed", "user_1", now, 1); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEWED); + coverLetter.setLatestReviewVersionId("rv_1"); + repository.saveAndFlush(coverLetter); + + SubmitCoverLetterResult result = service.submit("cl_reviewed"); + + assertThat(result.coverLetter().getLatestReviewVersionId()).isEqualTo("rv_1"); + assertThat(result.job()).isNull(); + assertThat(llmJobRepository.count()).isZero(); + } + + @Test + void submitThrowsInternalErrorWhenReviewingJobIsMissing() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_broken_reviewing", "user_1", now, 1); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEWING); + repository.saveAndFlush(coverLetter); + + assertThatThrownBy(() -> service.submit("cl_broken_reviewing")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INTERNAL_ERROR); + } + + @Test + void submitThrowsInternalErrorWhenReviewedVersionIsMissing() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_broken_reviewed", "user_1", now, 1); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEWED); + repository.saveAndFlush(coverLetter); + + assertThatThrownBy(() -> service.submit("cl_broken_reviewed")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INTERNAL_ERROR); + } + + @Test + void submitRetriesFailedReviewWithoutChangingFirstSubmittedAt() { + Instant firstSubmittedAt = Instant.parse("2026-06-20T02:00:00Z"); + Instant retriedAt = Instant.parse("2026-06-20T05:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("job")).willReturn("job_retry"); + given(clock.instant()).willReturn(retriedAt); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_failed", "user_1", firstSubmittedAt, 1); + ReflectionTestUtils.setField(coverLetter, "status", CoverLetterStatus.REVIEW_FAILED); + ReflectionTestUtils.setField(coverLetter, "submittedAt", firstSubmittedAt); + repository.saveAndFlush(coverLetter); + + SubmitCoverLetterResult result = service.submit("cl_failed"); + + assertThat(result.coverLetter().getStatus()).isEqualTo(CoverLetterStatus.REVIEWING); + assertThat(result.coverLetter().getSubmittedAt()).isEqualTo(firstSubmittedAt); + assertThat(result.coverLetter().getUpdatedAt()).isEqualTo(retriedAt); + assertThat(result.job().getId()).isEqualTo("job_retry"); + } + + @Test + void submitRejectsOtherRunningJob() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_busy", "user_1", now, 1); + LlmJob otherJob = LlmJob.pendingReview("job_other", coverLetter.getId(), now, 1); + ReflectionTestUtils.setField(otherJob, "type", LlmJobType.KEYWORD_ANALYSIS); + llmJobRepository.save(otherJob); + + assertThatThrownBy(() -> service.submit("cl_busy")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.LLM_JOB_ALREADY_RUNNING); + + assertThat(llmJobRepository.count()).isEqualTo(1); + } + + @Test + void submitThrowsNotFoundWhenCoverLetterIsMissingOtherOwnerOrDeleted() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + saveCompleteCoverLetter("cl_other", "user_2", now, 1); + CoverLetter deleted = saveCompleteCoverLetter("cl_deleted", "user_1", now, 1); + deleted.markDeleted(now.plusSeconds(60)); + repository.saveAndFlush(deleted); + + assertThatThrownBy(() -> service.submit("cl_missing")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.submit("cl_other")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + assertThatThrownBy(() -> service.submit("cl_deleted")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + 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); @@ -549,4 +728,28 @@ private CoverLetter coverLetterWithStatus(String id, CoverLetterStatus status, I ReflectionTestUtils.setField(coverLetter, "status", status); return coverLetter; } + + private CoverLetter saveCompleteCoverLetter( + String id, + String ownerId, + Instant now, + int questionCount + ) { + CoverLetter coverLetter = CoverLetter.draft(id, ownerId, now); + coverLetter.fillBasicInfo("제목", "회사", "직무", null, now); + coverLetter.fillPreferences("Spring Boot 경험", now); + repository.save(coverLetter); + + for (int index = 0; index < questionCount; index++) { + questionRepository.save(CoverLetterQuestion.create( + "clq_" + id + "_" + index, + coverLetter, + index + 1, + "질문 " + (index + 1), + 1000, + "답변 " + (index + 1) + )); + } + return coverLetter; + } } diff --git a/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java index 7406587..6923138 100644 --- a/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java @@ -12,6 +12,7 @@ import org.springframework.test.context.ActiveProfiles; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -106,4 +107,23 @@ void saveAndFindFailedJobRoundTripsError() { assertThat(found.getErrorMessage()).isEqualTo("LLM 응답 생성에 실패했습니다."); assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(60)); } + + @Test + void findLatestRunningJobByCoverLetter() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob oldJob = LlmJob.pendingReview("job_old", "cl_1", now, 3); + oldJob.markFailed(0, "실패", "ERROR", "실패", now.plusSeconds(30)); + LlmJob runningJob = LlmJob.pendingReview("job_running", "cl_1", now.plusSeconds(60), 3); + repository.saveAll(List.of(oldJob, runningJob)); + entityManager.flush(); + entityManager.clear(); + + LlmJob found = repository.findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc( + LlmJobTargetType.COVER_LETTER, + "cl_1", + List.of(LlmJobStatus.PENDING, LlmJobStatus.PROCESSING) + ).orElseThrow(); + + assertThat(found.getId()).isEqualTo("job_running"); + } }