diff --git a/docs/job-application-archive-api.md b/docs/job-application-archive-api.md new file mode 100644 index 00000000..6682d235 --- /dev/null +++ b/docs/job-application-archive-api.md @@ -0,0 +1,20 @@ +# 지원관리 보관함 API + +기본 경로는 `/api/job-applications`이며 모든 API는 로그인 사용자가 소유한 카드만 처리한다. + +## 카드 보관과 복원 + +- `POST /{jobApplicationId}/archive`: 카드에 보관 시각을 기록하고 활성 보드에서 제외한다. 빠진 카드가 있던 단계의 수동 순서는 0부터 연속으로 다시 정리한다. +- `POST /{jobApplicationId}/restore`: 카드가 보관 전에 속했던 단계를 유지하며 해당 단계의 마지막 순서로 복원한다. + +이미 보관된 카드를 다시 보관하거나 활성 카드를 복원하면 `409 JOB_APPLICATION_4091`을 반환한다. + +## 보관함 조회 + +`GET /archive?page=0&size=10`은 현재 사용자의 보관 카드만 `archivedAt DESC, id DESC` 순서로 페이지 조회한다. `page`는 최소 0으로, `size`는 1부터 공통 최대 페이지 크기까지 보정한다. + +## 영구 삭제 + +`DELETE /{jobApplicationId}`는 지원 카드와 카드가 직접 소유한 기술 태그·체크리스트·정량 스펙·실제 제출용 자기소개서만 삭제한다. `sourceJobPostingId`가 가리키는 저장 공고와 `mockApplyId`가 가리키는 모의지원 및 분석 결과는 삭제하지 않는다. + +반대로 출처 공고나 연결된 모의지원이 먼저 삭제되면 연결 ID만 `null`이 되며 카드의 독립 스냅샷은 유지된다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java index 13dcc474..36d29f22 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java @@ -1,13 +1,15 @@ package com.jobdri.jobdri_api.domain.jobapplication.controller; import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest; -import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest; import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationDetailUpdateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest; import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationPositionRequest; import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationBoardResponse; import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationDetailResponse; import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationArchiveService; import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationBoardService; import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationDetailService; import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationService; @@ -18,7 +20,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -38,6 +42,7 @@ public class JobApplicationController { private final UserService userService; private final JobApplicationBoardService jobApplicationBoardService; private final JobApplicationDetailService jobApplicationDetailService; + private final JobApplicationArchiveService jobApplicationArchiveService; @Operation(summary = "지원관리 칸반 조회", description = "활성 카드를 4개 열로 반환합니다. 회사명·공고명·직무명을 검색하며 count는 검색 결과 기준입니다. CREATED_DESC에서는 드래그를 비활성화합니다.") @GetMapping("/board") @@ -61,6 +66,53 @@ public ApiResponse move( jobApplicationBoardService.move(validateAuthenticatedUser(userDetails), jobApplicationId, request)); } + @Operation(summary = "지원 카드 보관", description = "활성 보드에서 카드를 제외하고 보관 시각을 기록합니다. 남은 열의 수동 순서는 연속으로 정리합니다.") + @PostMapping("/{jobApplicationId}/archive") + public ApiResponse archive( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + return ApiResponse.onSuccess( + "지원 카드 보관에 성공했습니다.", + jobApplicationArchiveService.archive(validateAuthenticatedUser(userDetails), jobApplicationId) + ); + } + + @Operation(summary = "지원 카드 복원", description = "보관 전 단계를 유지하며 해당 단계의 마지막 순서로 복원합니다.") + @PostMapping("/{jobApplicationId}/restore") + public ApiResponse restore( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + return ApiResponse.onSuccess( + "지원 카드 복원에 성공했습니다.", + jobApplicationArchiveService.restore(validateAuthenticatedUser(userDetails), jobApplicationId) + ); + } + + @Operation(summary = "지원 카드 보관함 조회", description = "보관 시각과 ID 내림차순으로 현재 사용자의 보관 카드를 페이지 조회합니다.") + @GetMapping("/archive") + public ApiResponse> archivePage( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return ApiResponse.onSuccess( + "지원 카드 보관함 조회에 성공했습니다.", + jobApplicationArchiveService.getArchive(validateAuthenticatedUser(userDetails), page, size) + ); + } + + @Operation(summary = "지원 카드 영구 삭제", description = "지원 카드와 카드가 직접 소유한 상세 데이터만 삭제합니다. 출처 공고와 연결된 모의지원은 유지합니다.") + @DeleteMapping("/{jobApplicationId}") + public ApiResponse delete( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + jobApplicationArchiveService.delete(validateAuthenticatedUser(userDetails), jobApplicationId); + return ApiResponse.onSuccess("지원 카드 영구 삭제에 성공했습니다.", null); + } + @Operation(summary = "지원 카드 수동 등록", description = "분석을 시작하지 않고 독립적인 지원관리 카드를 생성합니다.") @PostMapping public ApiResponse create( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java new file mode 100644 index 00000000..4a801e75 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; + +import java.time.LocalDateTime; + +public record JobApplicationArchiveItemResponse( + Long jobApplicationId, + String companyName, + String postingName, + String jobTitle, + LocalDateTime deadlineAt, + String currentLabel, + LocalDateTime currentAt, + JobApplicationStage stage, + LocalDateTime archivedAt, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java index e83eff52..c5c13fd7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java @@ -147,6 +147,21 @@ public void moveTo(JobApplicationStage stage, int stageOrder) { this.stageOrder = stageOrder; } + public void archive(LocalDateTime archivedAt) { + if (archivedAt == null || this.archivedAt != null) { + throw new IllegalStateException("활성 지원 카드만 보관할 수 있습니다."); + } + this.archivedAt = archivedAt; + } + + public void restore(int stageOrder) { + if (this.archivedAt == null || stageOrder < 0) { + throw new IllegalStateException("보관된 지원 카드와 0 이상의 순서가 필요합니다."); + } + this.archivedAt = null; + this.stageOrder = stageOrder; + } + public void updateDetails( DetailClassification detailClassification, String companyName, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java index ee152c6e..f267ca46 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java @@ -2,7 +2,10 @@ import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -50,6 +53,26 @@ select coalesce(max(ja.stageOrder), -1) @Query("select ja from JobApplication ja where ja.id = :id") Optional findByIdForUpdate(@Param("id") Long id); + @Query( + value = """ + select new com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse( + ja.id, ja.companyName, ja.postingName, ja.jobTitle, ja.deadlineAt, + ja.currentLabel, ja.currentAt, ja.stage, ja.archivedAt, ja.createdAt, ja.updatedAt + ) + from JobApplication ja + where ja.user.id = :userId and ja.archivedAt is not null + """, + countQuery = """ + select count(ja) + from JobApplication ja + where ja.user.id = :userId and ja.archivedAt is not null + """ + ) + Page findArchivePageByUserId( + @Param("userId") Long userId, + Pageable pageable + ); + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("update JobApplication ja set ja.sourceJobPosting = null where ja.sourceJobPosting.id = :jobPostingId") int clearSourceJobPosting(@Param("jobPostingId") Long jobPostingId); @@ -57,4 +80,8 @@ select coalesce(max(ja.stageOrder), -1) @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.jobPosting.id = :jobPostingId") int clearMockAppliesForJobPosting(@Param("jobPostingId") Long jobPostingId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.id = :mockApplyId") + int clearMockApply(@Param("mockApplyId") Long mockApplyId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java new file mode 100644 index 00000000..c5dcef53 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java @@ -0,0 +1,130 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.pagination.PaginationPolicy; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobApplicationArchiveService { + public static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE; + + private final JobApplicationRepository jobApplicationRepository; + private final UserService userService; + private final UserRepository userRepository; + private final EntityManager entityManager; + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_ARCHIVE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationResponse archive(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() != null) { + throw conflict("이미 보관된 지원 카드입니다."); + } + + List column = jobApplicationRepository.lockActiveColumns( + validatedUser.getId(), List.of(application.getStage())); + application.archive(LocalDateTime.now()); + reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage()); + return flushAndReload(application.getId()); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_RESTORE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationResponse restore(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() == null) { + throw conflict("보관되지 않은 지원 카드는 복원할 수 없습니다."); + } + + int lastOrder = jobApplicationRepository.findMaxStageOrder(validatedUser.getId(), application.getStage()) + 1; + application.restore(lastOrder); + return flushAndReload(application.getId()); + } + + public Page getArchive(User user, int page, int size) { + User validatedUser = userService.validateUser(user); + PageRequest pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), MAX_PAGE_SIZE), + Sort.by(Sort.Order.desc("archivedAt"), Sort.Order.desc("id")) + ); + return jobApplicationRepository.findArchivePageByUserId(validatedUser.getId(), pageable); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_DELETE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public void delete(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() == null) { + List column = jobApplicationRepository.lockActiveColumns( + validatedUser.getId(), List.of(application.getStage())); + reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage()); + } + jobApplicationRepository.delete(application); + jobApplicationRepository.flush(); + } + + private JobApplicationResponse flushAndReload(Long jobApplicationId) { + jobApplicationRepository.flush(); + entityManager.clear(); + JobApplication application = jobApplicationRepository.findDetailedById(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + return JobApplicationResponse.from(application); + } + + private void reorder(List cards, JobApplicationStage stage) { + for (int index = 0; index < cards.size(); index++) { + cards.get(index).moveTo(stage, index); + } + } + + private JobApplication getOwnedApplicationForUpdate(User user, Long jobApplicationId) { + JobApplication application = jobApplicationRepository.findByIdForUpdate(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + if (!application.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다."); + } + return application; + } + + private User lockValidatedUser(User user) { + User validatedUser = userService.validateUser(user); + return userRepository.findByIdForUpdate(validatedUser.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } + + private GeneralException notFound(Long jobApplicationId) { + return new GeneralException( + GeneralErrorCode.JOB_APPLICATION_NOT_FOUND, + "해당 지원 카드를 찾을 수 없습니다. jobApplicationId=" + jobApplicationId + ); + } + + private GeneralException conflict(String message) { + return new GeneralException(GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT, message); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index ee090335..f4923063 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; @@ -68,6 +69,7 @@ public class MockApplyService { private final MockApplyPersistenceService mockApplyPersistenceService; private final MockApplySequenceService mockApplySequenceService; private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final JobApplicationRepository jobApplicationRepository; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -298,6 +300,7 @@ public void deleteMockApply(User user, Long mockApplyId) { questionAnalysisRepository.deleteAllByMockApplyId(mockApplyId); analysisRepository.deleteByMockApplyId(mockApplyId); questionRepository.deleteAllByMockApplyId(mockApplyId); + jobApplicationRepository.clearMockApply(mockApplyId); mockApplyRepository.deleteByMockApplyId(mockApplyId); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationControllerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationControllerTest.java index 0db01498..4e676723 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationControllerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationControllerTest.java @@ -20,6 +20,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -155,6 +156,39 @@ void updateAndGetDetails() throws Exception { .andExpect(jsonPath("$.result.checklistItems[0].content").value("서류 제출")); } + @Test + @DisplayName("보관·보관함·복원·영구삭제 API 계약을 제공한다") + void archiveRestoreAndDeleteRoutes() throws Exception { + User owner = saveUser(); + var card = applications.create(owner, + new com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest( + "보관 기업", "보관 공고", "보관 직무", null, null, + null, null, null, null, null, null, null, null)); + + mockMvc.perform(post("/api/job-applications/{id}/archive", card.getJobApplicationId()) + .with(user(new UserDetailsImpl(owner)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.archivedAt").isNotEmpty()); + mockMvc.perform(get("/api/job-applications/archive") + .param("page", "0") + .param("size", "10") + .with(user(new UserDetailsImpl(owner)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalElements").value(1)) + .andExpect(jsonPath("$.result.content[0].jobApplicationId").value(card.getJobApplicationId())); + mockMvc.perform(post("/api/job-applications/{id}/restore", card.getJobApplicationId()) + .with(user(new UserDetailsImpl(owner)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.archivedAt").doesNotExist()); + mockMvc.perform(delete("/api/job-applications/{id}", card.getJobApplicationId()) + .with(user(new UserDetailsImpl(owner)))) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/job-applications/{id}", card.getJobApplicationId()) + .with(user(new UserDetailsImpl(owner)))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("JOB_APPLICATION_4041")); + } + private User saveUser() { return userRepository.save(User.signup( "API 테스트 사용자", diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveServiceTest.java new file mode 100644 index 00000000..321842cd --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveServiceTest.java @@ -0,0 +1,325 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; +import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationChecklistItemRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationDetailUpdateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationEssayRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationMetricRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationBoardResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationCardResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationMetricType; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class JobApplicationArchiveServiceTest { + + @Autowired JobApplicationArchiveService archiveService; + @Autowired JobApplicationBoardService boardService; + @Autowired JobApplicationService applicationService; + @Autowired JobApplicationDetailService detailService; + @Autowired MockApplyService mockApplyService; + @Autowired JobApplicationRepository applicationRepository; + @Autowired JobPostingRepository jobPostingRepository; + @Autowired MockApplyRepository mockApplyRepository; + @Autowired UserRepository userRepository; + @Autowired CompanyRepository companyRepository; + @Autowired ClassificationRepository classificationRepository; + @Autowired DetailClassificationRepository detailClassificationRepository; + @Autowired EntityManager entityManager; + + @Test + @DisplayName("카드를 보관하면 활성 보드에서 제외되고 남은 단계 순서가 압축된다") + void archiveExcludesCardAndCompactsStageOrder() { + User user = saveUser(); + JobApplicationResponse first = createCard(user, "첫 카드", JobApplicationStage.DOCUMENT); + JobApplicationResponse archived = createCard(user, "보관 카드", JobApplicationStage.DOCUMENT); + JobApplicationResponse last = createCard(user, "마지막 카드", JobApplicationStage.DOCUMENT); + + JobApplicationResponse result = archiveService.archive(user, archived.getJobApplicationId()); + JobApplicationBoardResponse board = boardService.getBoard(user, "", JobApplicationSort.MANUAL); + + assertThat(result.getArchivedAt()).isNotNull(); + assertThat(ids(board, JobApplicationStage.DOCUMENT)) + .containsExactly(first.getJobApplicationId(), last.getJobApplicationId()); + assertThat(column(board, JobApplicationStage.DOCUMENT).cards()) + .extracting(JobApplicationCardResponse::stageOrder) + .containsExactly(0, 1); + } + + @Test + @DisplayName("보관함은 사용자별로 격리되어 보관 시각 내림차순 페이지를 반환한다") + void archivePageIsIsolatedSortedAndBounded() { + User owner = saveUser(); + User other = saveUser(); + JobApplicationResponse first = createCard(owner, "먼저 보관", JobApplicationStage.PLANNED); + JobApplicationResponse second = createCard(owner, "나중 보관", JobApplicationStage.INTERVIEW); + JobApplicationResponse foreign = createCard(other, "타인 카드", JobApplicationStage.PLANNED); + archiveService.archive(owner, first.getJobApplicationId()); + archiveService.archive(owner, second.getJobApplicationId()); + archiveService.archive(other, foreign.getJobApplicationId()); + + Page firstPage = + archiveService.getArchive(owner, -1, 1); + Page secondPage = + archiveService.getArchive(owner, 1, 1); + Page bounded = + archiveService.getArchive(owner, 0, JobApplicationArchiveService.MAX_PAGE_SIZE + 10); + + assertThat(firstPage.getContent()).extracting(item -> item.jobApplicationId()) + .containsExactly(second.getJobApplicationId()); + assertThat(secondPage.getContent()).extracting(item -> item.jobApplicationId()) + .containsExactly(first.getJobApplicationId()); + assertThat(firstPage.getTotalElements()).isEqualTo(2); + assertThat(bounded.getSize()).isEqualTo(JobApplicationArchiveService.MAX_PAGE_SIZE); + } + + @Test + @DisplayName("복원 카드는 보관 전 단계의 마지막 순서에 배치된다") + void restoreToEndOfOriginalStage() { + User user = saveUser(); + JobApplicationResponse restored = createCard(user, "복원 카드", JobApplicationStage.INTERVIEW); + createCard(user, "기존 카드", JobApplicationStage.INTERVIEW); + archiveService.archive(user, restored.getJobApplicationId()); + JobApplicationResponse newest = createCard(user, "추가 카드", JobApplicationStage.INTERVIEW); + + JobApplicationResponse result = archiveService.restore(user, restored.getJobApplicationId()); + JobApplicationBoardResponse board = boardService.getBoard(user, "", JobApplicationSort.MANUAL); + + assertThat(result.getArchivedAt()).isNull(); + assertThat(result.getStage()).isEqualTo(JobApplicationStage.INTERVIEW); + assertThat(ids(board, JobApplicationStage.INTERVIEW).getLast()).isEqualTo(restored.getJobApplicationId()); + assertThat(ids(board, JobApplicationStage.INTERVIEW)).contains(newest.getJobApplicationId()); + assertThat(column(board, JobApplicationStage.INTERVIEW).cards()) + .extracting(JobApplicationCardResponse::stageOrder) + .containsExactlyElementsOf(IntStream.range(0, 3).boxed().toList()); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 단계의 보관 카드 두 개를 동시에 복원해도 순서가 중복되지 않는다") + void concurrentRestoresKeepContiguousUniqueOrder() throws Exception { + User user = saveUser(); + JobApplicationResponse first = createCard(user, "동시 복원 1", JobApplicationStage.DOCUMENT); + JobApplicationResponse second = createCard(user, "동시 복원 2", JobApplicationStage.DOCUMENT); + archiveService.archive(user, first.getJobApplicationId()); + archiveService.archive(user, second.getJobApplicationId()); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstRestore = executor.submit( + () -> restoreAfterSignal(user, first.getJobApplicationId(), ready, start)); + Future secondRestore = executor.submit( + () -> restoreAfterSignal(user, second.getJobApplicationId(), ready, start)); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + assertThat(List.of( + firstRestore.get(20, TimeUnit.SECONDS), + secondRestore.get(20, TimeUnit.SECONDS) + )).extracting(JobApplicationResponse::getStageOrder) + .containsExactlyInAnyOrder(0, 1) + .doesNotHaveDuplicates(); + + JobApplicationBoardResponse board = boardService.getBoard(user, "", JobApplicationSort.MANUAL); + assertThat(ids(board, JobApplicationStage.DOCUMENT)) + .containsExactlyInAnyOrder(first.getJobApplicationId(), second.getJobApplicationId()); + assertThat(column(board, JobApplicationStage.DOCUMENT).cards()) + .extracting(JobApplicationCardResponse::stageOrder) + .containsExactly(0, 1) + .doesNotHaveDuplicates(); + } finally { + start.countDown(); + executor.shutdownNow(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + } + + @Test + @DisplayName("보관과 복원은 상태 및 소유권을 검증한다") + void rejectInvalidStateForeignAndMissingCards() { + User owner = saveUser(); + User other = saveUser(); + JobApplicationResponse card = createCard(owner, "소유 카드", JobApplicationStage.PLANNED); + + assertError(() -> archiveService.restore(owner, card.getJobApplicationId()), + GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT); + assertError(() -> archiveService.archive(other, card.getJobApplicationId()), GeneralErrorCode.FORBIDDEN); + assertError(() -> archiveService.delete(other, card.getJobApplicationId()), GeneralErrorCode.FORBIDDEN); + assertError(() -> archiveService.archive(owner, Long.MAX_VALUE), GeneralErrorCode.JOB_APPLICATION_NOT_FOUND); + + archiveService.archive(owner, card.getJobApplicationId()); + assertError(() -> archiveService.archive(owner, card.getJobApplicationId()), + GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT); + } + + @Test + @DisplayName("카드 영구삭제는 하위 데이터만 삭제하고 출처 공고와 연결 모의지원을 보존한다") + void deleteCardCascadesOwnedChildrenButPreservesExternalData() { + User user = saveUser(); + JobPosting posting = savePosting(user); + JobApplicationResponse first = createCard(user, "앞 카드", JobApplicationStage.DOCUMENT); + JobApplicationResponse target = applicationService.createFromJobPosting( + user, new JobApplicationFromJobPostingRequest(posting.getId(), JobApplicationStage.DOCUMENT)); + JobApplicationResponse last = createCard(user, "뒤 카드", JobApplicationStage.DOCUMENT); + detailService.update(user, target.getJobApplicationId(), detailRequest(target.getUpdatedAt())); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.ACTUAL)); + JobApplication application = applicationRepository.findById(target.getJobApplicationId()).orElseThrow(); + ReflectionTestUtils.setField(application, "mockApply", mockApply); + entityManager.flush(); + entityManager.clear(); + + archiveService.delete(user, target.getJobApplicationId()); + + assertThat(applicationRepository.findById(target.getJobApplicationId())).isEmpty(); + assertThat(jobPostingRepository.findById(posting.getId())).isPresent(); + assertThat(mockApplyRepository.findById(mockApply.getId())).isPresent(); + assertThat(countChildren("job_application_checklist_items", target.getJobApplicationId())).isZero(); + assertThat(countChildren("job_application_metrics", target.getJobApplicationId())).isZero(); + assertThat(countChildren("job_application_essays", target.getJobApplicationId())).isZero(); + assertThat(ids(boardService.getBoard(user, "", JobApplicationSort.MANUAL), JobApplicationStage.DOCUMENT)) + .containsExactly(first.getJobApplicationId(), last.getJobApplicationId()); + } + + @Test + @DisplayName("연결된 모의지원이 먼저 삭제돼도 카드 스냅샷은 유지된다") + void deletingMockApplyClearsLinkWithoutDeletingCard() { + User user = saveUser(); + JobPosting posting = savePosting(user); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.ACTUAL)); + JobApplicationResponse card = createCard(user, "독립 스냅샷", JobApplicationStage.PLANNED); + JobApplication application = applicationRepository.findById(card.getJobApplicationId()).orElseThrow(); + ReflectionTestUtils.setField(application, "mockApply", mockApply); + entityManager.flush(); + entityManager.clear(); + + mockApplyService.deleteMockApply(user, mockApply.getId()); + + JobApplication retained = applicationRepository.findById(card.getJobApplicationId()).orElseThrow(); + assertThat(retained.getMockApply()).isNull(); + assertThat(retained.getPostingName()).isEqualTo("독립 스냅샷"); + } + + private JobApplicationDetailUpdateRequest detailRequest(LocalDateTime lastKnownUpdatedAt) { + return new JobApplicationDetailUpdateRequest( + lastKnownUpdatedAt, "상세 기업", "상세 공고", "상세 직무", CompanySize.MEDIUM, null, + null, null, null, List.of("Java"), null, null, null, "메모", + new BigDecimal("4.0"), new BigDecimal("4.5"), + List.of(new JobApplicationChecklistItemRequest("체크", true)), + List.of(new JobApplicationMetricRequest(JobApplicationMetricType.CERTIFICATE, "자격증", "기사")), + List.of(new JobApplicationEssayRequest("질문", "답변")) + ); + } + + private JobApplicationResponse restoreAfterSignal( + User user, + Long jobApplicationId, + CountDownLatch ready, + CountDownLatch start + ) throws Exception { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("동시 복원 시작 대기 시간이 초과되었습니다."); + } + return archiveService.restore(user, jobApplicationId); + } + + private JobApplicationResponse createCard(User user, String postingName, JobApplicationStage stage) { + return applicationService.create(user, new JobApplicationCreateRequest( + "테스트 기업", postingName, "서버 개발자", null, null, + null, null, null, List.of("Java"), null, stage, null, null + )); + } + + private JobPosting savePosting(User user) { + Company company = companyRepository.save(Company.create("외부 기업 " + UUID.randomUUID(), CompanySize.MEDIUM)); + Classification classification = Classification.create("보관 대분류 " + UUID.randomUUID()); + MiddleClassification middle = classification.addMiddleClassification("보관 중분류"); + DetailClassification detail = middle.addDetailClassification("보관 소분류"); + classificationRepository.save(classification); + detail = detailClassificationRepository.findById(detail.getId()).orElseThrow(); + return jobPostingRepository.save(JobPosting.create( + user, company, detail, JobPostingProfileColor.DEFAULT, + "외부 공고", "외부 직무", "업무", "요건", "우대" + )); + } + + private long countChildren(String table, Long jobApplicationId) { + return ((Number) entityManager.createNativeQuery( + "select count(*) from " + table + " where job_application_id = :id") + .setParameter("id", jobApplicationId) + .getSingleResult()).longValue(); + } + + private User saveUser() { + return userRepository.save(User.signup( + "보관 테스트", "application-archive-" + UUID.randomUUID() + "@example.com", "password")); + } + + private JobApplicationBoardResponse.Column column( + JobApplicationBoardResponse response, + JobApplicationStage stage + ) { + return response.columns().stream().filter(item -> item.stage() == stage).findFirst().orElseThrow(); + } + + private List ids(JobApplicationBoardResponse response, JobApplicationStage stage) { + return column(response, stage).cards().stream().map(JobApplicationCardResponse::jobApplicationId).toList(); + } + + private void assertError(Runnable action, GeneralErrorCode code) { + assertThatThrownBy(action::run) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(code); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailServiceTest.java index 5b313b90..5c515da2 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailServiceTest.java @@ -210,7 +210,7 @@ void rollbackWholeUpdateWhenChildPersistenceFails() { "롤백 사용자", "detail-rollback-" + UUID.randomUUID() + "@example.com", "encoded-password")); JobApplicationResponse card = createCard(user, "롤백 원본"); JobApplicationDetailUpdateRequest invalid = request( - card.getUpdatedAt(), + LocalDateTime.now().plusYears(1), List.of(new JobApplicationChecklistItemRequest("저장되면 안 됨", true)), Collections.singletonList(null), List.of(new JobApplicationEssayRequest("저장되면 안 되는 질문", "답변")),