diff --git a/docs/api/README.md b/docs/api/README.md index 1fd19b3..782d564 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -69,7 +69,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-018 | GET | `/cover-letters/{coverLetterId}/review-versions/{versionId}` | Implemented | REQ-006 | 첨삭 버전 상세 | | API-019 | PUT | `/cover-letters/{coverLetterId}/review-versions/{versionId}/final-answers` | Implemented | REQ-006 | 최종 작성본 일괄 저장 | | API-020 | POST | `/cover-letters/{coverLetterId}/keyword-analysis` | Implemented | REQ-009 | 키워드 분석 시작/재분석 | -| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Planned | REQ-009 | 최신 키워드 분석 조회 | +| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Implemented | REQ-009 | 최신 키워드 분석 조회 | | API-022 | POST | `/cover-letters/{coverLetterId}/interviews` | Planned | REQ-010 | 면접 세션/질문 생성 | | API-023 | POST | `/interview-threads/{threadId}/messages` | Planned | REQ-010 | 면접 답변 전송 | | API-024 | POST | `/cover-letters/{coverLetterId}/review-versions` | Implemented | REQ-006 | AI 첨삭 다시받기 | diff --git a/docs/api/keyword-analysis.md b/docs/api/keyword-analysis.md index 3ddb108..4454198 100644 --- a/docs/api/keyword-analysis.md +++ b/docs/api/keyword-analysis.md @@ -76,12 +76,24 @@ Conflict Response: GET /cover-letters/{coverLetterId}/keyword-analysis/latest ``` +`status`는 `NOT_STARTED`, `PROCESSING`, `COMPLETED`, `FAILED` 중 하나다. +`keywords`는 완료 상태에서만 분석 결과를 담고, 그 외 상태에서는 빈 배열이다. + 키워드 분석 결과가 없는 경우: ```json { - "coverLetterId": "cl_01HZ...", - "keywordAnalysis": null + "status": "NOT_STARTED", + "keywords": [] +} +``` + +진행 중인 키워드 분석 결과가 있는 경우: + +```json +{ + "status": "PROCESSING", + "keywords": [] } ``` @@ -89,9 +101,6 @@ Response: ```json { - "id": "ka_01HZ...", - "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", "status": "COMPLETED", "keywords": [ { @@ -102,9 +111,7 @@ Response: "keyword": "Spring", "importance": 88 } - ], - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:00:30" + ] } ``` @@ -112,16 +119,7 @@ Response: ```json { - "id": "ka_01HZ...", - "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", "status": "FAILED", - "keywords": [], - "error": { - "code": "LLM_PROVIDER_ERROR", - "message": "키워드 분석에 실패했습니다." - }, - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:01:00" + "keywords": [] } ``` diff --git a/docs/status.md b/docs/status.md index fd56ffc..d85be26 100644 --- a/docs/status.md +++ b/docs/status.md @@ -26,7 +26,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | 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), [#50](https://github.com/Rewrite-Team/Rewrite-BE/issues/50), [#52](https://github.com/Rewrite-Team/Rewrite-BE/issues/52), [#54](https://github.com/Rewrite-Team/Rewrite-BE/issues/54) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `ReviewVersionQueryServiceTest`, `ReviewVersionCommandServiceTest`, `ReviewVersionControllerTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `ReReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결, API-017/018 첨삭 버전 목록/상세 조회, API-019 최종 작성본 일괄 저장, API-024 AI 첨삭 다시받기 구현됨 | | 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 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회, 실제 LLM 키워드 분석 worker, keyword result 저장은 후속 이슈로 분리 | +| REQ-009 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56), [#58](https://github.com/Rewrite-Team/Rewrite-BE/issues/58) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회와 `KeywordAnalysisKeyword` 결과 저장 구조 구현됨. 실제 LLM 키워드 분석 worker는 후속 이슈로 분리 | | REQ-010 | AI 면접 | Planned | Medium | API-022, API-023, API-025 - API-029 | - | - | LLM Job/SSE 기반 | ## Open Maintenance Work @@ -59,7 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 최신 키워드 분석 조회 API | REQ-009 | API-021 | `KeywordAnalysis` 최신 상태와 완료 결과 조회 계약 구현 | +| 키워드 분석 worker 구현 | REQ-009 | API-020 이후 내부 실행 | OpenAI 키워드 분석 client/worker를 연결하고 완료 결과를 `KeywordAnalysisKeyword`로 저장 | | LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java index 4b2980f..e95a360 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java @@ -1,9 +1,11 @@ package com.daon.rewrite.keywordanalysis.controller; +import com.daon.rewrite.keywordanalysis.dto.LatestKeywordAnalysisResponse; import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisRequest; import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisResponse; import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,4 +29,11 @@ public StartKeywordAnalysisResponse startKeywordAnalysis( ) ); } + + @GetMapping("/cover-letters/{coverLetterId}/keyword-analysis/latest") + public LatestKeywordAnalysisResponse getLatestKeywordAnalysis(@PathVariable String coverLetterId) { + return LatestKeywordAnalysisResponse.from( + keywordAnalysisService.findMyLatestKeywordAnalysis(coverLetterId) + ); + } } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java new file mode 100644 index 0000000..1c78e94 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java @@ -0,0 +1,36 @@ +package com.daon.rewrite.keywordanalysis.dto; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult; + +import java.util.List; + +public record LatestKeywordAnalysisResponse( + String status, + List keywords +) { + + private static final String NOT_STARTED_STATUS = "NOT_STARTED"; + + public static LatestKeywordAnalysisResponse from(LatestKeywordAnalysisResult result) { + if (result.keywordAnalysis() == null) { + return new LatestKeywordAnalysisResponse(NOT_STARTED_STATUS, List.of()); + } + return new LatestKeywordAnalysisResponse( + result.keywordAnalysis().getStatus().name(), + result.keywords().stream() + .map(KeywordResponse::from) + .toList() + ); + } + + private record KeywordResponse( + String keyword, + int importance + ) { + + private static KeywordResponse from(KeywordAnalysisKeyword keyword) { + return new KeywordResponse(keyword.getKeyword(), keyword.getImportance()); + } + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java index cae9505..8507f21 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java @@ -84,4 +84,14 @@ public void restart(String sourceReviewVersionId) { this.status = KeywordAnalysisStatus.PROCESSING; this.completedAt = null; } + + public void complete(Instant completedAt) { + this.status = KeywordAnalysisStatus.COMPLETED; + this.completedAt = completedAt; + } + + public void fail(Instant completedAt) { + this.status = KeywordAnalysisStatus.FAILED; + this.completedAt = completedAt; + } } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java new file mode 100644 index 0000000..533832d --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java @@ -0,0 +1,73 @@ +package com.daon.rewrite.keywordanalysis.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "keyword_analysis_keywords", + uniqueConstraints = @UniqueConstraint( + name = "uk_keyword_analysis_keywords_order", + columnNames = {"keyword_analysis_id", "keyword_order"} + ) +) +public class KeywordAnalysisKeyword { + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "keyword_analysis_id", nullable = false) + private KeywordAnalysis keywordAnalysis; + + @Column(name = "keyword_order", nullable = false) + private int keywordOrder; + + @Column(name = "keyword", nullable = false, length = 100) + private String keyword; + + @Column(name = "importance", nullable = false) + private int importance; + + private KeywordAnalysisKeyword( + String id, + KeywordAnalysis keywordAnalysis, + int keywordOrder, + String keyword, + int importance + ) { + this.id = id; + this.keywordAnalysis = keywordAnalysis; + this.keywordOrder = keywordOrder; + this.keyword = keyword; + this.importance = importance; + } + + public static KeywordAnalysisKeyword of( + String id, + KeywordAnalysis keywordAnalysis, + int keywordOrder, + String keyword, + int importance + ) { + return new KeywordAnalysisKeyword( + id, + keywordAnalysis, + keywordOrder, + keyword, + importance + ); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java new file mode 100644 index 0000000..3eecadd --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java @@ -0,0 +1,11 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface KeywordAnalysisKeywordRepository extends JpaRepository { + + List findByKeywordAnalysisIdOrderByKeywordOrderAsc(String keywordAnalysisId); +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java index 4881ad0..e6fe5cd 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java @@ -9,6 +9,8 @@ import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; import com.daon.rewrite.llmjob.entity.LlmJob; import com.daon.rewrite.llmjob.entity.LlmJobStatus; @@ -40,6 +42,7 @@ public class KeywordAnalysisService { private final CoverLetterRepository coverLetterRepository; private final ReviewVersionRepository reviewVersionRepository; private final KeywordAnalysisRepository keywordAnalysisRepository; + private final KeywordAnalysisKeywordRepository keywordAnalysisKeywordRepository; private final LlmJobRepository llmJobRepository; private final IdGenerator idGenerator; private final Clock clock; @@ -87,6 +90,22 @@ public StartKeywordAnalysisResult startMyKeywordAnalysis( return new StartKeywordAnalysisResult(keywordAnalysis, job); } + @Transactional(readOnly = true) + public LatestKeywordAnalysisResult findMyLatestKeywordAnalysis(String coverLetterId) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + return keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId()) + .map(keywordAnalysis -> { + List keywords = keywordAnalysisKeywordRepository + .findByKeywordAnalysisIdOrderByKeywordOrderAsc(keywordAnalysis.getId()); + return LatestKeywordAnalysisResult.of(coverLetter.getId(), keywordAnalysis, keywords); + }) + .orElseGet(() -> LatestKeywordAnalysisResult.empty(coverLetter.getId())); + } + private String selectSourceReviewVersionId(CoverLetter coverLetter, String sourceReviewVersionId) { String normalizedSourceReviewVersionId = normalize(sourceReviewVersionId); String selectedSourceReviewVersionId = normalizedSourceReviewVersionId == null diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java new file mode 100644 index 0000000..186496e --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java @@ -0,0 +1,25 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; + +import java.util.List; + +public record LatestKeywordAnalysisResult( + String coverLetterId, + KeywordAnalysis keywordAnalysis, + List keywords +) { + + public static LatestKeywordAnalysisResult empty(String coverLetterId) { + return new LatestKeywordAnalysisResult(coverLetterId, null, List.of()); + } + + public static LatestKeywordAnalysisResult of( + String coverLetterId, + KeywordAnalysis keywordAnalysis, + List keywords + ) { + return new LatestKeywordAnalysisResult(coverLetterId, keywordAnalysis, keywords); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java index c9fc0ca..125f680 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java @@ -4,7 +4,9 @@ import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult; import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; import com.daon.rewrite.keywordanalysis.service.StartKeywordAnalysisResult; import com.daon.rewrite.llmjob.entity.LlmJob; @@ -16,8 +18,10 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import java.util.List; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -103,4 +107,76 @@ void startKeywordAnalysisReturnsConflictWhenJobAlreadyRunning() throws Exception .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("LLM_JOB_ALREADY_RUNNING")); } + + @Test + void getLatestKeywordAnalysisReturnsNotStartedStatusWhenAnalysisDoesNotExist() throws Exception { + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.empty("cl_1")); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.keywords").isEmpty()) + .andExpect(jsonPath("$.coverLetterId").doesNotExist()) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); + } + + @Test + void getLatestKeywordAnalysisReturnsCompletedAnalysisWithOrderedKeywords() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.complete(now.plusSeconds(90)); + List keywords = List.of( + KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95), + KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88) + ); + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.of("cl_1", keywordAnalysis, keywords)); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("COMPLETED")) + .andExpect(jsonPath("$.keywords[0].keyword").value("백엔드")) + .andExpect(jsonPath("$.keywords[0].importance").value(95)) + .andExpect(jsonPath("$.keywords[1].keyword").value("Spring")) + .andExpect(jsonPath("$.coverLetterId").doesNotExist()) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); + } + + @Test + void getLatestKeywordAnalysisReturnsFailedStatus() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.fail(now.plusSeconds(90)); + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.of("cl_1", keywordAnalysis, List.of())); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAILED")) + .andExpect(jsonPath("$.keywords").isEmpty()) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); + } + + @Test + void getLatestKeywordAnalysisReturnsNotFound() throws Exception { + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_missing")) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_missing")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java new file mode 100644 index 0000000..c92e5a1 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java @@ -0,0 +1,61 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +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 KeywordAnalysisKeywordRepositoryTest { + + @Autowired + private KeywordAnalysisKeywordRepository repository; + + @Autowired + private KeywordAnalysisRepository keywordAnalysisRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void findByKeywordAnalysisIdReturnsKeywordsOrderedByKeywordOrder() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.save(KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + )); + repository.save(KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88)); + repository.save(KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95)); + + entityManager.flush(); + entityManager.clear(); + + assertThat(repository.findByKeywordAnalysisIdOrderByKeywordOrderAsc("ka_1")) + .extracting( + KeywordAnalysisKeyword::getId, + KeywordAnalysisKeyword::getKeywordOrder, + KeywordAnalysisKeyword::getKeyword, + KeywordAnalysisKeyword::getImportance + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("kak_1", 1, "백엔드", 95), + org.assertj.core.groups.Tuple.tuple("kak_2", 2, "Spring", 88) + ); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java index 5450f72..a328740 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java @@ -52,4 +52,26 @@ void saveAndFindProcessingKeywordAnalysisRoundTripsThroughJpa() { assertThat(found.getCompletedAt()).isNull(); } + @Test + void saveAndFindFailedKeywordAnalysisRoundTripsThroughJpa() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.fail(now.plusSeconds(90)); + + repository.save(keywordAnalysis); + entityManager.flush(); + entityManager.clear(); + + KeywordAnalysis found = repository.findByCoverLetterId("cl_1").orElseThrow(); + + assertThat(found.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(90)); + } + } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java index 38a61b7..04221f0 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java @@ -8,7 +8,9 @@ import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; import com.daon.rewrite.llmjob.entity.LlmJob; import com.daon.rewrite.llmjob.entity.LlmJobStatus; @@ -40,6 +42,9 @@ class KeywordAnalysisServiceTest { @Autowired private KeywordAnalysisRepository keywordAnalysisRepository; + @Autowired + private KeywordAnalysisKeywordRepository keywordAnalysisKeywordRepository; + @Autowired private LlmJobRepository llmJobRepository; @@ -60,6 +65,7 @@ class KeywordAnalysisServiceTest { @AfterEach void cleanUp() { + keywordAnalysisKeywordRepository.deleteAll(); keywordAnalysisRepository.deleteAll(); reviewVersionRepository.deleteAll(); llmJobRepository.deleteAll(); @@ -170,6 +176,81 @@ void startMyKeywordAnalysisRejectsWhenJobAlreadyRunning() { .isEqualTo(ErrorCode.LLM_JOB_ALREADY_RUNNING); } + @Test + void findMyLatestKeywordAnalysisReturnsEmptyWhenAnalysisDoesNotExist() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.coverLetterId()).isEqualTo("cl_1"); + assertThat(result.keywordAnalysis()).isNull(); + assertThat(result.keywords()).isEmpty(); + } + + @Test + void findMyLatestKeywordAnalysisReturnsCompletedAnalysisWithOrderedKeywords() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.save(KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(120) + )); + keywordAnalysis.complete(now.plusSeconds(180)); + keywordAnalysisRepository.save(keywordAnalysis); + keywordAnalysisKeywordRepository.save(KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88)); + keywordAnalysisKeywordRepository.save(KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.keywordAnalysis().getId()).isEqualTo("ka_1"); + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.COMPLETED); + assertThat(result.keywords()) + .extracting(KeywordAnalysisKeyword::getKeyword, KeywordAnalysisKeyword::getImportance) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("백엔드", 95), + org.assertj.core.groups.Tuple.tuple("Spring", 88) + ); + } + + @Test + void findMyLatestKeywordAnalysisReturnsFailedAnalysisWithError() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(120) + ); + keywordAnalysis.fail(now.plusSeconds(180)); + keywordAnalysisRepository.save(keywordAnalysis); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(result.keywords()).isEmpty(); + } + + @Test + void findMyLatestKeywordAnalysisRejectsMissingOtherOwnerOrDeletedCoverLetter() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_other", "user_2", "rv_other", now); + CoverLetter deleted = saveReviewedCoverLetter("cl_deleted", "user_1", "rv_deleted", now); + deleted.markDeleted(now.plusSeconds(120)); + coverLetterRepository.save(deleted); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertLatestNotFound("cl_missing"); + assertLatestNotFound("cl_other"); + assertLatestNotFound("cl_deleted"); + } + private void assertNotFound(String coverLetterId) { assertThatThrownBy(() -> service.startMyKeywordAnalysis(coverLetterId, null)) .isInstanceOf(BusinessException.class) @@ -177,6 +258,13 @@ private void assertNotFound(String coverLetterId) { .isEqualTo(ErrorCode.NOT_FOUND); } + private void assertLatestNotFound(String coverLetterId) { + assertThatThrownBy(() -> service.findMyLatestKeywordAnalysis(coverLetterId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + private CoverLetter saveReviewedCoverLetter(String coverLetterId, String ownerId, String versionId, Instant now) { CoverLetter coverLetter = CoverLetter.draft(coverLetterId, ownerId, now); coverLetterRepository.save(coverLetter);