From ea623481958125b76c8c0aa866098aef505a593e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Mon, 14 Sep 2026 21:49:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]=20=EC=A7=80=EC=9B=90=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B9=B4=EB=93=9C=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JobApplicationController.java | 54 ++++++- .../JobApplicationArchiveItemResponse.java | 39 +++++ .../jobapplication/entity/JobApplication.java | 15 ++ .../repository/JobApplicationRepository.java | 11 ++ .../service/JobApplicationArchiveService.java | 147 ++++++++++++++++++ .../mockapply/service/MockApplyService.java | 3 + 6 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java 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..81513aec --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java @@ -0,0 +1,39 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; + +import java.time.LocalDateTime; +import java.util.List; + +public record JobApplicationArchiveItemResponse( + Long jobApplicationId, + String companyName, + String postingName, + String jobTitle, + List requiredSkills, + LocalDateTime deadlineAt, + String currentLabel, + LocalDateTime currentAt, + JobApplicationStage stage, + LocalDateTime archivedAt, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static JobApplicationArchiveItemResponse from(JobApplication application) { + return new JobApplicationArchiveItemResponse( + application.getId(), + application.getCompanyName(), + application.getPostingName(), + application.getJobTitle(), + List.copyOf(application.getRequiredSkills()), + application.getDeadlineAt(), + application.getCurrentLabel(), + application.getCurrentAt(), + application.getStage(), + application.getArchivedAt(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} 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..6cec86e6 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 @@ -3,6 +3,8 @@ import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; 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 +52,11 @@ select coalesce(max(ja.stageOrder), -1) @Query("select ja from JobApplication ja where ja.id = :id") Optional findByIdForUpdate(@Param("id") Long id); + Page findAllByUserIdAndArchivedAtIsNotNull(Long userId, Pageable pageable); + + @Query("select distinct ja from JobApplication ja left join fetch ja.requiredSkills where ja.id in :ids") + List findArchiveDetailsByIdIn(@Param("ids") List ids); + @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 +64,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..53b6f125 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java @@ -0,0 +1,147 @@ +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.PageImpl; +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; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@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")) + ); + Page applications = jobApplicationRepository + .findAllByUserIdAndArchivedAtIsNotNull(validatedUser.getId(), pageable); + if (applications.isEmpty()) { + return new PageImpl<>(List.of(), pageable, applications.getTotalElements()); + } + + List ids = applications.getContent().stream().map(JobApplication::getId).toList(); + Map detailsById = jobApplicationRepository.findArchiveDetailsByIdIn(ids).stream() + .collect(Collectors.toMap(JobApplication::getId, Function.identity())); + List content = ids.stream() + .map(detailsById::get) + .map(JobApplicationArchiveItemResponse::from) + .toList(); + return new PageImpl<>(content, pageable, applications.getTotalElements()); + } + + @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); } From 56457c3f23f62532e7326bbaf7beb32ef45bf978 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Mon, 14 Sep 2026 21:49:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Test]=20=EC=A7=80=EC=9B=90=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B4=80=20=EB=B3=B5=EC=9B=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JobApplicationControllerTest.java | 34 +++ .../JobApplicationArchiveServiceTest.java | 266 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveServiceTest.java 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..840627d9 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveServiceTest.java @@ -0,0 +1,266 @@ +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.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +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 + @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 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); + } +} From cbc6ac4214ec8d81f6787dbe5a7891943c65f8e6 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Mon, 14 Sep 2026 21:49:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Docs]=20=EC=A7=80=EC=9B=90=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B4=80=ED=95=A8=20API=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=A0=95=EB=A6=AC=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/job-application-archive-api.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/job-application-archive-api.md 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`이 되며 카드의 독립 스냅샷은 유지된다. From 5131e79d7f5a44caf824d19d03553e9ae3647544 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Mon, 14 Sep 2026 22:08:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=99=80=20=EB=8F=99=EC=8B=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JobApplicationArchiveItemResponse.java | 19 ------ .../repository/JobApplicationRepository.java | 24 ++++++-- .../service/JobApplicationArchiveService.java | 19 +----- .../JobApplicationArchiveServiceTest.java | 59 +++++++++++++++++++ 4 files changed, 80 insertions(+), 41 deletions(-) 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 index 81513aec..4a801e75 100644 --- 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 @@ -1,17 +1,14 @@ package com.jobdri.jobdri_api.domain.jobapplication.dto.response; -import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; import java.time.LocalDateTime; -import java.util.List; public record JobApplicationArchiveItemResponse( Long jobApplicationId, String companyName, String postingName, String jobTitle, - List requiredSkills, LocalDateTime deadlineAt, String currentLabel, LocalDateTime currentAt, @@ -20,20 +17,4 @@ public record JobApplicationArchiveItemResponse( LocalDateTime createdAt, LocalDateTime updatedAt ) { - public static JobApplicationArchiveItemResponse from(JobApplication application) { - return new JobApplicationArchiveItemResponse( - application.getId(), - application.getCompanyName(), - application.getPostingName(), - application.getJobTitle(), - List.copyOf(application.getRequiredSkills()), - application.getDeadlineAt(), - application.getCurrentLabel(), - application.getCurrentAt(), - application.getStage(), - application.getArchivedAt(), - application.getCreatedAt(), - application.getUpdatedAt() - ); - } } 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 6cec86e6..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,6 +2,7 @@ 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; @@ -52,10 +53,25 @@ select coalesce(max(ja.stageOrder), -1) @Query("select ja from JobApplication ja where ja.id = :id") Optional findByIdForUpdate(@Param("id") Long id); - Page findAllByUserIdAndArchivedAtIsNotNull(Long userId, Pageable pageable); - - @Query("select distinct ja from JobApplication ja left join fetch ja.requiredSkills where ja.id in :ids") - List findArchiveDetailsByIdIn(@Param("ids") List ids); + @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") 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 index 53b6f125..c5dcef53 100644 --- 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 @@ -15,7 +15,6 @@ import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @@ -23,9 +22,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -75,20 +71,7 @@ public Page getArchive(User user, int page, i Math.min(Math.max(size, 1), MAX_PAGE_SIZE), Sort.by(Sort.Order.desc("archivedAt"), Sort.Order.desc("id")) ); - Page applications = jobApplicationRepository - .findAllByUserIdAndArchivedAtIsNotNull(validatedUser.getId(), pageable); - if (applications.isEmpty()) { - return new PageImpl<>(List.of(), pageable, applications.getTotalElements()); - } - - List ids = applications.getContent().stream().map(JobApplication::getId).toList(); - Map detailsById = jobApplicationRepository.findArchiveDetailsByIdIn(ids).stream() - .collect(Collectors.toMap(JobApplication::getId, Function.identity())); - List content = ids.stream() - .map(detailsById::get) - .map(JobApplicationArchiveItemResponse::from) - .toList(); - return new PageImpl<>(content, pageable, applications.getTotalElements()); + return jobApplicationRepository.findArchivePageByUserId(validatedUser.getId(), pageable); } @Transactional 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 index 840627d9..321842cd 100644 --- 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 @@ -41,12 +41,18 @@ 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; @@ -138,6 +144,46 @@ void restoreToEndOfOriginalStage() { .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() { @@ -214,6 +260,19 @@ private JobApplicationDetailUpdateRequest detailRequest(LocalDateTime lastKnownU ); } + 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, From 7f9004b6ed8d1e0901caf04aac6ff0d6f449615d Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Mon, 14 Sep 2026 22:12:13 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]=20=EC=83=81=EC=84=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=A4=EB=B0=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?stale=20=EB=B6=84=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20(#302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobapplication/service/JobApplicationDetailServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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("저장되면 안 되는 질문", "답변")),