Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 |
| API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Planned | REQ-004 | 등록 step3 저장 |
| API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 |
| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 soft delete |
| 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-016 | GET | `/llm-jobs/{jobId}/stream` | Planned | REQ-005 | SSE 스트리밍 |
Expand Down
21 changes: 21 additions & 0 deletions docs/api/cover-letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,13 @@ Already Reviewed Response:
DELETE /cover-letters/{coverLetterId}
```

Validation:

```text
현재 사용자 소유이고 아직 삭제되지 않은 자기소개서만 삭제할 수 있다.
존재하지 않는 자기소개서, 다른 사용자 소유 자기소개서, 이미 삭제된 자기소개서는 모두 NOT_FOUND를 반환한다.
```

Response:

```json
Expand All @@ -459,3 +466,17 @@ Response:
"deletedAt": "2026-06-20T15:00:00"
}
```

Not Found Response:

```json
{
"error": {
"code": "NOT_FOUND",
"message": "리소스를 찾을 수 없습니다.",
"details": []
}
}
```

현재 구현에서는 `llm_jobs` persistence가 아직 없으므로 진행 중 LLM Job 취소 연결은 Job skeleton 구현 이후 반영한다.
3 changes: 1 addition & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), [#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-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 저장 | 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 이후 진행 권장 |
Expand Down Expand Up @@ -59,7 +59,6 @@ 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 |
| 자기소개서 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 |
| 등록 step2 저장 | REQ-004 | API-010 | preferences replace, trim, empty validation, DRAFT 상태 검증, service/web test |
| 등록 step3 저장 | REQ-004 | API-011 | questions replace, order 재부여, 글자 수 검증, service/web test |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import com.daon.rewrite.coverletter.dto.CoverLetterListResponse;
import com.daon.rewrite.coverletter.dto.CreateCoverLetterResponse;
import com.daon.rewrite.coverletter.dto.DeleteCoverLetterResponse;
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.DeleteMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
Expand All @@ -35,4 +38,9 @@ public CoverLetterListResponse findMyCoverLetters(
Page<CoverLetter> result = coverLetterService.findMyCoverLetters(page, size, status);
return CoverLetterListResponse.from(result, page, size);
}

@DeleteMapping("/cover-letters/{coverLetterId}")
public DeleteCoverLetterResponse deleteMyCoverLetter(@PathVariable String coverLetterId) {
return DeleteCoverLetterResponse.from(coverLetterService.deleteMyCoverLetter(coverLetterId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.daon.rewrite.coverletter.dto;

import com.daon.rewrite.coverletter.entity.CoverLetter;

import java.time.LocalDateTime;
import java.time.ZoneId;

public record DeleteCoverLetterResponse(
boolean success,
LocalDateTime deletedAt
) {
private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul");

public static DeleteCoverLetterResponse from(CoverLetter coverLetter) {
return new DeleteCoverLetterResponse(
true,
LocalDateTime.ofInstant(coverLetter.getDeletedAt(), API_ZONE)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface CoverLetterRepository extends JpaRepository<CoverLetter, String> {

Page<CoverLetter> findByOwnerIdAndDeletedAtIsNull(String ownerId, Pageable pageable);
Expand All @@ -15,4 +17,6 @@ Page<CoverLetter> findByOwnerIdAndStatusAndDeletedAtIsNull(
CoverLetterStatus status,
Pageable pageable
);

Optional<CoverLetter> findByIdAndOwnerIdAndDeletedAtIsNull(String id, String ownerId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public Page<CoverLetter> findMyCoverLetters(int page, int size, CoverLetterStatu
);
}

@Transactional
public CoverLetter deleteMyCoverLetter(String coverLetterId) {
CurrentUser currentUser = currentUserProvider.currentUser();
CoverLetter coverLetter = coverLetterRepository
.findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

coverLetter.markDeleted(Instant.now(clock));
return coverLetter;
}

private void validateListQuery(int page, int size) {
if (page < 1 || size < 1 || size > MAX_LIST_SIZE) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand Down Expand Up @@ -116,4 +117,30 @@ void findMyCoverLettersRejectsInvalidStatus() throws Exception {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR"));
}

@Test
void deleteMyCoverLetterReturnsDeletedAt() throws Exception {
CoverLetter deleted = CoverLetter.draft(
"cl_delete",
"user_1",
Instant.parse("2026-06-20T05:00:00Z")
);
deleted.markDeleted(Instant.parse("2026-06-20T06:00:00Z"));
given(coverLetterService.deleteMyCoverLetter("cl_delete")).willReturn(deleted);

mockMvc.perform(delete("/cover-letters/{coverLetterId}", "cl_delete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.deletedAt").value("2026-06-20T15:00:00"));
}

@Test
void deleteMyCoverLetterReturnsNotFound() throws Exception {
given(coverLetterService.deleteMyCoverLetter("cl_missing"))
.willThrow(new BusinessException(ErrorCode.NOT_FOUND));

mockMvc.perform(delete("/cover-letters/{coverLetterId}", "cl_missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("NOT_FOUND"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.time.Instant;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.data.domain.Page;
Expand Down Expand Up @@ -114,6 +115,25 @@ void findActiveByOwnerPaginates() {
.containsExactly("cl_1");
}

@Test
void findActiveByIdAndOwnerReturnsOnlyCurrentOwnerUndeletedCoverLetter() {
CoverLetter active = draft("cl_active", "user_1", "Active title", "2026-06-20T01:00:00Z");
CoverLetter deleted = draft("cl_deleted", "user_1", "Deleted title", "2026-06-20T02:00:00Z");
deleted.markDeleted(Instant.parse("2026-06-20T03:00:00Z"));
CoverLetter otherOwner = draft("cl_other", "user_2", "Other title", "2026-06-20T04:00:00Z");
repository.saveAll(List.of(active, deleted, otherOwner));
entityManager.flush();
entityManager.clear();

Optional<CoverLetter> found = repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_active", "user_1");

assertThat(found).hasValueSatisfying(coverLetter ->
assertThat(coverLetter.getId()).isEqualTo("cl_active")
);
assertThat(repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_deleted", "user_1")).isEmpty();
assertThat(repository.findByIdAndOwnerIdAndDeletedAtIsNull("cl_other", "user_1")).isEmpty();
}

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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,52 @@ void findMyCoverLettersRejectsInvalidPageAndSize() {
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}

@Test
void deleteMyCoverLetterMarksCurrentUserOwnedCoverLetterDeleted() {
Instant createdAt = Instant.parse("2026-06-20T01:00:00Z");
Instant deletedAt = Instant.parse("2026-06-20T05:00:00Z");
given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null));
given(clock.instant()).willReturn(deletedAt);
repository.save(draft("cl_delete", "user_1", "Delete title", createdAt));

CoverLetter result = service.deleteMyCoverLetter("cl_delete");

assertThat(result.getDeletedAt()).isEqualTo(deletedAt);
assertThat(result.getUpdatedAt()).isEqualTo(deletedAt);
assertThat(repository.findById("cl_delete")).hasValueSatisfying(saved -> {
assertThat(saved.getDeletedAt()).isEqualTo(deletedAt);
assertThat(saved.getUpdatedAt()).isEqualTo(deletedAt);
});
assertThat(service.findMyCoverLetters(1, 9, null).getContent())
.extracting(CoverLetter::getId)
.doesNotContain("cl_delete");
}

@Test
void deleteMyCoverLetterThrowsNotFoundWhenCoverLetterIsMissingOtherOwnerOrAlreadyDeleted() {
Instant now = Instant.parse("2026-06-20T01:00:00Z");
CoverLetter deleted = draft("cl_deleted", "user_1", "Deleted title", now);
deleted.markDeleted(now.plusSeconds(60));
repository.saveAll(List.of(
draft("cl_other", "user_2", "Other title", now),
deleted
));
given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null));

assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_missing"))
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.NOT_FOUND);
assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_other"))
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.NOT_FOUND);
assertThatThrownBy(() -> service.deleteMyCoverLetter("cl_deleted"))
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.NOT_FOUND);
}

private CoverLetter draft(String id, String ownerId, String title, Instant now) {
CoverLetter coverLetter = CoverLetter.draft(id, ownerId, now);
coverLetter.fillBasicInfo(title, "Rewrite Corp", "백엔드 개발자", null, now);
Expand Down
Loading