diff --git a/docs/api/cover-letters.md b/docs/api/cover-letters.md index c7f86a7..1ab8492 100644 --- a/docs/api/cover-letters.md +++ b/docs/api/cover-letters.md @@ -448,6 +448,8 @@ questions는 1개 이상이어야 한다. `REVIEW_FAILED` 상태의 사용자 수동 재시도에는 제품 도메인상 횟수 제한을 두지 않는다. 단, LLM 비용과 남용 방지를 위한 rate limit, 사용자 quota, 운영 정책은 별도로 적용할 수 있다. +새 Job이 생성되면 submit transaction commit 이후 최초 첨삭 worker가 비동기로 실행된다. worker 성공 시 `ReviewVersion`이 생성되고 `CoverLetter.status`는 `REVIEWED`가 된다. worker 실패 시 `LlmJob.status`는 `FAILED`, `CoverLetter.status`는 `REVIEW_FAILED`가 된다. + 이미 최초 첨삭 Job이 `PENDING` 또는 `PROCESSING` 상태이면 새 Job을 만들지 않고 기존 진행 중 Job을 반환한다. 이미 `REVIEWED` 상태이면 새 Job을 만들지 않고 기존 최신 `ReviewVersion` 정보를 반환한다. diff --git a/docs/api/llm-jobs.md b/docs/api/llm-jobs.md index d9a66ad..744affa 100644 --- a/docs/api/llm-jobs.md +++ b/docs/api/llm-jobs.md @@ -8,7 +8,7 @@ GET /llm-jobs/{jobId} ``` -현재 구현된 skeleton 범위에서는 저장된 Job 상태를 조회할 수 있다. `partialResult`는 아직 서버 메모리/cache 저장소와 실제 LLM 실행 흐름이 없으므로 항상 `null`이다. +현재 구현 범위에서는 최초 첨삭 Job이 submit 이후 비동기 worker로 실행될 수 있다. `partialResult`는 아직 서버 메모리/cache 저장소가 없으므로 항상 `null`이다. `targetType=COVER_LETTER`인 Job은 연결된 자기소개서의 owner와 soft delete 상태를 기준으로 접근 권한을 검증한다. 존재하지 않는 Job, 다른 사용자 소유 자기소개서에 연결된 Job, 삭제된 자기소개서에 연결된 Job은 모두 `NOT_FOUND`를 반환한다. @@ -76,7 +76,7 @@ Response: "total": 3, "message": "LLM 첨삭에 실패했습니다." }, - "attempt": 2, + "attempt": 1, "maxAttempts": 2, "resultRef": null, "error": { @@ -94,7 +94,7 @@ Response: GET /llm-jobs/{jobId}/stream ``` -아직 구현되지 않았다. API-016 SSE stream은 실제 LLM 실행 흐름과 partial result 저장소가 준비되는 후속 이슈에서 구현한다. +아직 구현되지 않았다. API-016 SSE stream은 partial result 저장소와 스트리밍 이벤트 발행이 준비되는 후속 이슈에서 구현한다. SSE는 LLM 작업의 실시간 표시 채널이다. 서버는 `Last-Event-ID` 기반 이벤트 replay를 지원하지 않는다. diff --git a/docs/status.md b/docs/status.md index 85d84fb..02927fa 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,8 +22,8 @@ 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), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계, OpenAI 최초 첨삭 client 구현됨. API-016 SSE와 비동기 worker 연결은 후속 이슈로 분리 | -| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 구현됨. API-017~019, API-024는 후속 범위 | +| 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), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회, 최초 첨삭 성공 결과 완료 경계, OpenAI 최초 첨삭 client, after-commit 비동기 worker 연결 구현됨. API-016 SSE, partial result, 자동 재시도는 후속 이슈로 분리 | +| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결 구현됨. API-017~019, API-024는 후속 범위 | | 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 | | REQ-009 | 키워드 분석 | Planned | Medium | API-020, API-021 | - | - | LLM Job 기반 | @@ -59,7 +59,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 최초 첨삭 비동기 worker | REQ-005, REQ-006 | API-014 이후 내부 실행 | PENDING Job 실행, provider client 호출, 성공 시 ReviewVersion 완료 경계 연결, 실패 상태와 자동 재시도 처리 | +| 첨삭 버전 목록/상세 조회 API | REQ-006 | API-017, API-018 | 완료된 `ReviewVersion`과 문항별 첨삭 결과를 사용자-facing API로 조회 | +| LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 | ## Update Rules 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 414076e..3740daf 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -108,6 +108,11 @@ public void completeReview(String reviewVersionId, Instant now) { this.updatedAt = now; } + public void failReview(Instant now) { + this.status = CoverLetterStatus.REVIEW_FAILED; + this.updatedAt = now; + } + public void setLatestReviewVersionId(String latestReviewVersionId) { this.latestReviewVersionId = latestReviewVersionId; } 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 8b31867..c4b00ad 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -16,7 +16,9 @@ import com.daon.rewrite.llmjob.entity.LlmJobTargetType; import com.daon.rewrite.llmjob.entity.LlmJobType; import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -59,6 +61,7 @@ public class CoverLetterService { private final LlmJobRepository llmJobRepository; private final IdGenerator idGenerator; private final Clock clock; + private final ApplicationEventPublisher eventPublisher; @Transactional public CoverLetter createDraft() { @@ -231,6 +234,7 @@ public SubmitCoverLetterResult submit(String coverLetterId) { questions.size() )); coverLetter.startReview(now); + eventPublisher.publishEvent(new LlmJobCreatedEvent(job.getId())); return new SubmitCoverLetterResult(coverLetter, job); } diff --git a/src/main/java/com/daon/rewrite/global/config/AsyncConfig.java b/src/main/java/com/daon/rewrite/global/config/AsyncConfig.java new file mode 100644 index 0000000..01aedeb --- /dev/null +++ b/src/main/java/com/daon/rewrite/global/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.daon.rewrite.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/java/com/daon/rewrite/llmjob/service/LlmJobCreatedEvent.java b/src/main/java/com/daon/rewrite/llmjob/service/LlmJobCreatedEvent.java new file mode 100644 index 0000000..4ff79f0 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/service/LlmJobCreatedEvent.java @@ -0,0 +1,4 @@ +package com.daon.rewrite.llmjob.service; + +public record LlmJobCreatedEvent(String jobId) { +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java index b5881f9..3d8534f 100644 --- a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java @@ -9,7 +9,7 @@ private FirstReviewClientException(Reason reason, String message, Throwable caus this.reason = reason; } - static FirstReviewClientException outputValidationFailed() { + public static FirstReviewClientException outputValidationFailed() { return new FirstReviewClientException( Reason.OUTPUT_VALIDATION_FAILED, "최초 첨삭 결과 구조가 올바르지 않습니다.", @@ -17,7 +17,7 @@ static FirstReviewClientException outputValidationFailed() { ); } - static FirstReviewClientException outputValidationFailed(Throwable cause) { + public static FirstReviewClientException outputValidationFailed(Throwable cause) { return new FirstReviewClientException( Reason.OUTPUT_VALIDATION_FAILED, "최초 첨삭 결과를 변환할 수 없습니다.", @@ -25,7 +25,7 @@ static FirstReviewClientException outputValidationFailed(Throwable cause) { ); } - static FirstReviewClientException providerError(Throwable cause) { + public static FirstReviewClientException providerError(Throwable cause) { return new FirstReviewClientException( Reason.PROVIDER_ERROR, "최초 첨삭 provider 호출에 실패했습니다.", diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventListener.java b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventListener.java new file mode 100644 index 0000000..45c0b9f --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventListener.java @@ -0,0 +1,21 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +class FirstReviewJobEventListener { + + private final FirstReviewJobWorker worker; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LlmJobCreatedEvent event) { + worker.execute(event.jobId()); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobTransactionService.java b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobTransactionService.java new file mode 100644 index 0000000..e14563b --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobTransactionService.java @@ -0,0 +1,124 @@ +package com.daon.rewrite.reviewversion.service; + +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; +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 com.daon.rewrite.reviewversion.client.FirstReviewClientException; +import com.daon.rewrite.reviewversion.client.FirstReviewQuestion; +import com.daon.rewrite.reviewversion.client.FirstReviewRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +class FirstReviewJobTransactionService { + + private static final String STARTED_MESSAGE = "첨삭을 시작합니다."; + private static final String FAILED_MESSAGE = "LLM 첨삭에 실패했습니다."; + private static final String PROVIDER_ERROR_CODE = "LLM_PROVIDER_ERROR"; + private static final String PROVIDER_ERROR_MESSAGE = "LLM 응답 생성에 실패했습니다."; + private static final String OUTPUT_VALIDATION_ERROR_CODE = "LLM_OUTPUT_VALIDATION_FAILED"; + private static final String OUTPUT_VALIDATION_ERROR_MESSAGE = "LLM 출력 형식이 올바르지 않습니다."; + + private final LlmJobRepository llmJobRepository; + private final CoverLetterRepository coverLetterRepository; + private final CoverLetterQuestionRepository questionRepository; + private final Clock clock; + + @Transactional + public FirstReviewWork start(String jobId) { + LlmJob job = findFirstReviewJobForUpdate(jobId); + if (job.getStatus() != LlmJobStatus.PENDING) { + return null; + } + + CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (coverLetter.getStatus() != CoverLetterStatus.REVIEWING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + List questions = questionRepository + .findByCoverLetterIdOrderByQuestionOrderAsc(coverLetter.getId()); + if (questions.isEmpty()) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + job.startProcessing(STARTED_MESSAGE); + return new FirstReviewWork(new FirstReviewRequest( + coverLetter.getTitle(), + coverLetter.getCompanyName(), + coverLetter.getPositionTitle(), + coverLetter.getJobPostingUrl(), + coverLetter.getPreferences(), + questions.stream() + .map(question -> new FirstReviewQuestion( + question.getId(), + question.getQuestionOrder(), + question.getQuestion(), + question.getMaxAnswerLength(), + question.getOriginalAnswer() + )) + .toList() + )); + } + + @Transactional + public void fail(String jobId, FirstReviewClientException.Reason reason) { + LlmJob job = findFirstReviewJobForUpdate(jobId); + if (job.getStatus() == LlmJobStatus.COMPLETED || job.getStatus() == LlmJobStatus.FAILED) { + return; + } + + CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + + Instant now = Instant.now(clock); + job.markFailed( + job.getProgressCurrent(), + FAILED_MESSAGE, + errorCode(reason), + errorMessage(reason), + now + ); + coverLetter.failReview(now); + } + + private LlmJob findFirstReviewJobForUpdate(String jobId) { + LlmJob job = llmJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (job.getType() != LlmJobType.COVER_LETTER_REVIEW + || job.getTargetType() != LlmJobTargetType.COVER_LETTER) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + return job; + } + + private String errorCode(FirstReviewClientException.Reason reason) { + if (reason == FirstReviewClientException.Reason.OUTPUT_VALIDATION_FAILED) { + return OUTPUT_VALIDATION_ERROR_CODE; + } + return PROVIDER_ERROR_CODE; + } + + private String errorMessage(FirstReviewClientException.Reason reason) { + if (reason == FirstReviewClientException.Reason.OUTPUT_VALIDATION_FAILED) { + return OUTPUT_VALIDATION_ERROR_MESSAGE; + } + return PROVIDER_ERROR_MESSAGE; + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorker.java b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorker.java new file mode 100644 index 0000000..6af5668 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorker.java @@ -0,0 +1,41 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.reviewversion.client.FirstReviewClient; +import com.daon.rewrite.reviewversion.client.FirstReviewClientException; +import com.daon.rewrite.reviewversion.client.FirstReviewResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FirstReviewJobWorker { + + private final FirstReviewJobTransactionService transactionService; + private final FirstReviewClient firstReviewClient; + private final ReviewVersionService reviewVersionService; + + public void execute(String jobId) { + FirstReviewWork work = transactionService.start(jobId); + if (work == null) { + return; + } + + try { + List results = firstReviewClient.review(work.request()); + reviewVersionService.completeFirstReview( + jobId, + results.stream() + .map(result -> new ReviewQuestionResultInput( + result.questionId(), + result.aiReport(), + result.rewrittenAnswer() + )) + .toList() + ); + } catch (FirstReviewClientException exception) { + transactionService.fail(jobId, exception.getReason()); + } + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewWork.java b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewWork.java new file mode 100644 index 0000000..05c281c --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/FirstReviewWork.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.reviewversion.client.FirstReviewRequest; + +record FirstReviewWork(FirstReviewRequest request) { +} diff --git a/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventIntegrationTest.java b/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventIntegrationTest.java new file mode 100644 index 0000000..cabdd66 --- /dev/null +++ b/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobEventIntegrationTest.java @@ -0,0 +1,106 @@ +package com.daon.rewrite.reviewversion.service; + +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.repository.CoverLetterQuestionRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.coverletter.service.CoverLetterService; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +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; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.Instant; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; + +@SpringBootTest +@ActiveProfiles("test") +class FirstReviewJobEventIntegrationTest { + + @Autowired + private CoverLetterService coverLetterService; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository questionRepository; + + @Autowired + private LlmJobRepository llmJobRepository; + + @MockitoBean + private FirstReviewJobWorker worker; + + @MockitoBean + private CurrentUserProvider currentUserProvider; + + @MockitoBean + private IdGenerator idGenerator; + + @MockitoBean + private Clock clock; + + @AfterEach + void cleanUp() { + llmJobRepository.deleteAll(); + questionRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void submitSchedulesFirstReviewWorkerAfterCommit() { + Instant now = Instant.parse("2026-06-25T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("job")).willReturn("job_1"); + given(clock.instant()).willReturn(now.plusSeconds(60)); + saveCompleteCoverLetter("cl_1", "user_1", now); + + coverLetterService.submit("cl_1"); + + then(worker).should(timeout(1000)).execute("job_1"); + } + + @Test + void submitDoesNotScheduleWorkerWhenReturningExistingReviewJob() { + Instant now = Instant.parse("2026-06-25T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + CoverLetter coverLetter = saveCompleteCoverLetter("cl_1", "user_1", now); + coverLetter.startReview(now.plusSeconds(30)); + coverLetterRepository.saveAndFlush(coverLetter); + llmJobRepository.save(LlmJob.pendingReview("job_existing", "cl_1", now.plusSeconds(30), 1)); + + coverLetterService.submit("cl_1"); + + then(worker).should(never()).execute("job_existing"); + } + + private CoverLetter saveCompleteCoverLetter(String id, String ownerId, Instant now) { + CoverLetter coverLetter = CoverLetter.draft(id, ownerId, now); + coverLetter.fillBasicInfo("제목", "회사", "직무", null, now); + coverLetter.fillPreferences("Spring Boot 경험", now); + coverLetterRepository.save(coverLetter); + + questionRepository.save(CoverLetterQuestion.create( + "clq_1", + coverLetter, + 1, + "질문", + 1000, + "답변" + )); + return coverLetter; + } +} diff --git a/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorkerTest.java b/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorkerTest.java new file mode 100644 index 0000000..7b8ff5d --- /dev/null +++ b/src/test/java/com/daon/rewrite/reviewversion/service/FirstReviewJobWorkerTest.java @@ -0,0 +1,182 @@ +package com.daon.rewrite.reviewversion.service; + +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.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobResultRefType; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.client.FirstReviewClient; +import com.daon.rewrite.reviewversion.client.FirstReviewClientException; +import com.daon.rewrite.reviewversion.client.FirstReviewRequest; +import com.daon.rewrite.reviewversion.client.FirstReviewResult; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import com.daon.rewrite.reviewversion.repository.ReviewVersionQuestionResultRepository; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@SpringBootTest +@ActiveProfiles("test") +class FirstReviewJobWorkerTest { + + @Autowired + private FirstReviewJobWorker worker; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository questionRepository; + + @Autowired + private LlmJobRepository llmJobRepository; + + @Autowired + private ReviewVersionRepository reviewVersionRepository; + + @Autowired + private ReviewVersionQuestionResultRepository questionResultRepository; + + @MockitoBean + private FirstReviewClient firstReviewClient; + + @MockitoBean + private IdGenerator idGenerator; + + @MockitoBean + private Clock clock; + + @AfterEach + void cleanUp() { + questionResultRepository.deleteAll(); + reviewVersionRepository.deleteAll(); + llmJobRepository.deleteAll(); + questionRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void executeCompletesPendingFirstReviewJob() { + Instant completedAt = Instant.parse("2026-06-25T05:00:00Z"); + savePendingReviewJob("cl_1", "job_1", 2); + given(firstReviewClient.review(any())).willReturn(List.of( + new FirstReviewResult("clq_2", "두 번째 리포트", "두 번째 수정본"), + new FirstReviewResult("clq_1", "첫 번째 리포트", "첫 번째 수정본") + )); + given(idGenerator.generate("rv")).willReturn("rv_1"); + given(idGenerator.generate("rvqr")).willReturn("rvqr_1", "rvqr_2"); + given(clock.instant()).willReturn(completedAt); + + worker.execute("job_1"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(FirstReviewRequest.class); + then(firstReviewClient).should().review(requestCaptor.capture()); + FirstReviewRequest request = requestCaptor.getValue(); + assertThat(request.title()).isEqualTo("제목"); + assertThat(request.companyName()).isEqualTo("회사"); + assertThat(request.positionTitle()).isEqualTo("직무"); + assertThat(request.preferences()).isEqualTo("Spring Boot 경험"); + assertThat(request.questions()).extracting("questionId") + .containsExactly("clq_1", "clq_2"); + + assertThat(coverLetterRepository.findById("cl_1")).hasValueSatisfying(coverLetter -> { + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.REVIEWED); + assertThat(coverLetter.getLatestReviewVersionId()).isEqualTo("rv_1"); + assertThat(coverLetter.getUpdatedAt()).isEqualTo(completedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.COMPLETED); + assertThat(job.getResultRefType()).isEqualTo(LlmJobResultRefType.REVIEW_VERSION); + assertThat(job.getResultRefId()).isEqualTo("rv_1"); + assertThat(job.getCompletedAt()).isEqualTo(completedAt); + }); + assertThat(questionResultRepository.findByReviewVersionIdOrderByQuestionOrderAsc("rv_1")) + .extracting(result -> result.getQuestion().getId()) + .containsExactly("clq_1", "clq_2"); + } + + @Test + void executeFailsJobAndCoverLetterWhenProviderFails() { + Instant failedAt = Instant.parse("2026-06-25T05:00:00Z"); + savePendingReviewJob("cl_1", "job_1", 1); + given(firstReviewClient.review(any())) + .willThrow(FirstReviewClientException.providerError(new IllegalStateException("provider down"))); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + assertThat(coverLetterRepository.findById("cl_1")).hasValueSatisfying(coverLetter -> + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.REVIEW_FAILED) + ); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + assertThat(job.getErrorMessage()).isEqualTo("LLM 응답 생성에 실패했습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(reviewVersionRepository.count()).isZero(); + } + + @Test + void executeFailsJobAndCoverLetterWhenOutputValidationFails() { + Instant failedAt = Instant.parse("2026-06-25T05:00:00Z"); + savePendingReviewJob("cl_1", "job_1", 1); + given(firstReviewClient.review(any())) + .willThrow(FirstReviewClientException.outputValidationFailed()); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + assertThat(coverLetterRepository.findById("cl_1")).hasValueSatisfying(coverLetter -> + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.REVIEW_FAILED) + ); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("LLM_OUTPUT_VALIDATION_FAILED"); + assertThat(job.getErrorMessage()).isEqualTo("LLM 출력 형식이 올바르지 않습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(reviewVersionRepository.count()).isZero(); + } + + private void savePendingReviewJob(String coverLetterId, String jobId, int questionCount) { + Instant now = Instant.parse("2026-06-25T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft(coverLetterId, "user_1", now); + coverLetter.fillBasicInfo("제목", "회사", "직무", "https://example.com/jobs/1", now); + coverLetter.fillPreferences("Spring Boot 경험", now); + coverLetter.startReview(now); + coverLetterRepository.save(coverLetter); + + for (int index = 1; index <= questionCount; index++) { + questionRepository.save(CoverLetterQuestion.create( + "clq_" + index, + coverLetter, + index, + "질문 " + index, + 1000, + "원본 답변 " + index + )); + } + + llmJobRepository.save(LlmJob.pendingReview(jobId, coverLetterId, now, questionCount)); + } +}