From 911c540c42ca78b83282ff1ddf33cc22d66a1f2a Mon Sep 17 00:00:00 2001 From: seoyeon83 Date: Thu, 25 Jun 2026 18:13:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?#172=20feat:=20=EC=A7=80=EC=9B=90=EC=84=9C?= =?UTF-8?q?=20CSV=EC=97=90=20=EC=B5=9C=EC=A2=85=20=ED=8F=89=EA=B0=80(final?= =?UTF-8?q?=5Fdecision)=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서류 평가 결과 전달용으로 CSV에 '최종 평가' 컬럼을 추가한다. 전화번호 다음 위치에 배치하고, 값은 한글로 표기한다. (PASS→합격, FAIL→불합격, HOLD→보류, PENDING→미정) --- .../domain/recruitment/service/CsvService.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 { From 4e2da2e068902dcf6d1f6357a85f9c5f25aeee42 Mon Sep 17 00:00:00 2001 From: seoyeon83 Date: Thu, 25 Jun 2026 18:13:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?#172=20feat:=20=EC=A7=80=EC=9B=90=EC=84=9C?= =?UTF-8?q?=20CSV=20=ED=95=A9=EA=B2=A9/=EB=B6=88=ED=95=A9=EA=B2=A9/?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=B6=94=EC=B6=9C=20=ED=95=84=ED=84=B0(de?= =?UTF-8?q?cision)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision 파라미터(PASS/FAIL/ALL, 기본 ALL)로 추출 범위를 지정한다. - DecisionFilter enum 신설 (ALL → final_decision 필터 미적용) - 리포지토리에 final_decision nullable 필터 조건 추가 (DB 레벨 필터) - S3 키에 추출 필터 표기하여 합격/불합격/전체 파일 구분 - decision 미지정 시 ALL로 동작하여 기존 호출과 하위호환 유지 --- .../RecruitmentAdminController.java | 9 ++++++--- .../recruitment/entity/DecisionFilter.java | 18 +++++++++++++++++ .../repository/ApplicantRepository.java | 10 +++++++--- .../service/RecruitmentService.java | 20 +++++++++++-------- 4 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/boaz/backend/domain/recruitment/entity/DecisionFilter.java 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/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/repository/ApplicantRepository.java b/src/main/java/com/boaz/backend/domain/recruitment/repository/ApplicantRepository.java index d48bf29..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 접근) 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 9e6412c..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 @@ -36,6 +36,7 @@ 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; @@ -598,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 @@ -641,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); } } From 092f1c14ef19d4887605879022b9a5b3bc185175 Mon Sep 17 00:00:00 2001 From: seoyeon83 Date: Thu, 25 Jun 2026 18:13:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?#172=20test:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EC=BB=AC=EB=9F=BC=C2=B7decision=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80/=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CsvServiceTest: '최종 평가' 컬럼 위치·한글 표기 검증 - RecruitmentServiceTest: decision=PASS 필터 전달·S3 키 표기, ALL→null 검증 - ApplicantRepositoryTest: 메서드 시그니처 변경 반영, final_decision 필터 케이스 추가 - RecruitmentIntegrationTest: decision=PASS end-to-end (합격자만 포함) 검증 - RecruitmentAdminControllerTest: decision 파라미터 전달·잘못된 값 400 검증 --- .../RecruitmentAdminControllerTest.java | 35 +++++++++-- .../RecruitmentIntegrationTest.java | 44 ++++++++++++- .../repository/ApplicantRepositoryTest.java | 39 ++++++++++-- .../recruitment/service/CsvServiceTest.java | 62 +++++++++++++++++++ .../service/RecruitmentServiceTest.java | 62 ++++++++++++++++--- 5 files changed, 222 insertions(+), 20 deletions(-) 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 41c47e0..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 @@ -7,6 +7,7 @@ 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; @@ -81,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 @@ -115,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())) @@ -128,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())) 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 1ebbd8d..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 @@ -21,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; @@ -606,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); } 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/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/RecruitmentServiceTest.java b/src/test/java/com/boaz/backend/domain/recruitment/service/RecruitmentServiceTest.java index 5d27ac1..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 @@ -47,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) @@ -1344,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()); } @@ -1365,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()); } @@ -1381,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()); + } } // ══════════════════════════════════════════════ From 4acf18a7830fc274f0230cc2ba78ed6f489ca77a Mon Sep 17 00:00:00 2001 From: seoyeon83 Date: Thu, 25 Jun 2026 18:13:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?#172=20chore:=20CSV=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=EC=9E=90=20=EC=8B=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit term 26(recruitment_id=1)에 부문별 final_decision=PASS·FAIL 지원자를 추가하여 decision=PASS/FAIL/ALL 추출 결과를 수동 검증할 수 있게 한다. (user/applicant id 5~10, 기존 지원자는 그대로 유지) --- src/main/resources/data.sql | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 9adc147..b061e99 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -59,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) @@ -198,6 +217,27 @@ VALUES (3, 1, 2, 'SUBMITTED', 'ANALYSIS', '이분석', 'ana@example.com', '01022 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, 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, 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) VALUES (1, 1, 'COMMON1답변', null, '2026-03-14 22:19:27', '2026-03-14 22:19:27');