From 661b7795c7a4d18b6b52de1d356bc4a60df35148 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 12 May 2026 21:34:11 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=A7=80=EC=9B=90=20=EC=83=9D=EC=84=B1=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 공고 기반 ACTUAL 타입 MockApply 생성 API 추가 - 소분류 기반 가상 JobPosting 및 MOCK 타입 MockApply 생성 API 추가 - MockApply 생성 응답에 jobPostingId, mockApplyId, applyType 반환 - 인증 사용자 기준으로 MockApply 생성 로직 적용 - 존재하지 않는 공고 및 소분류 예외 처리 추가 - MockApply 생성 API Swagger 문서화 - 실제/가상 공고 기반 MockApply 생성 서비스 테스트 추가 --- .../controller/MockApplyController.java | 137 ++++++++++++++++ .../request/MockApplyCreateActualRequest.java | 9 ++ .../request/MockApplyCreateMockRequest.java | 15 ++ .../dto/response/MockApplyCreateResponse.java | 18 +++ .../mockapply/service/MockApplyService.java | 92 +++++++++++ .../service/MockApplyServiceTest.java | 147 ++++++++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java 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 new file mode 100644 index 0000000..011266c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -0,0 +1,137 @@ +package com.jobdri.jobdri_api.domain.mockapply.controller; + +import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateActualRequest; +import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; +import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mock-applies") +@Tag(name = "MockApply", description = "모의 서류 지원 생성 API") +public class MockApplyController { + + private final MockApplyService mockApplyService; + + @Operation( + summary = "실제 공고 기반 모의 서류 지원 생성", + description = "기존 채용 공고 ID를 기준으로 로그인 사용자의 ACTUAL 타입 모의 서류 지원을 생성합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "모의 서류 지원 생성 성공", + 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}") + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 정보 누락", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "채용 공고 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"JOB_POSTING_4041\",\"message\":\"해당 공고를 찾을 수 없습니다. jobPostingId=999\",\"result\":null,\"error\":\"해당 공고를 찾을 수 없습니다. jobPostingId=999\"}") + ) + ) + }) + @PostMapping("/actual") + public ApiResponse createActualApply( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody MockApplyCreateActualRequest request + ) { + return ApiResponse.onSuccess( + "모의 서류 지원이 생성되었습니다.", + mockApplyService.createActualApply(getCurrentUser(userDetails), request.jobPostingId()) + ); + } + + @Operation( + summary = "가상 공고 기반 모의 서류 지원 생성", + description = "선택한 소분류를 기준으로 가상 채용 공고와 MOCK 타입 모의 서류 지원을 함께 생성합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "모의 서류 지원 생성 성공", + 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}") + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청값 검증 실패", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"REQ_4002\",\"message\":\"파라미터 형식이 잘못되었습니다.\",\"result\":null,\"error\":[\"[detailClassificationId] 소분류 ID는 필수입니다. (입력값: null)\"]}") + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 정보 누락", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "소분류 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\",\"result\":null,\"error\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\"}") + ) + ) + }) + @PostMapping("/mock") + public ApiResponse createMockApply( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody MockApplyCreateMockRequest request + ) { + return ApiResponse.onSuccess( + "모의 서류 지원이 생성되었습니다.", + mockApplyService.createMockApply(getCurrentUser(userDetails), request) + ); + } + + private User getCurrentUser(UserDetailsImpl userDetails) { + if (userDetails == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO); + } + return userDetails.getUser(); + } +} 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 new file mode 100644 index 0000000..a0c8da6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record MockApplyCreateActualRequest( + @NotNull(message = "공고 ID는 필수입니다.") + Long jobPostingId +) { +} 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 new file mode 100644 index 0000000..3ed547e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record MockApplyCreateMockRequest( + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId, + + String task, + + String requirement, + + String preferred +) { +} 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 new file mode 100644 index 0000000..395cdbd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; + +public record MockApplyCreateResponse( + Long jobPostingId, + Long mockApplyId, + ApplyType applyType +) { + public static MockApplyCreateResponse from(MockApply mockApply) { + return new MockApplyCreateResponse( + mockApply.getJobPosting().getId(), + mockApply.getId(), + mockApply.getApplyType() + ); + } +} 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 new file mode 100644 index 0000000..9a16b5b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -0,0 +1,92 @@ +package com.jobdri.jobdri_api.domain.mockapply.service; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +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.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; +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.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MockApplyService { + + private static final String VIRTUAL_COMPANY_NAME = "가상 기업"; + private static final CompanySize VIRTUAL_COMPANY_SIZE = CompanySize.STARTUP; + + private final MockApplyRepository mockApplyRepository; + private final JobPostingRepository jobPostingRepository; + private final DetailClassificationRepository detailClassificationRepository; + private final CompanyRepository companyRepository; + + @Transactional + public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + + MockApply mockApply = MockApply.create(user, jobPosting, ApplyType.ACTUAL); + return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + } + + @Transactional + public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { + DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + )); + + Company company = companyRepository.findByName(VIRTUAL_COMPANY_NAME) + .orElseGet(() -> companyRepository.save(Company.create(VIRTUAL_COMPANY_NAME, VIRTUAL_COMPANY_SIZE))); + + JobPosting jobPosting = JobPosting.create( + company, + detailClassification, + resolveTask(request.task(), detailClassification), + resolveRequirement(request.requirement(), detailClassification), + resolvePreferred(request.preferred(), detailClassification) + ); + JobPosting savedJobPosting = jobPostingRepository.save(jobPosting); + + MockApply mockApply = MockApply.create(user, savedJobPosting, ApplyType.MOCK); + return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + } + + private String resolveTask(String task, DetailClassification detailClassification) { + if (StringUtils.hasText(task)) { + return task; + } + return detailClassification.getDetailName() + " 직무 기반 가상 주요 업무를 수행합니다."; + } + + private String resolveRequirement(String requirement, DetailClassification detailClassification) { + if (StringUtils.hasText(requirement)) { + return requirement; + } + return detailClassification.getDetailName() + " 직무 수행에 필요한 기본 자격 요건을 갖춥니다."; + } + + private String resolvePreferred(String preferred, DetailClassification detailClassification) { + if (StringUtils.hasText(preferred)) { + return preferred; + } + return detailClassification.getDetailName() + " 직무 관련 경험과 역량을 우대합니다."; + } +} 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 new file mode 100644 index 0000000..c175bb2 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -0,0 +1,147 @@ +package com.jobdri.jobdri_api.domain.mockapply.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.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; +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.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 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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MockApplyServiceTest { + + @Autowired + private MockApplyService mockApplyService; + + @Autowired + private MockApplyRepository mockApplyRepository; + + @Autowired + private JobPostingRepository jobPostingRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private ClassificationRepository classificationRepository; + + @Autowired + private DetailClassificationRepository detailClassificationRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("기존 공고를 기준으로 ACTUAL 타입 모의 서류 지원을 생성한다") + void createActualApply() { + User user = saveUser("actual-apply@example.com"); + JobPosting jobPosting = saveJobPosting("백엔드 개발"); + + MockApplyCreateResponse response = mockApplyService.createActualApply(user, jobPosting.getId()); + + MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); + assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); + assertThat(response.applyType()).isEqualTo(ApplyType.ACTUAL); + assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); + assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); + assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.ACTUAL); + } + + @Test + @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") + void createMockApply() { + User user = saveUser("mock-apply@example.com"); + DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); + MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( + detailClassification.getId(), + null, + "", + "React 경험 우대" + ); + + MockApplyCreateResponse response = mockApplyService.createMockApply(user, request); + + MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); + JobPosting jobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); + assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); + assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); + assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); + assertThat(jobPosting.getCompany().getName()).isEqualTo("가상 기업"); + assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.STARTUP); + assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); + assertThat(jobPosting.getTask()).contains("프론트엔드 개발"); + assertThat(jobPosting.getRequirement()).contains("프론트엔드 개발"); + assertThat(jobPosting.getPreferred()).isEqualTo("React 경험 우대"); + } + + @Test + @DisplayName("존재하지 않는 공고 ID로 ACTUAL 타입 지원 생성 시 예외를 던진다") + void createActualApplyThrowsWhenJobPostingNotFound() { + User user = saveUser("missing-job-posting@example.com"); + + assertThatThrownBy(() -> mockApplyService.createActualApply(user, 9999L)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않는 소분류 ID로 MOCK 타입 지원 생성 시 예외를 던진다") + void createMockApplyThrowsWhenDetailClassificationNotFound() { + User user = saveUser("missing-detail-classification@example.com"); + MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(9999L, null, null, null); + + assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private JobPosting saveJobPosting(String detailName) { + Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(detailName); + return jobPostingRepository.save(JobPosting.create( + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + } + + private DetailClassification saveDetailClassification(String detailName) { + Classification classification = Classification.create("테스트 대분류 " + detailName); + MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류 " + detailName); + DetailClassification detailClassification = middleClassification.addDetailClassification(detailName); + classificationRepository.save(classification); + return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); + } +}