diff --git a/docs/api/README.md b/docs/api/README.md index 615e87f..246a08b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -55,7 +55,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-004 | POST | `/auth/refresh` | Planned | REQ-008 | refresh token rotation | | API-005 | GET | `/user/me` | Planned | REQ-008 | 내 정보 조회 | | API-006 | POST | `/auth/logout` | Planned | REQ-008 | 로그아웃 | -| API-007 | GET | `/cover-letters` | Planned | REQ-003 | 내 자기소개서 목록 | +| API-007 | GET | `/cover-letters` | Implemented | REQ-003 | 내 자기소개서 목록 | | API-008 | POST | `/cover-letters` | Implemented | REQ-003 | 자기소개서 초안 생성 | | API-009 | PUT | `/cover-letters/{coverLetterId}/basic-info` | Planned | REQ-004 | 등록 step1 저장 | | API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 | diff --git a/docs/status.md b/docs/status.md index 0674c37..7100d1f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -20,7 +20,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 |---|---|---|---|---|---|---|---| | REQ-001 | 공통 예외 응답 기반 | Verified | High | 공통 에러 응답 | [#2](https://github.com/Rewrite-Team/Rewrite-BE/issues/2), [PR #3](https://github.com/Rewrite-Team/Rewrite-BE/pull/3) | `GlobalExceptionHandlerTest`, `./gradlew test` | `BusinessException`, `ErrorCode`, `GlobalExceptionHandler`, `ErrorResponse` 구현됨 | | REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 | -| REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test` | API-008 자기소개서 초안 생성 계약과 DB/JPA persistence 전환 구현됨. 나머지 CRUD는 후속 slice 후보 기준으로 분리 진행 | +| 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), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회와 API-008 자기소개서 초안 생성 계약 구현됨. API-012/API-013은 후속 slice 후보 기준으로 분리 진행 | | REQ-004 | 자기소개서 등록 step 저장 | Planned | High | API-009, API-010, API-011 | - | - | DB/JPA 전환 이후 진행 권장 | | REQ-005 | 자기소개서 제출과 LLM Job 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 | | REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 | @@ -58,7 +58,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 현재 사용자 자기소개서 목록 조회 | REQ-003 | API-007 | DB/JPA repository 기반 pagination, status filter, owner filter, deletedAt 제외, controller/service/repository test | | 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test | | 자기소개서 soft delete | REQ-003 | API-013 | DB/JPA repository 기반 soft delete, 삭제 후 조회 제외, 하위 리소스 접근 정책 skeleton, service/web/repository test | | 등록 step1 저장 | REQ-004 | API-009 | basic info replace, trim, URL validation, DRAFT 상태 검증, service/web test | 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 ba2cf5e..5352ab8 100644 --- a/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java +++ b/src/main/java/com/daon/rewrite/coverletter/controller/CoverLetterController.java @@ -1,10 +1,16 @@ package com.daon.rewrite.coverletter.controller; +import com.daon.rewrite.coverletter.dto.CoverLetterListResponse; import com.daon.rewrite.coverletter.dto.CreateCoverLetterResponse; +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; import com.daon.rewrite.coverletter.service.CoverLetterService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -19,4 +25,14 @@ public class CoverLetterController { public CreateCoverLetterResponse createDraft() { return CreateCoverLetterResponse.from(coverLetterService.createDraft()); } + + @GetMapping("/cover-letters") + public CoverLetterListResponse findMyCoverLetters( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "9") int size, + @RequestParam(required = false) CoverLetterStatus status + ) { + Page result = coverLetterService.findMyCoverLetters(page, size, status); + return CoverLetterListResponse.from(result, page, size); + } } diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListItemResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListItemResponse.java new file mode 100644 index 0000000..ec95bbc --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListItemResponse.java @@ -0,0 +1,31 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +public record CoverLetterListItemResponse( + String id, + String title, + String companyName, + String positionTitle, + CoverLetterStatus status, + LocalDateTime createdAt, + String latestReviewVersionId +) { + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static CoverLetterListItemResponse from(CoverLetter coverLetter) { + return new CoverLetterListItemResponse( + coverLetter.getId(), + coverLetter.getTitle(), + coverLetter.getCompanyName(), + coverLetter.getPositionTitle(), + coverLetter.getStatus(), + LocalDateTime.ofInstant(coverLetter.getCreatedAt(), API_ZONE), + coverLetter.getLatestReviewVersionId() + ); + } +} diff --git a/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListResponse.java b/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListResponse.java new file mode 100644 index 0000000..58d1594 --- /dev/null +++ b/src/main/java/com/daon/rewrite/coverletter/dto/CoverLetterListResponse.java @@ -0,0 +1,27 @@ +package com.daon.rewrite.coverletter.dto; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import org.springframework.data.domain.Page; + +import java.util.List; + +public record CoverLetterListResponse( + List items, + int page, + int size, + long totalItems, + int totalPages +) { + public static CoverLetterListResponse from(Page pageResult, int requestedPage, int requestedSize) { + return new CoverLetterListResponse( + pageResult.getContent() + .stream() + .map(CoverLetterListItemResponse::from) + .toList(), + requestedPage, + requestedSize, + pageResult.getTotalElements(), + pageResult.getTotalPages() + ); + } +} 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 7f56420..e20bc38 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -71,4 +71,21 @@ private CoverLetter(String id, String ownerId, CoverLetterStatus status, Instant public static CoverLetter draft(String id, String ownerId, Instant now) { return new CoverLetter(id, ownerId, CoverLetterStatus.DRAFT, now); } + + public void fillBasicInfo(String title, String companyName, String positionTitle, String jobPostingUrl, Instant now) { + this.title = title; + this.companyName = companyName; + this.positionTitle = positionTitle; + this.jobPostingUrl = jobPostingUrl; + this.updatedAt = now; + } + + public void markDeleted(Instant now) { + this.deletedAt = 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 f9a03be..d5d7e82 100644 --- a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java +++ b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java @@ -1,7 +1,18 @@ package com.daon.rewrite.coverletter.repository; import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface CoverLetterRepository extends JpaRepository { + + Page findByOwnerIdAndDeletedAtIsNull(String ownerId, Pageable pageable); + + Page findByOwnerIdAndStatusAndDeletedAtIsNull( + String ownerId, + CoverLetterStatus status, + Pageable pageable + ); } 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 46a04c9..4b16e71 100644 --- a/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java +++ b/src/main/java/com/daon/rewrite/coverletter/service/CoverLetterService.java @@ -3,9 +3,16 @@ 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.CoverLetterStatus; 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.global.util.IdGenerator; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +24,7 @@ public class CoverLetterService { private static final String COVER_LETTER_ID_PREFIX = "cl"; + private static final int MAX_LIST_SIZE = 9; private final CurrentUserProvider currentUserProvider; private final CoverLetterRepository coverLetterRepository; @@ -34,4 +42,32 @@ public CoverLetter createDraft() { return coverLetterRepository.save(draft); } + + @Transactional(readOnly = true) + public Page findMyCoverLetters(int page, int size, CoverLetterStatus status) { + validateListQuery(page, size); + + CurrentUser currentUser = currentUserProvider.currentUser(); + Pageable pageable = PageRequest.of( + page - 1, + size, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + + if (status == null) { + return coverLetterRepository.findByOwnerIdAndDeletedAtIsNull(currentUser.id(), pageable); + } + + return coverLetterRepository.findByOwnerIdAndStatusAndDeletedAtIsNull( + currentUser.id(), + status, + pageable + ); + } + + 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/global/exception/GlobalExceptionHandler.java b/src/main/java/com/daon/rewrite/global/exception/GlobalExceptionHandler.java index 01b2b14..df7e644 100644 --- a/src/main/java/com/daon/rewrite/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/daon/rewrite/global/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -42,6 +43,18 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho )); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e + ) { + return ResponseEntity + .status(ErrorCode.VALIDATION_ERROR.getStatus()) + .body(ErrorResponse.of( + ErrorCode.VALIDATION_ERROR.getCode(), + ErrorCode.VALIDATION_ERROR.getMessage() + )); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { return ResponseEntity 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 09687b5..38f9bc6 100644 --- a/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/controller/CoverLetterControllerTest.java @@ -1,16 +1,24 @@ package com.daon.rewrite.coverletter.controller; 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.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; 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 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; @@ -40,4 +48,72 @@ void createDraftReturnsCreatedDraft() throws Exception { .andExpect(jsonPath("$.status").value("DRAFT")) .andExpect(jsonPath("$.createdAt").value("2026-06-20T14:00:00")); } + + @Test + void findMyCoverLettersReturnsPagedItems() throws Exception { + CoverLetter coverLetter = CoverLetter.draft( + "cl_1", + "user_1", + Instant.parse("2026-06-20T05:00:00Z") + ); + coverLetter.fillBasicInfo( + "2026 상반기 백엔드 개발자 자기소개서", + "Rewrite Corp", + "백엔드 개발자", + null, + Instant.parse("2026-06-20T05:00:00Z") + ); + coverLetter.setLatestReviewVersionId("rv_1"); + Page page = new PageImpl<>( + List.of(coverLetter), + PageRequest.of(0, 9), + 1 + ); + given(coverLetterService.findMyCoverLetters(1, 9, CoverLetterStatus.DRAFT)).willReturn(page); + + mockMvc.perform(get("/cover-letters") + .param("page", "1") + .param("size", "9") + .param("status", "DRAFT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].id").value("cl_1")) + .andExpect(jsonPath("$.items[0].title").value("2026 상반기 백엔드 개발자 자기소개서")) + .andExpect(jsonPath("$.items[0].companyName").value("Rewrite Corp")) + .andExpect(jsonPath("$.items[0].positionTitle").value("백엔드 개발자")) + .andExpect(jsonPath("$.items[0].status").value("DRAFT")) + .andExpect(jsonPath("$.items[0].createdAt").value("2026-06-20T14:00:00")) + .andExpect(jsonPath("$.items[0].latestReviewVersionId").value("rv_1")) + .andExpect(jsonPath("$.page").value(1)) + .andExpect(jsonPath("$.size").value(9)) + .andExpect(jsonPath("$.totalItems").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)); + } + + @Test + void findMyCoverLettersUsesDefaultPageAndSize() throws Exception { + given(coverLetterService.findMyCoverLetters(1, 9, null)) + .willReturn(Page.empty(PageRequest.of(0, 9))); + + mockMvc.perform(get("/cover-letters")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page").value(1)) + .andExpect(jsonPath("$.size").value(9)); + } + + @Test + void findMyCoverLettersRejectsInvalidPage() throws Exception { + given(coverLetterService.findMyCoverLetters(0, 9, null)) + .willThrow(new BusinessException(ErrorCode.VALIDATION_ERROR)); + + mockMvc.perform(get("/cover-letters").param("page", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } + + @Test + void findMyCoverLettersRejectsInvalidStatus() throws Exception { + mockMvc.perform(get("/cover-letters").param("status", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } } 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 de84711..18bf117 100644 --- a/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/repository/CoverLetterRepositoryTest.java @@ -9,8 +9,12 @@ import org.springframework.test.context.ActiveProfiles; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; @DataJpaTest @ActiveProfiles("test") @@ -47,4 +51,72 @@ void saveAndFindDraftRoundTripsThroughJpa() { assertThat(found.getDeletedAt()).isNull(); assertThat(found.getLatestReviewVersionId()).isNull(); } + + @Test + void findActiveByOwnerOrdersByCreatedAtDesc() { + CoverLetter oldOne = draft("cl_old", "user_1", "Old title", "2026-06-20T01:00:00Z"); + CoverLetter newOne = draft("cl_new", "user_1", "New title", "2026-06-20T03:00:00Z"); + CoverLetter otherOwner = draft("cl_other", "user_2", "Other title", "2026-06-20T04:00:00Z"); + CoverLetter deleted = draft("cl_deleted", "user_1", "Deleted title", "2026-06-20T05:00:00Z"); + deleted.markDeleted(Instant.parse("2026-06-20T06:00:00Z")); + repository.saveAll(List.of(oldOne, newOne, otherOwner, deleted)); + entityManager.flush(); + entityManager.clear(); + + Page page = repository.findByOwnerIdAndDeletedAtIsNull( + "user_1", + PageRequest.of(0, 9, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting(CoverLetter::getId) + .containsExactly("cl_new", "cl_old"); + } + + @Test + void findActiveByOwnerAndStatusFiltersStatus() { + repository.saveAll(List.of( + draft("cl_draft", "user_1", "Draft title", "2026-06-20T01:00:00Z"), + draft("cl_other_owner", "user_2", "Other title", "2026-06-20T02:00:00Z") + )); + entityManager.flush(); + entityManager.clear(); + + Page page = repository.findByOwnerIdAndStatusAndDeletedAtIsNull( + "user_1", + CoverLetterStatus.DRAFT, + PageRequest.of(0, 9, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent()).extracting(CoverLetter::getId) + .containsExactly("cl_draft"); + } + + @Test + void findActiveByOwnerPaginates() { + repository.saveAll(List.of( + draft("cl_1", "user_1", "Title 1", "2026-06-20T01:00:00Z"), + draft("cl_2", "user_1", "Title 2", "2026-06-20T02:00:00Z"), + draft("cl_3", "user_1", "Title 3", "2026-06-20T03:00:00Z") + )); + entityManager.flush(); + entityManager.clear(); + + Page page = repository.findByOwnerIdAndDeletedAtIsNull( + "user_1", + PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + assertThat(page.getTotalElements()).isEqualTo(3); + assertThat(page.getTotalPages()).isEqualTo(2); + assertThat(page.getContent()).extracting(CoverLetter::getId) + .containsExactly("cl_1"); + } + + 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)); + return coverLetter; + } } 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 66f1c62..f4f6e2b 100644 --- a/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java +++ b/src/test/java/com/daon/rewrite/coverletter/service/CoverLetterServiceTest.java @@ -5,6 +5,8 @@ import com.daon.rewrite.coverletter.entity.CoverLetter; import com.daon.rewrite.coverletter.entity.CoverLetterStatus; 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.global.util.IdGenerator; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -16,6 +18,9 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneId; +import java.util.List; + +import org.springframework.data.domain.Page; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.given; @@ -67,4 +72,53 @@ void createDraftPersistsCurrentUserOwnedDraft() { assertThat(saved.getUpdatedAt()).isEqualTo(now); }); } + + @Test + void findMyCoverLettersReturnsCurrentUserPage() { + Instant base = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.saveAll(List.of( + draft("cl_old", "user_1", "Old title", base), + draft("cl_new", "user_1", "New title", base.plusSeconds(60)), + draft("cl_other", "user_2", "Other title", base.plusSeconds(120)) + )); + + Page page = service.findMyCoverLetters(1, 9, null); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting(CoverLetter::getId) + .containsExactly("cl_new", "cl_old"); + } + + @Test + void findMyCoverLettersFiltersStatus() { + Instant now = Instant.parse("2026-06-20T01:00:00Z"); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + repository.save(draft("cl_draft", "user_1", "Draft title", now)); + + Page page = service.findMyCoverLetters(1, 9, CoverLetterStatus.DRAFT); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent()).extracting(CoverLetter::getId) + .containsExactly("cl_draft"); + } + + @Test + void findMyCoverLettersRejectsInvalidPageAndSize() { + assertThatThrownBy(() -> service.findMyCoverLetters(0, 9, null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + assertThatThrownBy(() -> service.findMyCoverLetters(1, 10, null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + 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); + return coverLetter; + } } diff --git a/src/test/java/com/daon/rewrite/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/daon/rewrite/global/exception/GlobalExceptionHandlerTest.java index 7d10d8f..7227da6 100644 --- a/src/test/java/com/daon/rewrite/global/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/daon/rewrite/global/exception/GlobalExceptionHandlerTest.java @@ -62,6 +62,16 @@ void unexpectedExceptionReturnsInternalErrorWithoutRawMessage() throws Exception .andExpect(jsonPath("$.error.details.length()").value(0)); } + @Test + void typeMismatchExceptionReturnsValidationError() throws Exception { + mockMvc.perform(get("/test/type-mismatch").param("status", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.error.message").value("입력값이 올바르지 않습니다.")) + .andExpect(jsonPath("$.error.details").isArray()) + .andExpect(jsonPath("$.error.details.length()").value(0)); + } + @RestController static class TestController { @@ -78,6 +88,10 @@ void validationError(@Valid @RequestBody TestRequest request) { void unexpectedError() { throw new IllegalStateException("raw internal message"); } + + @GetMapping("/test/type-mismatch") + void typeMismatch(TestStatus status) { + } } record TestRequest( @@ -85,4 +99,8 @@ record TestRequest( String title ) { } + + enum TestStatus { + ACTIVE + } }