diff --git a/src/main/java/com/boaz/backend/domain/admin/controller/AdminController.java b/src/main/java/com/boaz/backend/domain/admin/controller/AdminController.java index cdb8460..77b2f0c 100644 --- a/src/main/java/com/boaz/backend/domain/admin/controller/AdminController.java +++ b/src/main/java/com/boaz/backend/domain/admin/controller/AdminController.java @@ -5,6 +5,7 @@ import com.boaz.backend.domain.admin.dto.request.AdminUpdateRequest; import com.boaz.backend.domain.admin.dto.response.AdminAccountResponse; import com.boaz.backend.domain.admin.dto.response.AdminIdResponse; +import com.boaz.backend.domain.admin.dto.response.AdminMeResponse; import com.boaz.backend.domain.admin.service.AdminService; import com.boaz.backend.global.common.ApiResponse; import com.boaz.backend.global.security.AdminUserDetails; @@ -56,6 +57,15 @@ public ResponseEntity>> getAccounts( return ResponseEntity.ok(ApiResponse.ok(response)); } + @Operation(summary = "내 계정 정보 조회", description = "인증된 본인의 id, 소속, 이름을 반환.") + @GetMapping("/accounts/me") + public ResponseEntity> getMe( + @AuthenticationPrincipal AdminUserDetails userDetails + ) { + AdminMeResponse response = adminService.getMe(userDetails.getAdmin()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + @Operation(summary = "id별 계정 조회 (SUPER: 전체 / TEAM: 본인만)") @GetMapping("/accounts/{id}") public ResponseEntity> getAccount( diff --git a/src/main/java/com/boaz/backend/domain/admin/dto/request/AdminCreateRequest.java b/src/main/java/com/boaz/backend/domain/admin/dto/request/AdminCreateRequest.java index f9c4223..a4bcf58 100644 --- a/src/main/java/com/boaz/backend/domain/admin/dto/request/AdminCreateRequest.java +++ b/src/main/java/com/boaz/backend/domain/admin/dto/request/AdminCreateRequest.java @@ -44,6 +44,6 @@ public class AdminCreateRequest { @NotNull @Schema(description = "직책/소속", example = "서비스운영팀", - allowableValues = {"대표진", "디자인팀", "자료연구팀", "운영지원팀", "기획팀", "대외협력팀", "서비스운영팀"}) + allowableValues = {"대표진", "차기대표진", "디자인팀", "자료연구팀", "운영지원팀", "기획팀", "대외협력팀", "서비스운영팀"}) private Admin.TeamName teamName; } diff --git a/src/main/java/com/boaz/backend/domain/admin/dto/response/AdminMeResponse.java b/src/main/java/com/boaz/backend/domain/admin/dto/response/AdminMeResponse.java new file mode 100644 index 0000000..2ac0dca --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/admin/dto/response/AdminMeResponse.java @@ -0,0 +1,29 @@ +package com.boaz.backend.domain.admin.dto.response; + +import com.boaz.backend.domain.admin.entity.Admin; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AdminMeResponse { + + @Schema(description = "계정 ID", example = "1") + private Long id; + + @Schema(description = "직책/소속", example = "대표진", + allowableValues = {"대표진", "디자인팀", "자료연구팀", "운영지원팀", "기획팀", "대외협력팀", "서비스운영팀"}) + private String teamName; + + @Schema(description = "이름", example = "문혁준") + private String name; + + public static AdminMeResponse from(Admin admin) { + return AdminMeResponse.builder() + .id(admin.getId()) + .teamName(admin.getTeamName().name()) + .name(admin.getName()) + .build(); + } +} diff --git a/src/main/java/com/boaz/backend/domain/admin/entity/Admin.java b/src/main/java/com/boaz/backend/domain/admin/entity/Admin.java index a386681..048c72d 100644 --- a/src/main/java/com/boaz/backend/domain/admin/entity/Admin.java +++ b/src/main/java/com/boaz/backend/domain/admin/entity/Admin.java @@ -25,6 +25,7 @@ public enum Role { public enum TeamName { @Schema(description = "대표진") 대표진, + @Schema(description = "차기대표진") 차기대표진, @Schema(description = "디자인팀") 디자인팀, @Schema(description = "자료연구팀") 자료연구팀, @Schema(description = "운영지원팀") 운영지원팀, diff --git a/src/main/java/com/boaz/backend/domain/admin/repository/AdminRepository.java b/src/main/java/com/boaz/backend/domain/admin/repository/AdminRepository.java index 625ed1f..9306da9 100644 --- a/src/main/java/com/boaz/backend/domain/admin/repository/AdminRepository.java +++ b/src/main/java/com/boaz/backend/domain/admin/repository/AdminRepository.java @@ -1,7 +1,10 @@ package com.boaz.backend.domain.admin.repository; import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.global.common.enums.Track; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -17,4 +20,15 @@ public interface AdminRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); long countByRoleAndDeletedAtIsNull(Admin.Role role); + + // 지원서별 평가 조회 — 해당 부문 평가자 풀 (미평가자 null 포함용) + List findByTrackAndDeletedAtIsNullOrderByNameAsc(Track track); + + // 평가자 풀 = 해당 부문 + 전 부문 평가 권한자(차기 대표진). 차기 대표진은 본인 track과 무관하게 모든 지원자 풀에 포함. + @Query("SELECT DISTINCT a FROM Admin a WHERE a.deletedAt IS NULL " + + "AND (a.track = :track OR (a.role = :allTrackRole AND a.teamName = :allTrackTeam)) " + + "ORDER BY a.name ASC") + List findEvaluatorPool(@Param("track") Track track, + @Param("allTrackRole") Admin.Role allTrackRole, + @Param("allTrackTeam") Admin.TeamName allTrackTeam); } diff --git a/src/main/java/com/boaz/backend/domain/admin/service/AdminService.java b/src/main/java/com/boaz/backend/domain/admin/service/AdminService.java index 9fc780f..0dfcfb6 100644 --- a/src/main/java/com/boaz/backend/domain/admin/service/AdminService.java +++ b/src/main/java/com/boaz/backend/domain/admin/service/AdminService.java @@ -5,6 +5,7 @@ import com.boaz.backend.domain.admin.dto.request.AdminUpdateRequest; import com.boaz.backend.domain.admin.dto.response.AdminAccountResponse; import com.boaz.backend.domain.admin.dto.response.AdminIdResponse; +import com.boaz.backend.domain.admin.dto.response.AdminMeResponse; import com.boaz.backend.domain.admin.entity.Admin; import com.boaz.backend.domain.admin.repository.AdminRepository; import com.boaz.backend.domain.auth.repository.RefreshTokenRepository; @@ -66,6 +67,10 @@ public AdminIdResponse createAccount(AdminCreateRequest request, Admin currentAd return new AdminIdResponse(admin.getId()); } + public AdminMeResponse getMe(Admin currentAdmin) { + return AdminMeResponse.from(currentAdmin); + } + public AdminAccountResponse getAccount(Long id, Admin currentAdmin) { boolean isSelf = currentAdmin.getId().equals(id); boolean isTeam = currentAdmin.getRole() == Admin.Role.TEAM; diff --git a/src/main/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminController.java b/src/main/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminController.java new file mode 100644 index 0000000..8f8e913 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminController.java @@ -0,0 +1,112 @@ +package com.boaz.backend.domain.recruitment.controller; + +import com.boaz.backend.domain.recruitment.dto.request.EvaluationSaveRequest; +import com.boaz.backend.domain.recruitment.dto.request.FinalDecisionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.response.*; +import com.boaz.backend.domain.recruitment.service.RecruitmentService; +import com.boaz.backend.global.common.ApiResponse; +import com.boaz.backend.global.security.AdminUserDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "[Admin] Applicant Evaluation", description = "Admin 전용 지원서 평가 API") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/recruitment") +public class ApplicantEvaluationAdminController { + + private final RecruitmentService recruitmentService; + + @Operation(summary = "전체 지원서 조회 (지원자 대시보드)", + description = "공고 내 전체 지원서를 반환합니다. DRAFT 포함, 정렬·필터·검색은 프론트에서 처리. 차기대표진은 전 부문, 그 외(현재 대표진 포함)는 본인 부문만 조회.") + @GetMapping("/{recruitmentId}/applicants") + public ResponseEntity>> getApplicants( + @PathVariable Long recruitmentId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getApplicants(recruitmentId, userDetails.getAdmin()))); + } + + @Operation(summary = "전체 지원서 및 평가 조회 (평가 대시보드)", + description = "공고 내 SUBMITTED 지원서 + 평가 집계(합격/보류/불합 개수·총점) + 최종 평가를 반환합니다. 차기대표진은 전 부문, 그 외(현재 대표진 포함)는 본인 부문만 조회.") + @GetMapping("/{recruitmentId}/applicants/evaluations") + public ResponseEntity>> getApplicantEvaluations( + @PathVariable Long recruitmentId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getApplicantEvaluations(recruitmentId, userDetails.getAdmin()))); + } + + @Operation(summary = "최종 평가 수정 (대표진 전용)", + description = "현재 대표진 또는 차기대표진(role=SUPER)만 지원자의 최종 평가(final_decision)를 수정합니다. 현재 대표진은 본인 부문만, 차기대표진은 전 부문.") + @PatchMapping("/applicants/{applicantId}/final-decision") + public ResponseEntity> updateFinalDecision( + @PathVariable Long applicantId, + @RequestBody @Valid FinalDecisionUpdateRequest request, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.updateFinalDecision(applicantId, request, userDetails.getAdmin()))); + } + + @Operation(summary = "지원서별 평가 조회", + description = "한 지원자에 대한 해당 부문 평가자 전체의 평가를 반환합니다. 미평가 평가자는 null로 포함. 차기대표진 외(현재 대표진 포함)에는 본인 부문 지원자만 조회.") + @GetMapping("/applicants/{applicantId}/evaluations") + public ResponseEntity> getApplicantEvaluators( + @PathVariable Long applicantId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getApplicantEvaluators(applicantId, userDetails.getAdmin()))); + } + + @Operation(summary = "지원서별 면접 질문 조회", + description = "한 지원자에 대한 해당 부문 평가자별 면접 질문을 반환합니다. 미작성 평가자는 null로 포함. 차기대표진 외(현재 대표진 포함)에는 본인 부문 지원자만 조회.") + @GetMapping("/applicants/{applicantId}/interview-questions") + public ResponseEntity> getApplicantInterviewQuestions( + @PathVariable Long applicantId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getApplicantInterviewQuestions(applicantId, userDetails.getAdmin()))); + } + + @Operation(summary = "지원서 답변 조회", + description = "한 지원자가 작성한 문항별 답변을 문항 정보(질문 내용·유형)와 함께 문항 순서대로 반환합니다. (평가 사이드바 지원서 본문) 차기대표진 외(현재 대표진 포함)에는 본인 부문 지원자만 조회.") + @GetMapping("/applicants/{applicantId}/answers") + public ResponseEntity> getApplicantAnswers( + @PathVariable Long applicantId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getApplicantAnswers(applicantId, userDetails.getAdmin()))); + } + + @Operation(summary = "개인 평가 조회", + description = "로그인한 평가자 본인이 이 지원자에 매긴 평가를 반환합니다. 미평가 시 data=null.") + @GetMapping("/applicants/{applicantId}/evaluations/me") + public ResponseEntity> getMyEvaluation( + @PathVariable Long applicantId, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.getMyEvaluation(applicantId, userDetails.getAdmin()))); + } + + @Operation(summary = "개인 평가 저장 (upsert)", + description = "로그인한 평가자 본인의 평가를 저장합니다. 본인 부문 지원자만 가능, SUBMITTED만 가능.") + @PutMapping("/applicants/{applicantId}/evaluations/me") + public ResponseEntity> saveMyEvaluation( + @PathVariable Long applicantId, + @RequestBody @Valid EvaluationSaveRequest request, + @AuthenticationPrincipal AdminUserDetails userDetails) { + return ResponseEntity.ok(ApiResponse.ok( + recruitmentService.saveMyEvaluation(applicantId, request, userDetails.getAdmin()))); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminController.java b/src/main/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminController.java index d45e345..90836eb 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminController.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminController.java @@ -10,6 +10,7 @@ import com.boaz.backend.domain.recruitment.dto.response.RecruitmentIdResponse; import com.boaz.backend.domain.recruitment.dto.response.RecruitmentResponse; import com.boaz.backend.domain.recruitment.dto.response.SubscriptionResponse; +import com.boaz.backend.domain.recruitment.entity.DecisionFilter; import com.boaz.backend.domain.recruitment.service.RecruitmentService; import com.boaz.backend.global.common.ApiResponse; @@ -64,11 +65,13 @@ public ResponseEntity> deleteRecruitment( return ResponseEntity.ok(ApiResponse.ok(null)); } - @Operation(summary = "지원서 CSV 파일 생성") + @Operation(summary = "지원서 CSV 파일 생성", + description = "term 공고의 지원서를 부문별 CSV로 생성합니다. decision으로 합격(PASS)/불합격(FAIL)/전체(ALL) 추출 범위를 지정합니다.") @PostMapping("/applications/download") public ResponseEntity> downloadApplications( - @RequestParam Integer term) { - recruitmentService.downloadApplications(term); + @RequestParam Integer term, + @RequestParam(required = false, defaultValue = "ALL") DecisionFilter decision) { + recruitmentService.downloadApplications(term, decision); return ResponseEntity.ok(ApiResponse.ok(null)); } diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/request/EvaluationSaveRequest.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/request/EvaluationSaveRequest.java new file mode 100644 index 0000000..a47dc52 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/request/EvaluationSaveRequest.java @@ -0,0 +1,27 @@ +package com.boaz.backend.domain.recruitment.dto.request; + +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@NoArgsConstructor +public class EvaluationSaveRequest { + + @NotNull + private EvaluationDecision decision; + + // 1~10 또는 null (미점수). null이면 검증 통과 + @Min(1) + @Max(10) + private Integer score; + + private String memo; + + // 면접 질문 (null 허용) + private String interviewQuestion; +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/request/FinalDecisionUpdateRequest.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/request/FinalDecisionUpdateRequest.java new file mode 100644 index 0000000..df00eaa --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/request/FinalDecisionUpdateRequest.java @@ -0,0 +1,14 @@ +package com.boaz.backend.domain.recruitment.dto.request; + +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FinalDecisionUpdateRequest { + + @NotNull + private EvaluationDecision finalDecision; +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantAnswersResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantAnswersResponse.java new file mode 100644 index 0000000..439d7d4 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantAnswersResponse.java @@ -0,0 +1,44 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ApplicantAnswersResponse { + + private final Long applicantId; + + private final List answers; + + public static ApplicantAnswersResponse of(Long applicantId, List answers) { + return ApplicantAnswersResponse.builder() + .applicantId(applicantId) + .answers(answers) + .build(); + } + + @Getter + @Builder + public static class AnswerDetailResponse { + + private final Long questionId; + + private final String label; + + private final ApplicationQuestion.Category category; + + private final ApplicationQuestion.Type type; + + private final String content; + + private final Integer orderNum; + + // TEXT → 문자열, TABLE → 객체 (사용자용 AnswerItemResponse와 동일 규약) + private final JsonNode answer; + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluationResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluationResponse.java new file mode 100644 index 0000000..fc2e753 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluationResponse.java @@ -0,0 +1,89 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.global.common.enums.Track; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + + +@Getter +@Builder +public class ApplicantEvaluationResponse { + + private final Long id; + + private final Long userId; + + private final Track track; + + private final String name; + + private final String email; + + private final String phone; + + private final String university; + + private final String major; + + private final List minorDoubleMajor; + + private final Integer lastSemester; + + private final Applicant.MilitaryStatus militaryStatus; + + private final LocalDate birthDate; + + private final String graduationDate; + + private final Boolean gradSchoolPlan; + + private final LocalDateTime submittedAt; + + // 평가자별 applicant_eval 집계 (서버 계산) + private final int passCount; + + private final int holdCount; + + private final int failCount; + + private final int totalScore; + + private final EvaluationDecision finalDecision; + + // 로그인한 평가자 본인의 개인 평가 결정 (미평가 시 null) + private final EvaluationDecision myDecision; + + public static ApplicantEvaluationResponse of(Applicant a, List minorDoubleMajor, + int passCount, int holdCount, int failCount, int totalScore, + EvaluationDecision myDecision) { + return ApplicantEvaluationResponse.builder() + .id(a.getId()) + .userId(a.getUser().getId()) + .track(a.getTrack()) + .name(a.getName()) + .email(a.getEmail()) + .phone(a.getPhone()) + .university(a.getUniversity()) + .major(a.getMajor()) + .minorDoubleMajor(minorDoubleMajor) + .lastSemester(a.getLastSemester()) + .militaryStatus(a.getMilitaryStatus()) + .birthDate(a.getBirthDate()) + .graduationDate(a.getGraduationDate()) + .gradSchoolPlan(a.getGradSchoolPlan()) + .submittedAt(a.getSubmittedAt()) + .passCount(passCount) + .holdCount(holdCount) + .failCount(failCount) + .totalScore(totalScore) + .finalDecision(a.getFinalDecision()) + .myDecision(myDecision) + .build(); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluatorsResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluatorsResponse.java new file mode 100644 index 0000000..8d8212f --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantEvaluatorsResponse.java @@ -0,0 +1,23 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import lombok.Getter; + +import java.util.List; + + +@Getter +public class ApplicantEvaluatorsResponse { + + private final Long applicantId; + + private final List evaluations; + + private ApplicantEvaluatorsResponse(Long applicantId, List evaluations) { + this.applicantId = applicantId; + this.evaluations = evaluations; + } + + public static ApplicantEvaluatorsResponse of(Long applicantId, List evaluations) { + return new ApplicantEvaluatorsResponse(applicantId, evaluations); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantInterviewQuestionsResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantInterviewQuestionsResponse.java new file mode 100644 index 0000000..f5e12db --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantInterviewQuestionsResponse.java @@ -0,0 +1,25 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import lombok.Getter; + +import java.util.List; + + +@Getter +public class ApplicantInterviewQuestionsResponse { + + private final Long applicantId; + + private final List interviewQuestions; + + private ApplicantInterviewQuestionsResponse(Long applicantId, + List interviewQuestions) { + this.applicantId = applicantId; + this.interviewQuestions = interviewQuestions; + } + + public static ApplicantInterviewQuestionsResponse of( + Long applicantId, List interviewQuestions) { + return new ApplicantInterviewQuestionsResponse(applicantId, interviewQuestions); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantSummaryResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantSummaryResponse.java new file mode 100644 index 0000000..5a2e7e7 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/ApplicantSummaryResponse.java @@ -0,0 +1,69 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.global.common.enums.Track; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + + +@Getter +@Builder +public class ApplicantSummaryResponse { + + private final Long id; + + private final Long userId; + + private final Applicant.ApplicantStatus status; + + private final Track track; + + private final String name; + + private final String email; + + private final String phone; + + private final String university; + + private final String major; + + private final List minorDoubleMajor; + + private final Integer lastSemester; + + private final Applicant.MilitaryStatus militaryStatus; + + private final LocalDate birthDate; + + private final String graduationDate; + + private final Boolean gradSchoolPlan; + + private final LocalDateTime submittedAt; + + public static ApplicantSummaryResponse of(Applicant a, List minorDoubleMajor) { + return ApplicantSummaryResponse.builder() + .id(a.getId()) + .userId(a.getUser().getId()) + .status(a.getStatus()) + .track(a.getTrack()) + .name(a.getName()) + .email(a.getEmail()) + .phone(a.getPhone()) + .university(a.getUniversity()) + .major(a.getMajor()) + .minorDoubleMajor(minorDoubleMajor) + .lastSemester(a.getLastSemester()) + .militaryStatus(a.getMilitaryStatus()) + .birthDate(a.getBirthDate()) + .graduationDate(a.getGraduationDate()) + .gradSchoolPlan(a.getGradSchoolPlan()) + .submittedAt(a.getSubmittedAt()) + .build(); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorEvaluationResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorEvaluationResponse.java new file mode 100644 index 0000000..fa75fd8 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorEvaluationResponse.java @@ -0,0 +1,46 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.global.common.enums.Track; +import lombok.Getter; + + +@Getter +public class EvaluatorEvaluationResponse { + + private final Long adminId; + + private final String name; + + private final Track track; + + private final EvaluationDecision decision; + + private final Integer score; + + private final String memo; + + private EvaluatorEvaluationResponse(Long adminId, String name, Track track, + EvaluationDecision decision, Integer score, String memo) { + this.adminId = adminId; + this.name = name; + this.track = track; + this.decision = decision; + this.score = score; + this.memo = memo; + } + + // 평가자(Admin) + 본인 평가(eval, 미평가 시 null) + public static EvaluatorEvaluationResponse of(Admin admin, ApplicantEval eval) { + return new EvaluatorEvaluationResponse( + admin.getId(), + admin.getName(), + admin.getTrack(), + eval != null ? eval.getDecision() : null, + eval != null ? eval.getScore() : null, + eval != null ? eval.getMemo() : null + ); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorInterviewQuestionResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorInterviewQuestionResponse.java new file mode 100644 index 0000000..701784e --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/EvaluatorInterviewQuestionResponse.java @@ -0,0 +1,37 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.global.common.enums.Track; +import lombok.Getter; + + +@Getter +public class EvaluatorInterviewQuestionResponse { + + private final Long adminId; + + private final String name; + + private final Track track; + + private final String interviewQuestion; + + private EvaluatorInterviewQuestionResponse(Long adminId, String name, Track track, + String interviewQuestion) { + this.adminId = adminId; + this.name = name; + this.track = track; + this.interviewQuestion = interviewQuestion; + } + + // 평가자(Admin) + 본인 평가(eval, 미작성 시 null) + public static EvaluatorInterviewQuestionResponse of(Admin admin, ApplicantEval eval) { + return new EvaluatorInterviewQuestionResponse( + admin.getId(), + admin.getName(), + admin.getTrack(), + eval != null ? eval.getInterviewQuestion() : null + ); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/FinalDecisionResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/FinalDecisionResponse.java new file mode 100644 index 0000000..ea4b580 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/FinalDecisionResponse.java @@ -0,0 +1,23 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import lombok.Getter; + + +@Getter +public class FinalDecisionResponse { + + private final Long applicantId; + + private final EvaluationDecision finalDecision; + + private FinalDecisionResponse(Long applicantId, EvaluationDecision finalDecision) { + this.applicantId = applicantId; + this.finalDecision = finalDecision; + } + + public static FinalDecisionResponse from(Applicant applicant) { + return new FinalDecisionResponse(applicant.getId(), applicant.getFinalDecision()); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/dto/response/MyEvaluationResponse.java b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/MyEvaluationResponse.java new file mode 100644 index 0000000..60804ec --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/dto/response/MyEvaluationResponse.java @@ -0,0 +1,44 @@ +package com.boaz.backend.domain.recruitment.dto.response; + +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import lombok.Getter; + + +@Getter +public class MyEvaluationResponse { + + private final Long evaluationId; + + private final Long applicantId; + + private final EvaluationDecision decision; + + private final Integer score; + + private final String memo; + + private final String interviewQuestion; + + private MyEvaluationResponse(Long evaluationId, Long applicantId, + EvaluationDecision decision, Integer score, String memo, + String interviewQuestion) { + this.evaluationId = evaluationId; + this.applicantId = applicantId; + this.decision = decision; + this.score = score; + this.memo = memo; + this.interviewQuestion = interviewQuestion; + } + + public static MyEvaluationResponse from(ApplicantEval eval) { + return new MyEvaluationResponse( + eval.getId(), + eval.getApplicant().getId(), + eval.getDecision(), + eval.getScore(), + eval.getMemo(), + eval.getInterviewQuestion() + ); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/entity/Applicant.java b/src/main/java/com/boaz/backend/domain/recruitment/entity/Applicant.java index db25110..df9a317 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/entity/Applicant.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/entity/Applicant.java @@ -86,6 +86,10 @@ public enum ApplicantStatus { private Boolean gradSchoolPlan; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private EvaluationDecision finalDecision = EvaluationDecision.PENDING; + private LocalDateTime submittedAt; @Builder @@ -135,6 +139,11 @@ public void markSubmitted() { this.submittedAt = LocalDateTime.now(); } + // 대표진이 최종 평가 변경 + public void updateFinalDecision(EvaluationDecision finalDecision) { + this.finalDecision = finalDecision; + } + // 제출 시 전체 덮어쓰기 + SUBMITTED 전환 public void overwrite(Track track, String name, String email, String phone, String university, String major, String minorDoubleMajor, diff --git a/src/main/java/com/boaz/backend/domain/recruitment/entity/ApplicantEval.java b/src/main/java/com/boaz/backend/domain/recruitment/entity/ApplicantEval.java new file mode 100644 index 0000000..b6cd711 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/entity/ApplicantEval.java @@ -0,0 +1,68 @@ +package com.boaz.backend.domain.recruitment.entity; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.global.common.BaseEntity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "applicant_eval", + uniqueConstraints = @UniqueConstraint( + name = "uk_applicant_eval_applicant_admin", + columnNames = {"applicant_id", "admin_id"} + ) +) +public class ApplicantEval extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "applicant_id", nullable = false) + private Applicant applicant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private Admin admin; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private EvaluationDecision decision = EvaluationDecision.PENDING; + + // 점수: 미평가 시 null (집계 시 합산 제외) + private Integer score; + + @Column(columnDefinition = "TEXT") + private String memo; + + // 면접 질문: 평가자가 면접 때 물어볼 질문 (memo와 동일 성격, nullable) + @Column(columnDefinition = "TEXT") + private String interviewQuestion; + + @Builder + public ApplicantEval(Applicant applicant, Admin admin, EvaluationDecision decision, + Integer score, String memo, String interviewQuestion) { + this.applicant = applicant; + this.admin = admin; + this.decision = decision != null ? decision : EvaluationDecision.PENDING; + this.score = score; + this.memo = memo; + this.interviewQuestion = interviewQuestion; + } + + // 저장(upsert) 시 보낸 값으로 전체 반영 + public void update(EvaluationDecision decision, Integer score, String memo, String interviewQuestion) { + this.decision = decision != null ? decision : EvaluationDecision.PENDING; + this.score = score; + this.memo = memo; + this.interviewQuestion = interviewQuestion; + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/entity/DecisionFilter.java b/src/main/java/com/boaz/backend/domain/recruitment/entity/DecisionFilter.java new file mode 100644 index 0000000..bca1d6a --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/entity/DecisionFilter.java @@ -0,0 +1,18 @@ +package com.boaz.backend.domain.recruitment.entity; + +import io.swagger.v3.oas.annotations.media.Schema; + +// 지원서 CSV 추출 시 final_decision 기준 추출 범위 필터 (서류 평가 결과 전달용) +public enum DecisionFilter { + @Schema(description = "합격자만") + PASS, + @Schema(description = "불합격자만") + FAIL, + @Schema(description = "전체 (필터 없음, 기본값)") + ALL; + + // 필터에 대응하는 EvaluationDecision 반환. ALL이면 null(필터 미적용) + public EvaluationDecision toEvaluationDecisionOrNull() { + return this == ALL ? null : EvaluationDecision.valueOf(name()); + } +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/entity/EvaluationDecision.java b/src/main/java/com/boaz/backend/domain/recruitment/entity/EvaluationDecision.java new file mode 100644 index 0000000..ef630f1 --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/entity/EvaluationDecision.java @@ -0,0 +1,15 @@ +package com.boaz.backend.domain.recruitment.entity; + +import io.swagger.v3.oas.annotations.media.Schema; + + +public enum EvaluationDecision { + @Schema(description = "합격") + PASS, + @Schema(description = "불합격") + FAIL, + @Schema(description = "보류") + HOLD, + @Schema(description = "미정 (기본값)") + PENDING +} \ No newline at end of file diff --git a/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepository.java b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepository.java index 731911d..74142ff 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepository.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepository.java @@ -17,6 +17,10 @@ public interface ApplicantAnswerRepository extends JpaRepository findByApplicantIds(@Param("applicantIds") List applicantIds); + // 지원자 단건 답변 조회 (문항 페치 조인 + 문항 순서 정렬) — 평가 사이드바 지원서 본문용 + @Query("SELECT aa FROM ApplicantAnswer aa JOIN FETCH aa.question q WHERE aa.applicant.id = :applicantId ORDER BY q.orderNum ASC") + List findByApplicantIdWithQuestion(@Param("applicantId") Long applicantId); + // 지원자 단건 답변 전체 삭제 (임시저장 덮어쓰기, 제출 시 DRAFT 답변 교체) @org.springframework.data.jpa.repository.Modifying @Query("DELETE FROM ApplicantAnswer aa WHERE aa.applicant.id = :applicantId") diff --git a/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepository.java b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepository.java new file mode 100644 index 0000000..a5c14ee --- /dev/null +++ b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepository.java @@ -0,0 +1,43 @@ +package com.boaz.backend.domain.recruitment.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; + +public interface ApplicantEvalRepository extends JpaRepository { + + // 개인 평가 조회/저장(upsert) — (applicant_id, admin_id) 단건 + Optional findByApplicantIdAndAdminId(Long applicantId, Long adminId); + + // 개인 평가 저장 — 원자적 upsert. find→insert TOCTOU(최초 저장 동시 호출 시 유니크 위반) 회피. + // created_at/updated_at은 Auditing 우회이므로 직접 세팅, decision은 ENUM STRING(name) 바인딩. + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = "INSERT INTO applicant_eval " + + "(applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) " + + "VALUES (:applicantId, :adminId, :decision, :score, :memo, :interviewQuestion, NOW(6), NOW(6)) " + + "ON DUPLICATE KEY UPDATE " + + "decision = VALUES(decision), score = VALUES(score), memo = VALUES(memo), " + + "interview_question = VALUES(interview_question), updated_at = NOW(6)", + nativeQuery = true) + void upsert(@Param("applicantId") Long applicantId, + @Param("adminId") Long adminId, + @Param("decision") String decision, + @Param("score") Integer score, + @Param("memo") String memo, + @Param("interviewQuestion") String interviewQuestion); + + // 지원서별 평가 조회 — 한 지원자의 모든 평가 (평가자 정보 JOIN FETCH) + @Query("SELECT e FROM ApplicantEval e JOIN FETCH e.admin " + + "WHERE e.applicant.id = :applicantId") + List findByApplicantIdWithAdmin(@Param("applicantId") Long applicantId); + + // 평가 대시보드 집계용 — 공고 내 모든 평가 (Java에서 applicant 단위 집계) + @Query("SELECT e FROM ApplicantEval e WHERE e.applicant.recruitment.id = :recruitmentId") + List findByRecruitmentId(@Param("recruitmentId") Long recruitmentId); +} diff --git a/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepository.java b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepository.java index 00242bf..4dc5f62 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepository.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; import com.boaz.backend.global.common.enums.Track; public interface ApplicantRepository extends JpaRepository { @@ -21,16 +22,19 @@ public interface ApplicantRepository extends JpaRepository { // (recruitment_id, user_id) 조합으로 지원서 조회 Optional findByRecruitmentIdAndUserId(Long recruitmentId, Long userId); - // recruitment_id + track + status 기반 조회 (CSV 다운로드용) + // recruitment_id + track + status + (선택) final_decision 기반 조회 (CSV 다운로드용) // - JOIN FETCH a.user: user_id 접근 시 N+1 방지 // - ORDER BY a.submittedAt ASC: 제출 순 정렬 (DRAFT 생성 시점과 분리) + // - decision == null: 전체(필터 미적용), 값이 있으면 해당 final_decision 만 추출 @Query("SELECT a FROM Applicant a JOIN FETCH a.user " + "WHERE a.recruitment.id = :recruitmentId AND a.track = :track AND a.status = :status " + + "AND (:decision IS NULL OR a.finalDecision = :decision) " + "ORDER BY a.submittedAt ASC") - List findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( + List findSubmittedByRecruitmentIdAndTrackAndDecision( @Param("recruitmentId") Long recruitmentId, @Param("track") Track track, - @Param("status") Applicant.ApplicantStatus status + @Param("status") Applicant.ApplicantStatus status, + @Param("decision") EvaluationDecision decision ); // userId + status 기반 조회 (합격자 승격용, recruitment JOIN FETCH로 term 접근) @@ -39,4 +43,20 @@ Optional findByUserIdAndStatus( @Param("userId") Long userId, @Param("status") Applicant.ApplicantStatus status ); + + // 지원자 대시보드용: 공고 내 전체 지원자 (DRAFT 포함, user JOIN FETCH로 N+1 방지) + // - MySQL은 DESC 정렬 시 NULL을 뒤로 보내므로 DRAFT(submittedAt null)가 목록 끝에 위치 + @Query("SELECT a FROM Applicant a JOIN FETCH a.user " + + "WHERE a.recruitment.id = :recruitmentId " + + "ORDER BY a.submittedAt DESC") + List findByRecruitmentIdWithUser(@Param("recruitmentId") Long recruitmentId); + + // 평가 대시보드용: 공고 내 status 지원자 (SUBMITTED, user JOIN FETCH) + @Query("SELECT a FROM Applicant a JOIN FETCH a.user " + + "WHERE a.recruitment.id = :recruitmentId AND a.status = :status " + + "ORDER BY a.submittedAt DESC") + List findByRecruitmentIdAndStatusWithUser( + @Param("recruitmentId") Long recruitmentId, + @Param("status") Applicant.ApplicantStatus status + ); } diff --git a/src/main/java/com/boaz/backend/domain/recruitment/service/CsvService.java b/src/main/java/com/boaz/backend/domain/recruitment/service/CsvService.java index 38dd2a5..d0b2cb6 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/service/CsvService.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/service/CsvService.java @@ -20,7 +20,7 @@ public class CsvService { private final ObjectMapper objectMapper; private static final List BASE_HEADERS = List.of( - "user_id", "지원 부문", "성명", "이메일 주소", "전화번호", "대학교", "본전공", + "user_id", "지원 부문", "성명", "이메일 주소", "전화번호", "최종 평가", "대학교", "본전공", "복수/부전공", "마지막 재학 학기", "병역 이수 여부", "생년월일", "졸업 예정 시점", "대학원 진학 여부", "지원서 제출 일시" ); @@ -64,6 +64,7 @@ public byte[] generateCsv( row.add(applicant.getName()); row.add(applicant.getEmail()); row.add(applicant.getPhone() != null ? applicant.getPhone() : ""); + row.add(toKoreanDecision(applicant.getFinalDecision())); row.add(applicant.getUniversity() != null ? applicant.getUniversity() : ""); row.add(applicant.getMajor() != null ? applicant.getMajor() : ""); row.add(formatMinorDoubleMajor(applicant.getMinorDoubleMajor())); @@ -85,6 +86,17 @@ public byte[] generateCsv( return baos.toByteArray(); } + // 최종 평가(final_decision) ENUM → 결과 전달용 한글 표기 + private String toKoreanDecision(EvaluationDecision decision) { + if (decision == null) return ""; + return switch (decision) { + case PASS -> "합격"; + case FAIL -> "불합격"; + case HOLD -> "보류"; + case PENDING -> "미정"; + }; + } + private String formatMinorDoubleMajor(String minorDoubleMajorJson) { if (minorDoubleMajorJson == null) return ""; try { diff --git a/src/main/java/com/boaz/backend/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/boaz/backend/domain/recruitment/service/RecruitmentService.java index dc6aca0..7697eb7 100644 --- a/src/main/java/com/boaz/backend/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/boaz/backend/domain/recruitment/service/RecruitmentService.java @@ -33,6 +33,23 @@ import com.boaz.backend.domain.recruitment.repository.SubscriptionRepository; import com.boaz.backend.domain.user.entity.User; import com.boaz.backend.domain.user.repository.UserRepository; +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.repository.AdminRepository; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.DecisionFilter; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.domain.recruitment.repository.ApplicantEvalRepository; +import com.boaz.backend.domain.recruitment.dto.request.EvaluationSaveRequest; +import com.boaz.backend.domain.recruitment.dto.request.FinalDecisionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantAnswersResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantSummaryResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantEvaluationResponse; +import com.boaz.backend.domain.recruitment.dto.response.FinalDecisionResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantEvaluatorsResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantInterviewQuestionsResponse; +import com.boaz.backend.domain.recruitment.dto.response.EvaluatorInterviewQuestionResponse; +import com.boaz.backend.domain.recruitment.dto.response.EvaluatorEvaluationResponse; +import com.boaz.backend.domain.recruitment.dto.response.MyEvaluationResponse; import com.boaz.backend.global.common.enums.Track; import com.boaz.backend.global.exception.CustomException; import com.boaz.backend.global.exception.ErrorCode; @@ -57,12 +74,14 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -74,6 +93,8 @@ public class RecruitmentService { private final ApplicationQuestionRepository applicationQuestionRepository; private final ApplicantRepository applicantRepository; private final ApplicantAnswerRepository applicantAnswerRepository; + private final ApplicantEvalRepository applicantEvalRepository; + private final AdminRepository adminRepository; private final UserRepository userRepository; private final ObjectMapper objectMapper; private final SubscriptionRepository subscriptionRepository; @@ -578,23 +599,26 @@ public SubscriptionResponse subscribe(SubscriptionRequest request) { // Admin 전용 메서드 // ======================== - // 지원서 파일 다운로드 - public void downloadApplications(Integer term) { + // 지원서 파일 다운로드 (decision: 합격/불합격/전체 추출 필터) + public void downloadApplications(Integer term, DecisionFilter decision) { // 공고 존재 여부 확인 Recruitment recruitment = recruitmentRepository.findByTerm(term) .orElseThrow(() -> new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)); + // 필터에 대응하는 final_decision (ALL이면 null = 필터 미적용) + EvaluationDecision decisionFilter = decision.toEvaluationDecisionOrNull(); + String timestamp = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); // 부문별 CSV 생성 및 S3 업로드 for (Track track : List.of(Track.VISUALIZATION, Track.ANALYSIS, Track.ENGINEERING)) { - // 해당 부문 제출된 지원자 조회 (submittedAt 오름차순 + user JOIN FETCH) + // 해당 부문 + 필터 조건 제출 지원자 조회 (submittedAt 오름차순 + user JOIN FETCH) List applicants = applicantRepository - .findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( - recruitment.getId(), track, Applicant.ApplicantStatus.SUBMITTED); + .findSubmittedByRecruitmentIdAndTrackAndDecision( + recruitment.getId(), track, Applicant.ApplicantStatus.SUBMITTED, decisionFilter); // 공통 + 해당 부문 질문 조회 List questions = applicationQuestionRepository @@ -621,9 +645,9 @@ public void downloadApplications(Integer term) { throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); } - // S3 업로드 - String key = String.format("%d/applicants_%s_%s.csv", - term, track.name(), timestamp); + // S3 업로드 (키에 추출 필터 표기 → 합격/불합격/전체 파일 구분) + String key = String.format("%d/applicants_%s_%s_%s.csv", + term, track.name(), decision.name(), timestamp); s3Service.uploadCsv(recruitmentBucket, key, csv); } } @@ -945,4 +969,238 @@ public List getAllSubscriptions() { public void deleteAllSubscriptions() { subscriptionRepository.deleteAll(); } + + // ===== 지원서 평가 (어드민 전용) ===== + + // 전체 지원서 조회 (지원자 대시보드) — DRAFT 포함, 비대표진은 본인 track만 + public List getApplicants(Long recruitmentId, Admin currentAdmin) { + validateRecruitmentExists(recruitmentId); + boolean allTrack = isAllTrackViewer(currentAdmin); + + return applicantRepository.findByRecruitmentIdWithUser(recruitmentId).stream() + .filter(a -> allTrack || a.getTrack() == currentAdmin.getTrack()) + .map(a -> ApplicantSummaryResponse.of(a, parseMinorDoubleMajor(a.getMinorDoubleMajor()))) + .toList(); + } + + // 전체 지원서 및 평가 조회 (평가 대시보드) — SUBMITTED만 + 서버 집계, 비대표진은 본인 track만 + public List getApplicantEvaluations(Long recruitmentId, Admin currentAdmin) { + validateRecruitmentExists(recruitmentId); + boolean allTrack = isAllTrackViewer(currentAdmin); + + List applicants = applicantRepository + .findByRecruitmentIdAndStatusWithUser(recruitmentId, Applicant.ApplicantStatus.SUBMITTED) + .stream() + .filter(a -> allTrack || a.getTrack() == currentAdmin.getTrack()) + .toList(); + + // applicant_id 단위로 평가 집계 (PENDING 미포함, 총점 = null 아닌 score 합) + Map> evalsByApplicant = applicantEvalRepository.findByRecruitmentId(recruitmentId) + .stream() + .collect(Collectors.groupingBy(e -> e.getApplicant().getId())); + + List result = new ArrayList<>(); + for (Applicant a : applicants) { + List evals = evalsByApplicant.getOrDefault(a.getId(), Collections.emptyList()); + int pass = 0, hold = 0, fail = 0, total = 0; + EvaluationDecision myDecision = null; + for (ApplicantEval e : evals) { + switch (e.getDecision()) { + case PASS -> pass++; + case HOLD -> hold++; + case FAIL -> fail++; + default -> { /* PENDING: 개수 제외 */ } + } + if (e.getScore() != null) total += e.getScore(); + // 로그인 본인의 평가 결정 (admin_id는 FK라 프록시 id 접근만으로 추가 쿼리 없음) + if (e.getAdmin().getId().equals(currentAdmin.getId())) { + myDecision = e.getDecision(); + } + } + result.add(ApplicantEvaluationResponse.of( + a, parseMinorDoubleMajor(a.getMinorDoubleMajor()), pass, hold, fail, total, myDecision)); + } + return result; + } + + // 최종 평가 수정 — 현재/차기 대표진만. 단 현재 대표진은 본인 track 지원자만(차기 대표진은 전 부문) + @Transactional + public FinalDecisionResponse updateFinalDecision(Long applicantId, FinalDecisionUpdateRequest request, + Admin currentAdmin) { + validateFinalDecisionAuthority(currentAdmin); + + Applicant applicant = findSubmittedApplicantForEval(applicantId); + validateTrackAccess(currentAdmin, applicant); + applicant.updateFinalDecision(request.getFinalDecision()); + return FinalDecisionResponse.from(applicant); + } + + // 지원서별 평가 조회 — 한 지원자의 전체 평가자 평가 (미평가자 null 포함), 비대표진은 본인 track만 + public ApplicantEvaluatorsResponse getApplicantEvaluators(Long applicantId, Admin currentAdmin) { + Applicant applicant = findApplicantForEval(applicantId); + validateTrackAccess(currentAdmin, applicant); + + // 평가자 풀 = 해당 부문 + 차기 대표진(전 부문 평가 권한). 미평가자도 포함. + List evaluators = adminRepository.findEvaluatorPool( + applicant.getTrack(), Admin.Role.SUPER, Admin.TeamName.차기대표진); + + Map evalByAdmin = applicantEvalRepository.findByApplicantIdWithAdmin(applicantId) + .stream() + .collect(Collectors.toMap(e -> e.getAdmin().getId(), Function.identity())); + + List evaluations = evaluators.stream() + .map(admin -> EvaluatorEvaluationResponse.of(admin, evalByAdmin.get(admin.getId()))) + .toList(); + + return ApplicantEvaluatorsResponse.of(applicantId, evaluations); + } + + // 지원서별 면접 질문 조회 — 한 지원자의 부문 평가자별 면접 질문 (미작성자 null 포함), 비대표진은 본인 track만 + public ApplicantInterviewQuestionsResponse getApplicantInterviewQuestions(Long applicantId, Admin currentAdmin) { + Applicant applicant = findApplicantForEval(applicantId); + validateTrackAccess(currentAdmin, applicant); + + // 평가자 풀 = 해당 부문 + 차기 대표진(전 부문 평가 권한). 미작성자도 포함. + List evaluators = adminRepository.findEvaluatorPool( + applicant.getTrack(), Admin.Role.SUPER, Admin.TeamName.차기대표진); + + Map evalByAdmin = applicantEvalRepository.findByApplicantIdWithAdmin(applicantId) + .stream() + .collect(Collectors.toMap(e -> e.getAdmin().getId(), Function.identity())); + + List interviewQuestions = evaluators.stream() + .map(admin -> EvaluatorInterviewQuestionResponse.of(admin, evalByAdmin.get(admin.getId()))) + .toList(); + + return ApplicantInterviewQuestionsResponse.of(applicantId, interviewQuestions); + } + + // 개인 평가 조회 — 본인이 이 지원자에 매긴 평가 1건 (없으면 null), 비대표진은 본인 track만 + public MyEvaluationResponse getMyEvaluation(Long applicantId, Admin currentAdmin) { + Applicant applicant = findApplicantForEval(applicantId); + validateTrackAccess(currentAdmin, applicant); + + return applicantEvalRepository.findByApplicantIdAndAdminId(applicantId, currentAdmin.getId()) + .map(MyEvaluationResponse::from) + .orElse(null); + } + + // 개인 평가 저장 (upsert) — 본인 부문 지원자만 + @Transactional + public MyEvaluationResponse saveMyEvaluation(Long applicantId, EvaluationSaveRequest request, + Admin currentAdmin) { + Applicant applicant = findSubmittedApplicantForEval(applicantId); + + // 본인 부문 지원자만 평가 가능 (단, 대표진은 모든 부문 평가 가능) + validateTrackAccess(currentAdmin, applicant); + + // 원자적 upsert (없으면 INSERT, 있으면 UPDATE) — 동시 최초 저장 시에도 멱등 + applicantEvalRepository.upsert( + applicantId, currentAdmin.getId(), + request.getDecision().name(), request.getScore(), request.getMemo(), + request.getInterviewQuestion()); + + ApplicantEval eval = applicantEvalRepository + .findByApplicantIdAndAdminId(applicantId, currentAdmin.getId()) + .orElseThrow(() -> new CustomException(ErrorCode.INTERNAL_SERVER_ERROR)); + return MyEvaluationResponse.from(eval); + } + + // 지원서 답변 조회 — 한 지원자의 문항별 답변 (문항 정보 포함, 문항 순서대로), 비대표진은 본인 track만 + public ApplicantAnswersResponse getApplicantAnswers(Long applicantId, Admin currentAdmin) { + Applicant applicant = findApplicantForEval(applicantId); + validateTrackAccess(currentAdmin, applicant); + String trackName = applicant.getTrack() != null ? applicant.getTrack().getDisplayName() : null; + + List answers = + applicantAnswerRepository.findByApplicantIdWithQuestion(applicantId).stream() + .map(aa -> { + ApplicationQuestion q = aa.getQuestion(); + String content = q.getContent(); + // 문항 내용의 {Track} 치환 (지원자가 보던 표기와 일치) + if (content != null && trackName != null) { + content = content.replace("{Track}", trackName); + } + return ApplicantAnswersResponse.AnswerDetailResponse.builder() + .questionId(q.getId()) + .label(q.getLabel()) + .category(q.getCategory()) + .type(q.getType()) + .content(content) + .orderNum(q.getOrderNum()) + .answer(toAnswerNode(aa)) + .build(); + }) + .toList(); + + return ApplicantAnswersResponse.of(applicantId, answers); + } + + // 저장된 답변(answer_text/answer_json)을 JsonNode로 변환 (TEXT → 문자열, TABLE → 객체) + private JsonNode toAnswerNode(ApplicantAnswer aa) { + if (aa.getAnswerText() != null) { + return TextNode.valueOf(aa.getAnswerText()); + } + if (aa.getAnswerJson() != null) { + try { + return objectMapper.readTree(aa.getAnswerJson()); + } catch (Exception e) { + return objectMapper.nullNode(); + } + } + return objectMapper.nullNode(); + } + + private void validateRecruitmentExists(Long recruitmentId) { + if (!recruitmentRepository.existsById(recruitmentId)) { + throw new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND); + } + } + + private Applicant findApplicantForEval(Long applicantId) { + return applicantRepository.findById(applicantId) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + } + + private Applicant findSubmittedApplicantForEval(Long applicantId) { + Applicant applicant = findApplicantForEval(applicantId); + if (applicant.getStatus() != Applicant.ApplicantStatus.SUBMITTED) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + return applicant; + } + + // 전 부문 조회/평가 권한 = 차기 대표진 (role SUPER && teamName 차기대표진) + private boolean isAllTrackViewer(Admin admin) { + return admin.getRole() == Admin.Role.SUPER && admin.getTeamName() == Admin.TeamName.차기대표진; + } + + // 최종 평가 수정 권한 = 현재 대표진 또는 차기 대표진 (role SUPER 필수) + private void validateFinalDecisionAuthority(Admin admin) { + boolean authorized = admin.getRole() == Admin.Role.SUPER + && (admin.getTeamName() == Admin.TeamName.대표진 + || admin.getTeamName() == Admin.TeamName.차기대표진); + if (!authorized) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + } + + // 부문 접근 검증 — 차기 대표진은 전 부문 허용, 그 외(현재 대표진 포함)는 본인 track 지원자만 + private void validateTrackAccess(Admin admin, Applicant applicant) { + if (!isAllTrackViewer(admin) && admin.getTrack() != applicant.getTrack()) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + } + + // minor_double_major(JSON 문자열) → List + private List parseMinorDoubleMajor(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return null; + } + } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f83dbab..129ad48 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -89,7 +89,7 @@ swagger: server-url: "https://dev.bigdataboaz.com" cors: - allowed-origins: "http://localhost:5173,http://localhost:5174,https://boazwebv1.vercel.app,https://dev.bigdataboaz.com" + allowed-origins: "http://localhost:5173,http://localhost:5174,https://boazwebv1.vercel.app,https://dev.bigdataboaz.com,https://admin.bigdataboaz.com" jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9ddd9f0..d9ca806 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -90,7 +90,7 @@ swagger: password: ${SWAGGER_PASSWORD} cors: - allowed-origins: "https://www.bigdataboaz.com,https://dev.bigdataboaz.com,https://api.bigdataboaz.com" + allowed-origins: "https://www.bigdataboaz.com,https://dev.bigdataboaz.com,https://api.bigdataboaz.com,https://admin.bigdataboaz.com" jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index e189ba4..b061e99 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -10,9 +10,11 @@ TRUNCATE application_question; TRUNCATE archive; TRUNCATE applicants; TRUNCATE applicant_answer; +TRUNCATE applicant_eval; TRUNCATE curriculum; TRUNCATE faq; TRUNCATE review; +TRUNCATE subscription; -- 외래 키 체크 재활성화 SET FOREIGN_KEY_CHECKS = 1; @@ -20,11 +22,29 @@ SET FOREIGN_KEY_CHECKS = 1; -- 임시 데이터 -- admin 임시 데이터 (비밀번호: Boaz1234!) -INSERT INTO admin (username, password, role, name, track, term, team_name, created_by, created_at, updated_at) -VALUES ('boaz_super', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'SUPER', '김보아즈', 'ENGINEERING', 27, '대표진', NULL, NOW(), NOW()); +-- id=1: 현재 대표진(ENGINEERING) — 본인 부문만 조회/평가/최종결정 +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (1, 'boaz_super', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'SUPER', '김보아즈', 'ENGINEERING', 27, '대표진', NULL, NOW(), NOW()); -INSERT INTO admin (username, password, role, name, track, term, team_name, created_by, created_at, updated_at) -VALUES ('boaz_team', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'TEAM', '이보아즈', 'ANALYSIS', 27, '서비스운영팀', 1, NOW(), NOW()); +-- id=2: 서비스운영팀 TEAM(ANALYSIS) — 본인 부문 평가자 +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (2, 'boaz_team', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'TEAM', '이보아즈', 'ANALYSIS', 27, '서비스운영팀', 1, NOW(), NOW()); + +-- id=3: 차기 대표진(SUPER + 차기대표진) — 전 부문 조회/평가/최종결정 권한 +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (3, 'boaz_next_rep', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'SUPER', '최차기', 'ENGINEERING', 27, '차기대표진', 1, NOW(), NOW()); + +-- id=4: ENGINEERING 부문 평가자(TEAM) +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (4, 'boaz_eng_eval', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'TEAM', '박엔지', 'ENGINEERING', 27, '서비스운영팀', 1, NOW(), NOW()); + +-- id=5: ANALYSIS 부문 평가자(TEAM) +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (5, 'boaz_ana_eval', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'TEAM', '정분석', 'ANALYSIS', 27, '서비스운영팀', 1, NOW(), NOW()); + +-- id=6: VISUALIZATION 부문 평가자(TEAM) +INSERT INTO admin (id, username, password, role, name, track, term, team_name, created_by, created_at, updated_at) +VALUES (6, 'boaz_vis_eval', '$2a$10$7GogA3bv05pd830JHPishOFHtneEqmpZgE2s9fZuoARQxDZGg6N.y', 'TEAM', '한시각', 'VISUALIZATION', 27, '서비스운영팀', 1, NOW(), NOW()); -- users 임시 데이터 INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) @@ -39,6 +59,25 @@ VALUES (3, 'kakao', 'dummy_kakao_003', '박시각', 'OUTSIDER', NOW(), NOW()); INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) VALUES (4, 'kakao', 'dummy_kakao_004', '스웨거테스터', 'OUTSIDER', NOW(), NOW()); +-- CSV 추출 필터(decision=PASS/FAIL/ALL) 테스트용 user (id=5~10) +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (5, 'kakao', 'dummy_kakao_005', '합격엔지', 'OUTSIDER', NOW(), NOW()); + +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (6, 'kakao', 'dummy_kakao_006', '불합격엔지', 'OUTSIDER', NOW(), NOW()); + +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (7, 'kakao', 'dummy_kakao_007', '합격분석', 'OUTSIDER', NOW(), NOW()); + +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (8, 'kakao', 'dummy_kakao_008', '불합격분석', 'OUTSIDER', NOW(), NOW()); + +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (9, 'kakao', 'dummy_kakao_009', '합격시각', 'OUTSIDER', NOW(), NOW()); + +INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at) +VALUES (10, 'kakao', 'dummy_kakao_010', '불합격시각', 'OUTSIDER', NOW(), NOW()); + -- recruitment 임시 데이터 -- id=1: 26기 INSERT INTO recruitment (id, term, start_date, end_date, schedule, brochure_url, created_at, updated_at) @@ -168,15 +207,36 @@ VALUES (14, 25, 'ACTIVITY', '26기 신입기수 OT', NULL, 'ALL', 'https://examp -- applicant 임시 데이터 -- user OneToOne 적용: user_id=1~3 각 트랙 1명씩 --- status=SUBMITTED, submitted_at 설정 -INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, submitted_at, created_at, updated_at) -VALUES (1, 1, 1, 'SUBMITTED', 'ENGINEERING', '김엔지', 'eng@example.com', '01011111111', '한국대학교', '컴퓨터공학', '["통계학"]', 7, 'COMPLETED_OR_EXEMPT', '2002-01-01', '2026-08', false, '2026-03-14 22:19:27', '2026-03-14 22:19:27', '2026-03-14 22:19:27'); +-- status=SUBMITTED, submitted_at 설정, final_decision 명시(NOT NULL, DB 기본값 없음) +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (1, 1, 1, 'SUBMITTED', 'ENGINEERING', '김엔지', 'eng@example.com', '01011111111', '한국대학교', '컴퓨터공학', '["통계학"]', 7, 'COMPLETED_OR_EXEMPT', '2002-01-01', '2026-08', false, 'PENDING', '2026-03-14 22:19:27', '2026-03-14 22:19:27', '2026-03-14 22:19:27'); + +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (3, 1, 2, 'SUBMITTED', 'ANALYSIS', '이분석', 'ana@example.com', '01022222222', '한국대학교', '통계학', null, 7, 'COMPLETED_OR_EXEMPT', '2002-01-01', '2026-08', false, 'PASS', '2026-03-14 22:21:18', '2026-03-14 22:21:18', '2026-03-14 22:21:18'); + +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (4, 1, 3, 'SUBMITTED', 'VISUALIZATION', '박시각', 'vis@example.com', '01033333333', '한국대학교', '시각디자인', null, 7, 'NOT_COMPLETED', '2002-01-01', '2026-08', false, 'PENDING', '2026-03-14 22:21:58', '2026-03-14 22:21:58', '2026-03-14 22:21:58'); + +-- CSV 추출 필터 테스트용 지원자 (recruitment_id=1, term 26) +-- 각 부문별로 final_decision=PASS 1명 + FAIL 1명을 두어, decision=PASS/FAIL/ALL 추출 결과가 명확히 구분되도록 함 +-- (기존 id=1 ENG=PENDING, id=3 ANA=PASS, id=4 VIS=PENDING 는 그대로 유지) +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (5, 1, 5, 'SUBMITTED', 'ENGINEERING', '합격엔지', 'eng_pass@example.com', '01051110001', '한국대학교', '컴퓨터공학', null, 8, 'COMPLETED_OR_EXEMPT', '2001-05-10', '2026-02', false, 'PASS', '2026-03-15 09:00:00', '2026-03-15 09:00:00', '2026-03-15 09:00:00'); + +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (6, 1, 6, 'SUBMITTED', 'ENGINEERING', '불합격엔지', 'eng_fail@example.com', '01051110002', '한국대학교', '소프트웨어', null, 6, 'NOT_COMPLETED', '2002-07-22', '2026-08', false, 'FAIL', '2026-03-15 09:10:00', '2026-03-15 09:10:00', '2026-03-15 09:10:00'); + +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (7, 1, 7, 'SUBMITTED', 'ANALYSIS', '합격분석', 'ana_pass@example.com', '01052220001', '한국대학교', '통계학', '["수학"]', 7, 'COMPLETED_OR_EXEMPT', '2001-11-03', '2026-02', true, 'PASS', '2026-03-15 09:20:00', '2026-03-15 09:20:00', '2026-03-15 09:20:00'); -INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, submitted_at, created_at, updated_at) -VALUES (3, 1, 2, 'SUBMITTED', 'ANALYSIS', '이분석', 'ana@example.com', '01022222222', '한국대학교', '통계학', null, 7, 'COMPLETED_OR_EXEMPT', '2002-01-01', '2026-08', false, '2026-03-14 22:21:18', '2026-03-14 22:21:18', '2026-03-14 22:21:18'); +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (8, 1, 8, 'SUBMITTED', 'ANALYSIS', '불합격분석', 'ana_fail@example.com', '01052220002', '한국대학교', '경제학', null, 5, 'NOT_COMPLETED', '2003-02-14', '2027-02', false, 'FAIL', '2026-03-15 09:30:00', '2026-03-15 09:30:00', '2026-03-15 09:30:00'); -INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, submitted_at, created_at, updated_at) -VALUES (4, 1, 3, 'SUBMITTED', 'VISUALIZATION', '박시각', 'vis@example.com', '01033333333', '한국대학교', '시각디자인', null, 7, 'NOT_COMPLETED', '2002-01-01', '2026-08', false, '2026-03-14 22:21:58', '2026-03-14 22:21:58', '2026-03-14 22:21:58'); +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (9, 1, 9, 'SUBMITTED', 'VISUALIZATION', '합격시각', 'vis_pass@example.com', '01053330001', '한국대학교', '시각디자인', null, 8, 'COMPLETED_OR_EXEMPT', '2001-08-30', '2026-02', false, 'PASS', '2026-03-15 09:40:00', '2026-03-15 09:40:00', '2026-03-15 09:40:00'); + +INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at) +VALUES (10, 1, 10, 'SUBMITTED', 'VISUALIZATION', '불합격시각', 'vis_fail@example.com', '01053330002', '한국대학교', '산업디자인', null, 6, 'NOT_COMPLETED', '2002-04-18', '2026-08', false, 'FAIL', '2026-03-15 09:50:00', '2026-03-15 09:50:00', '2026-03-15 09:50:00'); -- applicant_answer 임시 데이터 (id=1, ENGINEERING) INSERT INTO applicant_answer (applicant_id, question_id, answer_text, answer_json, created_at, updated_at) @@ -226,6 +286,26 @@ VALUES (4, 5, 'VISUALIZATION2답변', null, NOW(), NOW()); INSERT INTO applicant_answer (applicant_id, question_id, answer_text, answer_json, created_at, updated_at) VALUES (4, 3, 'COMMON5답변', null, NOW(), NOW()); +-- applicant_eval 임시 데이터 (지원서 평가 #170) +-- 평가자 풀: 해당 부문 평가자 + 차기 대표진(전 부문). 미평가자(예: 차기대표진 id=3)는 조회 시 null로 표시됨. +-- 지원자 1(ENGINEERING): 부문 평가자 = id 1(현재 대표진/ENG), id 4(ENG 평가자), id 3(차기대표진/미평가) +INSERT INTO applicant_eval (applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) +VALUES (1, 4, 'PASS', 9, '기술 이해도가 높고 프로젝트 경험이 적절함.', '추천 시스템 프로젝트에서 콜드스타트 문제를 어떻게 해결했나요?', NOW(), NOW()); + +INSERT INTO applicant_eval (applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) +VALUES (1, 1, 'HOLD', 6, '포트폴리오는 좋으나 면접에서 확인 필요.', null, NOW(), NOW()); + +-- 지원자 3(ANALYSIS): 부문 평가자 = id 2(ANALYSIS 운영진), id 5(ANALYSIS 평가자), id 3(차기대표진/미평가) +INSERT INTO applicant_eval (applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) +VALUES (3, 5, 'PASS', 8, '데이터 분석 프로젝트 경험이 풍부함.', '협업 필터링 구현 과정에서 가장 어려웠던 점은 무엇이었나요?', NOW(), NOW()); + +INSERT INTO applicant_eval (applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) +VALUES (3, 2, 'FAIL', 3, '기초 통계 이해가 부족해 보임.', null, NOW(), NOW()); + +-- 지원자 4(VISUALIZATION): 부문 평가자 = id 6(VIS 평가자), id 3(차기대표진/미평가) +INSERT INTO applicant_eval (applicant_id, admin_id, decision, score, memo, interview_question, created_at, updated_at) +VALUES (4, 6, 'HOLD', 5, '시각화 도구 경험이 보통 수준.', '인터랙티브 대시보드를 만든다면 어떤 도구를 선택하겠습니까?', NOW(), NOW()); + -- Curriculum 임시데이터 INSERT INTO curriculum (track, curriculum_steps, created_at, updated_at) VALUES ('ANALYSIS', '[ diff --git a/src/test/java/com/boaz/backend/domain/admin/controller/AdminControllerTest.java b/src/test/java/com/boaz/backend/domain/admin/controller/AdminControllerTest.java new file mode 100644 index 0000000..3e64505 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/admin/controller/AdminControllerTest.java @@ -0,0 +1,424 @@ +package com.boaz.backend.domain.admin.controller; + +import com.boaz.backend.domain.admin.dto.response.AdminAccountResponse; +import com.boaz.backend.domain.admin.dto.response.AdminIdResponse; +import com.boaz.backend.domain.admin.dto.response.AdminMeResponse; +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.service.AdminService; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import com.boaz.backend.global.config.JacksonConfig; +import com.boaz.backend.global.security.AdminUserDetails; +import com.boaz.backend.global.security.UserPrincipal; +import com.boaz.backend.support.TestSecurityConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@WebMvcTest( + value = AdminController.class, + excludeAutoConfiguration = {SecurityAutoConfiguration.class, SecurityFilterAutoConfiguration.class} +) +@Import({TestSecurityConfig.class, JacksonConfig.class}) +class AdminControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean AdminService adminService; + + private static final String CREATE_BODY = + "{\"username\":\"boaz_team2\",\"password\":\"Boaz1234!\",\"role\":\"TEAM\"," + + "\"name\":\"김보아즈\",\"track\":\"ANALYSIS\",\"term\":25,\"team_name\":\"기획팀\"}"; + + private Admin admin(Long id, Admin.Role role) { + Admin a = Admin.builder() + .username("user" + id).password("ENC").role(role).name("name" + id) + .track(Track.ANALYSIS).term(25).teamName(Admin.TeamName.기획팀).createdBy(null) + .build(); + ReflectionTestUtils.setField(a, "id", id); + return a; + } + + private UsernamePasswordAuthenticationToken adminAuth(Admin.Role role) { + AdminUserDetails principal = new AdminUserDetails(admin(1L, role)); + return new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + } + + private UsernamePasswordAuthenticationToken adminAuth() { + return adminAuth(Admin.Role.SUPER); + } + + private UsernamePasswordAuthenticationToken userAuth() { + return new UsernamePasswordAuthenticationToken( + new UserPrincipal(1L), null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + // ────────────────────────────────────────────── + // ADMIN-001: GET /api/v1/admin/accounts + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-001 GET /api/v1/admin/accounts") + class GetAccounts { + + @Test + @DisplayName("TC-004 SUPER 정상 조회 → 200 + 목록") + void success() throws Exception { + when(adminService.getAccounts(any())) + .thenReturn(List.of(AdminAccountResponse.from(admin(1L, Admin.Role.SUPER)), + AdminAccountResponse.from(admin(2L, Admin.Role.TEAM)))); + + mockMvc.perform(get("/api/v1/admin/accounts").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + @DisplayName("EX-002 TEAM 호출 → 서비스 UNAUTHORIZED → 401") + void teamForbidden() throws Exception { + when(adminService.getAccounts(any())).thenThrow(new CustomException(ErrorCode.UNAUTHORIZED)); + + mockMvc.perform(get("/api/v1/admin/accounts").with(authentication(adminAuth(Admin.Role.TEAM)))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("UNAUTHORIZED")); + } + + @Test + @DisplayName("TC-005 미인증 → 401 TOKEN_NOT_FOUND") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/accounts")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403 ACCESS_DENIED") + void forbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/accounts").with(authentication(userAuth()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ACCESS_DENIED")); + } + } + + // ────────────────────────────────────────────── + // ADMIN-002: POST /api/v1/admin/accounts + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-002 POST /api/v1/admin/accounts") + class CreateAccount { + + @Test + @DisplayName("TC-005 정상 생성 → 201 + id") + void success() throws Exception { + when(adminService.createAccount(any(), any())).thenReturn(new AdminIdResponse(13L)); + + mockMvc.perform(post("/api/v1/admin/accounts") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_BODY)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.data.id").value(13)); + } + + @Test + @DisplayName("TC-006 비밀번호 규칙 위반(@Pattern) → 400 INVALID_INPUT_VALUE") + void invalidPassword() throws Exception { + String body = CREATE_BODY.replace("Boaz1234!", "1234"); + + mockMvc.perform(post("/api/v1/admin/accounts") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("EX-003 username 중복 → 409 DUPLICATE_USERNAME") + void duplicateUsername() throws Exception { + when(adminService.createAccount(any(), any())) + .thenThrow(new CustomException(ErrorCode.DUPLICATE_USERNAME)); + + mockMvc.perform(post("/api/v1/admin/accounts") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_BODY)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("DUPLICATE_USERNAME")); + } + + @Test + @DisplayName("TC-007 미인증 → 401 TOKEN_NOT_FOUND") + void unauthorized() throws Exception { + mockMvc.perform(post("/api/v1/admin/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + } + + // ────────────────────────────────────────────── + // ADMIN-007: GET /api/v1/admin/accounts/me + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-007 GET /api/v1/admin/accounts/me") + class GetMe { + + @Test + @DisplayName("TC-003 인증 주체 → 200 + {id, team_name, name}") + void success() throws Exception { + when(adminService.getMe(any())) + .thenReturn(AdminMeResponse.builder().id(1L).teamName("대표진").name("문혁준").build()); + + mockMvc.perform(get("/api/v1/admin/accounts/me").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.team_name").value("대표진")) + .andExpect(jsonPath("$.data.name").value("문혁준")) + .andExpect(jsonPath("$.data.password").doesNotExist()) + .andExpect(jsonPath("$.data.role").doesNotExist()); + } + + @Test + @DisplayName("TEAM 도 호출 가능 → 200") + void teamAllowed() throws Exception { + when(adminService.getMe(any())) + .thenReturn(AdminMeResponse.builder().id(5L).teamName("기획팀").name("홍길동").build()); + + mockMvc.perform(get("/api/v1/admin/accounts/me").with(authentication(adminAuth(Admin.Role.TEAM)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(5)); + } + + @Test + @DisplayName("TC-004 미인증 → 401 TOKEN_NOT_FOUND") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/accounts/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + } + + // ────────────────────────────────────────────── + // ADMIN-003: GET /api/v1/admin/accounts/{id} + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-003 GET /api/v1/admin/accounts/{id}") + class GetAccount { + + @Test + @DisplayName("정상 조회 → 200") + void success() throws Exception { + when(adminService.getAccount(eq(2L), any())) + .thenReturn(AdminAccountResponse.from(admin(2L, Admin.Role.TEAM))); + + mockMvc.perform(get("/api/v1/admin/accounts/2").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(2)); + } + + @Test + @DisplayName("TC-005 id 타입 불일치 → 400 INVALID_PARAMETER_TYPE") + void typeMismatch() throws Exception { + mockMvc.perform(get("/api/v1/admin/accounts/abc").with(authentication(adminAuth()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_PARAMETER_TYPE")); + } + + @Test + @DisplayName("EX-004 존재하지 않는 계정 → 404 ADMIN_NOT_FOUND") + void notFound() throws Exception { + when(adminService.getAccount(eq(999L), any())) + .thenThrow(new CustomException(ErrorCode.ADMIN_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/accounts/999").with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("ADMIN_NOT_FOUND")); + } + + @Test + @DisplayName("EX-002 TEAM 타 계정 → 서비스 UNAUTHORIZED → 401") + void teamOther() throws Exception { + when(adminService.getAccount(eq(2L), any())) + .thenThrow(new CustomException(ErrorCode.UNAUTHORIZED)); + + mockMvc.perform(get("/api/v1/admin/accounts/2").with(authentication(adminAuth(Admin.Role.TEAM)))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("UNAUTHORIZED")); + } + + @Test + @DisplayName("EX-001 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/accounts/2")) + .andExpect(status().isUnauthorized()); + } + } + + // ────────────────────────────────────────────── + // ADMIN-004: PATCH /api/v1/admin/accounts/{id} + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-004 PATCH /api/v1/admin/accounts/{id}") + class UpdateAccount { + + @Test + @DisplayName("TC-009 정상 수정 → 200 + id") + void success() throws Exception { + when(adminService.updateAccount(eq(2L), any(), any())).thenReturn(new AdminIdResponse(2L)); + + mockMvc.perform(patch("/api/v1/admin/accounts/2") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"새이름\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(2)); + } + + @Test + @DisplayName("EX-002 본인 role 변경 → 403 CANNOT_MODIFY_OWN_ROLE") + void cannotModifyOwnRole() throws Exception { + when(adminService.updateAccount(eq(1L), any(), any())) + .thenThrow(new CustomException(ErrorCode.CANNOT_MODIFY_OWN_ROLE)); + + mockMvc.perform(patch("/api/v1/admin/accounts/1") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"TEAM\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("CANNOT_MODIFY_OWN_ROLE")); + } + + @Test + @DisplayName("EX-008 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(patch("/api/v1/admin/accounts/2") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + } + + // ────────────────────────────────────────────── + // ADMIN-005: DELETE /api/v1/admin/accounts/{id} + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-005 DELETE /api/v1/admin/accounts/{id}") + class DeleteAccount { + + @Test + @DisplayName("TC-006 정상 삭제 → 200 + data null") + void success() throws Exception { + doNothing().when(adminService).deleteAccount(eq(2L), any()); + + mockMvc.perform(delete("/api/v1/admin/accounts/2").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("EX-002 마지막 SUPER 삭제 → 400 LAST_SUPER_ACCOUNT") + void lastSuper() throws Exception { + doThrow(new CustomException(ErrorCode.LAST_SUPER_ACCOUNT)) + .when(adminService).deleteAccount(eq(1L), any()); + + mockMvc.perform(delete("/api/v1/admin/accounts/1").with(authentication(adminAuth()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("LAST_SUPER_ACCOUNT")); + } + + @Test + @DisplayName("EX-005 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(delete("/api/v1/admin/accounts/2")) + .andExpect(status().isUnauthorized()); + } + } + + // ────────────────────────────────────────────── + // ADMIN-006: PATCH /api/v1/admin/accounts/{id}/password + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-006 PATCH /api/v1/admin/accounts/{id}/password") + class ResetPassword { + + @Test + @DisplayName("TC-008 정상 변경 → 200 + data null") + void success() throws Exception { + doNothing().when(adminService).resetPassword(eq(2L), any(), any()); + + mockMvc.perform(patch("/api/v1/admin/accounts/2/password") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"new_password\":\"NewBoaz1234!\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)); + } + + @Test + @DisplayName("TC-007 newPassword 규칙 위반(@Pattern) → 400 INVALID_INPUT_VALUE") + void invalidNewPassword() throws Exception { + mockMvc.perform(patch("/api/v1/admin/accounts/2/password") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"new_password\":\"1234\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("EX-003 본인 변경 currentPassword 불일치 → 401 INVALID_CURRENT_PASSWORD") + void invalidCurrentPassword() throws Exception { + doThrow(new CustomException(ErrorCode.INVALID_CURRENT_PASSWORD)) + .when(adminService).resetPassword(eq(1L), any(), any()); + + mockMvc.perform(patch("/api/v1/admin/accounts/1/password") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"current_password\":\"wrong\",\"new_password\":\"NewBoaz1234!\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("INVALID_CURRENT_PASSWORD")); + } + + @Test + @DisplayName("EX-007 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(patch("/api/v1/admin/accounts/2/password") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"new_password\":\"NewBoaz1234!\"}")) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/admin/integration/AdminIntegrationTest.java b/src/test/java/com/boaz/backend/domain/admin/integration/AdminIntegrationTest.java new file mode 100644 index 0000000..1aa7d82 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/admin/integration/AdminIntegrationTest.java @@ -0,0 +1,267 @@ +package com.boaz.backend.domain.admin.integration; + +import com.boaz.backend.domain.admin.dto.request.AdminCreateRequest; +import com.boaz.backend.domain.admin.dto.request.AdminPasswordResetRequest; +import com.boaz.backend.domain.admin.dto.request.AdminUpdateRequest; +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.repository.AdminRepository; +import com.boaz.backend.domain.admin.service.AdminService; +import com.boaz.backend.domain.auth.entity.RefreshToken; +import com.boaz.backend.domain.auth.repository.RefreshTokenRepository; +import com.boaz.backend.global.common.enums.AccountType; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import com.boaz.backend.support.TestcontainersBase; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class AdminIntegrationTest extends TestcontainersBase { + + @Autowired AdminService adminService; + @Autowired AdminRepository adminRepository; + @Autowired RefreshTokenRepository refreshTokenRepository; + @Autowired PasswordEncoder passwordEncoder; + + @PersistenceContext EntityManager em; + + private int seq = 0; + + private Admin saveAdmin(Admin.Role role, String rawPassword) { + Admin a = Admin.builder() + .username("user" + (++seq)).password(passwordEncoder.encode(rawPassword)).role(role) + .name("name" + seq).track(Track.ANALYSIS).term(25).teamName(Admin.TeamName.기획팀).createdBy(null) + .build(); + return adminRepository.save(a); + } + + private void saveRefreshToken(Long adminId) { + refreshTokenRepository.save(RefreshToken.builder() + .accountId(adminId).accountType(AccountType.ADMIN) + .token("token-" + adminId).expiresAt(LocalDateTime.now().plusDays(1)) + .build()); + } + + private AdminCreateRequest createReq(String username) { + AdminCreateRequest req = new AdminCreateRequest(); + ReflectionTestUtils.setField(req, "username", username); + ReflectionTestUtils.setField(req, "password", "Boaz1234!"); + ReflectionTestUtils.setField(req, "role", Admin.Role.TEAM); + ReflectionTestUtils.setField(req, "name", "김보아즈"); + ReflectionTestUtils.setField(req, "track", Track.ANALYSIS); + ReflectionTestUtils.setField(req, "term", 25); + ReflectionTestUtils.setField(req, "teamName", Admin.TeamName.기획팀); + return req; + } + + @Nested + @DisplayName("계정 생성 end-to-end (ADMIN-002)") + class CreateAccount { + + @Test + @DisplayName("생성 → DB 영속, password BCrypt 해시, createdBy = 생성자 id") + void persistsWithHashedPassword() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + em.flush(); + em.clear(); + + var res = adminService.createAccount(createReq("new_team"), superAdmin); + em.flush(); + em.clear(); + + Admin saved = adminRepository.findById(res.getId()).orElseThrow(); + assertThat(saved.getPassword()).isNotEqualTo("Boaz1234!"); + assertThat(passwordEncoder.matches("Boaz1234!", saved.getPassword())).isTrue(); + assertThat(saved.getCreatedBy()).isEqualTo(superAdmin.getId()); + } + + @Test + @DisplayName("동일 username 재생성 → DUPLICATE_USERNAME") + void duplicateUsername() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + adminService.createAccount(createReq("dup_team"), superAdmin); + em.flush(); + em.clear(); + + assertThatThrownBy(() -> adminService.createAccount(createReq("dup_team"), superAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATE_USERNAME); + } + } + + @Nested + @DisplayName("계정 수정 end-to-end (ADMIN-004)") + class UpdateAccount { + + @Test + @DisplayName("role 변경 → 영속 반영 + 대상 RefreshToken 삭제") + void roleChangeDeletesToken() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + Admin target = saveAdmin(Admin.Role.TEAM, "Team1234!"); + saveRefreshToken(target.getId()); + em.flush(); + em.clear(); + + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "role", JsonNullable.of(Admin.Role.SUPER)); + adminService.updateAccount(target.getId(), req, superAdmin); + em.flush(); + em.clear(); + + assertThat(adminRepository.findById(target.getId()).orElseThrow().getRole()) + .isEqualTo(Admin.Role.SUPER); + assertThat(refreshTokenRepository.findByAccountTypeAndAccountId(AccountType.ADMIN, target.getId())) + .isEmpty(); + } + + @Test + @DisplayName("프로필 필드만 수정 → 대상 RefreshToken 유지") + void profileOnlyKeepsToken() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + Admin target = saveAdmin(Admin.Role.TEAM, "Team1234!"); + saveRefreshToken(target.getId()); + em.flush(); + em.clear(); + + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "name", JsonNullable.of("변경된이름")); + adminService.updateAccount(target.getId(), req, superAdmin); + em.flush(); + em.clear(); + + assertThat(adminRepository.findById(target.getId()).orElseThrow().getName()) + .isEqualTo("변경된이름"); + assertThat(refreshTokenRepository.findByAccountTypeAndAccountId(AccountType.ADMIN, target.getId())) + .isPresent(); + } + } + + @Nested + @DisplayName("계정 삭제 end-to-end (ADMIN-005)") + class DeleteAccount { + + @Test + @DisplayName("soft delete → deletedAt 세팅, 목록 제외, RefreshToken 삭제") + void softDeleteExcludesAndDeletesToken() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + Admin target = saveAdmin(Admin.Role.TEAM, "Team1234!"); + saveRefreshToken(target.getId()); + em.flush(); + em.clear(); + + adminService.deleteAccount(target.getId(), superAdmin); + em.flush(); + em.clear(); + + // 레코드는 물리 존재하지만 deletedAt 세팅됨 + Admin reloaded = adminRepository.findById(target.getId()).orElseThrow(); + assertThat(reloaded.isDeleted()).isTrue(); + // 활성 목록(findAll...DeletedAtIsNull)에서 제외 + assertThat(adminRepository.findByIdAndDeletedAtIsNull(target.getId())).isEmpty(); + assertThat(adminRepository.findAllByDeletedAtIsNullOrderByCreatedAtAsc()) + .extracting(Admin::getId).doesNotContain(target.getId()); + // RefreshToken 삭제 + assertThat(refreshTokenRepository.findByAccountTypeAndAccountId(AccountType.ADMIN, target.getId())) + .isEmpty(); + } + + @Test + @DisplayName("마지막 SUPER 삭제 → LAST_SUPER_ACCOUNT (삭제 안 됨)") + void lastSuperBlocked() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + em.flush(); + em.clear(); + + assertThatThrownBy(() -> adminService.deleteAccount(superAdmin.getId(), superAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.LAST_SUPER_ACCOUNT); + assertThat(adminRepository.findByIdAndDeletedAtIsNull(superAdmin.getId())).isPresent(); + } + } + + @Nested + @DisplayName("비밀번호 변경 end-to-end (ADMIN-006)") + class ResetPassword { + + @Test + @DisplayName("SUPER 타인 초기화 → 해시 변경(matches new) + RefreshToken 삭제") + void superResetsOther() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + Admin target = saveAdmin(Admin.Role.TEAM, "Team1234!"); + saveRefreshToken(target.getId()); + em.flush(); + em.clear(); + + AdminPasswordResetRequest req = new AdminPasswordResetRequest(); + ReflectionTestUtils.setField(req, "newPassword", "NewBoaz1234!"); + adminService.resetPassword(target.getId(), req, superAdmin); + em.flush(); + em.clear(); + + Admin reloaded = adminRepository.findById(target.getId()).orElseThrow(); + assertThat(passwordEncoder.matches("NewBoaz1234!", reloaded.getPassword())).isTrue(); + assertThat(refreshTokenRepository.findByAccountTypeAndAccountId(AccountType.ADMIN, target.getId())) + .isEmpty(); + } + + @Test + @DisplayName("본인 변경 (currentPassword 일치) → 해시 변경") + void selfChange() { + Admin self = saveAdmin(Admin.Role.TEAM, "Team1234!"); + saveRefreshToken(self.getId()); + em.flush(); + em.clear(); + + Admin currentAdmin = adminRepository.findById(self.getId()).orElseThrow(); + AdminPasswordResetRequest req = new AdminPasswordResetRequest(); + ReflectionTestUtils.setField(req, "currentPassword", "Team1234!"); + ReflectionTestUtils.setField(req, "newPassword", "NewBoaz1234!"); + adminService.resetPassword(self.getId(), req, currentAdmin); + em.flush(); + em.clear(); + + assertThat(passwordEncoder.matches("NewBoaz1234!", + adminRepository.findById(self.getId()).orElseThrow().getPassword())).isTrue(); + } + } + + @Nested + @DisplayName("모든 계정 조회 end-to-end (ADMIN-001)") + class GetAccounts { + + @Test + @DisplayName("soft delete 제외하고 활성 계정만 반환") + void excludesDeleted() { + Admin superAdmin = saveAdmin(Admin.Role.SUPER, "Super1234!"); + Admin active = saveAdmin(Admin.Role.TEAM, "Team1234!"); + Admin deleted = saveAdmin(Admin.Role.TEAM, "Team1234!"); + deleted.softDelete(); + em.flush(); + em.clear(); + + var result = adminService.getAccounts(superAdmin); + + assertThat(result).extracting("id") + .contains(superAdmin.getId(), active.getId()) + .doesNotContain(deleted.getId()); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/admin/repository/AdminRepositoryTest.java b/src/test/java/com/boaz/backend/domain/admin/repository/AdminRepositoryTest.java new file mode 100644 index 0000000..6b54393 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/admin/repository/AdminRepositoryTest.java @@ -0,0 +1,198 @@ +package com.boaz.backend.domain.admin.repository; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.config.JpaConfig; +import com.boaz.backend.support.TestcontainersBase; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaConfig.class) +@ActiveProfiles("test") +class AdminRepositoryTest extends TestcontainersBase { + + @Autowired AdminRepository adminRepository; + @Autowired TestEntityManager em; + + private Admin persistAdmin(String username, Admin.Role role, Track track, + String name, Admin.TeamName team) { + Admin a = Admin.builder() + .username(username).password("ENC").role(role).name(name) + .track(track).term(25).teamName(team).createdBy(null) + .build(); + return em.persistFlushFind(a); + } + + private Admin persistDeletedAdmin(String username, Admin.Role role, Track track, + String name, Admin.TeamName team) { + Admin a = persistAdmin(username, role, track, name, team); + a.softDelete(); + em.flush(); + return a; + } + + private void setCreatedAt(Long id, String createdAt) { + EntityManager entityManager = em.getEntityManager(); + entityManager.createNativeQuery("UPDATE admin SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, id) + .executeUpdate(); + } + + @Nested + @DisplayName("findByUsernameAndDeletedAtIsNull") + class FindByUsername { + + @Test + @DisplayName("활성 계정만 username 으로 조회, soft delete 된 username 은 empty") + void onlyActive() { + persistAdmin("active", Admin.Role.TEAM, Track.ANALYSIS, "활성", Admin.TeamName.기획팀); + persistDeletedAdmin("deleted", Admin.Role.TEAM, Track.ANALYSIS, "삭제됨", Admin.TeamName.기획팀); + em.clear(); + + assertThat(adminRepository.findByUsernameAndDeletedAtIsNull("active")).isPresent(); + assertThat(adminRepository.findByUsernameAndDeletedAtIsNull("deleted")).isEmpty(); + } + } + + @Nested + @DisplayName("existsByUsernameAndDeletedAtIsNull") + class ExistsByUsername { + + @Test + @DisplayName("활성 username 은 true, soft delete 된 username 은 false (재사용 가능)") + void softDeleteReusable() { + persistAdmin("active", Admin.Role.TEAM, Track.ANALYSIS, "활성", Admin.TeamName.기획팀); + persistDeletedAdmin("recycled", Admin.Role.TEAM, Track.ANALYSIS, "삭제됨", Admin.TeamName.기획팀); + em.clear(); + + assertThat(adminRepository.existsByUsernameAndDeletedAtIsNull("active")).isTrue(); + assertThat(adminRepository.existsByUsernameAndDeletedAtIsNull("recycled")).isFalse(); + assertThat(adminRepository.existsByUsernameAndDeletedAtIsNull("none")).isFalse(); + } + } + + @Nested + @DisplayName("findAllByDeletedAtIsNullOrderByCreatedAtAsc") + class FindAllOrdered { + + @Test + @DisplayName("created_at 오름차순 + soft delete 제외") + void orderedExcludingDeleted() { + Admin a1 = persistAdmin("a1", Admin.Role.SUPER, Track.ANALYSIS, "first", Admin.TeamName.대표진); + Admin a2 = persistAdmin("a2", Admin.Role.TEAM, Track.ANALYSIS, "second", Admin.TeamName.기획팀); + Admin a3 = persistAdmin("a3", Admin.Role.TEAM, Track.ENGINEERING, "third", Admin.TeamName.기획팀); + Admin deleted = persistAdmin("a4", Admin.Role.TEAM, Track.VISUALIZATION, "deleted", Admin.TeamName.기획팀); + + // @CreatedDate 가 동일 트랜잭션에서 같은 값일 수 있어 created_at 을 네이티브로 명시 + setCreatedAt(a1.getId(), "2026-03-01 10:00:00"); + setCreatedAt(a2.getId(), "2026-03-05 10:00:00"); + setCreatedAt(a3.getId(), "2026-03-10 10:00:00"); + setCreatedAt(deleted.getId(), "2026-03-07 10:00:00"); + deleted.softDelete(); + em.flush(); + em.clear(); + + List result = adminRepository.findAllByDeletedAtIsNullOrderByCreatedAtAsc(); + + assertThat(result).extracting(Admin::getName).containsExactly("first", "second", "third"); + } + + @Test + @DisplayName("활성 계정 없음 → 빈 리스트") + void empty() { + assertThat(adminRepository.findAllByDeletedAtIsNullOrderByCreatedAtAsc()).isEmpty(); + } + } + + @Nested + @DisplayName("findByIdAndDeletedAtIsNull") + class FindById { + + @Test + @DisplayName("활성 계정은 반환, soft delete 된 계정은 empty") + void onlyActive() { + Admin active = persistAdmin("active", Admin.Role.TEAM, Track.ANALYSIS, "활성", Admin.TeamName.기획팀); + Admin deleted = persistDeletedAdmin("deleted", Admin.Role.TEAM, Track.ANALYSIS, "삭제됨", Admin.TeamName.기획팀); + em.clear(); + + assertThat(adminRepository.findByIdAndDeletedAtIsNull(active.getId())).isPresent(); + assertThat(adminRepository.findByIdAndDeletedAtIsNull(deleted.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("countByRoleAndDeletedAtIsNull") + class CountByRole { + + @Test + @DisplayName("역할별 활성 계정 수 (soft delete 제외)") + void countExcludingDeleted() { + persistAdmin("s1", Admin.Role.SUPER, Track.ANALYSIS, "super1", Admin.TeamName.대표진); + persistAdmin("s2", Admin.Role.SUPER, Track.ANALYSIS, "super2", Admin.TeamName.대표진); + persistDeletedAdmin("s3", Admin.Role.SUPER, Track.ANALYSIS, "super-deleted", Admin.TeamName.대표진); + persistAdmin("t1", Admin.Role.TEAM, Track.ANALYSIS, "team1", Admin.TeamName.기획팀); + em.clear(); + + assertThat(adminRepository.countByRoleAndDeletedAtIsNull(Admin.Role.SUPER)).isEqualTo(2); + assertThat(adminRepository.countByRoleAndDeletedAtIsNull(Admin.Role.TEAM)).isEqualTo(1); + } + } + + @Nested + @DisplayName("findByTrackAndDeletedAtIsNullOrderByNameAsc") + class FindByTrack { + + @Test + @DisplayName("해당 부문 활성 평가자만 이름 오름차순") + void byTrackOrdered() { + persistAdmin("e1", Admin.Role.TEAM, Track.ENGINEERING, "나", Admin.TeamName.기획팀); + persistAdmin("e2", Admin.Role.TEAM, Track.ENGINEERING, "가", Admin.TeamName.기획팀); + persistAdmin("a1", Admin.Role.TEAM, Track.ANALYSIS, "다", Admin.TeamName.기획팀); + persistDeletedAdmin("e3", Admin.Role.TEAM, Track.ENGINEERING, "라", Admin.TeamName.기획팀); + em.clear(); + + List result = adminRepository.findByTrackAndDeletedAtIsNullOrderByNameAsc(Track.ENGINEERING); + + assertThat(result).extracting(Admin::getName).containsExactly("가", "나"); + } + } + + @Nested + @DisplayName("findEvaluatorPool") + class FindEvaluatorPool { + + @Test + @DisplayName("해당 부문 OR (전부문 권한 role+team) 평가자 풀, 이름순, soft delete 제외") + void pool() { + // 해당 부문(ENGINEERING) + persistAdmin("e1", Admin.Role.TEAM, Track.ENGINEERING, "가", Admin.TeamName.기획팀); + // 전부문 권한자 (role=SUPER AND team=차기대표진) — 본인 track 무관 포함 + persistAdmin("all", Admin.Role.SUPER, Track.ANALYSIS, "나", Admin.TeamName.차기대표진); + // 제외 대상: 다른 부문 + 전부문 권한 아님 + persistAdmin("a1", Admin.Role.TEAM, Track.ANALYSIS, "다", Admin.TeamName.기획팀); + // 제외 대상: ENGINEERING 이지만 soft delete + persistDeletedAdmin("e2", Admin.Role.TEAM, Track.ENGINEERING, "라", Admin.TeamName.기획팀); + em.clear(); + + List result = adminRepository.findEvaluatorPool( + Track.ENGINEERING, Admin.Role.SUPER, Admin.TeamName.차기대표진); + + assertThat(result).extracting(Admin::getName).containsExactly("가", "나"); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/admin/service/AdminServiceTest.java b/src/test/java/com/boaz/backend/domain/admin/service/AdminServiceTest.java new file mode 100644 index 0000000..cd2957b --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/admin/service/AdminServiceTest.java @@ -0,0 +1,547 @@ +package com.boaz.backend.domain.admin.service; + +import com.boaz.backend.domain.admin.dto.request.AdminCreateRequest; +import com.boaz.backend.domain.admin.dto.request.AdminPasswordResetRequest; +import com.boaz.backend.domain.admin.dto.request.AdminUpdateRequest; +import com.boaz.backend.domain.admin.dto.response.AdminAccountResponse; +import com.boaz.backend.domain.admin.dto.response.AdminIdResponse; +import com.boaz.backend.domain.admin.dto.response.AdminMeResponse; +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.repository.AdminRepository; +import com.boaz.backend.domain.auth.repository.RefreshTokenRepository; +import com.boaz.backend.global.common.enums.AccountType; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @InjectMocks AdminService adminService; + + @Mock AdminRepository adminRepository; + @Mock PasswordEncoder passwordEncoder; + @Mock RefreshTokenRepository refreshTokenRepository; + + // ────────────────────────────────────────────── + // 헬퍼 + // ────────────────────────────────────────────── + + private Admin admin(Long id, Admin.Role role) { + return adminWith(id, role, Track.ANALYSIS, "name" + id, Admin.TeamName.기획팀); + } + + private Admin adminWith(Long id, Admin.Role role, Track track, String name, Admin.TeamName team) { + Admin a = Admin.builder() + .username("user" + id).password("OLD_HASH").role(role).name(name) + .track(track).term(25).teamName(team).createdBy(null) + .build(); + ReflectionTestUtils.setField(a, "id", id); + return a; + } + + private AdminCreateRequest createRequest(String username, Track track) { + AdminCreateRequest req = new AdminCreateRequest(); + ReflectionTestUtils.setField(req, "username", username); + ReflectionTestUtils.setField(req, "password", "Boaz1234!"); + ReflectionTestUtils.setField(req, "role", Admin.Role.TEAM); + ReflectionTestUtils.setField(req, "name", "김보아즈"); + ReflectionTestUtils.setField(req, "track", track); + ReflectionTestUtils.setField(req, "term", 25); + ReflectionTestUtils.setField(req, "teamName", Admin.TeamName.기획팀); + return req; + } + + private AdminPasswordResetRequest pwRequest(String current, String newPw) { + AdminPasswordResetRequest req = new AdminPasswordResetRequest(); + ReflectionTestUtils.setField(req, "currentPassword", current); + ReflectionTestUtils.setField(req, "newPassword", newPw); + return req; + } + + // ────────────────────────────────────────────── + // ADMIN-001: getAccounts (모든 계정 조회, SUPER only) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-001 getAccounts") + class GetAccounts { + + @Test + @DisplayName("TC-001 SUPER 조회 → created_at 오름차순 목록 매핑") + void superList() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findAllByDeletedAtIsNullOrderByCreatedAtAsc()) + .thenReturn(List.of(admin(1L, Admin.Role.SUPER), admin(2L, Admin.Role.TEAM))); + + List result = adminService.getAccounts(currentAdmin); + + assertThat(result).hasSize(2); + assertThat(result).extracting(AdminAccountResponse::getId).containsExactly(1L, 2L); + } + + @Test + @DisplayName("TC-002 계정 없음 → 빈 배열 (예외 없음)") + void empty() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findAllByDeletedAtIsNullOrderByCreatedAtAsc()).thenReturn(List.of()); + + assertThat(adminService.getAccounts(currentAdmin)).isEmpty(); + } + + @Test + @DisplayName("TC-003 TEAM 호출 → UNAUTHORIZED (DB 조회 안 함)") + void teamForbidden() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + + assertThatThrownBy(() -> adminService.getAccounts(currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).findAllByDeletedAtIsNullOrderByCreatedAtAsc(); + } + } + + // ────────────────────────────────────────────── + // ADMIN-002: createAccount (계정 생성, SUPER only) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-002 createAccount") + class CreateAccount { + + @Test + @DisplayName("TC-001 SUPER 생성 → 201, password 인코딩 + createdBy 기록") + void success() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + AdminCreateRequest req = createRequest("boaz_team2", Track.ANALYSIS); + when(adminRepository.existsByUsernameAndDeletedAtIsNull("boaz_team2")).thenReturn(false); + when(passwordEncoder.encode("Boaz1234!")).thenReturn("ENCODED"); + when(adminRepository.save(any(Admin.class))).thenAnswer(inv -> { + Admin a = inv.getArgument(0); + ReflectionTestUtils.setField(a, "id", 13L); + return a; + }); + + AdminIdResponse res = adminService.createAccount(req, currentAdmin); + + assertThat(res.getId()).isEqualTo(13L); + ArgumentCaptor captor = ArgumentCaptor.forClass(Admin.class); + verify(adminRepository).save(captor.capture()); + assertThat(captor.getValue().getPassword()).isEqualTo("ENCODED"); + assertThat(captor.getValue().getCreatedBy()).isEqualTo(1L); + } + + @Test + @DisplayName("TC-002 TEAM 호출 → UNAUTHORIZED, save 안 함") + void teamForbidden() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + AdminCreateRequest req = createRequest("boaz_team2", Track.ANALYSIS); + + assertThatThrownBy(() -> adminService.createAccount(req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-003 track=ALL → INVALID_TRACK_SELECTION, save 안 함") + void trackAll() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + AdminCreateRequest req = createRequest("boaz_team2", Track.ALL); + + assertThatThrownBy(() -> adminService.createAccount(req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TRACK_SELECTION); + verify(adminRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-004 username 중복 → DUPLICATE_USERNAME, save 안 함") + void duplicateUsername() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + AdminCreateRequest req = createRequest("boaz_team2", Track.ANALYSIS); + when(adminRepository.existsByUsernameAndDeletedAtIsNull("boaz_team2")).thenReturn(true); + + assertThatThrownBy(() -> adminService.createAccount(req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATE_USERNAME); + verify(adminRepository, never()).save(any()); + } + } + + // ────────────────────────────────────────────── + // ADMIN-007: getMe (내 계정 정보 조회) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-007 getMe") + class GetMe { + + @Test + @DisplayName("TC-001 SUPER 본인 정보 → id/team_name/name 매핑, repository 미상호작용") + void superSelf() { + Admin currentAdmin = adminWith(1L, Admin.Role.SUPER, Track.ANALYSIS, "문혁준", Admin.TeamName.대표진); + + AdminMeResponse res = adminService.getMe(currentAdmin); + + assertThat(res.getId()).isEqualTo(1L); + assertThat(res.getName()).isEqualTo("문혁준"); + assertThat(res.getTeamName()).isEqualTo("대표진"); + verifyNoInteractions(adminRepository, refreshTokenRepository, passwordEncoder); + } + + @Test + @DisplayName("TC-002 TEAM 도 호출 가능 → 본인 정보 반환 (권한 거부 없음)") + void teamAllowed() { + Admin currentAdmin = adminWith(5L, Admin.Role.TEAM, Track.ENGINEERING, "홍길동", Admin.TeamName.기획팀); + + AdminMeResponse res = adminService.getMe(currentAdmin); + + assertThat(res.getId()).isEqualTo(5L); + assertThat(res.getName()).isEqualTo("홍길동"); + assertThat(res.getTeamName()).isEqualTo("기획팀"); + } + } + + // ────────────────────────────────────────────── + // ADMIN-003: getAccount (id별 계정 조회) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-003 getAccount") + class GetAccount { + + @Test + @DisplayName("TC-001 SUPER 가 타 계정 조회 → 반환") + void superOther() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(admin(2L, Admin.Role.TEAM))); + + AdminAccountResponse res = adminService.getAccount(2L, currentAdmin); + + assertThat(res.getId()).isEqualTo(2L); + } + + @Test + @DisplayName("TC-002 TEAM 이 본인 계정 조회 → 반환") + void teamSelf() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(5L)).thenReturn(Optional.of(currentAdmin)); + + AdminAccountResponse res = adminService.getAccount(5L, currentAdmin); + + assertThat(res.getId()).isEqualTo(5L); + } + + @Test + @DisplayName("TC-003 TEAM 이 타 계정 조회 → UNAUTHORIZED (DB 조회 안 함)") + void teamOther() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + + assertThatThrownBy(() -> adminService.getAccount(2L, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).findByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("TC-004 존재하지 않는 계정 → ADMIN_NOT_FOUND") + void notFound() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminService.getAccount(999L, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ADMIN_NOT_FOUND); + } + } + + // ────────────────────────────────────────────── + // ADMIN-004: updateAccount (id별 계정 수정) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-004 updateAccount") + class UpdateAccount { + + @Test + @DisplayName("TC-001 SUPER 프로필 필드만 수정 → 변경, role 미전송 시 토큰 삭제 안 함") + void profileOnly() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + Admin target = admin(2L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(target)); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "name", JsonNullable.of("새이름")); + + AdminIdResponse res = adminService.updateAccount(2L, req, currentAdmin); + + assertThat(res.getId()).isEqualTo(2L); + assertThat(target.getName()).isEqualTo("새이름"); + verify(refreshTokenRepository, never()).deleteByAccountTypeAndAccountId(any(), anyLong()); + } + + @Test + @DisplayName("TC-002 SUPER 가 타 계정 role 변경 → 변경 + RefreshToken 삭제") + void roleChange() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + Admin target = admin(2L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(target)); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "role", JsonNullable.of(Admin.Role.SUPER)); + + adminService.updateAccount(2L, req, currentAdmin); + + assertThat(target.getRole()).isEqualTo(Admin.Role.SUPER); + verify(refreshTokenRepository).deleteByAccountTypeAndAccountId(AccountType.ADMIN, 2L); + } + + @Test + @DisplayName("TC-003 TEAM 이 타 계정 수정 → UNAUTHORIZED (DB 조회 안 함)") + void teamOther() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + AdminUpdateRequest req = new AdminUpdateRequest(); + + assertThatThrownBy(() -> adminService.updateAccount(2L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).findByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("TC-004 TEAM 이 본인에 role 전송 → UNAUTHORIZED (CANNOT_MODIFY_OWN_ROLE 아님, 순서 2>3)") + void teamSelfRole() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "role", JsonNullable.of(Admin.Role.SUPER)); + + assertThatThrownBy(() -> adminService.updateAccount(5L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + } + + @Test + @DisplayName("TC-005 SUPER 가 본인 role 변경 → CANNOT_MODIFY_OWN_ROLE (DB 조회 안 함)") + void superSelfRole() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "role", JsonNullable.of(Admin.Role.TEAM)); + + assertThatThrownBy(() -> adminService.updateAccount(1L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CANNOT_MODIFY_OWN_ROLE); + verify(adminRepository, never()).findByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("TC-006 존재하지 않는 계정 → ADMIN_NOT_FOUND") + void notFound() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + AdminUpdateRequest req = new AdminUpdateRequest(); + + assertThatThrownBy(() -> adminService.updateAccount(999L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ADMIN_NOT_FOUND); + } + + @Test + @DisplayName("TC-007 track=ALL → INVALID_TRACK_SELECTION") + void trackAll() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(admin(2L, Admin.Role.TEAM))); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "track", JsonNullable.of(Track.ALL)); + + assertThatThrownBy(() -> adminService.updateAccount(2L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TRACK_SELECTION); + } + + @Test + @DisplayName("TC-008 term < 0 → INVALID_INPUT_VALUE") + void negativeTerm() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(admin(2L, Admin.Role.TEAM))); + AdminUpdateRequest req = new AdminUpdateRequest(); + ReflectionTestUtils.setField(req, "term", JsonNullable.of(-1)); + + assertThatThrownBy(() -> adminService.updateAccount(2L, req, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + } + + // ────────────────────────────────────────────── + // ADMIN-005: deleteAccount (id별 계정 삭제, soft delete) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-005 deleteAccount") + class DeleteAccount { + + @Test + @DisplayName("TC-001 SUPER 가 타 계정(TEAM) 삭제 → soft delete + RefreshToken 삭제") + void superDeletesOther() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + Admin target = admin(2L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(target)); + + adminService.deleteAccount(2L, currentAdmin); + + assertThat(target.isDeleted()).isTrue(); + verify(refreshTokenRepository).deleteByAccountTypeAndAccountId(AccountType.ADMIN, 2L); + // 대상이 TEAM 이므로 SUPER 카운트 조회 안 함 + verify(adminRepository, never()).countByRoleAndDeletedAtIsNull(any()); + } + + @Test + @DisplayName("TC-002 SUPER 본인 삭제 (다른 SUPER 존재) → 허용") + void superSelfDeleteAllowed() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(currentAdmin)); + when(adminRepository.countByRoleAndDeletedAtIsNull(Admin.Role.SUPER)).thenReturn(2L); + + adminService.deleteAccount(1L, currentAdmin); + + assertThat(currentAdmin.isDeleted()).isTrue(); + verify(refreshTokenRepository).deleteByAccountTypeAndAccountId(AccountType.ADMIN, 1L); + } + + @Test + @DisplayName("TC-003 마지막 SUPER 삭제 시도 → LAST_SUPER_ACCOUNT") + void lastSuper() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(currentAdmin)); + when(adminRepository.countByRoleAndDeletedAtIsNull(Admin.Role.SUPER)).thenReturn(1L); + + assertThatThrownBy(() -> adminService.deleteAccount(1L, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.LAST_SUPER_ACCOUNT); + assertThat(currentAdmin.isDeleted()).isFalse(); + verify(refreshTokenRepository, never()).deleteByAccountTypeAndAccountId(any(), anyLong()); + } + + @Test + @DisplayName("TC-004 TEAM 이 타 계정 삭제 → UNAUTHORIZED (DB 조회 안 함)") + void teamOther() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + + assertThatThrownBy(() -> adminService.deleteAccount(2L, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).findByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("TC-005 존재하지 않는 계정 → ADMIN_NOT_FOUND") + void notFound() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminService.deleteAccount(999L, currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ADMIN_NOT_FOUND); + } + } + + // ────────────────────────────────────────────── + // ADMIN-006: resetPassword (비밀번호 초기화/변경) + // ────────────────────────────────────────────── + @Nested + @DisplayName("ADMIN-006 resetPassword") + class ResetPassword { + + @Test + @DisplayName("TC-001 SUPER 타인 초기화 (currentPassword 불필요) → 변경 + 토큰 삭제, matches 미호출") + void superResetsOther() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + Admin target = admin(2L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(2L)).thenReturn(Optional.of(target)); + when(passwordEncoder.encode("NewBoaz1234!")).thenReturn("ENC2"); + + adminService.resetPassword(2L, pwRequest(null, "NewBoaz1234!"), currentAdmin); + + assertThat(target.getPassword()).isEqualTo("ENC2"); + verify(refreshTokenRepository).deleteByAccountTypeAndAccountId(AccountType.ADMIN, 2L); + verify(passwordEncoder, never()).matches(any(), any()); + } + + @Test + @DisplayName("TC-002 본인 변경 (currentPassword 일치) → 변경 + 토큰 삭제") + void selfChangeMatches() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + when(adminRepository.findByIdAndDeletedAtIsNull(5L)).thenReturn(Optional.of(currentAdmin)); + when(passwordEncoder.matches("old", "OLD_HASH")).thenReturn(true); + when(passwordEncoder.encode("NewBoaz1234!")).thenReturn("ENC2"); + + adminService.resetPassword(5L, pwRequest("old", "NewBoaz1234!"), currentAdmin); + + assertThat(currentAdmin.getPassword()).isEqualTo("ENC2"); + verify(refreshTokenRepository).deleteByAccountTypeAndAccountId(AccountType.ADMIN, 5L); + } + + @Test + @DisplayName("TC-003 본인 변경인데 currentPassword 누락 → INVALID_INPUT_VALUE") + void selfCurrentPasswordBlank() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(currentAdmin)); + + assertThatThrownBy(() -> adminService.resetPassword(1L, pwRequest(null, "NewBoaz1234!"), currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + verify(refreshTokenRepository, never()).deleteByAccountTypeAndAccountId(any(), anyLong()); + } + + @Test + @DisplayName("TC-004 본인 변경인데 currentPassword 불일치 → INVALID_CURRENT_PASSWORD") + void selfCurrentPasswordMismatch() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(currentAdmin)); + when(passwordEncoder.matches("wrong", "OLD_HASH")).thenReturn(false); + + assertThatThrownBy(() -> adminService.resetPassword(1L, pwRequest("wrong", "NewBoaz1234!"), currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_CURRENT_PASSWORD); + verify(refreshTokenRepository, never()).deleteByAccountTypeAndAccountId(any(), anyLong()); + } + + @Test + @DisplayName("TC-005 TEAM 이 타 계정 변경 → UNAUTHORIZED (DB 조회 안 함)") + void teamOther() { + Admin currentAdmin = admin(5L, Admin.Role.TEAM); + + assertThatThrownBy(() -> adminService.resetPassword(2L, pwRequest(null, "NewBoaz1234!"), currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.UNAUTHORIZED); + verify(adminRepository, never()).findByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("TC-006 존재하지 않는 계정 → ADMIN_NOT_FOUND") + void notFound() { + Admin currentAdmin = admin(1L, Admin.Role.SUPER); + when(adminRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminService.resetPassword(999L, pwRequest(null, "NewBoaz1234!"), currentAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ADMIN_NOT_FOUND); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminControllerTest.java b/src/test/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminControllerTest.java new file mode 100644 index 0000000..6363b10 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/recruitment/controller/ApplicantEvaluationAdminControllerTest.java @@ -0,0 +1,369 @@ +package com.boaz.backend.domain.recruitment.controller; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantAnswersResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantEvaluatorsResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantInterviewQuestionsResponse; +import com.boaz.backend.domain.recruitment.dto.response.MyEvaluationResponse; +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.domain.recruitment.service.RecruitmentService; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import com.boaz.backend.global.security.AdminUserDetails; +import com.boaz.backend.global.security.UserPrincipal; +import com.boaz.backend.support.TestSecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ActiveProfiles("test") +@WebMvcTest( + value = ApplicantEvaluationAdminController.class, + excludeAutoConfiguration = {SecurityAutoConfiguration.class, SecurityFilterAutoConfiguration.class} +) +@Import(TestSecurityConfig.class) +class ApplicantEvaluationAdminControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @MockitoBean RecruitmentService recruitmentService; + + private UsernamePasswordAuthenticationToken adminAuth() { + Admin admin = Admin.builder() + .username("rep").password("p").role(Admin.Role.SUPER).name("대표") + .track(Track.ENGINEERING).term(27).teamName(Admin.TeamName.대표진).createdBy(null) + .build(); + ReflectionTestUtils.setField(admin, "id", 1L); + return new UsernamePasswordAuthenticationToken( + new AdminUserDetails(admin), null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER"))); + } + + private UsernamePasswordAuthenticationToken userAuth() { + return new UsernamePasswordAuthenticationToken( + new UserPrincipal(1L), null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + private MyEvaluationResponse myEval() { + ApplicantEval e = mock(ApplicantEval.class); + Applicant a = mock(Applicant.class); + when(a.getId()).thenReturn(101L); + when(e.getId()).thenReturn(9L); + when(e.getApplicant()).thenReturn(a); + when(e.getDecision()).thenReturn(EvaluationDecision.PASS); + when(e.getScore()).thenReturn(10); + when(e.getMemo()).thenReturn("great"); + return MyEvaluationResponse.from(e); + } + + // ── 전체 지원서 조회 ────────────────────────────────── + @Nested + @DisplayName("GET /{recruitmentId}/applicants") + class GetApplicants { + + @Test + @DisplayName("[정상] 200 + data 배열") + void success() throws Exception { + given(recruitmentService.getApplicants(eq(3L), any())).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/admin/recruitment/3/applicants").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + @DisplayName("[Security] 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment/3/applicants")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] User 권한 → 403") + void forbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment/3/applicants").with(authentication(userAuth()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ACCESS_DENIED")); + } + + @Test + @DisplayName("[예외] 존재하지 않는 공고 → 404") + void notFound() throws Exception { + given(recruitmentService.getApplicants(eq(999L), any())) + .willThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/recruitment/999/applicants").with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("RECRUITMENT_NOT_FOUND")); + } + } + + // ── 전체 지원서 및 평가 조회 ────────────────────────── + @Nested + @DisplayName("GET /{recruitmentId}/applicants/evaluations") + class GetApplicantEvaluations { + + @Test + @DisplayName("[정상] 200 + data 배열") + void success() throws Exception { + given(recruitmentService.getApplicantEvaluations(eq(3L), any())).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/admin/recruitment/3/applicants/evaluations").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + } + } + + // ── 최종 평가 수정 ──────────────────────────────────── + @Nested + @DisplayName("PATCH /applicants/{applicantId}/final-decision") + class UpdateFinalDecision { + + @Test + @DisplayName("[정상] 200") + void success() throws Exception { + given(recruitmentService.updateFinalDecision(eq(101L), any(), any())).willReturn(null); + + mockMvc.perform(patch("/api/v1/admin/recruitment/applicants/101/final-decision") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"final_decision\":\"PASS\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)); + } + + @Test + @DisplayName("[검증] final_decision 누락 → 400") + void missingField() throws Exception { + mockMvc.perform(patch("/api/v1/admin/recruitment/applicants/101/final-decision") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("[권한] 대표진 아님(서비스 throw) → 403") + void notRepresentative() throws Exception { + given(recruitmentService.updateFinalDecision(eq(101L), any(), any())) + .willThrow(new CustomException(ErrorCode.ACCESS_DENIED)); + + mockMvc.perform(patch("/api/v1/admin/recruitment/applicants/101/final-decision") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"final_decision\":\"PASS\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ACCESS_DENIED")); + } + } + + // ── 지원서별 평가 조회 ──────────────────────────────── + @Nested + @DisplayName("GET /applicants/{applicantId}/evaluations") + class GetApplicantEvaluators { + + @Test + @DisplayName("[정상] 200 + applicant_id + evaluations") + void success() throws Exception { + given(recruitmentService.getApplicantEvaluators(eq(101L), any())) + .willReturn(ApplicantEvaluatorsResponse.of(101L, List.of())); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/evaluations").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applicant_id").value(101)) + .andExpect(jsonPath("$.data.evaluations").isArray()); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → 404") + void notFound() throws Exception { + given(recruitmentService.getApplicantEvaluators(eq(999L), any())) + .willThrow(new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/999/evaluations").with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("APPLICATION_NOT_FOUND")); + } + } + + // ── 지원서별 면접 질문 조회 ────────────────────────── + @Nested + @DisplayName("GET /applicants/{applicantId}/interview-questions") + class GetApplicantInterviewQuestions { + + @Test + @DisplayName("[정상] 200 + applicant_id + interview_questions 배열") + void success() throws Exception { + given(recruitmentService.getApplicantInterviewQuestions(eq(101L), any())) + .willReturn(ApplicantInterviewQuestionsResponse.of(101L, List.of())); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/interview-questions") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applicant_id").value(101)) + .andExpect(jsonPath("$.data.interview_questions").isArray()); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → 404") + void notFound() throws Exception { + given(recruitmentService.getApplicantInterviewQuestions(eq(999L), any())) + .willThrow(new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/999/interview-questions") + .with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("APPLICATION_NOT_FOUND")); + } + } + + // ── 지원서 답변 조회 ────────────────────────────────── + @Nested + @DisplayName("GET /applicants/{applicantId}/answers") + class GetApplicantAnswers { + + @Test + @DisplayName("[정상] 200 + applicant_id + answers 배열") + void success() throws Exception { + given(recruitmentService.getApplicantAnswers(eq(101L), any())) + .willReturn(ApplicantAnswersResponse.of(101L, List.of())); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/answers").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applicant_id").value(101)) + .andExpect(jsonPath("$.data.answers").isArray()); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → 404") + void notFound() throws Exception { + given(recruitmentService.getApplicantAnswers(eq(999L), any())) + .willThrow(new CustomException(ErrorCode.APPLICATION_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/999/answers").with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("APPLICATION_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 → 401") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/answers")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + } + + // ── 개인 평가 조회 ──────────────────────────────────── + @Nested + @DisplayName("GET /applicants/{applicantId}/evaluations/me") + class GetMyEvaluation { + + @Test + @DisplayName("[정상] 평가 있음 → 200 + data") + void found() throws Exception { + MyEvaluationResponse res = myEval(); + given(recruitmentService.getMyEvaluation(eq(101L), any())).willReturn(res); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/evaluations/me").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.evaluation_id").value(9)) + .andExpect(jsonPath("$.data.decision").value("PASS")); + } + + @Test + @DisplayName("[정상] 평가 없음 → 200 + data null") + void none() throws Exception { + given(recruitmentService.getMyEvaluation(eq(101L), any())).willReturn(null); + + mockMvc.perform(get("/api/v1/admin/recruitment/applicants/101/evaluations/me").with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue())); + } + } + + // ── 개인 평가 저장 ──────────────────────────────────── + @Nested + @DisplayName("PUT /applicants/{applicantId}/evaluations/me") + class SaveMyEvaluation { + + @Test + @DisplayName("[정상] 200") + void success() throws Exception { + MyEvaluationResponse res = myEval(); + given(recruitmentService.saveMyEvaluation(eq(101L), any(), any())).willReturn(res); + + mockMvc.perform(put("/api/v1/admin/recruitment/applicants/101/evaluations/me") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"decision\":\"PASS\",\"score\":10,\"memo\":\"great\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.decision").value("PASS")); + } + + @Test + @DisplayName("[검증] decision 누락 → 400") + void missingDecision() throws Exception { + mockMvc.perform(put("/api/v1/admin/recruitment/applicants/101/evaluations/me") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"score\":5}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("[검증] score 범위 초과(11) → 400") + void scoreOutOfRange() throws Exception { + mockMvc.perform(put("/api/v1/admin/recruitment/applicants/101/evaluations/me") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"decision\":\"PASS\",\"score\":11}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("[권한] 타 부문(서비스 throw) → 403") + void trackMismatch() throws Exception { + given(recruitmentService.saveMyEvaluation(eq(101L), any(), any())) + .willThrow(new CustomException(ErrorCode.ACCESS_DENIED)); + + mockMvc.perform(put("/api/v1/admin/recruitment/applicants/101/evaluations/me") + .with(authentication(adminAuth())) + .contentType("application/json") + .content("{\"decision\":\"PASS\",\"score\":10}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ACCESS_DENIED")); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminControllerTest.java b/src/test/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminControllerTest.java index 64a1008..74836c5 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminControllerTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/controller/RecruitmentAdminControllerTest.java @@ -1,6 +1,16 @@ package com.boaz.backend.domain.recruitment.controller; +import com.boaz.backend.domain.recruitment.dto.response.QuestionIdResponse; +import com.boaz.backend.domain.recruitment.dto.response.QuestionIdsResponse; +import com.boaz.backend.domain.recruitment.dto.response.QuestionResponse; +import com.boaz.backend.domain.recruitment.dto.response.RecruitmentIdResponse; +import com.boaz.backend.domain.recruitment.dto.response.RecruitmentResponse; import com.boaz.backend.domain.recruitment.dto.response.SubscriptionResponse; +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion; +import com.boaz.backend.domain.recruitment.entity.DecisionFilter; +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Category; +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Type; +import com.boaz.backend.domain.recruitment.entity.Recruitment; import com.boaz.backend.domain.recruitment.entity.Subscription; import com.boaz.backend.domain.recruitment.service.RecruitmentService; import com.boaz.backend.global.exception.CustomException; @@ -23,10 +33,17 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDateTime; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -65,15 +82,41 @@ private UsernamePasswordAuthenticationToken userAuth() { class DownloadApplications { @Test - @DisplayName("정상 요청 → 200") + @DisplayName("정상 요청(decision 미지정) → 200, 기본값 ALL 전달") void success() throws Exception { - doNothing().when(recruitmentService).downloadApplications(27); + doNothing().when(recruitmentService).downloadApplications(eq(27), any()); mockMvc.perform(post("/api/v1/admin/recruitment/applications/download") .with(authentication(adminAuth())) .param("term", "27")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)); + + verify(recruitmentService).downloadApplications(27, DecisionFilter.ALL); + } + + @Test + @DisplayName("decision=PASS 지정 → 200, PASS 필터 전달") + void successWithPassFilter() throws Exception { + doNothing().when(recruitmentService).downloadApplications(eq(27), any()); + + mockMvc.perform(post("/api/v1/admin/recruitment/applications/download") + .with(authentication(adminAuth())) + .param("term", "27") + .param("decision", "PASS")) + .andExpect(status().isOk()); + + verify(recruitmentService).downloadApplications(27, DecisionFilter.PASS); + } + + @Test + @DisplayName("decision 값이 ENUM 범위 밖 → 400") + void invalidDecision() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment/applications/download") + .with(authentication(adminAuth())) + .param("term", "27") + .param("decision", "INVALID")) + .andExpect(status().isBadRequest()); } @Test @@ -99,7 +142,7 @@ void forbidden() throws Exception { @DisplayName("EX-001 존재하지 않는 term → 404 RECRUITMENT_NOT_FOUND") void notFound() throws Exception { doThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)) - .when(recruitmentService).downloadApplications(999); + .when(recruitmentService).downloadApplications(eq(999), any()); mockMvc.perform(post("/api/v1/admin/recruitment/applications/download") .with(authentication(adminAuth())) @@ -112,7 +155,7 @@ void notFound() throws Exception { @DisplayName("EX-002 S3 업로드 실패 → 500 S3_UPLOAD_FAILED") void s3Failed() throws Exception { doThrow(new CustomException(ErrorCode.S3_UPLOAD_FAILED)) - .when(recruitmentService).downloadApplications(27); + .when(recruitmentService).downloadApplications(eq(27), any()); mockMvc.perform(post("/api/v1/admin/recruitment/applications/download") .with(authentication(adminAuth())) @@ -259,4 +302,500 @@ private SubscriptionResponse buildSubscriptionResponse(Long id, String email) { ReflectionTestUtils.setField(s, "id", id); return SubscriptionResponse.from(s); } + + private RecruitmentResponse buildRecruitmentResponse(Long id, int term) { + Recruitment r = Recruitment.create(term, + LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), "[]", null); + ReflectionTestUtils.setField(r, "id", id); + return RecruitmentResponse.from(r, LocalDateTime.now()); + } + + private QuestionResponse buildQuestionResponse(Long id, int orderNum) { + ApplicationQuestion q = ApplicationQuestion.create( + null, "L" + id, Category.COMMON, Type.TEXT, "content", null, 500, null, orderNum, true); + ReflectionTestUtils.setField(q, "id", id); + return QuestionResponse.from(q); + } + + private String recruitmentCreateJson(boolean withTerm) throws Exception { + Map body = new HashMap<>(); + if (withTerm) body.put("term", 28); + body.put("start_date", "2026-08-01T00:00:00"); + body.put("end_date", "2026-08-15T23:59:59"); + body.put("schedule", List.of()); + body.put("brochure_url", "https://e.com/b.pdf"); + return objectMapper.writeValueAsString(body); + } + + private String questionsCreateJson(boolean withQuestions) throws Exception { + Map body = new HashMap<>(); + body.put("recruitment_id", 1); + if (withQuestions) { + Map item = new HashMap<>(); + item.put("label", "공통1"); + item.put("category", "COMMON"); + item.put("type", "TEXT"); + item.put("content", "질문"); + item.put("limit_length", 500); + item.put("order_num", 1); + item.put("is_required", true); + body.put("questions", List.of(item)); + } else { + body.put("questions", List.of()); + } + return objectMapper.writeValueAsString(body); + } + + // ────────────────────────────────────────────── + // REC-ADMIN-002: GET /api/v1/admin/recruitment + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-002 GET /api/v1/admin/recruitment") + class GetAllRecruitments { + + @Test + @DisplayName("TC-004 정상 조회 → 200 + 목록") + void success() throws Exception { + when(recruitmentService.getAllRecruitments()) + .thenReturn(List.of(buildRecruitmentResponse(12L, 27), buildRecruitmentResponse(11L, 26))); + + mockMvc.perform(get("/api/v1/admin/recruitment") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].recruitment_id").value(12)); + } + + @Test + @DisplayName("TC-005 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("TOKEN_NOT_FOUND")); + } + + @Test + @DisplayName("TC-006 User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment") + .with(authentication(userAuth()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ACCESS_DENIED")); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-003: POST /api/v1/admin/recruitment + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-003 POST /api/v1/admin/recruitment") + class CreateRecruitment { + + @Test + @DisplayName("TC-006 정상 등록 → 201 + recruitment_id") + void success() throws Exception { + when(recruitmentService.createRecruitment(any())).thenReturn(RecruitmentIdResponse.of(13L)); + + mockMvc.perform(post("/api/v1/admin/recruitment") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(recruitmentCreateJson(true))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.data.recruitment_id").value(13)); + } + + @Test + @DisplayName("TC-005 필수 필드(term) 누락 → 400 INVALID_INPUT_VALUE (@Valid)") + void missingField() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(recruitmentCreateJson(false))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("EX-003 중복 기수 → 409 DUPLICATE_TERM") + void duplicateTerm() throws Exception { + when(recruitmentService.createRecruitment(any())) + .thenThrow(new CustomException(ErrorCode.DUPLICATE_TERM)); + + mockMvc.perform(post("/api/v1/admin/recruitment") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(recruitmentCreateJson(true))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("DUPLICATE_TERM")); + } + + @Test + @DisplayName("TC-007 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment") + .contentType(MediaType.APPLICATION_JSON) + .content(recruitmentCreateJson(true))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment") + .with(authentication(userAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(recruitmentCreateJson(true))) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-004: PATCH /api/v1/admin/recruitment/{id} + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-004 PATCH /api/v1/admin/recruitment/{id}") + class UpdateRecruitment { + + @Test + @DisplayName("TC-007 정상 수정 → 200 + recruitment_id") + void success() throws Exception { + when(recruitmentService.updateRecruitment(eq(12L), any())).thenReturn(RecruitmentIdResponse.of(12L)); + + mockMvc.perform(patch("/api/v1/admin/recruitment/12") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"start_date\":\"2026-08-01T00:00:00\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recruitment_id").value(12)); + } + + @Test + @DisplayName("EX-003 존재하지 않는 공고 → 404 RECRUITMENT_NOT_FOUND") + void notFound() throws Exception { + when(recruitmentService.updateRecruitment(eq(999L), any())) + .thenThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + mockMvc.perform(patch("/api/v1/admin/recruitment/999") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"term\":28}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("RECRUITMENT_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(patch("/api/v1/admin/recruitment/12") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(patch("/api/v1/admin/recruitment/12") + .with(authentication(userAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-005: DELETE /api/v1/admin/recruitment/{id} + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-005 DELETE /api/v1/admin/recruitment/{id}") + class DeleteRecruitment { + + @Test + @DisplayName("TC-005 정상 삭제 → 200") + void success() throws Exception { + doNothing().when(recruitmentService).deleteRecruitment(12L); + + mockMvc.perform(delete("/api/v1/admin/recruitment/12") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)); + } + + @Test + @DisplayName("EX-001 연관 데이터 존재 → 400 RECRUITMENT_HAS_REFERENCES") + void hasReferences() throws Exception { + doThrow(new CustomException(ErrorCode.RECRUITMENT_HAS_REFERENCES)) + .when(recruitmentService).deleteRecruitment(12L); + + mockMvc.perform(delete("/api/v1/admin/recruitment/12") + .with(authentication(adminAuth()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("RECRUITMENT_HAS_REFERENCES")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(delete("/api/v1/admin/recruitment/12")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(delete("/api/v1/admin/recruitment/12") + .with(authentication(userAuth()))) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-006: POST /api/v1/admin/recruitment/questions + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-006 POST /api/v1/admin/recruitment/questions") + class CreateQuestions { + + @Test + @DisplayName("TC-001 정상 등록 → 201 + ids") + void success() throws Exception { + when(recruitmentService.createQuestions(any())).thenReturn(QuestionIdsResponse.of(List.of(1L, 2L))); + + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(questionsCreateJson(true))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.ids.length()").value(2)); + } + + @Test + @DisplayName("TC-009 questions 빈 배열 → 400 INVALID_INPUT_VALUE (@NotEmpty)") + void emptyQuestions() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(questionsCreateJson(false))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("TC-009b item의 order_num 0 → 400 INVALID_INPUT_VALUE (@Valid cascade @Positive)") + void itemOrderNumNotPositive() throws Exception { + Map item = new HashMap<>(); + item.put("label", "공통1"); + item.put("category", "COMMON"); + item.put("type", "TEXT"); + item.put("content", "질문"); + item.put("limit_length", 500); + item.put("order_num", 0); // @Positive 위반 + item.put("is_required", true); + Map body = new HashMap<>(); + body.put("recruitment_id", 1); + body.put("questions", List.of(item)); + + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("EX-005 존재하지 않는 공고 → 404 RECRUITMENT_NOT_FOUND") + void notFound() throws Exception { + when(recruitmentService.createQuestions(any())) + .thenThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(questionsCreateJson(true))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("RECRUITMENT_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .contentType(MediaType.APPLICATION_JSON) + .content(questionsCreateJson(true))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(post("/api/v1/admin/recruitment/questions") + .with(authentication(userAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content(questionsCreateJson(true))) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-007: GET /api/v1/admin/recruitment/{recruitmentId}/questions + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-007 GET /api/v1/admin/recruitment/{recruitmentId}/questions") + class GetAdminQuestions { + + @Test + @DisplayName("TC-006 정상 조회 → 200 + 목록") + void success() throws Exception { + when(recruitmentService.getAdminQuestions(12L)) + .thenReturn(List.of(buildQuestionResponse(1L, 1), buildQuestionResponse(2L, 10))); + + mockMvc.perform(get("/api/v1/admin/recruitment/12/questions") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].order_num").value(1)); + } + + @Test + @DisplayName("EX-001 존재하지 않는 공고 → 404 RECRUITMENT_NOT_FOUND") + void notFound() throws Exception { + when(recruitmentService.getAdminQuestions(999L)) + .thenThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/recruitment/999/questions") + .with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("RECRUITMENT_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment/12/questions")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/recruitment/12/questions") + .with(authentication(userAuth()))) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-008: PATCH /api/v1/admin/recruitment/questions/{questionId} + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-008 PATCH /api/v1/admin/recruitment/questions/{questionId}") + class UpdateQuestion { + + @Test + @DisplayName("TC-008 정상 수정 → 200 + question_id") + void success() throws Exception { + when(recruitmentService.updateQuestion(eq(1L), any())).thenReturn(QuestionIdResponse.of(1L)); + + mockMvc.perform(patch("/api/v1/admin/recruitment/questions/1") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"수정된 내용\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.question_id").value(1)); + } + + @Test + @DisplayName("EX-003 존재하지 않는 질문 → 404 QUESTIONS_NOT_FOUND") + void notFound() throws Exception { + when(recruitmentService.updateQuestion(eq(999L), any())) + .thenThrow(new CustomException(ErrorCode.QUESTIONS_NOT_FOUND)); + + mockMvc.perform(patch("/api/v1/admin/recruitment/questions/999") + .with(authentication(adminAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"x\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("QUESTIONS_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(patch("/api/v1/admin/recruitment/questions/1") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(patch("/api/v1/admin/recruitment/questions/1") + .with(authentication(userAuth())) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + } + + // ────────────────────────────────────────────── + // REC-ADMIN-009: DELETE /api/v1/admin/recruitment/questions/{questionId} + // ────────────────────────────────────────────── + @Nested + @DisplayName("REC-ADMIN-009 DELETE /api/v1/admin/recruitment/questions/{questionId}") + class DeleteQuestion { + + @Test + @DisplayName("TC-004 정상 삭제 → 200") + void success() throws Exception { + doNothing().when(recruitmentService).deleteQuestion(1L); + + mockMvc.perform(delete("/api/v1/admin/recruitment/questions/1") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)); + } + + @Test + @DisplayName("EX-001 참조 답변 존재 → 400 QUESTION_HAS_ANSWERS") + void hasAnswers() throws Exception { + doThrow(new CustomException(ErrorCode.QUESTION_HAS_ANSWERS)) + .when(recruitmentService).deleteQuestion(1L); + + mockMvc.perform(delete("/api/v1/admin/recruitment/questions/1") + .with(authentication(adminAuth()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("QUESTION_HAS_ANSWERS")); + } + + @Test + @DisplayName("EX-002 존재하지 않는 질문 → 404 QUESTIONS_NOT_FOUND") + void notFound() throws Exception { + doThrow(new CustomException(ErrorCode.QUESTIONS_NOT_FOUND)) + .when(recruitmentService).deleteQuestion(999L); + + mockMvc.perform(delete("/api/v1/admin/recruitment/questions/999") + .with(authentication(adminAuth()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("QUESTIONS_NOT_FOUND")); + } + + @Test + @DisplayName("[Security] 미인증 요청 → 401") + void unauthorized() throws Exception { + mockMvc.perform(delete("/api/v1/admin/recruitment/questions/1")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("[Security] User 권한으로 접근 → 403") + void forbidden() throws Exception { + mockMvc.perform(delete("/api/v1/admin/recruitment/questions/1") + .with(authentication(userAuth()))) + .andExpect(status().isForbidden()); + } + } } diff --git a/src/test/java/com/boaz/backend/domain/recruitment/integration/ApplicantEvaluationIntegrationTest.java b/src/test/java/com/boaz/backend/domain/recruitment/integration/ApplicantEvaluationIntegrationTest.java new file mode 100644 index 0000000..0b2f3c2 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/recruitment/integration/ApplicantEvaluationIntegrationTest.java @@ -0,0 +1,296 @@ +package com.boaz.backend.domain.recruitment.integration; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.repository.AdminRepository; +import com.boaz.backend.domain.recruitment.dto.request.EvaluationSaveRequest; +import com.boaz.backend.domain.recruitment.dto.request.FinalDecisionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantAnswersResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantEvaluationResponse; +import com.boaz.backend.domain.recruitment.dto.response.ApplicantInterviewQuestionsResponse; +import com.boaz.backend.domain.recruitment.dto.response.MyEvaluationResponse; +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.ApplicantAnswer; +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.domain.recruitment.entity.Recruitment; +import com.boaz.backend.domain.recruitment.repository.ApplicantAnswerRepository; +import com.boaz.backend.domain.recruitment.repository.ApplicantRepository; +import com.boaz.backend.domain.recruitment.repository.ApplicationQuestionRepository; +import com.boaz.backend.domain.recruitment.repository.RecruitmentRepository; +import com.boaz.backend.domain.recruitment.service.RecruitmentService; +import com.boaz.backend.domain.user.entity.User; +import com.boaz.backend.domain.user.repository.UserRepository; +import com.boaz.backend.global.common.enums.MemberType; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import com.boaz.backend.support.TestcontainersBase; +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.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +@DisplayName("지원서 평가 통합 테스트") +class ApplicantEvaluationIntegrationTest extends TestcontainersBase { + + @Autowired RecruitmentService recruitmentService; + @Autowired RecruitmentRepository recruitmentRepository; + @Autowired ApplicantRepository applicantRepository; + @Autowired ApplicationQuestionRepository applicationQuestionRepository; + @Autowired ApplicantAnswerRepository applicantAnswerRepository; + @Autowired AdminRepository adminRepository; + @Autowired UserRepository userRepository; + + private int seq = 0; + + private Recruitment saveRecruitment(int term) { + LocalDateTime now = LocalDateTime.now(); + return recruitmentRepository.save(Recruitment.create(term, now.minusDays(1), now.plusDays(1), "{}", null)); + } + + private Applicant saveApplicant(Recruitment r, Track track, Applicant.ApplicantStatus status) { + User u = userRepository.save(User.builder() + .provider("kakao").providerId("p" + (++seq)).nickname("n").memberType(MemberType.OUTSIDER).build()); + return applicantRepository.save(Applicant.builder() + .recruitment(r).user(u).status(status).track(track) + .name("name").email("a@example.com").phone("01000000000").build()); + } + + private Admin saveAdmin(Admin.Role role, Admin.TeamName team, Track track) { + return adminRepository.save(Admin.builder() + .username("adm" + (++seq)).password("p").role(role).name("name" + seq) + .track(track).term(27).teamName(team).createdBy(null).build()); + } + + private EvaluationSaveRequest saveReq(EvaluationDecision d, Integer score, String memo) { + return saveReq(d, score, memo, null); + } + + private EvaluationSaveRequest saveReq(EvaluationDecision d, Integer score, String memo, String interviewQuestion) { + EvaluationSaveRequest r = new EvaluationSaveRequest(); + ReflectionTestUtils.setField(r, "decision", d); + ReflectionTestUtils.setField(r, "score", score); + ReflectionTestUtils.setField(r, "memo", memo); + ReflectionTestUtils.setField(r, "interviewQuestion", interviewQuestion); + return r; + } + + private FinalDecisionUpdateRequest decisionReq(EvaluationDecision d) { + FinalDecisionUpdateRequest r = new FinalDecisionUpdateRequest(); + ReflectionTestUtils.setField(r, "finalDecision", d); + return r; + } + + @Test + @DisplayName("개인 평가 저장 → 평가 대시보드 집계에 반영") + void saveThenAggregate() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin ev1 = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin ev2 = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + recruitmentService.saveMyEvaluation(a.getId(), saveReq(EvaluationDecision.PASS, 9, "good"), ev1); + recruitmentService.saveMyEvaluation(a.getId(), saveReq(EvaluationDecision.HOLD, 5, "maybe"), ev2); + + List dashboard = recruitmentService.getApplicantEvaluations(r.getId(), ev1); + ApplicantEvaluationResponse row = dashboard.stream() + .filter(x -> x.getId().equals(a.getId())).findFirst().orElseThrow(); + + assertThat(row.getPassCount()).isEqualTo(1); + assertThat(row.getHoldCount()).isEqualTo(1); + assertThat(row.getTotalScore()).isEqualTo(14); + assertThat(row.getMyDecision()).isEqualTo(EvaluationDecision.PASS); // 본인(ev1) 평가 = PASS + } + + @Test + @DisplayName("개인 평가 저장은 upsert — 같은 평가자 재저장 시 행 추가 없이 갱신") + void resaveIsUpsert() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin ev = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + recruitmentService.saveMyEvaluation(a.getId(), saveReq(EvaluationDecision.HOLD, 5, "v1"), ev); + MyEvaluationResponse second = recruitmentService.saveMyEvaluation( + a.getId(), saveReq(EvaluationDecision.PASS, 10, "v2"), ev); + + assertThat(second.getDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(second.getScore()).isEqualTo(10); + // 같은 평가자의 평가는 1건만 (집계 개수로 검증) + ApplicantEvaluationResponse row = recruitmentService.getApplicantEvaluations(r.getId(), ev).stream() + .filter(x -> x.getId().equals(a.getId())).findFirst().orElseThrow(); + assertThat(row.getPassCount()).isEqualTo(1); + assertThat(row.getHoldCount()).isZero(); + } + + @Test + @DisplayName("타 부문 지원자 평가 저장 → ACCESS_DENIED") + void crossTrackRejected() { + Recruitment r = saveRecruitment(27); + Applicant engApplicant = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin analysisAdmin = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ANALYSIS); + + assertThatThrownBy(() -> recruitmentService.saveMyEvaluation( + engApplicant.getId(), saveReq(EvaluationDecision.PASS, 9, "x"), analysisAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("대표진 최종 평가 수정 → 대시보드 final_decision 반영") + void finalDecisionByRepresentative() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin rep = saveAdmin(Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + + recruitmentService.updateFinalDecision(a.getId(), decisionReq(EvaluationDecision.PASS), rep); + + ApplicantEvaluationResponse row = recruitmentService.getApplicantEvaluations(r.getId(), rep).stream() + .filter(x -> x.getId().equals(a.getId())).findFirst().orElseThrow(); + assertThat(row.getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + } + + @Test + @DisplayName("서비스운영팀 SUPER의 최종 평가 수정 → ACCESS_DENIED (대표진 아님)") + void finalDecisionByNonRepresentative() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin superOps = saveAdmin(Admin.Role.SUPER, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision( + a.getId(), decisionReq(EvaluationDecision.PASS), superOps)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("평가자 풀 = 해당 부문 + 차기 대표진 — 엔지 지원자 풀에 타 부문 차기대표진 포함") + void evaluatorPoolIncludesNextRepresentative() { + Recruitment r = saveRecruitment(27); + Applicant eng = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin engEvaluator = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin analysisNextRep = saveAdmin(Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ANALYSIS); + Admin analysisNormal = saveAdmin(Admin.Role.TEAM, Admin.TeamName.기획팀, Track.ANALYSIS); + + // 지원서별 평가 조회 (자기 부문이라 조회 가능한 엔지 평가자로 호출) + var res = recruitmentService.getApplicantEvaluators(eng.getId(), engEvaluator); + + assertThat(res.getEvaluations()).extracting(e -> e.getAdminId()) + .contains(engEvaluator.getId(), analysisNextRep.getId()) // 엔지 평가자 + 타 부문 차기대표진 포함 + .doesNotContain(analysisNormal.getId()); // 타 부문 일반 운영진은 제외 + + // 면접 질문 조회도 동일 풀 + var iq = recruitmentService.getApplicantInterviewQuestions(eng.getId(), engEvaluator); + assertThat(iq.getInterviewQuestions()).extracting(q -> q.getAdminId()) + .contains(engEvaluator.getId(), analysisNextRep.getId()) + .doesNotContain(analysisNormal.getId()); + } + + @Test + @DisplayName("면접 질문 저장(개인 평가) → 지원서별 면접 질문 조회 & 개인 평가 조회에 반영") + void interviewQuestionSaveAndQuery() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin ev1 = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin ev2 = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + recruitmentService.saveMyEvaluation(a.getId(), + saveReq(EvaluationDecision.PASS, 9, "good", "콜드스타트 문제를 어떻게 해결했나요?"), ev1); + + // 개인 평가 조회에 면접 질문 포함 (라운드트립) + MyEvaluationResponse mine = recruitmentService.getMyEvaluation(a.getId(), ev1); + assertThat(mine.getInterviewQuestion()).isEqualTo("콜드스타트 문제를 어떻게 해결했나요?"); + + // 지원서별 면접 질문 조회 — 평가자 풀 전체, 미작성자(ev2)는 null + ApplicantInterviewQuestionsResponse res = + recruitmentService.getApplicantInterviewQuestions(a.getId(), ev1); + assertThat(res.getApplicantId()).isEqualTo(a.getId()); + assertThat(res.getInterviewQuestions()).hasSize(2); + assertThat(res.getInterviewQuestions()) + .anySatisfy(q -> { + assertThat(q.getAdminId()).isEqualTo(ev1.getId()); + assertThat(q.getInterviewQuestion()).isEqualTo("콜드스타트 문제를 어떻게 해결했나요?"); + }) + .anySatisfy(q -> { + assertThat(q.getAdminId()).isEqualTo(ev2.getId()); + assertThat(q.getInterviewQuestion()).isNull(); + }); + } + + @Test + @DisplayName("지원서 답변 조회 — 문항 순서대로 답변 + 문항 정보 반환 (TEXT/TABLE)") + void getApplicantAnswers() { + Recruitment r = saveRecruitment(27); + Applicant a = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + ApplicationQuestion q1 = applicationQuestionRepository.save(ApplicationQuestion.create( + r, "공통1", ApplicationQuestion.Category.COMMON, ApplicationQuestion.Type.TEXT, + "지원 동기", null, 500, null, 1, true)); + ApplicationQuestion q2 = applicationQuestionRepository.save(ApplicationQuestion.create( + r, "엔지1", ApplicationQuestion.Category.ENGINEERING, ApplicationQuestion.Type.TABLE, + "기술 스택", null, null, "{\"multiple\":false}", 2, true)); + // 일부러 순서를 뒤섞어 저장 + applicantAnswerRepository.save(ApplicantAnswer.builder() + .applicant(a).question(q2).answerJson("{\"Python\":\"능숙\"}").build()); + applicantAnswerRepository.save(ApplicantAnswer.builder() + .applicant(a).question(q1).answerText("저는 ~~").build()); + Admin viewer = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + ApplicantAnswersResponse res = recruitmentService.getApplicantAnswers(a.getId(), viewer); + + assertThat(res.getApplicantId()).isEqualTo(a.getId()); + assertThat(res.getAnswers()).hasSize(2); + // orderNum 1(q1) 먼저 + ApplicantAnswersResponse.AnswerDetailResponse first = res.getAnswers().get(0); + assertThat(first.getQuestionId()).isEqualTo(q1.getId()); + assertThat(first.getType()).isEqualTo(ApplicationQuestion.Type.TEXT); + assertThat(first.getAnswer().asText()).isEqualTo("저는 ~~"); + ApplicantAnswersResponse.AnswerDetailResponse second = res.getAnswers().get(1); + assertThat(second.getType()).isEqualTo(ApplicationQuestion.Type.TABLE); + assertThat(second.getAnswer().get("Python").asText()).isEqualTo("능숙"); + } + + @Test + @DisplayName("타 부문 지원서 답변 조회 — 비대표진/현재 대표진은 차단, 차기 대표진만 가능") + void getApplicantAnswersTrackAccess() { + Recruitment r = saveRecruitment(27); + Applicant eng = saveApplicant(r, Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + Admin analysisAdmin = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ANALYSIS); + Admin currentRep = saveAdmin(Admin.Role.SUPER, Admin.TeamName.대표진, Track.ANALYSIS); + Admin nextRep = saveAdmin(Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ANALYSIS); + + // 비대표진(분석)이 엔지 지원자 답변 조회 → 차단 + assertThatThrownBy(() -> recruitmentService.getApplicantAnswers(eng.getId(), analysisAdmin)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + + // 현재 대표진(분석)도 엔지 지원자 답변 조회 → 차단 (본인 track만) + assertThatThrownBy(() -> recruitmentService.getApplicantAnswers(eng.getId(), currentRep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + + // 차기 대표진은 타 부문이어도 조회 가능 + ApplicantAnswersResponse res = recruitmentService.getApplicantAnswers(eng.getId(), nextRep); + assertThat(res.getApplicantId()).isEqualTo(eng.getId()); + assertThat(res.getAnswers()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 지원자 답변 조회 → APPLICATION_NOT_FOUND") + void getApplicantAnswersNotFound() { + Admin viewer = saveAdmin(Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + assertThatThrownBy(() -> recruitmentService.getApplicantAnswers(999999L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } +} diff --git a/src/test/java/com/boaz/backend/domain/recruitment/integration/RecruitmentIntegrationTest.java b/src/test/java/com/boaz/backend/domain/recruitment/integration/RecruitmentIntegrationTest.java index 76c8e6c..23f5a3c 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/integration/RecruitmentIntegrationTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/integration/RecruitmentIntegrationTest.java @@ -2,6 +2,11 @@ import com.boaz.backend.domain.recruitment.dto.request.ApplicationRequest; import com.boaz.backend.domain.recruitment.dto.request.DraftApplicationRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionItemRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionsCreateRequest; +import com.boaz.backend.domain.recruitment.dto.request.RecruitmentCreateRequest; +import com.boaz.backend.domain.recruitment.dto.request.RecruitmentUpdateRequest; import com.boaz.backend.domain.recruitment.dto.request.SubscriptionRequest; import com.boaz.backend.domain.recruitment.dto.response.ApplicationResponse; import com.boaz.backend.domain.recruitment.dto.response.DeadlineResponse; @@ -16,6 +21,8 @@ import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion; import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Category; import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Type; +import com.boaz.backend.domain.recruitment.entity.DecisionFilter; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; import com.boaz.backend.domain.recruitment.entity.Recruitment; import com.boaz.backend.domain.recruitment.repository.ApplicantAnswerRepository; import com.boaz.backend.domain.recruitment.repository.ApplicantRepository; @@ -31,7 +38,10 @@ import com.boaz.backend.global.exception.ErrorCode; import com.boaz.backend.global.util.S3Service; import com.boaz.backend.support.TestcontainersBase; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.test.util.ReflectionTestUtils; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.junit.jupiter.api.DisplayName; @@ -598,15 +608,53 @@ void uploadsThreeTracks() { em.flush(); em.clear(); - recruitmentService.downloadApplications(27); + recruitmentService.downloadApplications(27, DecisionFilter.ALL); verify(s3Service, times(3)).uploadCsv(any(), any(), any()); } + @Test + @DisplayName("decision=PASS → 합격자만 CSV에 포함, 키에 PASS 표기") + void passFilterOnlyIncludesPass() { + Recruitment r = saveActiveRecruitment(27); + saveQuestion(r, Category.COMMON, 1); + + Applicant pass = applicantRepository.save(Applicant.builder() + .recruitment(r).user(saveUser()).status(Applicant.ApplicantStatus.SUBMITTED) + .track(Track.ENGINEERING).name("합격자").email("pass@example.com").phone("01011112222") + .build()); + pass.markSubmitted(); + pass.updateFinalDecision(EvaluationDecision.PASS); + + Applicant fail = applicantRepository.save(Applicant.builder() + .recruitment(r).user(saveUser()).status(Applicant.ApplicantStatus.SUBMITTED) + .track(Track.ENGINEERING).name("불합격자").email("fail@example.com").phone("01033334444") + .build()); + fail.markSubmitted(); + fail.updateFinalDecision(EvaluationDecision.FAIL); + em.flush(); + em.clear(); + + recruitmentService.downloadApplications(27, DecisionFilter.PASS); + + org.mockito.ArgumentCaptor keyCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + org.mockito.ArgumentCaptor csvCaptor = org.mockito.ArgumentCaptor.forClass(byte[].class); + verify(s3Service, times(3)).uploadCsv(any(), keyCaptor.capture(), csvCaptor.capture()); + + // ENGINEERING 파일만 추출해 내용 검증 + int engIdx = keyCaptor.getAllValues().indexOf( + keyCaptor.getAllValues().stream().filter(k -> k.contains("ENGINEERING")).findFirst().orElseThrow()); + String engCsv = new String(csvCaptor.getAllValues().get(engIdx), java.nio.charset.StandardCharsets.UTF_8); + + assertThat(keyCaptor.getAllValues()).allMatch(k -> k.contains("_PASS_")); + assertThat(engCsv).contains("합격자").contains("합격"); + assertThat(engCsv).doesNotContain("불합격자"); + } + @Test @DisplayName("존재하지 않는 term → RECRUITMENT_NOT_FOUND") void notFound() { - assertThatThrownBy(() -> recruitmentService.downloadApplications(999)) + assertThatThrownBy(() -> recruitmentService.downloadApplications(999, DecisionFilter.ALL)) .isInstanceOf(CustomException.class) .extracting("errorCode").isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); } @@ -668,4 +716,296 @@ void subscriptionsLifecycle() { assertThat(subscriptionRepository.count()).isZero(); } } + + // ────────────────────────────────────────────── + // Admin CRUD 통합 테스트 헬퍼 + // ────────────────────────────────────────────── + + private RecruitmentCreateRequest buildCreateReq(int term, LocalDateTime start, LocalDateTime end, String brochure) { + RecruitmentCreateRequest req = new RecruitmentCreateRequest(); + ReflectionTestUtils.setField(req, "term", term); + ReflectionTestUtils.setField(req, "startDate", start); + ReflectionTestUtils.setField(req, "endDate", end); + ReflectionTestUtils.setField(req, "schedule", objectMapper.createArrayNode()); + ReflectionTestUtils.setField(req, "brochureUrl", brochure); + return req; + } + + private QuestionItemRequest buildItem(String label, Category category, Type type, + Integer limitLength, JsonNode metadata, int orderNum) { + QuestionItemRequest item = new QuestionItemRequest(); + ReflectionTestUtils.setField(item, "label", label); + ReflectionTestUtils.setField(item, "category", category); + ReflectionTestUtils.setField(item, "type", type); + ReflectionTestUtils.setField(item, "content", "내용 " + label); + ReflectionTestUtils.setField(item, "limitLength", limitLength); + ReflectionTestUtils.setField(item, "metadata", metadata); + ReflectionTestUtils.setField(item, "orderNum", orderNum); + ReflectionTestUtils.setField(item, "isRequired", true); + return item; + } + + private QuestionsCreateRequest buildQuestionsReq(Long recruitmentId, QuestionItemRequest... items) { + QuestionsCreateRequest req = new QuestionsCreateRequest(); + ReflectionTestUtils.setField(req, "recruitmentId", recruitmentId); + ReflectionTestUtils.setField(req, "questions", List.of(items)); + return req; + } + + @Nested + @DisplayName("모집 공고 CRUD end-to-end (REC-ADMIN-002~005)") + class AdminRecruitmentCrud { + + @Test + @DisplayName("REC-ADMIN-003 공고 등록 → 실제 DB 영속") + void createPersists() { + LocalDateTime now = LocalDateTime.now(); + RecruitmentCreateRequest req = buildCreateReq(28, now.plusDays(1), now.plusDays(15), "https://e.com/b.pdf"); + + recruitmentService.createRecruitment(req); + em.flush(); + em.clear(); + + assertThat(recruitmentRepository.findByTerm(28)).isPresent(); + } + + @Test + @DisplayName("REC-ADMIN-003 동일 기수 재등록 → DUPLICATE_TERM") + void createDuplicateTerm() { + saveActiveRecruitment(27); + em.flush(); + LocalDateTime now = LocalDateTime.now(); + RecruitmentCreateRequest req = buildCreateReq(27, now.plusDays(1), now.plusDays(15), null); + + assertThatThrownBy(() -> recruitmentService.createRecruitment(req)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATE_TERM); + } + + @Test + @DisplayName("REC-ADMIN-004 start_date만 수정 → 해당 필드만 변경, 나머지 유지") + void updatePartial() { + Recruitment r = saveActiveRecruitment(27); + Long id = r.getId(); + em.flush(); + em.clear(); + + LocalDateTime newStart = LocalDateTime.now().minusDays(3).withNano(0); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "startDate", newStart); + + recruitmentService.updateRecruitment(id, req); + em.flush(); + em.clear(); + + Recruitment reloaded = recruitmentRepository.findById(id).orElseThrow(); + assertThat(reloaded.getStartDate()).isEqualToIgnoringNanos(newStart); + assertThat(reloaded.getTerm()).isEqualTo(27); + } + + @Test + @DisplayName("REC-ADMIN-004 brochure_url null 명시 전송 → 삭제") + void updateDeleteBrochure() { + LocalDateTime now = LocalDateTime.now(); + Recruitment r = recruitmentRepository.save( + Recruitment.create(27, now.minusDays(1), now.plusDays(1), "{}", "https://e.com/b.pdf")); + Long id = r.getId(); + em.flush(); + em.clear(); + + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "brochureUrl", JsonNullable.of(null)); + + recruitmentService.updateRecruitment(id, req); + em.flush(); + em.clear(); + + assertThat(recruitmentRepository.findById(id).orElseThrow().getBrochureUrl()).isNull(); + } + + @Test + @DisplayName("REC-ADMIN-005 연관 데이터 없는 공고 → 삭제됨") + void deleteNoRefs() { + Recruitment r = saveActiveRecruitment(27); + Long id = r.getId(); + em.flush(); + em.clear(); + + recruitmentService.deleteRecruitment(id); + em.flush(); + em.clear(); + + assertThat(recruitmentRepository.findById(id)).isEmpty(); + } + + @Test + @DisplayName("REC-ADMIN-005 연관 질문 존재 → RECRUITMENT_HAS_REFERENCES") + void deleteWithRefs() { + Recruitment r = saveActiveRecruitment(27); + saveQuestion(r, Category.COMMON, 1); + em.flush(); + em.clear(); + + assertThatThrownBy(() -> recruitmentService.deleteRecruitment(r.getId())) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.RECRUITMENT_HAS_REFERENCES); + } + } + + @Nested + @DisplayName("지원서 질문 CRUD end-to-end (REC-ADMIN-006~009)") + class AdminQuestionCrud { + + @Test + @DisplayName("REC-ADMIN-006 TEXT+TABLE 다건 등록 → limit_length/metadata 분기 영속") + void createQuestionsPersist() throws Exception { + Recruitment r = saveActiveRecruitment(27); + em.flush(); + em.clear(); + + JsonNode metadata = objectMapper.readTree("{\"rows\":[\"A\"],\"columns\":[\"B\"]}"); + QuestionsCreateRequest req = buildQuestionsReq(r.getId(), + buildItem("C1", Category.COMMON, Type.TEXT, 500, null, 1), + buildItem("E1", Category.ENGINEERING, Type.TABLE, null, metadata, 1)); + + recruitmentService.createQuestions(req); + em.flush(); + em.clear(); + + List saved = questionRepository.findByRecruitmentIdOrderByOrderNumAsc(r.getId()); + assertThat(saved).hasSize(2); + ApplicationQuestion text = saved.stream().filter(q -> q.getType() == Type.TEXT).findFirst().orElseThrow(); + ApplicationQuestion table = saved.stream().filter(q -> q.getType() == Type.TABLE).findFirst().orElseThrow(); + assertThat(text.getLimitLength()).isEqualTo(500); + assertThat(text.getMetadata()).isNull(); + assertThat(table.getLimitLength()).isNull(); + assertThat(table.getMetadata()).isNotNull(); + } + + @Test + @DisplayName("REC-ADMIN-006 DB에 동일 label 존재 → DUPLICATE_QUESTION_LABEL") + void createDuplicateLabel() { + Recruitment r = saveActiveRecruitment(27); + questionRepository.save(ApplicationQuestion.create( + r, "C1", Category.COMMON, Type.TEXT, "기존", null, 500, null, 1, true)); + em.flush(); + em.clear(); + + QuestionsCreateRequest req = buildQuestionsReq(r.getId(), + buildItem("C1", Category.COMMON, Type.TEXT, 500, null, 2)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATE_QUESTION_LABEL); + } + + @Test + @DisplayName("REC-ADMIN-006 TC-011 두 번째 항목 충돌 시 앞 항목 포함 전체 미저장 (원자성)") + void createPartialFailureRollsBackAll() { + Recruitment r = saveActiveRecruitment(27); + questionRepository.save(ApplicationQuestion.create( + r, "EXIST", Category.COMMON, Type.TEXT, "기존", null, 500, null, 1, true)); + em.flush(); + em.clear(); + + // item1(NEW1)은 유효하지만, item2(EXIST)가 DB label 과 충돌 → 전체 실패해야 함 + QuestionsCreateRequest req = buildQuestionsReq(r.getId(), + buildItem("NEW1", Category.COMMON, Type.TEXT, 500, null, 2), + buildItem("EXIST", Category.ENGINEERING, Type.TEXT, 500, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATE_QUESTION_LABEL); + em.flush(); + em.clear(); + + // 기존 EXIST 1건만 남고 NEW1 은 저장되지 않아야 함 + List saved = questionRepository.findByRecruitmentIdOrderByOrderNumAsc(r.getId()); + assertThat(saved).extracting(ApplicationQuestion::getLabel).containsExactly("EXIST"); + } + + @Test + @DisplayName("REC-ADMIN-007 order_num 오름차순 반환 (is_active 무관)") + void getAdminQuestionsOrder() { + Recruitment r = saveClosedRecruitment(26); // 마감 공고 + saveQuestion(r, Category.COMMON, 10); + saveQuestion(r, Category.COMMON, 1); + em.flush(); + em.clear(); + + List result = recruitmentService.getAdminQuestions(r.getId()); + + assertThat(result).extracting(QuestionResponse::getOrderNum).containsExactly(1, 10); + } + + @Test + @DisplayName("REC-ADMIN-007 질문 없음 → 빈 배열 (QUESTIONS_NOT_FOUND 아님)") + void getAdminQuestionsEmpty() { + Recruitment r = saveActiveRecruitment(27); + em.flush(); + em.clear(); + + assertThat(recruitmentService.getAdminQuestions(r.getId())).isEmpty(); + } + + @Test + @DisplayName("REC-ADMIN-008 TEXT→TABLE 타입 변경 → metadata 저장, limit_length 정리") + void updateTypeChange() throws Exception { + Recruitment r = saveActiveRecruitment(27); + ApplicationQuestion q = saveQuestion(r, Category.COMMON, 1); // TEXT, limit 500 + Long qid = q.getId(); + em.flush(); + em.clear(); + + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "type", Type.TABLE); + ReflectionTestUtils.setField(req, "metadata", + JsonNullable.of(objectMapper.readTree("{\"rows\":[\"A\"],\"columns\":[\"B\"]}"))); + + recruitmentService.updateQuestion(qid, req); + em.flush(); + em.clear(); + + ApplicationQuestion reloaded = questionRepository.findById(qid).orElseThrow(); + assertThat(reloaded.getType()).isEqualTo(Type.TABLE); + assertThat(reloaded.getMetadata()).isNotNull(); + assertThat(reloaded.getLimitLength()).isNull(); + } + + @Test + @DisplayName("REC-ADMIN-009 답변 없는 질문 → 삭제됨") + void deleteQuestionNoAnswers() { + Recruitment r = saveActiveRecruitment(27); + ApplicationQuestion q = saveQuestion(r, Category.COMMON, 1); + Long qid = q.getId(); + em.flush(); + em.clear(); + + recruitmentService.deleteQuestion(qid); + em.flush(); + em.clear(); + + assertThat(questionRepository.findById(qid)).isEmpty(); + } + + @Test + @DisplayName("REC-ADMIN-009 참조 답변 존재 → QUESTION_HAS_ANSWERS") + void deleteQuestionWithAnswers() { + Recruitment r = saveActiveRecruitment(27); + User u = saveUser(); + ApplicationQuestion q = saveQuestion(r, Category.COMMON, 1); + Applicant a = applicantRepository.save(Applicant.builder() + .recruitment(r).user(u).status(Applicant.ApplicantStatus.SUBMITTED) + .track(Track.ENGINEERING).name("n").email("a@example.com").phone("01012345678") + .build()); + answerRepository.save(ApplicantAnswer.builder() + .applicant(a).question(q).answerText("답").build()); + em.flush(); + em.clear(); + + assertThatThrownBy(() -> recruitmentService.deleteQuestion(q.getId())) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.QUESTION_HAS_ANSWERS); + } + } } diff --git a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepositoryTest.java b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepositoryTest.java index 69926f9..0621c67 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepositoryTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantAnswerRepositoryTest.java @@ -67,6 +67,41 @@ private void persistAnswer(Applicant a, ApplicationQuestion q, String text) { em.persist(ApplicantAnswer.builder().applicant(a).question(q).answerText(text).build()); } + @Test + @DisplayName("findByApplicantIdWithQuestion: 문항 순서(orderNum)대로 + question 페치 조인") + void findByApplicantIdWithQuestion() { + Recruitment r = persistRecruitment(27); + ApplicationQuestion q1 = persistQuestion(r, 1); + ApplicationQuestion q2 = persistQuestion(r, 2); + ApplicationQuestion q3 = persistQuestion(r, 3); + Applicant a = persistApplicant(r); + // 일부러 순서를 뒤섞어 저장 + persistAnswer(a, q3, "답3"); + persistAnswer(a, q1, "답1"); + persistAnswer(a, q2, "답2"); + em.flush(); + em.clear(); + + List answers = answerRepository.findByApplicantIdWithQuestion(a.getId()); + + assertThat(answers).hasSize(3); + assertThat(answers).extracting(ans -> ans.getQuestion().getOrderNum()) + .containsExactly(1, 2, 3); + // em.clear() 이후에도 question 접근 가능해야 함 (페치 조인) + assertThat(answers.get(0).getQuestion().getContent()).isEqualTo("content"); + } + + @Test + @DisplayName("findByApplicantIdWithQuestion: 답변 없으면 빈 리스트") + void findByApplicantIdWithQuestionEmpty() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r); + em.flush(); + em.clear(); + + assertThat(answerRepository.findByApplicantIdWithQuestion(a.getId())).isEmpty(); + } + @Test @DisplayName("existsByQuestionId / findByApplicantIds") void existsAndFind() { diff --git a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepositoryTest.java b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepositoryTest.java new file mode 100644 index 0000000..6fccde9 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantEvalRepositoryTest.java @@ -0,0 +1,170 @@ +package com.boaz.backend.domain.recruitment.repository; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.domain.recruitment.entity.Recruitment; +import com.boaz.backend.domain.user.entity.User; +import com.boaz.backend.global.common.enums.MemberType; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.config.JpaConfig; +import com.boaz.backend.support.TestcontainersBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaConfig.class) +@ActiveProfiles("test") +class ApplicantEvalRepositoryTest extends TestcontainersBase { + + @Autowired ApplicantEvalRepository applicantEvalRepository; + @Autowired TestEntityManager em; + + private Recruitment persistRecruitment(int term) { + LocalDateTime now = LocalDateTime.now(); + return em.persistFlushFind(Recruitment.create(term, now.minusDays(1), now.plusDays(1), "{}", null)); + } + + private User persistUser(String pid) { + return em.persistFlushFind(User.builder() + .provider("kakao").providerId(pid).nickname("n-" + pid).memberType(MemberType.OUTSIDER).build()); + } + + private Applicant persistApplicant(Recruitment r, User u, Track track) { + return em.persistFlushFind(Applicant.builder() + .recruitment(r).user(u).status(Applicant.ApplicantStatus.SUBMITTED).track(track) + .name("name").email("a@example.com").phone("01000000000").build()); + } + + private Admin persistAdmin(String username, Track track) { + return em.persistFlushFind(Admin.builder() + .username(username).password("p").role(Admin.Role.TEAM).name("name-" + username) + .track(track).term(27).teamName(Admin.TeamName.서비스운영팀).createdBy(null).build()); + } + + @Nested + @DisplayName("upsert (네이티브 INSERT ... ON DUPLICATE KEY UPDATE)") + class Upsert { + + @Test + @DisplayName("기존 행 없음 → INSERT") + void insert() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Admin admin = persistAdmin("ev1", Track.ENGINEERING); + em.clear(); + + applicantEvalRepository.upsert(a.getId(), admin.getId(), "PASS", 9, "good", "면접 질문1?"); + + Optional found = applicantEvalRepository.findByApplicantIdAndAdminId(a.getId(), admin.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(found.get().getScore()).isEqualTo(9); + assertThat(found.get().getMemo()).isEqualTo("good"); + assertThat(found.get().getInterviewQuestion()).isEqualTo("면접 질문1?"); + } + + @Test + @DisplayName("기존 행 있음 → UPDATE (행 추가 없음)") + void update() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Admin admin = persistAdmin("ev1", Track.ENGINEERING); + em.clear(); + + applicantEvalRepository.upsert(a.getId(), admin.getId(), "HOLD", 5, "maybe", "질문 v1"); + applicantEvalRepository.upsert(a.getId(), admin.getId(), "PASS", 10, "changed", "질문 v2"); + + assertThat(applicantEvalRepository.count()).isEqualTo(1); + ApplicantEval e = applicantEvalRepository.findByApplicantIdAndAdminId(a.getId(), admin.getId()).orElseThrow(); + assertThat(e.getDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(e.getScore()).isEqualTo(10); + assertThat(e.getMemo()).isEqualTo("changed"); + assertThat(e.getInterviewQuestion()).isEqualTo("질문 v2"); + } + + @Test + @DisplayName("같은 키 반복 호출 → 유니크 위반 없이 멱등(행 1개)") + void idempotent() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Admin admin = persistAdmin("ev1", Track.ENGINEERING); + em.clear(); + + applicantEvalRepository.upsert(a.getId(), admin.getId(), "PASS", 8, "x", "q"); + applicantEvalRepository.upsert(a.getId(), admin.getId(), "PASS", 8, "x", "q"); + + assertThat(applicantEvalRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("score null 저장 가능") + void nullScore() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Admin admin = persistAdmin("ev1", Track.ENGINEERING); + em.clear(); + + applicantEvalRepository.upsert(a.getId(), admin.getId(), "PENDING", null, null, null); + + ApplicantEval e = applicantEvalRepository.findByApplicantIdAndAdminId(a.getId(), admin.getId()).orElseThrow(); + assertThat(e.getScore()).isNull(); + assertThat(e.getMemo()).isNull(); + assertThat(e.getInterviewQuestion()).isNull(); + assertThat(e.getDecision()).isEqualTo(EvaluationDecision.PENDING); + } + } + + @Nested + @DisplayName("findByApplicantIdWithAdmin / findByRecruitmentId") + class Queries { + + @Test + @DisplayName("findByApplicantIdWithAdmin — 한 지원자의 평가 + admin JOIN FETCH") + void byApplicantWithAdmin() { + Recruitment r = persistRecruitment(27); + Applicant a = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Admin ev1 = persistAdmin("ev1", Track.ENGINEERING); + Admin ev2 = persistAdmin("ev2", Track.ENGINEERING); + em.persistFlushFind(ApplicantEval.builder().applicant(a).admin(ev1).decision(EvaluationDecision.PASS).score(9).build()); + em.persistFlushFind(ApplicantEval.builder().applicant(a).admin(ev2).decision(EvaluationDecision.FAIL).score(2).build()); + em.clear(); + + List result = applicantEvalRepository.findByApplicantIdWithAdmin(a.getId()); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getAdmin().getName()).isNotNull(); // JOIN FETCH 로 즉시 로딩 + } + + @Test + @DisplayName("findByRecruitmentId — 공고 내 모든 평가 (집계용)") + void byRecruitment() { + Recruitment r = persistRecruitment(27); + Applicant a1 = persistApplicant(r, persistUser("p1"), Track.ENGINEERING); + Applicant a2 = persistApplicant(r, persistUser("p2"), Track.ENGINEERING); + Admin ev1 = persistAdmin("ev1", Track.ENGINEERING); + em.persistFlushFind(ApplicantEval.builder().applicant(a1).admin(ev1).decision(EvaluationDecision.PASS).score(9).build()); + em.persistFlushFind(ApplicantEval.builder().applicant(a2).admin(ev1).decision(EvaluationDecision.HOLD).score(5).build()); + em.clear(); + + List result = applicantEvalRepository.findByRecruitmentId(r.getId()); + + assertThat(result).hasSize(2); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepositoryTest.java b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepositoryTest.java index b4ef334..702f1f6 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepositoryTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepositoryTest.java @@ -1,6 +1,7 @@ package com.boaz.backend.domain.recruitment.repository; import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; import com.boaz.backend.domain.recruitment.entity.Recruitment; import com.boaz.backend.domain.user.entity.User; import com.boaz.backend.global.common.enums.MemberType; @@ -76,11 +77,11 @@ void match() { } @Nested - @DisplayName("findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt") + @DisplayName("findSubmittedByRecruitmentIdAndTrackAndDecision") class FindSubmittedByTrack { @Test - @DisplayName("지정 트랙 SUBMITTED 만 submitted_at 오름차순, user JOIN FETCH") + @DisplayName("decision=null → 지정 트랙 SUBMITTED 전체, submitted_at 오름차순, user JOIN FETCH") void submittedOrdered() { Recruitment r = persistRecruitment(27); LocalDateTime base = LocalDateTime.now(); @@ -98,14 +99,44 @@ void submittedOrdered() { em.clear(); List result = applicantRepository - .findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( - r.getId(), Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED); + .findSubmittedByRecruitmentIdAndTrackAndDecision( + r.getId(), Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED, null); assertThat(result).hasSize(2); assertThat(result.get(0).getSubmittedAt()).isBefore(result.get(1).getSubmittedAt()); // JOIN FETCH 로 user 가 즉시 로딩되어 접근 가능해야 함 assertThat(result.get(0).getUser().getId()).isNotNull(); } + + @Test + @DisplayName("decision=PASS → 해당 트랙의 final_decision=PASS 지원자만 반환") + void filterByPassDecision() { + Recruitment r = persistRecruitment(27); + LocalDateTime base = LocalDateTime.now(); + + Applicant pass = persistApplicant(r, persistUser("eng-pass"), Track.ENGINEERING, + Applicant.ApplicantStatus.SUBMITTED, base.plusHours(1)); + pass.updateFinalDecision(EvaluationDecision.PASS); + Applicant fail = persistApplicant(r, persistUser("eng-fail"), Track.ENGINEERING, + Applicant.ApplicantStatus.SUBMITTED, base.plusHours(2)); + fail.updateFinalDecision(EvaluationDecision.FAIL); + em.flush(); + em.clear(); + + List passResult = applicantRepository + .findSubmittedByRecruitmentIdAndTrackAndDecision( + r.getId(), Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED, + EvaluationDecision.PASS); + List failResult = applicantRepository + .findSubmittedByRecruitmentIdAndTrackAndDecision( + r.getId(), Track.ENGINEERING, Applicant.ApplicantStatus.SUBMITTED, + EvaluationDecision.FAIL); + + assertThat(passResult).hasSize(1); + assertThat(passResult.get(0).getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(failResult).hasSize(1); + assertThat(failResult.get(0).getFinalDecision()).isEqualTo(EvaluationDecision.FAIL); + } } @Nested diff --git a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicationQuestionRepositoryTest.java b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicationQuestionRepositoryTest.java index 6a3d995..c0b7041 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicationQuestionRepositoryTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/repository/ApplicationQuestionRepositoryTest.java @@ -86,6 +86,12 @@ void existsQueries() { assertThat(questionRepository.existsByRecruitmentIdAndLabel(r.getId(), "공통1")).isTrue(); assertThat(questionRepository.existsByRecruitmentIdAndLabel(r.getId(), "없는라벨")).isFalse(); + // label 자기 자신 제외 (REC-ADMIN-008 질문 수정 중복 검증) + assertThat(questionRepository.existsByRecruitmentIdAndLabelAndIdNot( + r.getId(), "공통1", savedId)).isFalse(); + assertThat(questionRepository.existsByRecruitmentIdAndLabelAndIdNot( + r.getId(), "공통1", savedId + 1)).isTrue(); + assertThat(questionRepository.existsByRecruitmentIdAndCategoryAndOrderNum( r.getId(), Category.COMMON, 1)).isTrue(); // 자기 자신(savedId) 제외 시 중복 아님 @@ -94,4 +100,17 @@ void existsQueries() { assertThat(questionRepository.existsByRecruitmentIdAndCategoryAndOrderNumAndIdNot( r.getId(), Category.COMMON, 1, savedId + 1)).isTrue(); } + + @Test + @DisplayName("existsByRecruitmentId: 공고 연관 질문 존재 여부 (REC-ADMIN-005 삭제 가드)") + void existsByRecruitmentId() { + Recruitment r = persistRecruitment(27); + Recruitment empty = persistRecruitment(26); + persistQuestion(r, "공통1", Category.COMMON, 1); + em.flush(); + em.clear(); + + assertThat(questionRepository.existsByRecruitmentId(r.getId())).isTrue(); + assertThat(questionRepository.existsByRecruitmentId(empty.getId())).isFalse(); + } } diff --git a/src/test/java/com/boaz/backend/domain/recruitment/service/CsvServiceTest.java b/src/test/java/com/boaz/backend/domain/recruitment/service/CsvServiceTest.java index b6dd12e..cbf43d0 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/service/CsvServiceTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/service/CsvServiceTest.java @@ -92,6 +92,68 @@ private String generateAndExtractCell(Applicant applicant, ApplicationQuestion q return last; } + @Nested + @DisplayName("최종 평가(final_decision) 컬럼") + class FinalDecisionColumn { + + @Test + @DisplayName("헤더에 '최종 평가' 컬럼이 '전화번호' 다음에 포함") + void headerContainsFinalDecision() throws IOException { + Recruitment r = makeRecruitment(); + Applicant a = makeApplicant(r); + + byte[] csv = csvService.generateCsv(List.of(a), List.of(), List.of()); + String header = new String(csv, java.nio.charset.StandardCharsets.UTF_8) + .replaceAll("\r", "").split("\n")[0]; + String[] cols = header.split(",", -1); + + assertThat(header).contains("최종 평가"); + // 전화번호(index 4) 바로 뒤가 최종 평가(index 5) + assertThat(unquote(cols[4])).isEqualTo("전화번호"); + assertThat(unquote(cols[5])).isEqualTo("최종 평가"); + } + + @Test + @DisplayName("final_decision=PASS → '합격', FAIL → '불합격' 한글 출력") + void decisionRenderedInKorean() throws IOException { + Recruitment r = makeRecruitment(); + Applicant pass = makeApplicant(r); + pass.updateFinalDecision(com.boaz.backend.domain.recruitment.entity.EvaluationDecision.PASS); + + assertThat(decisionCell(pass)).isEqualTo("합격"); + + Applicant fail = makeApplicant(r); + fail.updateFinalDecision(com.boaz.backend.domain.recruitment.entity.EvaluationDecision.FAIL); + assertThat(decisionCell(fail)).isEqualTo("불합격"); + } + + @Test + @DisplayName("기본값(PENDING) → '미정' 출력") + void defaultPendingRendered() throws IOException { + Recruitment r = makeRecruitment(); + Applicant a = makeApplicant(r); // 기본 final_decision = PENDING + + assertThat(decisionCell(a)).isEqualTo("미정"); + } + + // 데이터 행에서 '최종 평가' 셀(index 5) 추출 + private String decisionCell(Applicant a) throws IOException { + byte[] csv = csvService.generateCsv(List.of(a), List.of(), List.of()); + String dataRow = new String(csv, java.nio.charset.StandardCharsets.UTF_8) + .replaceAll("\r", "").split("\n")[1]; + String[] cells = dataRow.split(",", -1); + return unquote(cells[5]); + } + + private String unquote(String cell) { + String c = cell.trim(); + if (c.startsWith("\"") && c.endsWith("\"")) { + c = c.substring(1, c.length() - 1).replace("\"\"", "\""); + } + return c; + } + } + @Nested @DisplayName("formatAnswer — TEXT 답변") class TextAnswer { diff --git a/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentEvaluationServiceTest.java b/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentEvaluationServiceTest.java new file mode 100644 index 0000000..808c187 --- /dev/null +++ b/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentEvaluationServiceTest.java @@ -0,0 +1,732 @@ +package com.boaz.backend.domain.recruitment.service; + +import com.boaz.backend.domain.admin.entity.Admin; +import com.boaz.backend.domain.admin.repository.AdminRepository; +import com.boaz.backend.domain.recruitment.dto.request.EvaluationSaveRequest; +import com.boaz.backend.domain.recruitment.dto.request.FinalDecisionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.response.*; +import com.boaz.backend.domain.recruitment.entity.Applicant; +import com.boaz.backend.domain.recruitment.entity.ApplicantAnswer; +import com.boaz.backend.domain.recruitment.entity.ApplicantEval; +import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion; +import com.boaz.backend.domain.recruitment.entity.EvaluationDecision; +import com.boaz.backend.domain.recruitment.entity.Recruitment; +import com.boaz.backend.domain.recruitment.repository.ApplicantAnswerRepository; +import com.boaz.backend.domain.recruitment.repository.ApplicantEvalRepository; +import com.boaz.backend.domain.recruitment.repository.ApplicantRepository; +import com.boaz.backend.domain.recruitment.repository.RecruitmentRepository; +import com.boaz.backend.domain.user.entity.User; +import com.boaz.backend.global.common.enums.MemberType; +import com.boaz.backend.global.common.enums.Track; +import com.boaz.backend.global.exception.CustomException; +import com.boaz.backend.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("지원서 평가 (어드민) 서비스 단위 테스트") +class RecruitmentEvaluationServiceTest { + + @InjectMocks + RecruitmentService recruitmentService; + + @Mock RecruitmentRepository recruitmentRepository; + @Mock ApplicantRepository applicantRepository; + @Mock ApplicantAnswerRepository applicantAnswerRepository; + @Mock ApplicantEvalRepository applicantEvalRepository; + @Mock AdminRepository adminRepository; + @Spy ObjectMapper objectMapper; + + // ── 헬퍼 ────────────────────────────────────────── + + private Admin admin(Long id, Admin.Role role, Admin.TeamName team, Track track) { + Admin a = Admin.builder() + .username("u" + id).password("p").role(role).name("admin" + id) + .track(track).term(27).teamName(team).createdBy(null) + .build(); + ReflectionTestUtils.setField(a, "id", id); + return a; + } + + private User user(Long id) { + User u = User.builder() + .provider("kakao").providerId("pid" + id) + .nickname("nick" + id).memberType(MemberType.OUTSIDER) + .build(); + ReflectionTestUtils.setField(u, "id", id); + return u; + } + + private Applicant applicant(Long id, Applicant.ApplicantStatus status, Track track) { + Applicant a = Applicant.builder() + .recruitment(mock(Recruitment.class)).user(user(id)).status(status).track(track) + .name("name" + id).email(id + "@example.com").phone("01000000000") + .build(); + ReflectionTestUtils.setField(a, "id", id); + return a; + } + + private ApplicantEval eval(Long id, Applicant applicant, Admin admin, + EvaluationDecision decision, Integer score, String memo) { + ApplicantEval e = ApplicantEval.builder() + .applicant(applicant).admin(admin).decision(decision).score(score).memo(memo) + .build(); + ReflectionTestUtils.setField(e, "id", id); + return e; + } + + private ApplicationQuestion question(Long id, Integer orderNum, ApplicationQuestion.Type type, String content) { + ApplicationQuestion q = ApplicationQuestion.create( + mock(Recruitment.class), "label" + id, ApplicationQuestion.Category.COMMON, + type, content, null, 500, null, orderNum, true); + ReflectionTestUtils.setField(q, "id", id); + return q; + } + + private ApplicantAnswer answer(Applicant applicant, ApplicationQuestion question, String text, String json) { + return ApplicantAnswer.builder() + .applicant(applicant).question(question).answerText(text).answerJson(json) + .build(); + } + + // ── 1. 전체 지원서 조회 (지원자 대시보드) ────────────── + + @Nested + @DisplayName("getApplicants") + class GetApplicants { + + @Test + @DisplayName("[정상] 차기 대표진은 전 부문(DRAFT 포함) 반환") + void nextRepresentativeSeesAll() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + given(recruitmentRepository.existsById(1L)).willReturn(true); + given(applicantRepository.findByRecruitmentIdWithUser(1L)).willReturn(List.of( + applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING), + applicant(102L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS) + )); + + List result = recruitmentService.getApplicants(1L, rep); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("[권한] 현재 대표진은 본인 track만 반환 (전 부문 아님)") + void currentRepresentativeOwnTrackOnly() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + given(recruitmentRepository.existsById(1L)).willReturn(true); + given(applicantRepository.findByRecruitmentIdWithUser(1L)).willReturn(List.of( + applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING), + applicant(102L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS), + applicant(103L, Applicant.ApplicantStatus.DRAFT, null) + )); + + List result = recruitmentService.getApplicants(1L, rep); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTrack()).isEqualTo(Track.ENGINEERING); + } + + @Test + @DisplayName("[권한] 비대표진은 본인 track만 반환") + void nonRepresentativeOwnTrackOnly() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(recruitmentRepository.existsById(1L)).willReturn(true); + given(applicantRepository.findByRecruitmentIdWithUser(1L)).willReturn(List.of( + applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING), + applicant(102L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS), + applicant(103L, Applicant.ApplicantStatus.DRAFT, null) + )); + + List result = recruitmentService.getApplicants(1L, me); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTrack()).isEqualTo(Track.ENGINEERING); + } + + @Test + @DisplayName("[예외] 존재하지 않는 공고 → RECRUITMENT_NOT_FOUND") + void recruitmentNotFound() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(recruitmentRepository.existsById(999L)).willReturn(false); + + assertThatThrownBy(() -> recruitmentService.getApplicants(999L, me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); + verify(applicantRepository, never()).findByRecruitmentIdWithUser(any()); + } + } + + // ── 2. 전체 지원서 및 평가 조회 (평가 대시보드, 서버 집계) ── + + @Nested + @DisplayName("getApplicantEvaluations") + class GetApplicantEvaluations { + + @Test + @DisplayName("[정상] PASS/HOLD/FAIL 개수 + 총점(null·PENDING 제외) 집계, final_decision 반영") + void aggregate() { + Admin ev1 = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin ev2 = admin(2L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + Applicant a1 = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + a1.updateFinalDecision(EvaluationDecision.PENDING); + Applicant a2 = applicant(102L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + a2.updateFinalDecision(EvaluationDecision.PASS); + + given(recruitmentRepository.existsById(1L)).willReturn(true); + given(applicantRepository.findByRecruitmentIdAndStatusWithUser(1L, Applicant.ApplicantStatus.SUBMITTED)) + .willReturn(List.of(a1, a2)); + given(applicantEvalRepository.findByRecruitmentId(1L)).willReturn(List.of( + // a1: PASS(9), HOLD(6), PENDING(null) → pass1 hold1 fail0 total15 + eval(1L, a1, ev1, EvaluationDecision.PASS, 9, "good"), + eval(2L, a1, ev2, EvaluationDecision.HOLD, 6, "maybe"), + eval(3L, a1, admin(3L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING), + EvaluationDecision.PENDING, null, null), + // a2: FAIL(3) → fail1 total3 + eval(4L, a2, ev1, EvaluationDecision.FAIL, 3, "no") + )); + + // 로그인 본인(ev1)으로 조회 → my_decision은 ev1의 결정 + List result = recruitmentService.getApplicantEvaluations(1L, ev1); + + ApplicantEvaluationResponse r1 = result.get(0); + assertThat(r1.getPassCount()).isEqualTo(1); + assertThat(r1.getHoldCount()).isEqualTo(1); + assertThat(r1.getFailCount()).isEqualTo(0); + assertThat(r1.getTotalScore()).isEqualTo(15); // 9 + 6, null 제외 + assertThat(r1.getFinalDecision()).isEqualTo(EvaluationDecision.PENDING); + assertThat(r1.getMyDecision()).isEqualTo(EvaluationDecision.PASS); // ev1의 a1 평가 + + ApplicantEvaluationResponse r2 = result.get(1); + assertThat(r2.getFailCount()).isEqualTo(1); + assertThat(r2.getTotalScore()).isEqualTo(3); + assertThat(r2.getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(r2.getMyDecision()).isEqualTo(EvaluationDecision.FAIL); // ev1의 a2 평가 + } + + @Test + @DisplayName("[정상] 평가가 없는 지원자는 개수·총점 0") + void noEval() { + Applicant a1 = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + + given(recruitmentRepository.existsById(1L)).willReturn(true); + given(applicantRepository.findByRecruitmentIdAndStatusWithUser(1L, Applicant.ApplicantStatus.SUBMITTED)) + .willReturn(List.of(a1)); + given(applicantEvalRepository.findByRecruitmentId(1L)).willReturn(List.of()); + + Admin rep = admin(9L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ANALYSIS); + ApplicantEvaluationResponse r = recruitmentService.getApplicantEvaluations(1L, rep).get(0); + assertThat(r.getPassCount()).isZero(); + assertThat(r.getHoldCount()).isZero(); + assertThat(r.getFailCount()).isZero(); + assertThat(r.getTotalScore()).isZero(); + assertThat(r.getMyDecision()).isNull(); // 본인 미평가 → null + } + } + + // ── 3. 최종 평가 수정 (대표진 전용) ─────────────────── + + @Nested + @DisplayName("updateFinalDecision") + class UpdateFinalDecision { + + private FinalDecisionUpdateRequest req(EvaluationDecision d) { + FinalDecisionUpdateRequest r = new FinalDecisionUpdateRequest(); + ReflectionTestUtils.setField(r, "finalDecision", d); + return r; + } + + @Test + @DisplayName("[정상] 현재 대표진이 본인 track 지원자 최종 평가 변경") + void currentRepresentativeOwnTrack() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + + FinalDecisionResponse res = recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), rep); + + assertThat(res.getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(a.getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + } + + @Test + @DisplayName("[정상] 차기 대표진은 타 부문 지원자도 최종 평가 변경") + void nextRepresentativeCrossTrack() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + + FinalDecisionResponse res = recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), rep); + + assertThat(res.getFinalDecision()).isEqualTo(EvaluationDecision.PASS); + } + + @Test + @DisplayName("[권한] 현재 대표진이 타 부문 지원자 최종 평가 → ACCESS_DENIED") + void currentRepresentativeCrossTrackDenied() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), rep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + assertThat(a.getFinalDecision()).isEqualTo(EvaluationDecision.PENDING); // 변경 안 됨 + } + + @Test + @DisplayName("[권한] 서비스운영팀 SUPER → ACCESS_DENIED (대표진 아님)") + void notRepresentativeTeam() { + Admin superNotRep = admin(1L, Admin.Role.SUPER, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), superNotRep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(applicantRepository, never()).findById(any()); + } + + @Test + @DisplayName("[권한] TEAM 대표진 → ACCESS_DENIED (SUPER 아님)") + void notRepresentativeRole() { + Admin teamRep = admin(1L, Admin.Role.TEAM, Admin.TeamName.대표진, Track.ENGINEERING); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), teamRep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("[예외] DRAFT 지원서 → INVALID_INPUT_VALUE") + void draftRejected() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.DRAFT, null))); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision(101L, req(EvaluationDecision.PASS), rep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.updateFinalDecision(999L, req(EvaluationDecision.PASS), rep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } + } + + // ── 4. 지원서별 평가 조회 ───────────────────────────── + + @Nested + @DisplayName("getApplicantEvaluators") + class GetApplicantEvaluators { + + @Test + @DisplayName("[정상] 부문 평가자 전체 반환, 미평가자는 decision/score/memo null") + void mergeWithUnevaluated() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + Admin ev1 = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin ev2 = admin(2L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(adminRepository.findEvaluatorPool(Track.ENGINEERING, Admin.Role.SUPER, Admin.TeamName.차기대표진)) + .willReturn(List.of(ev1, ev2)); + given(applicantEvalRepository.findByApplicantIdWithAdmin(101L)) + .willReturn(List.of(eval(1L, a, ev1, EvaluationDecision.PASS, 8, "ok"))); + + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + ApplicantEvaluatorsResponse res = recruitmentService.getApplicantEvaluators(101L, viewer); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getEvaluations()).hasSize(2); + EvaluatorEvaluationResponse e1 = res.getEvaluations().get(0); + assertThat(e1.getDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(e1.getScore()).isEqualTo(8); + EvaluatorEvaluationResponse e2 = res.getEvaluations().get(1); // 미평가 + assertThat(e2.getDecision()).isNull(); + assertThat(e2.getScore()).isNull(); + assertThat(e2.getMemo()).isNull(); + } + + @Test + @DisplayName("[권한] 현재 대표진도 타 부문 지원자 조회 → ACCESS_DENIED (본인 track만)") + void currentRepresentativeCrossTrackDenied() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + Admin rep = admin(5L, Admin.Role.SUPER, Admin.TeamName.대표진, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + + assertThatThrownBy(() -> recruitmentService.getApplicantEvaluators(101L, rep)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(adminRepository, never()).findEvaluatorPool(any(), any(), any()); + } + + @Test + @DisplayName("[정상] 차기 대표진은 타 부문 지원자도 조회 가능") + void nextRepresentativeCrossTrack() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + Admin rep = admin(9L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(adminRepository.findEvaluatorPool(Track.ANALYSIS, Admin.Role.SUPER, Admin.TeamName.차기대표진)) + .willReturn(List.of()); + given(applicantEvalRepository.findByApplicantIdWithAdmin(101L)).willReturn(List.of()); + + ApplicantEvaluatorsResponse res = recruitmentService.getApplicantEvaluators(101L, rep); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getEvaluations()).isEmpty(); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.getApplicantEvaluators(999L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } + } + + // ── 5. 개인 평가 조회 ───────────────────────────────── + + @Nested + @DisplayName("getMyEvaluation") + class GetMyEvaluation { + + @Test + @DisplayName("[정상] 본인 평가 존재 → 단건 반환") + void found() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(applicantEvalRepository.findByApplicantIdAndAdminId(101L, 1L)) + .willReturn(Optional.of(eval(7L, a, me, EvaluationDecision.HOLD, 5, "hmm"))); + + MyEvaluationResponse res = recruitmentService.getMyEvaluation(101L, me); + + assertThat(res).isNotNull(); + assertThat(res.getEvaluationId()).isEqualTo(7L); + assertThat(res.getDecision()).isEqualTo(EvaluationDecision.HOLD); + } + + @Test + @DisplayName("[정상] 본인 평가 없음 → null") + void none() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING))); + given(applicantEvalRepository.findByApplicantIdAndAdminId(101L, 1L)).willReturn(Optional.empty()); + + assertThat(recruitmentService.getMyEvaluation(101L, me)).isNull(); + } + + @Test + @DisplayName("[권한] 비대표진이 타 부문 지원자 개인 평가 조회 → ACCESS_DENIED") + void crossTrackDenied() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS))); + + assertThatThrownBy(() -> recruitmentService.getMyEvaluation(101L, me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(applicantEvalRepository, never()).findByApplicantIdAndAdminId(any(), any()); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.getMyEvaluation(999L, me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } + } + + // ── 6. 개인 평가 저장 (upsert) ──────────────────────── + + @Nested + @DisplayName("saveMyEvaluation") + class SaveMyEvaluation { + + private EvaluationSaveRequest req(EvaluationDecision d, Integer score, String memo, String interviewQuestion) { + EvaluationSaveRequest r = new EvaluationSaveRequest(); + ReflectionTestUtils.setField(r, "decision", d); + ReflectionTestUtils.setField(r, "score", score); + ReflectionTestUtils.setField(r, "memo", memo); + ReflectionTestUtils.setField(r, "interviewQuestion", interviewQuestion); + return r; + } + + @Test + @DisplayName("[정상] 본인 부문 지원자 → upsert(면접질문 포함) 호출 후 저장값 반환") + void success() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + ApplicantEval saved = ApplicantEval.builder() + .applicant(a).admin(me).decision(EvaluationDecision.PASS).score(10).memo("great") + .interviewQuestion("면접 질문?").build(); + ReflectionTestUtils.setField(saved, "id", 9L); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(applicantEvalRepository.findByApplicantIdAndAdminId(101L, 1L)).willReturn(Optional.of(saved)); + + MyEvaluationResponse res = recruitmentService.saveMyEvaluation( + 101L, req(EvaluationDecision.PASS, 10, "great", "면접 질문?"), me); + + verify(applicantEvalRepository).upsert(101L, 1L, "PASS", 10, "great", "면접 질문?"); + assertThat(res.getDecision()).isEqualTo(EvaluationDecision.PASS); + assertThat(res.getScore()).isEqualTo(10); + assertThat(res.getInterviewQuestion()).isEqualTo("면접 질문?"); + } + + @Test + @DisplayName("[정상] 차기 대표진은 타 부문 지원자도 평가 가능") + void nextRepresentativeCrossTrack() { + Admin rep = admin(1L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(applicantEvalRepository.findByApplicantIdAndAdminId(101L, 1L)) + .willReturn(Optional.of(eval(9L, a, rep, EvaluationDecision.PASS, 8, "ok"))); + + MyEvaluationResponse res = recruitmentService.saveMyEvaluation( + 101L, req(EvaluationDecision.PASS, 8, "ok", null), rep); + + verify(applicantEvalRepository).upsert(101L, 1L, "PASS", 8, "ok", null); + assertThat(res.getDecision()).isEqualTo(EvaluationDecision.PASS); + } + + @Test + @DisplayName("[권한] 비대표진이 타 부문 지원자 → ACCESS_DENIED, upsert 미호출") + void trackMismatch() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ANALYSIS); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING))); + + assertThatThrownBy(() -> recruitmentService.saveMyEvaluation( + 101L, req(EvaluationDecision.PASS, 10, "x", "q"), me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(applicantEvalRepository, never()).upsert(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("[예외] DRAFT 지원서 → INVALID_INPUT_VALUE") + void draftRejected() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.DRAFT, null))); + + assertThatThrownBy(() -> recruitmentService.saveMyEvaluation( + 101L, req(EvaluationDecision.PASS, 10, "x", "q"), me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin me = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.saveMyEvaluation( + 999L, req(EvaluationDecision.PASS, 10, "x", "q"), me)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } + } + + // ── 7. 지원서 답변 조회 ─────────────────────────────── + + @Nested + @DisplayName("getApplicantAnswers") + class GetApplicantAnswers { + + @Test + @DisplayName("[정상] 문항 정보 + 답변(TEXT/TABLE) 순서대로 반환, {Track} 치환") + void success() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + ApplicationQuestion q1 = question(1L, 1, ApplicationQuestion.Type.TEXT, "{Track} 지원 동기를 작성해주세요."); + ApplicationQuestion q2 = question(2L, 2, ApplicationQuestion.Type.TABLE, "기술 스택 숙련도"); + + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(applicantAnswerRepository.findByApplicantIdWithQuestion(101L)).willReturn(List.of( + answer(a, q1, "저는 ~~", null), + answer(a, q2, null, "{\"Python\":\"능숙\"}") + )); + + ApplicantAnswersResponse res = recruitmentService.getApplicantAnswers(101L, viewer); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getAnswers()).hasSize(2); + + ApplicantAnswersResponse.AnswerDetailResponse first = res.getAnswers().get(0); + assertThat(first.getQuestionId()).isEqualTo(1L); + assertThat(first.getType()).isEqualTo(ApplicationQuestion.Type.TEXT); + assertThat(first.getContent()).isEqualTo("엔지니어링 지원 동기를 작성해주세요."); // {Track} 치환 + assertThat(first.getAnswer().asText()).isEqualTo("저는 ~~"); + + ApplicantAnswersResponse.AnswerDetailResponse second = res.getAnswers().get(1); + assertThat(second.getType()).isEqualTo(ApplicationQuestion.Type.TABLE); + assertThat(second.getAnswer().get("Python").asText()).isEqualTo("능숙"); + } + + @Test + @DisplayName("[정상] 답변 없음 → 빈 배열") + void empty() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ANALYSIS); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS))); + given(applicantAnswerRepository.findByApplicantIdWithQuestion(101L)).willReturn(List.of()); + + ApplicantAnswersResponse res = recruitmentService.getApplicantAnswers(101L, viewer); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getAnswers()).isEmpty(); + } + + @Test + @DisplayName("[권한] 비대표진이 타 부문 지원서 답변 조회 → ACCESS_DENIED") + void crossTrackDenied() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS))); + + assertThatThrownBy(() -> recruitmentService.getApplicantAnswers(101L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(applicantAnswerRepository, never()).findByApplicantIdWithQuestion(any()); + } + + @Test + @DisplayName("[정상] 차기 대표진은 타 부문 지원서 답변도 조회 가능") + void nextRepresentativeCrossTrack() { + Admin rep = admin(9L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + given(applicantRepository.findById(101L)) + .willReturn(Optional.of(applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS))); + given(applicantAnswerRepository.findByApplicantIdWithQuestion(101L)).willReturn(List.of()); + + ApplicantAnswersResponse res = recruitmentService.getApplicantAnswers(101L, rep); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getAnswers()).isEmpty(); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.getApplicantAnswers(999L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + verify(applicantAnswerRepository, never()).findByApplicantIdWithQuestion(any()); + } + } + + // ── 8. 지원서별 면접 질문 조회 ──────────────────────── + + @Nested + @DisplayName("getApplicantInterviewQuestions") + class GetApplicantInterviewQuestions { + + private ApplicantEval evalWithQuestion(Applicant a, Admin admin, String interviewQuestion) { + return ApplicantEval.builder() + .applicant(a).admin(admin).decision(EvaluationDecision.PASS).score(8).memo("m") + .interviewQuestion(interviewQuestion).build(); + } + + @Test + @DisplayName("[정상] 부문 평가자 전체 반환, 미작성자는 interview_question null") + void mergeWithUnwritten() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ENGINEERING); + Admin ev1 = admin(1L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin ev2 = admin(2L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(adminRepository.findEvaluatorPool(Track.ENGINEERING, Admin.Role.SUPER, Admin.TeamName.차기대표진)) + .willReturn(List.of(ev1, ev2)); + given(applicantEvalRepository.findByApplicantIdWithAdmin(101L)) + .willReturn(List.of(evalWithQuestion(a, ev1, "프로젝트 X에 대해 설명해주세요"))); + + ApplicantInterviewQuestionsResponse res = recruitmentService.getApplicantInterviewQuestions(101L, viewer); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getInterviewQuestions()).hasSize(2); + EvaluatorInterviewQuestionResponse q1 = res.getInterviewQuestions().get(0); + assertThat(q1.getAdminId()).isEqualTo(1L); + assertThat(q1.getTrack()).isEqualTo(Track.ENGINEERING); + assertThat(q1.getInterviewQuestion()).isEqualTo("프로젝트 X에 대해 설명해주세요"); + EvaluatorInterviewQuestionResponse q2 = res.getInterviewQuestions().get(1); // 미작성 + assertThat(q2.getInterviewQuestion()).isNull(); + } + + @Test + @DisplayName("[권한] 비대표진이 타 부문 지원자 조회 → ACCESS_DENIED") + void crossTrackDenied() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + + assertThatThrownBy(() -> recruitmentService.getApplicantInterviewQuestions(101L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.ACCESS_DENIED); + verify(adminRepository, never()).findEvaluatorPool(any(), any(), any()); + } + + @Test + @DisplayName("[정상] 차기 대표진은 타 부문 지원자도 조회 가능") + void nextRepresentativeCrossTrack() { + Applicant a = applicant(101L, Applicant.ApplicantStatus.SUBMITTED, Track.ANALYSIS); + Admin rep = admin(9L, Admin.Role.SUPER, Admin.TeamName.차기대표진, Track.ENGINEERING); + given(applicantRepository.findById(101L)).willReturn(Optional.of(a)); + given(adminRepository.findEvaluatorPool(Track.ANALYSIS, Admin.Role.SUPER, Admin.TeamName.차기대표진)) + .willReturn(List.of()); + given(applicantEvalRepository.findByApplicantIdWithAdmin(101L)).willReturn(List.of()); + + ApplicantInterviewQuestionsResponse res = recruitmentService.getApplicantInterviewQuestions(101L, rep); + + assertThat(res.getApplicantId()).isEqualTo(101L); + assertThat(res.getInterviewQuestions()).isEmpty(); + } + + @Test + @DisplayName("[예외] 존재하지 않는 지원자 → APPLICATION_NOT_FOUND") + void notFound() { + Admin viewer = admin(5L, Admin.Role.TEAM, Admin.TeamName.서비스운영팀, Track.ENGINEERING); + given(applicantRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.getApplicantInterviewQuestions(999L, viewer)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.APPLICATION_NOT_FOUND); + } + } +} diff --git a/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentServiceTest.java b/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentServiceTest.java index 21020a7..3108ba3 100644 --- a/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentServiceTest.java +++ b/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentServiceTest.java @@ -3,7 +3,14 @@ import com.boaz.backend.domain.recruitment.dto.request.AnswerRequest; import com.boaz.backend.domain.recruitment.dto.request.ApplicationRequest; import com.boaz.backend.domain.recruitment.dto.request.DraftApplicationRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionItemRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionsCreateRequest; +import com.boaz.backend.domain.recruitment.dto.request.QuestionUpdateRequest; +import com.boaz.backend.domain.recruitment.dto.request.RecruitmentCreateRequest; +import com.boaz.backend.domain.recruitment.dto.request.RecruitmentUpdateRequest; import com.boaz.backend.domain.recruitment.dto.request.SubscriptionRequest; +import org.openapitools.jackson.nullable.JsonNullable; +import java.util.concurrent.atomic.AtomicLong; import com.boaz.backend.domain.recruitment.dto.response.*; import com.boaz.backend.domain.recruitment.entity.*; import com.boaz.backend.domain.recruitment.repository.*; @@ -40,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -1337,18 +1345,18 @@ void uploadThreeCsvFiles() throws Exception { Applicant eng = createSubmittedApplicant(r, u); when(recruitmentRepository.findByTerm(27)).thenReturn(Optional.of(r)); - when(applicantRepository.findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( - eq(1L), eq(Track.ENGINEERING), any())).thenReturn(List.of(eng)); - when(applicantRepository.findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( - eq(1L), eq(Track.ANALYSIS), any())).thenReturn(Collections.emptyList()); - when(applicantRepository.findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt( - eq(1L), eq(Track.VISUALIZATION), any())).thenReturn(Collections.emptyList()); + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision( + eq(1L), eq(Track.ENGINEERING), any(), any())).thenReturn(List.of(eng)); + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision( + eq(1L), eq(Track.ANALYSIS), any(), any())).thenReturn(Collections.emptyList()); + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision( + eq(1L), eq(Track.VISUALIZATION), any(), any())).thenReturn(Collections.emptyList()); when(applicationQuestionRepository.findByRecruitmentIdAndCategories(any(), any(), any())) .thenReturn(Collections.emptyList()); when(applicantAnswerRepository.findByApplicantIds(any())).thenReturn(Collections.emptyList()); when(csvService.generateCsv(any(), any(), any())).thenReturn(new byte[0]); - recruitmentService.downloadApplications(27); + recruitmentService.downloadApplications(27, DecisionFilter.ALL); verify(s3Service, times(3)).uploadCsv(eq("test-bucket"), any(), any()); } @@ -1358,13 +1366,13 @@ void uploadThreeCsvFiles() throws Exception { void emptyApplicants() throws Exception { Recruitment r = createActiveRecruitment(); when(recruitmentRepository.findByTerm(27)).thenReturn(Optional.of(r)); - when(applicantRepository.findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt(any(), any(), any())) + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision(any(), any(), any(), any())) .thenReturn(Collections.emptyList()); when(applicationQuestionRepository.findByRecruitmentIdAndCategories(any(), any(), any())) .thenReturn(Collections.emptyList()); when(csvService.generateCsv(any(), any(), any())).thenReturn(new byte[0]); - recruitmentService.downloadApplications(27); + recruitmentService.downloadApplications(27, DecisionFilter.ALL); verify(s3Service, times(3)).uploadCsv(any(), any(), any()); } @@ -1374,11 +1382,52 @@ void emptyApplicants() throws Exception { void notFound() { when(recruitmentRepository.findByTerm(999)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> recruitmentService.downloadApplications(999)) + assertThatThrownBy(() -> recruitmentService.downloadApplications(999, DecisionFilter.ALL)) .isInstanceOf(CustomException.class) .extracting(e -> ((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); } + + @Test + @DisplayName("TC-012 decision=PASS → 합격 필터로 조회하고 S3 키에 PASS 표기") + void passFilter() throws Exception { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findByTerm(27)).thenReturn(Optional.of(r)); + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision(any(), any(), any(), any())) + .thenReturn(Collections.emptyList()); + when(applicationQuestionRepository.findByRecruitmentIdAndCategories(any(), any(), any())) + .thenReturn(Collections.emptyList()); + when(csvService.generateCsv(any(), any(), any())).thenReturn(new byte[0]); + + recruitmentService.downloadApplications(27, DecisionFilter.PASS); + + // 조회 시 final_decision=PASS 필터가 전달됨 + verify(applicantRepository, times(3)).findSubmittedByRecruitmentIdAndTrackAndDecision( + any(), any(), any(), eq(EvaluationDecision.PASS)); + // S3 키에 PASS 표기 (부문별 3건) + org.mockito.ArgumentCaptor keyCaptor = + org.mockito.ArgumentCaptor.forClass(String.class); + verify(s3Service, times(3)).uploadCsv(any(), keyCaptor.capture(), any()); + assertThat(keyCaptor.getAllValues()).allMatch(k -> k.contains("_PASS_")); + } + + @Test + @DisplayName("TC-013 decision=ALL → final_decision 필터 null(전체) 전달") + void allFilterPassesNull() throws Exception { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findByTerm(27)).thenReturn(Optional.of(r)); + when(applicantRepository.findSubmittedByRecruitmentIdAndTrackAndDecision(any(), any(), any(), any())) + .thenReturn(Collections.emptyList()); + when(applicationQuestionRepository.findByRecruitmentIdAndCategories(any(), any(), any())) + .thenReturn(Collections.emptyList()); + when(csvService.generateCsv(any(), any(), any())).thenReturn(new byte[0]); + + recruitmentService.downloadApplications(27, DecisionFilter.ALL); + + // ALL → null 필터(전체)로 조회 + verify(applicantRepository, times(3)).findSubmittedByRecruitmentIdAndTrackAndDecision( + any(), any(), any(), isNull()); + } } // ══════════════════════════════════════════════ @@ -1569,4 +1618,782 @@ void submitAtNextMidnight() { .isEqualTo(ErrorCode.RECRUITMENT_CLOSED); } } + + // ────────────────────────────────────────────── + // Admin CRUD 테스트 헬퍼 + // ────────────────────────────────────────────── + + private RecruitmentCreateRequest buildCreateRecruitmentReq( + Integer term, LocalDateTime start, LocalDateTime end, String brochure) { + RecruitmentCreateRequest req = new RecruitmentCreateRequest(); + ReflectionTestUtils.setField(req, "term", term); + ReflectionTestUtils.setField(req, "startDate", start); + ReflectionTestUtils.setField(req, "endDate", end); + ReflectionTestUtils.setField(req, "schedule", objectMapper.createArrayNode()); + ReflectionTestUtils.setField(req, "brochureUrl", brochure); + return req; + } + + private QuestionItemRequest buildQuestionItem(String label, ApplicationQuestion.Category category, + ApplicationQuestion.Type type, Integer limitLength, JsonNode metadata, int orderNum) { + QuestionItemRequest item = new QuestionItemRequest(); + ReflectionTestUtils.setField(item, "label", label); + ReflectionTestUtils.setField(item, "category", category); + ReflectionTestUtils.setField(item, "type", type); + ReflectionTestUtils.setField(item, "content", "질문 " + label); + ReflectionTestUtils.setField(item, "limitLength", limitLength); + ReflectionTestUtils.setField(item, "metadata", metadata); + ReflectionTestUtils.setField(item, "orderNum", orderNum); + ReflectionTestUtils.setField(item, "isRequired", true); + return item; + } + + private QuestionsCreateRequest buildQuestionsCreateReq(Long recruitmentId, QuestionItemRequest... items) { + QuestionsCreateRequest req = new QuestionsCreateRequest(); + ReflectionTestUtils.setField(req, "recruitmentId", recruitmentId); + ReflectionTestUtils.setField(req, "questions", List.of(items)); + return req; + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-002: 모든 모집 공고 조회 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-002 모든 모집 공고 조회") + class GetAllRecruitments { + + @Test + @DisplayName("TC-001 공고 여러 건 → term 내림차순 목록 반환") + void termDescList() { + Recruitment r27 = createActiveRecruitment(); // term 27 + Recruitment r26 = createExpiredRecruitment(); // term 26 + when(recruitmentRepository.findAllByOrderByTermDesc()).thenReturn(List.of(r27, r26)); + + List result = recruitmentService.getAllRecruitments(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getTerm()).isEqualTo(27); + } + + @Test + @DisplayName("TC-002 마감/예정 공고 포함 → is_active 무관 전체 필드 반환") + void inactiveStillFullFields() { + Recruitment upcoming = createUpcomingRecruitment(); // term 28, start 미래 + Recruitment expired = createExpiredRecruitment(); // term 26, end 과거 + when(recruitmentRepository.findAllByOrderByTermDesc()).thenReturn(List.of(upcoming, expired)); + + List result = recruitmentService.getAllRecruitments(); + + RecruitmentResponse expiredRes = result.stream() + .filter(r -> r.getTerm() == 26).findFirst().orElseThrow(); + assertThat(expiredRes.getIsActive()).isFalse(); + assertThat(expiredRes.getStartDate()).isNotNull(); + assertThat(expiredRes.getEndDate()).isNotNull(); + + RecruitmentResponse upcomingRes = result.stream() + .filter(r -> r.getTerm() == 28).findFirst().orElseThrow(); + assertThat(upcomingRes.getIsActive()).isFalse(); + assertThat(upcomingRes.getStartDate()).isNotNull(); + } + + @Test + @DisplayName("TC-003 공고 없음 → 빈 배열 (404 아님)") + void empty() { + when(recruitmentRepository.findAllByOrderByTermDesc()).thenReturn(Collections.emptyList()); + + assertThat(recruitmentService.getAllRecruitments()).isEmpty(); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-003: 모집 공고 등록 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-003 모집 공고 등록") + class CreateRecruitment { + + @Test + @DisplayName("TC-001 신규 기수 등록 → recruitment_id 반환") + void createSuccess() { + LocalDateTime start = LocalDateTime.now().plusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(15); + RecruitmentCreateRequest req = buildCreateRecruitmentReq(28, start, end, "https://e.com/b.pdf"); + when(recruitmentRepository.existsByTerm(28)).thenReturn(false); + when(recruitmentRepository.save(any())).thenAnswer(inv -> { + Recruitment r = inv.getArgument(0); + ReflectionTestUtils.setField(r, "id", 13L); + return r; + }); + + RecruitmentIdResponse res = recruitmentService.createRecruitment(req); + + assertThat(res.getRecruitmentId()).isEqualTo(13L); + verify(recruitmentRepository).save(any(Recruitment.class)); + } + + @Test + @DisplayName("TC-002 brochure_url 미입력 → null 저장") + void nullBrochure() { + LocalDateTime start = LocalDateTime.now().plusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(15); + RecruitmentCreateRequest req = buildCreateRecruitmentReq(28, start, end, null); + when(recruitmentRepository.existsByTerm(28)).thenReturn(false); + when(recruitmentRepository.save(any())).thenAnswer(inv -> { + Recruitment r = inv.getArgument(0); + ReflectionTestUtils.setField(r, "id", 13L); + return r; + }); + + recruitmentService.createRecruitment(req); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(Recruitment.class); + verify(recruitmentRepository).save(captor.capture()); + assertThat(captor.getValue().getBrochureUrl()).isNull(); + } + + @Test + @DisplayName("TC-003 이미 존재하는 기수 → DUPLICATE_TERM") + void duplicateTerm() { + RecruitmentCreateRequest req = buildCreateRecruitmentReq( + 27, LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(15), null); + when(recruitmentRepository.existsByTerm(27)).thenReturn(true); + + assertThatThrownBy(() -> recruitmentService.createRecruitment(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_TERM); + verify(recruitmentRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-004 end_date == start_date → INVALID_INPUT_VALUE") + void endNotAfterStart() { + LocalDateTime same = LocalDateTime.now().plusDays(1); + RecruitmentCreateRequest req = buildCreateRecruitmentReq(28, same, same, null); + when(recruitmentRepository.existsByTerm(28)).thenReturn(false); + + assertThatThrownBy(() -> recruitmentService.createRecruitment(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + verify(recruitmentRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-004b end_date < start_date → INVALID_INPUT_VALUE") + void endBeforeStart() { + LocalDateTime start = LocalDateTime.now().plusDays(15); + LocalDateTime end = LocalDateTime.now().plusDays(1); // end < start + RecruitmentCreateRequest req = buildCreateRecruitmentReq(28, start, end, null); + when(recruitmentRepository.existsByTerm(28)).thenReturn(false); + + assertThatThrownBy(() -> recruitmentService.createRecruitment(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + verify(recruitmentRepository, never()).save(any()); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-004: 모집 공고 수정 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-004 모집 공고 수정") + class UpdateRecruitment { + + @Test + @DisplayName("TC-001 start_date만 수정 → 해당 필드만 변경") + void updateStartOnly() { + Recruitment r = createActiveRecruitment(); // term 27, id 1 + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + LocalDateTime newStart = LocalDateTime.now().minusDays(2); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "startDate", newStart); + + RecruitmentIdResponse res = recruitmentService.updateRecruitment(1L, req); + + assertThat(res.getRecruitmentId()).isEqualTo(1L); + assertThat(r.getStartDate()).isEqualTo(newStart); + assertThat(r.getTerm()).isEqualTo(27); // 미포함 필드 유지 + } + + @Test + @DisplayName("TC-002 schedule 수정 → 배열 전체 교체") + void replaceSchedule() throws Exception { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + var schedule = objectMapper.createArrayNode(); + schedule.add(objectMapper.createObjectNode().put("step", "서류")); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "schedule", schedule); + + recruitmentService.updateRecruitment(1L, req); + + assertThat(r.getSchedule()).isEqualTo(objectMapper.writeValueAsString(schedule)); + } + + @Test + @DisplayName("TC-003 brochure_url null 명시 전송 → 삭제") + void deleteBrochure() { + Recruitment r = createActiveRecruitment(); // brochure 존재 + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "brochureUrl", JsonNullable.of(null)); + + recruitmentService.updateRecruitment(1L, req); + + assertThat(r.getBrochureUrl()).isNull(); + } + + @Test + @DisplayName("TC-003b brochure_url 미전송(undefined) → 기존 값 유지") + void brochureUndefinedKept() { + Recruitment r = createActiveRecruitment(); // brochure 존재 + String original = r.getBrochureUrl(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "startDate", LocalDateTime.now().minusDays(2)); + // brochureUrl 은 JsonNullable.undefined() 기본값 → 전송 안 함 + + recruitmentService.updateRecruitment(1L, req); + + assertThat(r.getBrochureUrl()).isEqualTo(original); + } + + @Test + @DisplayName("TC-004 존재하지 않는 공고 → RECRUITMENT_NOT_FOUND") + void notFound() { + when(recruitmentRepository.findById(999L)).thenReturn(Optional.empty()); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment(999L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); + } + + @Test + @DisplayName("TC-005 다른 공고와 term 중복 → DUPLICATE_TERM") + void duplicateTerm() { + Recruitment r = createActiveRecruitment(); // id 1 + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(recruitmentRepository.existsByTermAndIdNot(26, 1L)).thenReturn(true); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "term", 26); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment(1L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_TERM); + } + + @Test + @DisplayName("TC-006 end_date만 수정했는데 기존 start_date보다 이전 → INVALID_INPUT_VALUE") + void endBeforeExistingStart() { + Recruitment r = createActiveRecruitment(); // start = now-1d + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + RecruitmentUpdateRequest req = new RecruitmentUpdateRequest(); + ReflectionTestUtils.setField(req, "endDate", LocalDateTime.now().minusDays(2)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment(1L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-005: 모집 공고 삭제 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-005 모집 공고 삭제") + class DeleteRecruitment { + + @Test + @DisplayName("TC-001 연관 데이터 없는 공고 → 삭제") + void deleteSuccess() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicantRepository.existsByRecruitmentId(1L)).thenReturn(false); + when(applicationQuestionRepository.existsByRecruitmentId(1L)).thenReturn(false); + + recruitmentService.deleteRecruitment(1L); + + verify(recruitmentRepository).delete(r); + } + + @Test + @DisplayName("TC-002 존재하지 않는 공고 → RECRUITMENT_NOT_FOUND") + void notFound() { + when(recruitmentRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.deleteRecruitment(999L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); + } + + @Test + @DisplayName("TC-003 연관 지원자 존재 → RECRUITMENT_HAS_REFERENCES") + void hasApplicants() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicantRepository.existsByRecruitmentId(1L)).thenReturn(true); + + assertThatThrownBy(() -> recruitmentService.deleteRecruitment(1L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_HAS_REFERENCES); + verify(recruitmentRepository, never()).delete(any()); + } + + @Test + @DisplayName("TC-004 연관 질문 존재 → RECRUITMENT_HAS_REFERENCES") + void hasQuestions() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicantRepository.existsByRecruitmentId(1L)).thenReturn(false); + when(applicationQuestionRepository.existsByRecruitmentId(1L)).thenReturn(true); + + assertThatThrownBy(() -> recruitmentService.deleteRecruitment(1L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_HAS_REFERENCES); + verify(recruitmentRepository, never()).delete(any()); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-006: 지원서 질문 등록 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-006 지원서 질문 등록") + class CreateQuestions { + + @Test + @DisplayName("TC-001 TEXT+TABLE 다건 등록 → ids 반환") + void createMixedSuccess() throws Exception { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.existsByRecruitmentIdAndLabel(eq(1L), any())).thenReturn(false); + when(applicationQuestionRepository.existsByRecruitmentIdAndCategoryAndOrderNum(eq(1L), any(), any())) + .thenReturn(false); + AtomicLong seq = new AtomicLong(100); + when(applicationQuestionRepository.save(any())).thenAnswer(inv -> { + ApplicationQuestion q = inv.getArgument(0); + ReflectionTestUtils.setField(q, "id", seq.getAndIncrement()); + return q; + }); + + JsonNode metadata = objectMapper.readTree("{\"rows\":[\"A\"],\"columns\":[\"B\"]}"); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1), + buildQuestionItem("E1", ApplicationQuestion.Category.ENGINEERING, + ApplicationQuestion.Type.TABLE, null, metadata, 1)); + + QuestionIdsResponse res = recruitmentService.createQuestions(req); + + assertThat(res.getIds()).hasSize(2); + verify(applicationQuestionRepository, times(2)).save(any()); + } + + @Test + @DisplayName("TC-002 다른 category면 동일 order_num 허용") + void sameOrderDifferentCategory() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.existsByRecruitmentIdAndLabel(eq(1L), any())).thenReturn(false); + when(applicationQuestionRepository.existsByRecruitmentIdAndCategoryAndOrderNum(eq(1L), any(), any())) + .thenReturn(false); + when(applicationQuestionRepository.save(any())).thenAnswer(inv -> { + ApplicationQuestion q = inv.getArgument(0); + ReflectionTestUtils.setField(q, "id", 1L); + return q; + }); + + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1), + buildQuestionItem("E1", ApplicationQuestion.Category.ENGINEERING, + ApplicationQuestion.Type.TEXT, 500, null, 1)); + + QuestionIdsResponse res = recruitmentService.createQuestions(req); + + assertThat(res.getIds()).hasSize(2); + } + + @Test + @DisplayName("TC-003 존재하지 않는 공고 → RECRUITMENT_NOT_FOUND") + void notFound() { + when(recruitmentRepository.findById(999L)).thenReturn(Optional.empty()); + QuestionsCreateRequest req = buildQuestionsCreateReq(999L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); + } + + @Test + @DisplayName("TC-004 요청 내 label 중복 → DUPLICATE_QUESTION_LABEL") + void inRequestDuplicateLabel() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1), + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 2)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_LABEL); + verify(applicationQuestionRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-005 요청 내 동일 category order_num 중복 → DUPLICATE_QUESTION_ORDER") + void inRequestDuplicateOrder() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1), + buildQuestionItem("C2", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_ORDER); + } + + @Test + @DisplayName("TC-006 DB에 동일 label 존재 → DUPLICATE_QUESTION_LABEL") + void dbDuplicateLabel() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.existsByRecruitmentIdAndLabel(1L, "C1")).thenReturn(true); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_LABEL); + verify(applicationQuestionRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-006b DB에 동일 category order_num 존재 → DUPLICATE_QUESTION_ORDER") + void dbDuplicateOrder() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.existsByRecruitmentIdAndLabel(1L, "C1")).thenReturn(false); + when(applicationQuestionRepository.existsByRecruitmentIdAndCategoryAndOrderNum( + 1L, ApplicationQuestion.Category.COMMON, 1)).thenReturn(true); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, 500, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_ORDER); + verify(applicationQuestionRepository, never()).save(any()); + } + + @Test + @DisplayName("TC-007 TEXT인데 limit_length 누락 → MISSING_PARAMETER") + void textMissingLimitLength() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("C1", ApplicationQuestion.Category.COMMON, + ApplicationQuestion.Type.TEXT, null, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.MISSING_PARAMETER); + } + + @Test + @DisplayName("TC-007b TABLE인데 metadata 누락 → MISSING_PARAMETER") + void tableMissingMetadata() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + QuestionsCreateRequest req = buildQuestionsCreateReq(1L, + buildQuestionItem("T1", ApplicationQuestion.Category.ENGINEERING, + ApplicationQuestion.Type.TABLE, null, null, 1)); + + assertThatThrownBy(() -> recruitmentService.createQuestions(req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.MISSING_PARAMETER); + verify(applicationQuestionRepository, never()).save(any()); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-007: 지원서 질문 목록 조회 (어드민) + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-007 지원서 질문 목록 조회 (어드민)") + class GetAdminQuestions { + + @Test + @DisplayName("TC-001 질문 존재 → order_num 오름차순 반환") + void orderAscList() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.findByRecruitmentIdOrderByOrderNumAsc(1L)) + .thenReturn(List.of( + createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true), + createTextQuestion(2L, r, ApplicationQuestion.Category.ENGINEERING, 10, true))); + + List result = recruitmentService.getAdminQuestions(1L); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getOrderNum()).isEqualTo(1); + } + + @Test + @DisplayName("TC-002 마감/예정 공고도 조회 가능 (is_active 무관)") + void inactiveStillReturns() { + Recruitment r = createExpiredRecruitment(); // id 2 + when(recruitmentRepository.findById(2L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.findByRecruitmentIdOrderByOrderNumAsc(2L)) + .thenReturn(List.of(createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true))); + + List result = recruitmentService.getAdminQuestions(2L); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("TC-003 {Track} 플레이스홀더 미치환 확인") + void trackPlaceholderNotReplaced() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = ApplicationQuestion.create(r, "E1", + ApplicationQuestion.Category.ENGINEERING, ApplicationQuestion.Type.TEXT, + "{Track} 관련 경험", null, 500, null, 1, true); + ReflectionTestUtils.setField(q, "id", 5L); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.findByRecruitmentIdOrderByOrderNumAsc(1L)) + .thenReturn(List.of(q)); + + List result = recruitmentService.getAdminQuestions(1L); + + assertThat(result.get(0).getContent()).isEqualTo("{Track} 관련 경험"); + } + + @Test + @DisplayName("TC-004 질문 없음 → 빈 배열 (QUESTIONS_NOT_FOUND 아님)") + void emptyList() { + Recruitment r = createActiveRecruitment(); + when(recruitmentRepository.findById(1L)).thenReturn(Optional.of(r)); + when(applicationQuestionRepository.findByRecruitmentIdOrderByOrderNumAsc(1L)) + .thenReturn(Collections.emptyList()); + + assertThat(recruitmentService.getAdminQuestions(1L)).isEmpty(); + } + + @Test + @DisplayName("TC-005 존재하지 않는 공고 → RECRUITMENT_NOT_FOUND") + void notFound() { + when(recruitmentRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.getAdminQuestions(999L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECRUITMENT_NOT_FOUND); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-008: 지원서 질문 수정 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-008 지원서 질문 수정") + class UpdateQuestion { + + @Test + @DisplayName("TC-001 content만 수정 → 해당 필드만 변경") + void updateContentOnly() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "content", "수정된 질문 내용"); + + QuestionIdResponse res = recruitmentService.updateQuestion(1L, req); + + assertThat(res.getQuestionId()).isEqualTo(1L); + assertThat(q.getContent()).isEqualTo("수정된 질문 내용"); + } + + @Test + @DisplayName("TC-002 TEXT→TABLE 타입 변경 (metadata 동반, limit_length 정리)") + void changeTypeToTable() throws Exception { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + JsonNode metadata = objectMapper.readTree("{\"rows\":[\"A\"],\"columns\":[\"B\"]}"); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "type", ApplicationQuestion.Type.TABLE); + ReflectionTestUtils.setField(req, "metadata", JsonNullable.of(metadata)); + + recruitmentService.updateQuestion(1L, req); + + assertThat(q.getType()).isEqualTo(ApplicationQuestion.Type.TABLE); + assertThat(q.getMetadata()).isNotNull(); + assertThat(q.getLimitLength()).isNull(); + } + + @Test + @DisplayName("TC-003 TABLE→TEXT 변경인데 limit_length 누락 → MISSING_PARAMETER") + void changeTypeToTextMissingLimit() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTableQuestion(1L, r, ApplicationQuestion.Category.ENGINEERING, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "type", ApplicationQuestion.Type.TEXT); + + assertThatThrownBy(() -> recruitmentService.updateQuestion(1L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.MISSING_PARAMETER); + } + + @Test + @DisplayName("TC-004 존재하지 않는 질문 → QUESTIONS_NOT_FOUND") + void notFound() { + when(applicationQuestionRepository.findById(999L)).thenReturn(Optional.empty()); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + + assertThatThrownBy(() -> recruitmentService.updateQuestion(999L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.QUESTIONS_NOT_FOUND); + } + + @Test + @DisplayName("TC-005 같은 공고 내 label 중복 → DUPLICATE_QUESTION_LABEL") + void duplicateLabel() { + Recruitment r = createActiveRecruitment(); // id 1 + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + when(applicationQuestionRepository.existsByRecruitmentIdAndLabelAndIdNot(1L, "공통1", 1L)) + .thenReturn(true); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "label", "공통1"); + + assertThatThrownBy(() -> recruitmentService.updateQuestion(1L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_LABEL); + } + + @Test + @DisplayName("TC-006 order_num 중복 (resolvedCategory 기준) → DUPLICATE_QUESTION_ORDER") + void duplicateOrder() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + when(applicationQuestionRepository.existsByRecruitmentIdAndCategoryAndOrderNumAndIdNot( + 1L, ApplicationQuestion.Category.COMMON, 1, 1L)).thenReturn(true); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "orderNum", 1); + + assertThatThrownBy(() -> recruitmentService.updateQuestion(1L, req)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_QUESTION_ORDER); + } + + @Test + @DisplayName("TC-007 metadata null 명시 전송 → 삭제") + void deleteMetadata() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTableQuestion(1L, r, ApplicationQuestion.Category.ENGINEERING, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "metadata", JsonNullable.of(null)); + + recruitmentService.updateQuestion(1L, req); + + assertThat(q.getMetadata()).isNull(); + } + + @Test + @DisplayName("TC-007b metadata 미전송(undefined) → 기존 값 유지") + void metadataUndefinedKept() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTableQuestion(1L, r, ApplicationQuestion.Category.ENGINEERING, 1, true); + String original = q.getMetadata(); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + QuestionUpdateRequest req = new QuestionUpdateRequest(); + ReflectionTestUtils.setField(req, "content", "내용만 수정"); + // metadata, limitLength, description 모두 JsonNullable.undefined() 기본값 → 미전송 + + recruitmentService.updateQuestion(1L, req); + + assertThat(q.getMetadata()).isEqualTo(original); + } + } + + // ══════════════════════════════════════════════ + // REC-ADMIN-009: 지원서 질문 삭제 + // ══════════════════════════════════════════════ + @Nested + @DisplayName("REC-ADMIN-009 지원서 질문 삭제") + class DeleteQuestion { + + @Test + @DisplayName("TC-001 답변 없는 질문 → 삭제") + void deleteSuccess() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + when(applicantAnswerRepository.existsByQuestionId(1L)).thenReturn(false); + + recruitmentService.deleteQuestion(1L); + + verify(applicationQuestionRepository).delete(q); + } + + @Test + @DisplayName("TC-002 존재하지 않는 질문 → QUESTIONS_NOT_FOUND") + void notFound() { + when(applicationQuestionRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recruitmentService.deleteQuestion(999L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.QUESTIONS_NOT_FOUND); + } + + @Test + @DisplayName("TC-003 참조 답변 존재 → QUESTION_HAS_ANSWERS") + void hasAnswers() { + Recruitment r = createActiveRecruitment(); + ApplicationQuestion q = createTextQuestion(1L, r, ApplicationQuestion.Category.COMMON, 1, true); + when(applicationQuestionRepository.findById(1L)).thenReturn(Optional.of(q)); + when(applicantAnswerRepository.existsByQuestionId(1L)).thenReturn(true); + + assertThatThrownBy(() -> recruitmentService.deleteQuestion(1L)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.QUESTION_HAS_ANSWERS); + verify(applicationQuestionRepository, never()).delete(any()); + } + } }