diff --git a/docs/api/README.md b/docs/api/README.md index 0b2a75d..a6a4d8f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,7 +63,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | 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-015 | GET | `/llm-jobs/{jobId}` | Planned | REQ-005 | LLM 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 | 첨삭 버전 목록 | | API-018 | GET | `/cover-letters/{coverLetterId}/review-versions/{versionId}` | Planned | REQ-006 | 첨삭 버전 상세 | diff --git a/docs/api/llm-jobs.md b/docs/api/llm-jobs.md index a37c73a..d9a66ad 100644 --- a/docs/api/llm-jobs.md +++ b/docs/api/llm-jobs.md @@ -8,6 +8,10 @@ GET /llm-jobs/{jobId} ``` +현재 구현된 skeleton 범위에서는 저장된 Job 상태를 조회할 수 있다. `partialResult`는 아직 서버 메모리/cache 저장소와 실제 LLM 실행 흐름이 없으므로 항상 `null`이다. + +`targetType=COVER_LETTER`인 Job은 연결된 자기소개서의 owner와 soft delete 상태를 기준으로 접근 권한을 검증한다. 존재하지 않는 Job, 다른 사용자 소유 자기소개서에 연결된 Job, 삭제된 자기소개서에 연결된 Job은 모두 `NOT_FOUND`를 반환한다. + Response: ```json @@ -24,22 +28,7 @@ Response: }, "attempt": 1, "maxAttempts": 2, - "partialResult": { - "questions": [ - { - "questionId": "clq_01HZ_1", - "order": 1, - "aiReport": "지원 동기는 구체적이지만 직무 경험과의 연결이 더 필요합니다.", - "rewrittenAnswer": "저는 백엔드 개발자로서..." - }, - { - "questionId": "clq_01HZ_2", - "order": 2, - "aiReport": "프로젝트 경험의 문제 상황은 잘 드러나지만", - "rewrittenAnswer": "" - } - ] - }, + "partialResult": null, "resultRef": null, "error": null, "createdAt": "2026-06-20T14:10:00", @@ -105,6 +94,8 @@ Response: GET /llm-jobs/{jobId}/stream ``` +아직 구현되지 않았다. API-016 SSE stream은 실제 LLM 실행 흐름과 partial result 저장소가 준비되는 후속 이슈에서 구현한다. + SSE는 LLM 작업의 실시간 표시 채널이다. 서버는 `Last-Event-ID` 기반 이벤트 replay를 지원하지 않는다. 연결이 끊기면 클라이언트는 `GET /llm-jobs/{jobId}`로 상태를 다시 조회한다. diff --git a/docs/status.md b/docs/status.md index 5c2e00e..db4a103 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 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 | +| 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-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 | -| 자기소개서 제출과 LLM Job skeleton | REQ-005 | API-014, API-015 | 필수 step 데이터 검증, cover letter 상태 전환, LLM job persistence skeleton, service/web/repository test | +| 자기소개서 제출 API | REQ-005 | API-014 | 필수 step 데이터 검증, cover letter 상태 전환, COVER_LETTER_REVIEW Job 생성, service/web/repository test | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/llmjob/controller/LlmJobController.java b/src/main/java/com/daon/rewrite/llmjob/controller/LlmJobController.java new file mode 100644 index 0000000..164fdc0 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/controller/LlmJobController.java @@ -0,0 +1,20 @@ +package com.daon.rewrite.llmjob.controller; + +import com.daon.rewrite.llmjob.dto.LlmJobResponse; +import com.daon.rewrite.llmjob.service.LlmJobService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LlmJobController { + + private final LlmJobService llmJobService; + + @GetMapping("/llm-jobs/{jobId}") + public LlmJobResponse findJob(@PathVariable String jobId) { + return LlmJobResponse.from(llmJobService.findMyJob(jobId)); + } +} diff --git a/src/main/java/com/daon/rewrite/llmjob/dto/LlmJobResponse.java b/src/main/java/com/daon/rewrite/llmjob/dto/LlmJobResponse.java new file mode 100644 index 0000000..632085f --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/dto/LlmJobResponse.java @@ -0,0 +1,80 @@ +package com.daon.rewrite.llmjob.dto; + +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 java.time.LocalDateTime; +import java.time.ZoneId; + +public record LlmJobResponse( + String id, + LlmJobType type, + LlmJobStatus status, + LlmJobTargetType targetType, + String targetId, + ProgressResponse progress, + int attempt, + int maxAttempts, + Object partialResult, + ResultRefResponse resultRef, + ErrorResponse error, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static LlmJobResponse from(LlmJob job) { + return new LlmJobResponse( + job.getId(), + job.getType(), + job.getStatus(), + job.getTargetType(), + job.getTargetId(), + new ProgressResponse( + job.getProgressCurrent(), + job.getProgressTotal(), + job.getProgressMessage() + ), + job.getAttempt(), + job.getMaxAttempts(), + null, + ResultRefResponse.from(job), + ErrorResponse.from(job), + LocalDateTime.ofInstant(job.getCreatedAt(), API_ZONE), + job.getCompletedAt() == null ? null : LocalDateTime.ofInstant(job.getCompletedAt(), API_ZONE) + ); + } + + public record ProgressResponse( + int current, + int total, + String message + ) { + } + + public record ResultRefResponse( + String type, + String id + ) { + private static ResultRefResponse from(LlmJob job) { + if (job.getResultRefType() == null || job.getResultRefId() == null) { + return null; + } + return new ResultRefResponse(job.getResultRefType().name(), job.getResultRefId()); + } + } + + public record ErrorResponse( + String code, + String message + ) { + private static ErrorResponse from(LlmJob job) { + if (job.getErrorCode() == null) { + return null; + } + return new ErrorResponse(job.getErrorCode(), job.getErrorMessage()); + } + } +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java new file mode 100644 index 0000000..31ae0c9 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java @@ -0,0 +1,139 @@ +package com.daon.rewrite.llmjob.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "llm_jobs") +public class LlmJob { + + private static final int DEFAULT_ATTEMPT = 1; + private static final int DEFAULT_MAX_ATTEMPTS = 2; + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 40) + private LlmJobType type; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private LlmJobStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 40) + private LlmJobTargetType targetType; + + @Column(name = "target_id", nullable = false, length = 64) + private String targetId; + + @Column(name = "progress_current", nullable = false) + private int progressCurrent; + + @Column(name = "progress_total", nullable = false) + private int progressTotal; + + @Column(name = "progress_message") + private String progressMessage; + + @Column(name = "attempt", nullable = false) + private int attempt; + + @Column(name = "max_attempts", nullable = false) + private int maxAttempts; + + @Enumerated(EnumType.STRING) + @Column(name = "result_ref_type", length = 40) + private LlmJobResultRefType resultRefType; + + @Column(name = "result_ref_id", length = 64) + private String resultRefId; + + @Column(name = "error_code", length = 100) + private String errorCode; + + @Column(name = "error_message", columnDefinition = "text") + private String errorMessage; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; + + private LlmJob( + String id, + LlmJobType type, + LlmJobStatus status, + LlmJobTargetType targetType, + String targetId, + int progressTotal, + Instant createdAt + ) { + this.id = id; + this.type = type; + this.status = status; + this.targetType = targetType; + this.targetId = targetId; + this.progressCurrent = 0; + this.progressTotal = progressTotal; + this.attempt = DEFAULT_ATTEMPT; + this.maxAttempts = DEFAULT_MAX_ATTEMPTS; + this.createdAt = createdAt; + } + + public static LlmJob pendingReview(String id, String coverLetterId, Instant now, int progressTotal) { + return new LlmJob( + id, + LlmJobType.COVER_LETTER_REVIEW, + LlmJobStatus.PENDING, + LlmJobTargetType.COVER_LETTER, + coverLetterId, + progressTotal, + now + ); + } + + public void markCompleted( + int progressCurrent, + String progressMessage, + LlmJobResultRefType resultRefType, + String resultRefId, + Instant completedAt + ) { + this.status = LlmJobStatus.COMPLETED; + this.progressCurrent = progressCurrent; + this.progressMessage = progressMessage; + this.resultRefType = resultRefType; + this.resultRefId = resultRefId; + this.completedAt = completedAt; + } + + public void markFailed( + int progressCurrent, + String progressMessage, + String errorCode, + String errorMessage, + Instant completedAt + ) { + this.status = LlmJobStatus.FAILED; + this.progressCurrent = progressCurrent; + this.progressMessage = progressMessage; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.completedAt = completedAt; + } +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobResultRefType.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobResultRefType.java new file mode 100644 index 0000000..23e071c --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobResultRefType.java @@ -0,0 +1,9 @@ +package com.daon.rewrite.llmjob.entity; + +public enum LlmJobResultRefType { + REVIEW_VERSION, + KEYWORD_ANALYSIS, + INTERVIEW_SESSION, + INTERVIEW_QUESTION, + INTERVIEW_MESSAGE +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobStatus.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobStatus.java new file mode 100644 index 0000000..afa3878 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobStatus.java @@ -0,0 +1,9 @@ +package com.daon.rewrite.llmjob.entity; + +public enum LlmJobStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED, + CANCELED +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobTargetType.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobTargetType.java new file mode 100644 index 0000000..f9420bc --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobTargetType.java @@ -0,0 +1,5 @@ +package com.daon.rewrite.llmjob.entity; + +public enum LlmJobTargetType { + COVER_LETTER +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobType.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobType.java new file mode 100644 index 0000000..c53c080 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJobType.java @@ -0,0 +1,9 @@ +package com.daon.rewrite.llmjob.entity; + +public enum LlmJobType { + COVER_LETTER_REVIEW, + COVER_LETTER_RE_REVIEW, + KEYWORD_ANALYSIS, + INTERVIEW_QUESTION_GENERATION, + INTERVIEW_MESSAGE_FEEDBACK +} diff --git a/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java new file mode 100644 index 0000000..999ec3e --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java @@ -0,0 +1,7 @@ +package com.daon.rewrite.llmjob.repository; + +import com.daon.rewrite.llmjob.entity.LlmJob; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LlmJobRepository extends JpaRepository { +} diff --git a/src/main/java/com/daon/rewrite/llmjob/service/LlmJobService.java b/src/main/java/com/daon/rewrite/llmjob/service/LlmJobService.java new file mode 100644 index 0000000..8609da8 --- /dev/null +++ b/src/main/java/com/daon/rewrite/llmjob/service/LlmJobService.java @@ -0,0 +1,39 @@ +package com.daon.rewrite.llmjob.service; + +import com.daon.rewrite.auth.CurrentUser; +import com.daon.rewrite.auth.CurrentUserProvider; +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.LlmJobTargetType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LlmJobService { + + private final LlmJobRepository llmJobRepository; + private final CoverLetterRepository coverLetterRepository; + private final CurrentUserProvider currentUserProvider; + + @Transactional(readOnly = true) + public LlmJob findMyJob(String jobId) { + LlmJob job = llmJobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + if (job.getTargetType() != LlmJobTargetType.COVER_LETTER) { + throw new BusinessException(ErrorCode.NOT_FOUND); + } + + CurrentUser currentUser = currentUserProvider.currentUser(); + coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(job.getTargetId(), currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + return job; + } +} diff --git a/src/test/java/com/daon/rewrite/llmjob/controller/LlmJobControllerTest.java b/src/test/java/com/daon/rewrite/llmjob/controller/LlmJobControllerTest.java new file mode 100644 index 0000000..59e3244 --- /dev/null +++ b/src/test/java/com/daon/rewrite/llmjob/controller/LlmJobControllerTest.java @@ -0,0 +1,114 @@ +package com.daon.rewrite.llmjob.controller; + +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.LlmJobResultRefType; +import com.daon.rewrite.llmjob.service.LlmJobService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(LlmJobController.class) +class LlmJobControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LlmJobService llmJobService; + + @Test + void findJobReturnsPendingJob() throws Exception { + LlmJob job = LlmJob.pendingReview( + "job_1", + "cl_1", + Instant.parse("2026-06-20T05:10:00Z"), + 3 + ); + given(llmJobService.findMyJob("job_1")).willReturn(job); + + mockMvc.perform(get("/llm-jobs/{jobId}", "job_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("job_1")) + .andExpect(jsonPath("$.type").value("COVER_LETTER_REVIEW")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.targetType").value("COVER_LETTER")) + .andExpect(jsonPath("$.targetId").value("cl_1")) + .andExpect(jsonPath("$.progress.current").value(0)) + .andExpect(jsonPath("$.progress.total").value(3)) + .andExpect(jsonPath("$.progress.message").doesNotExist()) + .andExpect(jsonPath("$.attempt").value(1)) + .andExpect(jsonPath("$.maxAttempts").value(2)) + .andExpect(jsonPath("$.partialResult").doesNotExist()) + .andExpect(jsonPath("$.resultRef").doesNotExist()) + .andExpect(jsonPath("$.error").doesNotExist()) + .andExpect(jsonPath("$.createdAt").value("2026-06-20T14:10:00")) + .andExpect(jsonPath("$.completedAt").doesNotExist()); + } + + @Test + void findJobReturnsCompletedJobWithResultRef() throws Exception { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob job = LlmJob.pendingReview("job_done", "cl_1", now, 3); + job.markCompleted( + 3, + "첨삭이 완료되었습니다.", + LlmJobResultRefType.REVIEW_VERSION, + "rv_1", + now.plusSeconds(60) + ); + given(llmJobService.findMyJob("job_done")).willReturn(job); + + mockMvc.perform(get("/llm-jobs/{jobId}", "job_done")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("COMPLETED")) + .andExpect(jsonPath("$.progress.current").value(3)) + .andExpect(jsonPath("$.progress.message").value("첨삭이 완료되었습니다.")) + .andExpect(jsonPath("$.resultRef.type").value("REVIEW_VERSION")) + .andExpect(jsonPath("$.resultRef.id").value("rv_1")) + .andExpect(jsonPath("$.error").doesNotExist()) + .andExpect(jsonPath("$.completedAt").value("2026-06-20T14:11:00")); + } + + @Test + void findJobReturnsFailedJobWithError() throws Exception { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob job = LlmJob.pendingReview("job_failed", "cl_1", now, 3); + job.markFailed( + 0, + "LLM 첨삭에 실패했습니다.", + "LLM_PROVIDER_ERROR", + "LLM 응답 생성에 실패했습니다.", + now.plusSeconds(60) + ); + given(llmJobService.findMyJob("job_failed")).willReturn(job); + + mockMvc.perform(get("/llm-jobs/{jobId}", "job_failed")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAILED")) + .andExpect(jsonPath("$.resultRef").doesNotExist()) + .andExpect(jsonPath("$.error.code").value("LLM_PROVIDER_ERROR")) + .andExpect(jsonPath("$.error.message").value("LLM 응답 생성에 실패했습니다.")) + .andExpect(jsonPath("$.completedAt").value("2026-06-20T14:11:00")); + } + + @Test + void findJobReturnsNotFound() throws Exception { + given(llmJobService.findMyJob("job_missing")) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(get("/llm-jobs/{jobId}", "job_missing")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } +} diff --git a/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java new file mode 100644 index 0000000..7406587 --- /dev/null +++ b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java @@ -0,0 +1,109 @@ +package com.daon.rewrite.llmjob.repository; + +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.entity.LlmJobTargetType; +import com.daon.rewrite.llmjob.entity.LlmJobType; +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 static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class LlmJobRepositoryTest { + + @Autowired + private LlmJobRepository repository; + + @Autowired + private EntityManager entityManager; + + @Test + void saveAndFindPendingReviewJobRoundTripsThroughJpa() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob job = LlmJob.pendingReview("job_1", "cl_1", now, 3); + + repository.save(job); + entityManager.flush(); + entityManager.clear(); + + LlmJob found = repository.findById("job_1").orElseThrow(); + + assertThat(found.getId()).isEqualTo("job_1"); + assertThat(found.getType()).isEqualTo(LlmJobType.COVER_LETTER_REVIEW); + assertThat(found.getStatus()).isEqualTo(LlmJobStatus.PENDING); + assertThat(found.getTargetType()).isEqualTo(LlmJobTargetType.COVER_LETTER); + assertThat(found.getTargetId()).isEqualTo("cl_1"); + assertThat(found.getProgressCurrent()).isZero(); + assertThat(found.getProgressTotal()).isEqualTo(3); + assertThat(found.getProgressMessage()).isNull(); + assertThat(found.getAttempt()).isEqualTo(1); + assertThat(found.getMaxAttempts()).isEqualTo(2); + assertThat(found.getResultRefType()).isNull(); + assertThat(found.getResultRefId()).isNull(); + assertThat(found.getErrorCode()).isNull(); + assertThat(found.getErrorMessage()).isNull(); + assertThat(found.getCreatedAt()).isEqualTo(now); + assertThat(found.getCompletedAt()).isNull(); + } + + @Test + void saveAndFindCompletedJobRoundTripsResultReference() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob job = LlmJob.pendingReview("job_done", "cl_1", now, 3); + job.markCompleted( + 3, + "첨삭이 완료되었습니다.", + LlmJobResultRefType.REVIEW_VERSION, + "rv_1", + now.plusSeconds(60) + ); + + repository.save(job); + entityManager.flush(); + entityManager.clear(); + + LlmJob found = repository.findById("job_done").orElseThrow(); + + assertThat(found.getStatus()).isEqualTo(LlmJobStatus.COMPLETED); + assertThat(found.getProgressCurrent()).isEqualTo(3); + assertThat(found.getProgressTotal()).isEqualTo(3); + assertThat(found.getProgressMessage()).isEqualTo("첨삭이 완료되었습니다."); + assertThat(found.getResultRefType()).isEqualTo(LlmJobResultRefType.REVIEW_VERSION); + assertThat(found.getResultRefId()).isEqualTo("rv_1"); + assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(60)); + } + + @Test + void saveAndFindFailedJobRoundTripsError() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + LlmJob job = LlmJob.pendingReview("job_failed", "cl_1", now, 3); + job.markFailed( + 0, + "LLM 첨삭에 실패했습니다.", + "LLM_PROVIDER_ERROR", + "LLM 응답 생성에 실패했습니다.", + now.plusSeconds(60) + ); + + repository.save(job); + entityManager.flush(); + entityManager.clear(); + + LlmJob found = repository.findById("job_failed").orElseThrow(); + + assertThat(found.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(found.getProgressCurrent()).isZero(); + assertThat(found.getProgressMessage()).isEqualTo("LLM 첨삭에 실패했습니다."); + assertThat(found.getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + assertThat(found.getErrorMessage()).isEqualTo("LLM 응답 생성에 실패했습니다."); + assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(60)); + } +} diff --git a/src/test/java/com/daon/rewrite/llmjob/service/LlmJobServiceTest.java b/src/test/java/com/daon/rewrite/llmjob/service/LlmJobServiceTest.java new file mode 100644 index 0000000..ef479de --- /dev/null +++ b/src/test/java/com/daon/rewrite/llmjob/service/LlmJobServiceTest.java @@ -0,0 +1,113 @@ +package com.daon.rewrite.llmjob.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.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.LlmJobResultRefType; +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.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +class LlmJobServiceTest { + + @Autowired + private LlmJobService service; + + @Autowired + private LlmJobRepository llmJobRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @MockitoBean + private CurrentUserProvider currentUserProvider; + + @AfterEach + void cleanUp() { + llmJobRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void findMyJobReturnsCurrentUserCoverLetterJob() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + LlmJob job = llmJobRepository.save(LlmJob.pendingReview("job_1", "cl_1", now, 3)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LlmJob result = service.findMyJob("job_1"); + + assertThat(result.getId()).isEqualTo(job.getId()); + assertThat(result.getTargetId()).isEqualTo("cl_1"); + } + + @Test + void findMyJobThrowsNotFoundWhenJobIsMissing() { + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.findMyJob("job_missing")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void findMyJobThrowsNotFoundWhenCoverLetterBelongsToOtherOwner() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + coverLetterRepository.save(CoverLetter.draft("cl_other", "user_2", now)); + llmJobRepository.save(LlmJob.pendingReview("job_other", "cl_other", now, 3)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.findMyJob("job_other")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void findMyJobThrowsNotFoundWhenCoverLetterIsDeleted() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + CoverLetter deleted = CoverLetter.draft("cl_deleted", "user_1", now); + deleted.markDeleted(now.plusSeconds(60)); + coverLetterRepository.save(deleted); + llmJobRepository.save(LlmJob.pendingReview("job_deleted", "cl_deleted", now, 3)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.findMyJob("job_deleted")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void findMyJobReturnsCompletedAndFailedJobsForDtoConversion() { + Instant now = Instant.parse("2026-06-20T05:10:00Z"); + coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + LlmJob completed = LlmJob.pendingReview("job_completed", "cl_1", now, 3); + completed.markCompleted(3, "첨삭이 완료되었습니다.", LlmJobResultRefType.REVIEW_VERSION, "rv_1", now.plusSeconds(60)); + LlmJob failed = LlmJob.pendingReview("job_failed", "cl_1", now, 3); + failed.markFailed(0, "LLM 첨삭에 실패했습니다.", "LLM_PROVIDER_ERROR", "LLM 응답 생성에 실패했습니다.", now.plusSeconds(120)); + llmJobRepository.save(completed); + llmJobRepository.save(failed); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThat(service.findMyJob("job_completed").getResultRefId()).isEqualTo("rv_1"); + assertThat(service.findMyJob("job_failed").getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + } +}