diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index ef4e117..df720cf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -59,7 +59,7 @@ public ApiResponse getMyMockApplies( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"ACTUAL\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"ACTUAL\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -88,7 +88,7 @@ public ApiResponse createActualApply( ) { return ApiResponse.onSuccess( "모의 서류 지원이 생성되었습니다.", - mockApplyService.createActualApply(userDetails.getUser(), request.jobPostingId()) + mockApplyService.createActualApply(userDetails.getUser(), request.jobPostingId(), request.sequence()) ); } @@ -103,7 +103,7 @@ public ApiResponse createActualApply( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -132,7 +132,11 @@ public ApiResponse createMockApplyFromJobPosting( ) { return ApiResponse.onSuccess( "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApplyFromJobPosting(userDetails.getUser(), request.jobPostingId()) + mockApplyService.createMockApplyFromJobPosting( + userDetails.getUser(), + request.jobPostingId(), + request.sequence() + ) ); } @@ -147,7 +151,7 @@ public ApiResponse createMockApplyFromJobPosting( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java index a0c8da6..d56f9ba 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java @@ -1,9 +1,16 @@ package com.jobdri.jobdri_api.domain.mockapply.dto.request; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateActualRequest( @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId + Long jobPostingId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateActualRequest(Long jobPostingId) { + this(jobPostingId, null); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java index 469355a..af1d53f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java @@ -1,9 +1,16 @@ package com.jobdri.jobdri_api.domain.mockapply.dto.request; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateMockFromJobPostingRequest( @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId + Long jobPostingId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateMockFromJobPostingRequest(Long jobPostingId) { + this(jobPostingId, null); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java index 1ba940c..b4a567e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateMockRequest( @NotNull(message = "회사 ID는 필수입니다.") @@ -11,8 +12,15 @@ public record MockApplyCreateMockRequest( Long middleClassificationId, @NotNull(message = "소분류 ID는 필수입니다.") - Long detailClassificationId + Long detailClassificationId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateMockRequest(Long companyId, Long middleClassificationId, Long detailClassificationId) { + this(companyId, middleClassificationId, detailClassificationId, null); + } + public JobPostingMockGenerateRequest toJobPostingMockGenerateRequest() { return new JobPostingMockGenerateRequest(companyId, middleClassificationId, detailClassificationId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java index 395cdbd..6e34956 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java @@ -6,13 +6,15 @@ public record MockApplyCreateResponse( Long jobPostingId, Long mockApplyId, - ApplyType applyType + ApplyType applyType, + int sequence ) { public static MockApplyCreateResponse from(MockApply mockApply) { return new MockApplyCreateResponse( mockApply.getJobPosting().getId(), mockApply.getId(), - mockApply.getApplyType() + mockApply.getApplyType(), + mockApply.getSequence() ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index fe1e4b5..b3d6eee 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -16,7 +16,13 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) -@Table(name = "mock_applies") +@Table( + name = "mock_applies", + uniqueConstraints = @UniqueConstraint( + name = "uk_mock_apply_user_posting_sequence", + columnNames = {"user_id", "job_posting_id", "sequence"} + ) +) public class MockApply { @Id @@ -39,6 +45,8 @@ public class MockApply { @Column(nullable = false) private MockApplyStatus status; + private Integer sequence; + @Column(nullable = false) private LocalDateTime createdAt; @@ -50,11 +58,16 @@ public class MockApply { private List questions = new ArrayList<>(); public static MockApply create(User user, JobPosting jobPosting, ApplyType applyType) { + return create(user, jobPosting, applyType, null); + } + + public static MockApply create(User user, JobPosting jobPosting, ApplyType applyType, Integer sequence) { return MockApply.builder() .user(user) .jobPosting(jobPosting) .applyType(applyType) .status(MockApplyStatus.APPLICATION_CREATED) + .sequence(sequence) .createdAt(LocalDateTime.now()) .build(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java index f1bd89c..0e898e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java @@ -13,6 +13,10 @@ public interface MockApplyRepository extends JpaRepository { long countByUserIdAndJobPostingId(Long userId, Long jobPostingId); default int calculateSequence(MockApply mockApply) { + if (mockApply.getSequence() != null && mockApply.getSequence() > 0) { + return mockApply.getSequence(); + } + return Math.toIntExact(countSequenceByUserIdAndJobPostingId( mockApply.getUser().getId(), mockApply.getJobPosting().getId(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java new file mode 100644 index 0000000..d4c9f8d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.mockapply.service; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MockApplyPersistenceService { + + private final MockApplyRepository mockApplyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public MockApply saveAndFlush(MockApply mockApply) { + return mockApplyRepository.saveAndFlush(mockApply); + } +} 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 23c1206..e0f3046 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 @@ -25,44 +25,75 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.sql.SQLException; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MockApplyService { + private static final int SEQUENCE_SAVE_MAX_RETRY = 5; + private static final String SEQUENCE_UNIQUE_CONSTRAINT = "uk_mock_apply_user_posting_sequence"; + private static final String UNIQUE_VIOLATION_SQL_STATE = "23505"; + private final MockApplyRepository mockApplyRepository; private final JobPostingRepository jobPostingRepository; private final CompanyRepository companyRepository; private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; + private final MockApplyPersistenceService mockApplyPersistenceService; - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { + return createActualApply(user, jobPostingId, null); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") + public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.ACTUAL); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply mockApply = saveMockApplyWithSequence( + validatedUser, + jobPosting, + ApplyType.ACTUAL, + sequence + ); + return MockApplyCreateResponse.from(mockApply); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { + return createMockApplyFromJobPosting(user, jobPostingId, null); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") + public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.MOCK); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply mockApply = saveMockApplyWithSequence( + validatedUser, + jobPosting, + ApplyType.MOCK, + sequence + ); + return MockApplyCreateResponse.from(mockApply); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { User validatedUser = userService.validateUser(user); @@ -90,8 +121,13 @@ public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockReq "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId )); - MockApply mockApply = MockApply.create(validatedUser, savedJobPosting, ApplyType.MOCK); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply mockApply = saveMockApplyWithSequence( + validatedUser, + savedJobPosting, + ApplyType.MOCK, + request.sequence() + ); + return MockApplyCreateResponse.from(mockApply); } public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { @@ -109,8 +145,9 @@ public MockApplySequenceResponse getMockApplySequence(User user, Long mockApplyI mockApplyRepository.countByUserIdAndJobPostingId(validatedUser.getId(), jobPostingId) ); int sequence = mockApplyRepository.calculateSequence(mockApply); + totalCount = Math.max(totalCount, sequence); - if (sequence < 1 || sequence > totalCount) { + if (sequence < 1) { throw new GeneralException( GeneralErrorCode.MOCK_APPLY_NOT_FOUND, "해당 공고에 연결된 모의 서류 지원 순서를 찾을 수 없습니다. mockApplyId=" + mockApplyId @@ -159,4 +196,77 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } + + private int resolveSequence(User user, JobPosting jobPosting, Integer requestedSequence) { + if (isPositiveSequence(requestedSequence)) { + return requestedSequence; + } + return Math.toIntExact(mockApplyRepository.countByUserIdAndJobPostingId( + user.getId(), + jobPosting.getId() + )) + 1; + } + + private boolean isPositiveSequence(Integer sequence) { + return sequence != null && sequence > 0; + } + + private MockApply saveMockApplyWithSequence( + User user, + JobPosting jobPosting, + ApplyType applyType, + Integer requestedSequence + ) { + int sequence = resolveSequence(user, jobPosting, requestedSequence); + for (int attempt = 0; attempt < SEQUENCE_SAVE_MAX_RETRY; attempt++) { + try { + return mockApplyPersistenceService.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); + } catch (DataIntegrityViolationException e) { + if (!isSequenceUniqueConflict(e)) { + throw e; + } + if (isPositiveSequence(requestedSequence)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 사용 중인 지원 순번입니다. sequence=" + requestedSequence + ); + } + sequence++; + } + } + + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 생성에 실패했습니다." + ); + } + + private boolean isSequenceUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation + && isSequenceConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (cause instanceof SQLException sqlException + && UNIQUE_VIOLATION_SQL_STATE.equals(sqlException.getSQLState()) + && containsSequenceConstraint(sqlException.getMessage())) { + return true; + } + if (containsSequenceConstraint(cause.getMessage())) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private boolean isSequenceConstraintName(String constraintName) { + return containsSequenceConstraint(constraintName); + } + + private boolean containsSequenceConstraint(String value) { + return value != null + && value.toLowerCase(Locale.ROOT).contains(SEQUENCE_UNIQUE_CONSTRAINT); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index a6acfb5..bff8e14 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -161,6 +161,34 @@ void analyzeReturnsSequence() { assertThat(response.sequence()).isEqualTo(2); } + @Test + @DisplayName("분석 응답은 저장된 지원 순번을 우선 반환한다") + void analyzeReturnsStoredSequence() { + User user = saveUser("analysis-stored-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); + Question question = saveQuestion(mockApply, "재지원 분석 문항입니다.", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 81, + 82, + 83, + "저장 순번 분석입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발했습니다.", + "mentioned", + "성과 지표가 부족합니다.", + "Spring Boot API를 개발해 응답 시간을 개선했습니다." + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.sequence()).isEqualTo(4); + } + @Test @DisplayName("LLM 분석 실패 시 크레딧 차감과 분석 저장을 롤백한다") void analyzeRollsBackCreditWhenLlmFails() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java index 96ce1a3..155ed99 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -326,6 +326,25 @@ void saveAnswersReturnsSequence() { assertThat(response.sequence()).isEqualTo(2); } + @Test + @DisplayName("답변 저장 응답은 저장된 지원 순번을 우선 반환한다") + void saveAnswersReturnsStoredSequence() { + User user = saveUser("answer-stored-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); + QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("재지원 저장 순번 문항입니다.", 700, false) + ))); + Long questionId = selected.questions().get(0).questionId(); + + QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( + new QuestionAnswerSaveRequest.AnswerItem(questionId, "네 번째 지원 답변입니다.") + ))); + + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.sequence()).isEqualTo(4); + } + @Test @DisplayName("해당 지원서에 속하지 않은 문항은 답변 저장에 사용할 수 없다") void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 88a9385..22a7842 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -33,10 +33,15 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -72,6 +77,9 @@ class MockApplyServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @MockBean private MockJobPostingGenerationService mockJobPostingGenerationService; @@ -86,17 +94,68 @@ void createActualApply() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); assertThat(response.applyType()).isEqualTo(ApplyType.ACTUAL); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.ACTUAL); assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); + assertThat(mockApply.getSequence()).isEqualTo(1); + } + + @Test + @DisplayName("요청 순번이 있으면 ACTUAL 타입 지원에 저장한다") + void createActualApplyWithRequestedSequence() { + User user = saveUser("actual-apply-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + + MockApplyCreateResponse response = mockApplyService.createActualApply(user, jobPosting.getId(), 3); + + MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); + MockApplySequenceResponse sequenceResponse = mockApplyService.getMockApplySequence(user, mockApply.getId()); + assertThat(response.sequence()).isEqualTo(3); + assertThat(mockApply.getSequence()).isEqualTo(3); + assertThat(sequenceResponse.sequence()).isEqualTo(3); + assertThat(sequenceResponse.totalCount()).isEqualTo(3); + } + + @Test + @DisplayName("요청 순번이 0 이하이면 다음 유효 순번을 저장한다") + void createActualApplyIgnoresNonPositiveRequestedSequence() { + User user = saveUser("actual-apply-non-positive-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + saveMockApply(user, jobPosting, ApplyType.ACTUAL, 1); + saveMockApply(user, jobPosting, ApplyType.ACTUAL, 3); + + MockApplyCreateResponse zeroResponse = mockApplyService.createActualApply(user, jobPosting.getId(), 0); + MockApplyCreateResponse negativeResponse = mockApplyService.createActualApply(user, jobPosting.getId(), -1); + + MockApply zeroSequenceApply = mockApplyRepository.findById(zeroResponse.mockApplyId()).orElseThrow(); + MockApply negativeSequenceApply = mockApplyRepository.findById(negativeResponse.mockApplyId()).orElseThrow(); + assertThat(zeroResponse.sequence()).isEqualTo(4); + assertThat(negativeResponse.sequence()).isEqualTo(5); + assertThat(zeroSequenceApply.getSequence()).isEqualTo(4); + assertThat(negativeSequenceApply.getSequence()).isEqualTo(5); + } + + @Test + @DisplayName("같은 공고에 이미 사용 중인 순번을 명시하면 예외를 던진다") + void createActualApplyThrowsWhenRequestedSequenceDuplicated() { + User user = saveUser("actual-apply-sequence-duplicate@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + mockApplyService.createActualApply(user, jobPosting.getId(), 2); + + assertThatThrownBy(() -> mockApplyService.createActualApply(user, jobPosting.getId(), 2)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); } @Test @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") void createMockApply() { User user = saveUser("mock-apply@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + String companyName = "선택 기업 " + UUID.randomUUID(); + Company company = saveCompany(companyName, CompanySize.MEDIUM); DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); Long middleClassificationId = detailClassification.getMiddleClassification().getId(); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( @@ -106,7 +165,7 @@ void createMockApply() { ); when(mockJobPostingGenerationService.generate(any())) .thenReturn(new JobPostingMockGenerateResponse( - "선택 기업", + companyName, "프론트엔드 개발자", "웹 프론트엔드 개발 및 운영", "HTML/CSS/JavaScript 기본기", @@ -120,11 +179,13 @@ void createMockApply() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); JobPosting jobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); + assertThat(mockApply.getSequence()).isEqualTo(1); assertThat(jobPosting.getCompany().getId()).isEqualTo(company.getId()); - assertThat(jobPosting.getCompany().getName()).isEqualTo("선택 기업"); + assertThat(jobPosting.getCompany().getName()).isEqualTo(companyName); assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); assertThat(jobPosting.getTask()).isEqualTo("웹 프론트엔드 개발 및 운영"); @@ -143,9 +204,11 @@ void createMockApplyFromJobPosting() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); + assertThat(mockApply.getSequence()).isEqualTo(1); } @Test @@ -250,7 +313,7 @@ void createMockApplyFromJobPostingThrowsWhenForbidden() { @DisplayName("존재하지 않는 소분류 ID로 MOCK 타입 지원 생성 시 예외를 던진다") void createMockApplyThrowsWhenDetailClassificationNotFound() { User user = saveUser("missing-detail-classification@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 1L, 9999L); when(mockJobPostingGenerationService.generate(any())) .thenThrow(new GeneralException( @@ -280,7 +343,7 @@ void createMockApplyThrowsWhenCompanyNotFound() { @DisplayName("소분류가 중분류에 속하지 않으면 MOCK 타입 지원 생성 시 예외를 던진다") void createMockApplyThrowsWhenMiddleClassificationMismatched() { User user = saveUser("middle-mismatch@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); DetailClassification detailClassification = saveDetailClassification("데이터 분석"); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, detailClassification.getId()); when(mockJobPostingGenerationService.generate(any())) @@ -310,27 +373,49 @@ void getMockApplyJobPostingThrowsWhenForbidden() { } private User saveUser(String email) { - return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + return inNewTransaction(() -> userRepository.save(User.signup("테스트 사용자", email, "encoded-password"))); } private JobPosting saveJobPosting(User user, String detailName) { - Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); - DetailClassification detailClassification = saveDetailClassification(detailName); - return jobPostingRepository.save(JobPosting.create( - user, - company, - detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" - )); + return inNewTransaction(() -> { + Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassificationInCurrentTransaction(detailName); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + }); } private DetailClassification saveDetailClassification(String detailName) { - Classification classification = Classification.create("테스트 대분류 " + detailName); + return inNewTransaction(() -> saveDetailClassificationInCurrentTransaction(detailName)); + } + + private DetailClassification saveDetailClassificationInCurrentTransaction(String detailName) { + Classification classification = Classification.create("테스트 대분류 " + detailName + " " + UUID.randomUUID()); MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류 " + detailName); DetailClassification detailClassification = middleClassification.addDetailClassification(detailName); classificationRepository.save(classification); return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); } + + private Company saveCompany(String name, CompanySize size) { + return inNewTransaction(() -> companyRepository.save(Company.create(name, size))); + } + + private MockApply saveMockApply(User user, JobPosting jobPosting, ApplyType applyType, Integer sequence) { + return inNewTransaction(() -> mockApplyRepository.saveAndFlush( + MockApply.create(user, jobPosting, applyType, sequence) + )); + } + + private T inNewTransaction(Supplier action) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return transactionTemplate.execute(status -> action.get()); + } }