diff --git a/docs/general-job-implementation.md b/docs/general-job-implementation.md new file mode 100644 index 00000000..461ddf6f --- /dev/null +++ b/docs/general-job-implementation.md @@ -0,0 +1,33 @@ +# 일반 직군 API 구현 + +기준: origin/main `6acc8e4` (2026-10-20 fetch). 계약 단일 출처: 상위 작업 디렉터리 `Doc/general-job-impl-spec/01-overview.md`, 상세 `03-api.md`, 비개발 선행 `Doc/job-family-impl-spec/03-api.md`. + +- [가정] 사용자 전체 병렬 구현 요청에 따라 비개발 선행 미완료 C-3·C-5·C-7·C-8을 이 PR에서 먼저 구현한다. 병합 순서 auth → Analysis → Chat → API → Web은 유지한다. +- [가정] 기존 record 생성자 호출 호환을 위해 새 컴포넌트는 끝에 추가하고 기존 인자 생성자를 유지한다. JSON의 의미와 계약 값은 동일하다. +- [가정] 자료 분석 가능 판정과 `/profile` 변환은 AnalysisLaunchService에 모은다. 수동 재분석도 저장된 materials 전체를 사용한다. +- [가정] 옛 dataUpload는 현재 자료 API 검증·저장을 재사용한다. 직무 없음은 BACKEND, 문서 종류 PORTFOLIO, 새 file0 한 건으로 변환한다. 기존 사용자 정보를 덮어쓰는 별도 옛 저장 호출은 사용하지 않는다. +- 마이그레이션: 기존 최신 V38 확인. 비개발 V39, 일반 직군 V40 추가. 운영 DB·배포·병합은 수행하지 않는다. + +검증 결과와 PR은 완료 시 기록한다. + +## G-A31 시작 직무 스냅샷 + +최초 `prepareInterview`에서 일반 직군이면 외부 분석/테일러 호출 전에 `InterviewJobSnapshotService.capture`를 별도 짧은 트랜잭션으로 실행한다. 면접 행을 PESSIMISTIC_WRITE로 잠그고 소유자·상태·기존 tailor를 확인한다. 공유 persona를 수정하지 않고 TECH를 면접 전용으로 복제해 현재 auth major/jobTitle을 기록한다. SOLO 대표 ID 또는 MULTI 첫 링크만 교체한다. clone에 `interview_snapshot=true`, 원래 표시 이름을 `snapshot_source_name`으로 남긴다. 두 컬럼은 V40에 포함한다. 일반 선택 목록/직접 면접관 조회/새 면접에서 snapshot clone 선택은 막고, 면접 권한을 확인하는 목록·채점·Chat은 clone을 사용한다. 실제 면접관 표시 이름은 원래 이름을 유지한다. + +복제본이 이미 연결됐거나 tailor가 있거나 면접이 종료됐으면 재기록하지 않는다. 외부 접수 전에 실패해 tailor가 없어도 clone은 확정값으로 남는다. 즉시 테일러·대기 재개·재시도는 snapshot major/jobTitle을 읽는다. 질문 세트 출처 직무와 snapshot 직무가 다르면 409로 막고 신규 PENDING을 만들지 않는다. 질문 풀의 면접별 세트 예약은 기존 멱등 규칙대로 남는다. IT 기존 경로는 유지한다. + +## 계약 대조와 검증 + +- 01 §3.1: JobRole·Major 15개(개발3·비개발6·일반6), 일반 GitHub 숨김·보완 없음·자소서 허용. +- 01 §3.3: WORK_SAMPLE은 일반에서만 허용. 공통 RESUME·CAREER_DESCRIPTION 및 PORTFOLIO와 합쳐 일반 4종. +- 01 §3.4: 직무명 30자·자소서 5개/문항200/답2000, 일반은 이력서 외 자료 또는 자소서 필수. Reason 신규6개 대응. +- 신규 profile DTO는 jobRole/documents/githubUrls/supplements/callbackUrl, 일반만 jobTitle/coverLetters. IT에는 두 새 키를 생략한다. 비개발 선행 분석 차단·skipped 제거. +- Flyway V1~V40을 격리 PostgreSQL 16에 적용하고 스냅샷 생성·동시 시작·원본/완료 면접 보존을 검증했다. Testcontainers 대신 기존 프로젝트 SpringBootTest 및 일회용 Docker PostgreSQL을 사용했다. +- `JAVA_HOME` Java21, DB `127.0.0.1:55439`, 콜백 `localhost:18039`, 외부 auth/AI/Chat는 `127.0.0.1:9`, 더미 S3 자격으로 `./gradlew test`: **948개 통과, 실패0·skip0**. +- 자체 리뷰와 루트/독립 리뷰 반영: 콜백 직무명 소실, 공유 persona 변경 위험, 직무 불일치의 고립 PENDING, OSIV stale FK 재복제 위험을 수정했다. 코드 리뷰 전용 실행 도구는 제공되지 않아 저장소 인계의 자체 리뷰 기준을 적용했다. +- `git diff --check` 통과. 최종 fetch 기준 origin/main은 `6acc8e4`로 동일. +- 이슈: https://github.com/Alpha-origin/Repit-api-server/issues/113. 브랜치는 상위 작업에서 지정한 `codex/general-job-api`, PR 제목은 저장소 템플릿의 브랜치명 동일 규칙을 따른다. + +## 남은 통합 검증 + +Auth·Analysis·Chat 선행 PR 병합 뒤 일반6직군 E2E, 유료 모델 실제 분석·이미지 PDF·꼬리질문·채점 품질 검증은 별도다. 이번 작업은 API 코드·격리 DB 테스트까지이며 배포·병합·운영 DB 변경은 하지 않았다. diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java index 93bb5ad3..8f75f74b 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java @@ -136,8 +136,7 @@ private void replay(String jobId) { public ResponseEntity generate( @AuthenticationPrincipal AuthUser authUser ) { - MetaDataResponse forRequest = metaService.getMaterials(authUser.token()).toAnalysisInput(); - return ResponseEntity.ok(analysisLaunchService.launch(authUser.id(), authUser.user().getMajor(), forRequest)); + return ResponseEntity.ok(analysisLaunchService.launchMaterials(authUser.id(), metaService.getMaterials(authUser.token()))); } /** diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java index 2974870d..3dd117a0 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java @@ -33,13 +33,18 @@ public class MetaDataController { public ResponseEntity upload(@AuthenticationPrincipal AuthUser authUser, @RequestPart("file") MultipartFile file, @RequestParam List gitUrls) throws IOException { - // 분석할 수 없는 직무(비개발)면 자료를 저장하기 전에 422로 막는다. 저장만 되고 분석은 실패하는 일을 막는다. - analysisLaunchService.requireSupportedJob(authUser.user().getMajor()); - MetaDataResponse metaData = metaService.dataUpload(authUser.token(), file, gitUrls); - - // 올린 자료로 곧바로 분석을 시작한다. 접수까지 함께 해야 이 분석에 주인이 남고, - // 주인이 없으면 나중에 면접을 열 때 이 결과를 찾지 못한다. - return ResponseEntity.ok(analysisLaunchService.launch(authUser.id(), authUser.user().getMajor(), metaData)); + String jobRole = repit.repit_api_server.domain.userdata.persona.support.UserJobMajors + .normalize(authUser.user().getMajor()); + MaterialsPayload payload = new MaterialsPayload(jobRole == null ? "BACKEND" : jobRole, + List.of(new MaterialsPayload.DocumentInput("PORTFOLIO", "NEW", "file0", null)), + gitUrls, List.of()); + MaterialsSaveResponse saved = materialsService.replace(authUser, payload, Map.of("file0", file)); + if (saved.analysis() == null || saved.analysis().jobId() == null) { + throw repit.repit_api_server.global.exception.BusinessException.unprocessable( + "자료는 저장했지만 분석을 요청하지 못했습니다. 다시 분석해 주세요."); + } + return ResponseEntity.ok(GenerateResponse.builder().jobId(saved.analysis().jobId()) + .status(saved.analysis().status()).build()); } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java index 3da4d6fb..c37df198 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java @@ -1,6 +1,7 @@ package repit.repit_api_server.domain.metadata.dto.request; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.CoverLetterEntry; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; import java.util.List; @@ -14,5 +15,11 @@ public record AuthMaterialsRequest( String major, List documents, List gitUrls, - List supplements) { + List supplements, + String jobTitle, + List coverLetters) { + + public AuthMaterialsRequest(String major, List documents, List gitUrls, List supplements) { + this(major, documents, gitUrls, supplements, null, List.of()); + } } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java index b1fac0bf..a4dacc29 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java @@ -12,11 +12,20 @@ public record MaterialsPayload( String jobRole, List documents, List gitUrls, - List supplements) { + List supplements, + String jobTitle, + List coverLetters) { + + public MaterialsPayload(String jobRole, List documents, List gitUrls, List supplements) { + this(jobRole, documents, gitUrls, supplements, null, List.of()); + } public static final String SOURCE_NEW = "NEW"; public static final String SOURCE_EXISTING = "EXISTING"; + public record CoverLetterInput(String question, String answer) { + } + public record DocumentInput(String type, String source, String fileKey, String fileUrl) { } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequest.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequest.java index 800f0079..c2d1927c 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequest.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequest.java @@ -1,5 +1,6 @@ package repit.repit_api_server.domain.metadata.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -7,15 +8,42 @@ import java.util.List; -/** 분석 서버 POST /profile 요청. 자료를 한 번 분석해 질문 생성의 재료인 종합 데이터를 만든다. */ +/** 분석 서버 POST /profile 요청(계약 C-3·G-C3). 저장된 자료 전체를 분석한다. */ @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class ProfileRequest { - // 사용자 전공. 탐색 우선순위에만 쓰이고 없으면 비워 보낸다. - private String major; - private String portfolioUrl; + private String jobRole; + private List documents; private List githubUrls; + private List supplements; private String callbackUrl; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List coverLetters; + + @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class Document { + private String type; + private String url; + private String title; + } + + @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class Supplement { + private String title; + private Integer documentIndex; + private String goal; + private String role; + private String decision; + private String outcome; + } + + @Getter @Builder @NoArgsConstructor @AllArgsConstructor + public static class CoverLetter { + private String question; + private String answer; + } } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java index 7d2c82a4..89366278 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java @@ -13,7 +13,16 @@ public record AuthMaterialsResponse( List documents, List gitUrls, List supplements, - String legacyPortfolio) { + String legacyPortfolio, + String jobTitle, + List coverLetters) { + + public AuthMaterialsResponse(String major, List documents, List gitUrls, List supplements, String legacyPortfolio) { + this(major, documents, gitUrls, supplements, legacyPortfolio, null, List.of()); + } + + public record CoverLetterEntry(String question, String answer) { + } public record DocumentEntry(String type, String fileUrl, String fileName, Integer pageCount) { } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java index 9fdd1da5..2fc61c61 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java @@ -17,6 +17,12 @@ public record MaterialError(String field, Reason reason, String detail) { /** Web 문구 표(비개발 06 §3.4)와 같은 사유 목록. */ public enum Reason { JOB_ROLE_REQUIRED, + JOB_TITLE_REQUIRED, + JOB_TITLE_INVALID, + JOB_TITLE_NOT_ALLOWED, + MATERIAL_REQUIRED, + COVER_LETTER_INVALID, + COVER_LETTER_NOT_ALLOWED, NO_DOCUMENT, REQUIRED_DOC_MISSING, TOO_MANY_DOCUMENTS, diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java index e7c801b9..fccde36e 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java @@ -15,14 +15,23 @@ public record MaterialsResponse( String jobRole, List documents, List gitUrls, - List supplements) { + List supplements, + String jobTitle, + List coverLetters) { + + public MaterialsResponse(String jobRole, List documents, List gitUrls, List supplements) { + this(jobRole, documents, gitUrls, supplements, null, List.of()); + } /** - * 지금 분석 요청(/profile)이 portfolioUrl로 받는 문서 종류. + * 옛 포트폴리오 업로드를 문서 목록으로 변환할 때 쓰는 종류. * 옛 업로드 경로(dataUpload)로 올린 PDF도 이 종류의 문서 한 건으로 보여 준다. */ public static final String PORTFOLIO_TYPE = "PORTFOLIO"; + public record CoverLetter(String question, String answer) { + } + public record MaterialDocument(int index, String type, String fileUrl, String fileName, Integer pageCount) { } @@ -31,7 +40,7 @@ public record Supplement(String title, Integer documentIndex, } /** - * 지금 /profile 요청 모양(포트폴리오 문서 + GitHub)으로 옮긴다. 분석을 시작하는 모든 길이 이것을 쓴다. + * 옛 포트폴리오·GitHub 업로드 경로와의 호환을 위해 기존 자료 형식으로 옮긴다. * 문서 순서는 사용자가 정하므로 첫 문서가 아니라 종류로 찾는다. 여러 개면 앞의 것이고, 없으면 fileUrl이 null이다. */ public MetaDataResponse toAnalysisInput() { @@ -57,7 +66,10 @@ public static MaterialsResponse from(AuthMaterialsResponse auth) { auth.gitUrls() == null ? List.of() : auth.gitUrls(), auth.supplements() == null ? List.of() : auth.supplements().stream() .map(s -> new Supplement(s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome())) - .toList()); + .toList(), + auth.jobTitle(), + auth.coverLetters() == null ? List.of() : auth.coverLetters().stream() + .map(c -> new CoverLetter(c.question(), c.answer())).toList()); } private static List documentsOf(AuthMaterialsResponse auth) { diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java index e0e2e7f7..1ae763fa 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java @@ -10,8 +10,6 @@ public record MaterialsSaveResponse(MaterialsResponse materials, Analysis analys public static final String ANALYSIS_ACCEPTED = "accepted"; /** 저장은 끝났지만 분석 요청이 실패했다. 자료는 그대로 두고 다시 분석하게 한다. */ public static final String ANALYSIS_FAILED = "failed"; - /** 저장은 끝났지만 아직 분석하지 않는 직무라 요청하지 않았다(비개발, 분석 서버 새 요청 모양 C-3 전). */ - public static final String ANALYSIS_SKIPPED = "skipped"; public record Analysis(String jobId, String status) { } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java index 9759bd9c..0f81deb4 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java @@ -25,6 +25,8 @@ public class ProfileStatusResponse { public static final String INVALID_MATERIAL = "INVALID_MATERIAL"; /** 코드에서 확인되는 근거가 모자라 질문을 만들 수 없다(422). */ public static final String INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"; + /** 필수 문서에서 읽은 내용이 없음. */ + public static final String UNREADABLE_DOCUMENT = "UNREADABLE_DOCUMENT"; /** 분석 서버 오류이거나 결과를 받지 못했다. */ public static final String INTERNAL = "INTERNAL"; @@ -53,7 +55,7 @@ public static ProfileStatusResponse none() { *

웹은 사유마다 안내가 다르다 — 저장소 공개 설정, 자료 교체, 자료 보강, 다시 시도. */ public static String reasonOf(Integer statusCode, String code, String message) { - if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, INTERNAL).contains(code)) { + if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, UNREADABLE_DOCUMENT, INTERNAL).contains(code)) { return code; } if (statusCode == null) { diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java index 041a9a58..742200fc 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java @@ -8,7 +8,8 @@ import org.springframework.transaction.annotation.Transactional; import repit.repit_api_server.domain.metadata.dto.request.ProfileRequest; import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; -import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import java.util.List; import repit.repit_api_server.domain.metadata.entity.enums.AnalysisResultType; import repit.repit_api_server.domain.metadata.repository.AnalysisDataRepository; import repit.repit_api_server.domain.metadata.support.JobRole; @@ -46,37 +47,45 @@ public class AnalysisLaunchService { private final AnalysisDataRepository analysisDataRepository; private final QuestionPoolService questionPoolService; - static final String NON_DEV_NOT_SUPPORTED = "비개발 직무는 아직 자료 분석을 지원하지 않습니다."; - @Value("${app.callback-base-url}") private String callbackBaseUrl; - /** - * 지금 /profile 요청 모양으로 분석할 수 있는 직무가 아니면 422로 막는다(비개발, 계약 C-3 전). - * 자료를 저장한 뒤 분석하는 길은 저장 전에 이것으로 먼저 막는다. - */ - public void requireSupportedJob(String major) { - if (!JobRole.supportsCurrentProfileRequest(major)) { - throw BusinessException.unprocessable(NON_DEV_NOT_SUPPORTED); + /** 일반 직군의 자소서만 있는 요청도 지원한다. IT는 새 필드 키를 보내지 않는다. */ + public GenerateResponse launchMaterials(Long userId, MaterialsResponse saved) { + JobRole job = jobOf(saved); + if (!canAnalyze(saved)) { + throw BusinessException.unprocessable("분석할 자료가 부족합니다. 직무에 필요한 자료를 저장해 주세요."); } - } - - /** - * 종합 데이터 분석(/profile)을 시작한다. 질문은 이 결과가 도착한 뒤 사이클로 따로 만든다. - * - * @param major 사용자 전공. 분석 서버가 탐색 우선순위에만 쓰고, 없으면 비워 보낸다. 비개발 직무면 422. - */ - public GenerateResponse launch(Long userId, String major, MetaDataResponse metaData) { - requireSupportedJob(major); ProfileRequest request = ProfileRequest.builder() - .major(major == null || major.isBlank() ? null : major) - .portfolioUrl(metaData.getFileUrl()) - .githubUrls(metaData.getGitUrls()) + .jobRole(job.name()) + .documents(saved.documents().stream().map(d -> new ProfileRequest.Document( + d.type(), d.fileUrl(), d.fileName())).toList()) + .githubUrls(saved.gitUrls()) + .supplements(saved.supplements().stream().map(s -> new ProfileRequest.Supplement( + s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome())).toList()) + .jobTitle(job.requiresJobTitle() ? saved.jobTitle() : null) + .coverLetters(job.coverLettersAllowed() ? saved.coverLetters().stream() + .map(c -> new ProfileRequest.CoverLetter(c.question(), c.answer())).toList() : List.of()) .callbackUrl(callbackBaseUrl + PROFILE_CALLBACK_PATH) .build(); return launch(userId, AnalysisResultType.PROFILE, () -> aiServerClient.requestProfile(request)); } + static boolean canAnalyze(MaterialsResponse saved) { + JobRole job = jobOf(saved); + if (job.requiresJobTitle()) { + return saved.jobTitle() != null && !saved.jobTitle().isBlank() + && (saved.documents().stream().anyMatch(d -> !"RESUME".equals(d.type())) + || !saved.coverLetters().isEmpty()); + } + return saved.documents().stream().anyMatch(d -> job.isRequiredType(d.type())) + && (job.githubRule() != JobRole.GithubRule.REQUIRED || !saved.gitUrls().isEmpty()); + } + + private static JobRole jobOf(MaterialsResponse saved) { + return JobRole.find(saved.jobRole()).orElse(JobRole.BACKEND); + } + /** * 쓸 수 있는 종합 데이터가 없는 사용자면 저장된 자료로 요청한다. 면접 준비는 이 분석과 질문 사이클을 기다렸다가 * 이어간다. 두 경우다. @@ -97,10 +106,6 @@ public GenerateResponse launch(Long userId, String major, MetaDataResponse metaD // ponytail: 잠근 채 인증·분석 서버를 부르므로 그동안 DB 커넥션을 붙잡는다. 사용자당 드물게 한 번 도는 길이라 감수한다. @Transactional public void launchIfMissing(AuthUser authUser) { - // 비개발 직무는 지금 요청 모양으로 맞는 분석이 나오지 않는다. 면접 준비는 그 전에 422로 막는다(InterviewService). - if (!JobRole.supportsCurrentProfileRequest(authUser.user().getMajor())) { - return; - } if (!needsProfile(authUser.id())) { return; } @@ -109,12 +114,12 @@ public void launchIfMissing(AuthUser authUser) { return; } // 옛 metaData의 파일 주소가 아니라 저장된 자료의 포트폴리오 문서로 요청한다. 자료 저장 뒤 분석하는 길과 같다. - MetaDataResponse saved = metaService.getMaterials(authUser.token()).toAnalysisInput(); - if (saved.getFileUrl() == null || saved.getFileUrl().isBlank() || saved.getGitUrls().isEmpty()) { + MaterialsResponse saved = metaService.getMaterials(authUser.token()); + if (!canAnalyze(saved)) { return; } log.info("쓸 수 있는 종합 데이터가 없어 저장된 자료로 분석을 요청합니다. userId={}", authUser.id()); - launch(authUser.id(), authUser.user().getMajor(), saved); + launchMaterials(authUser.id(), saved); } private boolean needsProfile(Long userId) { diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java index de7e9eec..b4e55bba 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 마이페이지의 직무·문서·GitHub·보완 입력을 한 번에 저장하고 분석을 다시 요청한다(계약 C-1). @@ -75,7 +76,9 @@ private MaterialsSaveResponse save(AuthUser authUser, ValidatedMaterials next, M documents.add(new DocumentEntry(document.type(), fileUrl, document.fileName(), document.pageCount())); } request = new AuthMaterialsRequest(next.jobRole().authMajor(), documents, next.gitUrls(), - toEntries(next.supplements())); + toEntries(next.supplements()), next.jobTitle(), next.coverLetters().stream() + .map(c -> new repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.CoverLetterEntry( + c.question(), c.answer())).toList()); authCalled = true; stored = MaterialsResponse.from(authServerClient.putMaterials(authUser.token(), request)); } catch (RuntimeException e) { @@ -128,28 +131,20 @@ private static boolean isStored(MaterialsResponse stored, JobRole jobRole, AuthM return jobRole.name().equals(stored.jobRole()) && storedDocuments.equals(sentDocuments) && stored.gitUrls().equals(request.gitUrls()) - && stored.supplements().equals(sentSupplements); + && stored.supplements().equals(sentSupplements) + && Objects.equals(stored.jobTitle(), request.jobTitle()) + && stored.coverLetters().equals(request.coverLetters().stream() + .map(c -> new MaterialsResponse.CoverLetter(c.question(), c.answer())).toList()); } - /** - * 저장된 자료로 분석을 요청한다. 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지는 지금 요청 모양 그대로 - * 포트폴리오 문서와 GitHub를 보낸다({@link MaterialsResponse#toAnalysisInput()}, 다시 분석하는 길과 같다). - * 비개발 직무는 그 모양으로는 맞는 분석이 나오지 않아 요청하지 않는다. - * - *

저장은 이미 끝났으므로 분석 요청이 실패해도 저장을 되돌리지 않는다. 화면이 다시 분석하게 한다. - */ + /** 저장 완료 후 전체 자료를 분석한다. 분석 실패는 저장을 되돌리지 않는다. */ private Analysis launchAnalysis(AuthUser authUser, JobRole jobRole, MaterialsResponse stored) { - if (!jobRole.supportsCurrentProfileRequest()) { - return new Analysis(null, MaterialsSaveResponse.ANALYSIS_SKIPPED); - } - MetaDataResponse input = stored.toAnalysisInput(); - // 개발 직무는 포트폴리오가 필수라 검사를 통과했으면 있어야 한다. 없으면 인증 서버가 돌려준 저장값이 이상한 것이다. - if (input.getFileUrl() == null) { - log.error("자료는 저장했지만 저장값에 포트폴리오 문서가 없어 분석을 요청하지 못했습니다. userId={}", authUser.id()); + if (!AnalysisLaunchService.canAnalyze(stored)) { + log.error("저장된 자료가 분석 조건을 충족하지 않습니다. userId={}", authUser.id()); return new Analysis(null, MaterialsSaveResponse.ANALYSIS_FAILED); } try { - GenerateResponse response = analysisLaunchService.launch(authUser.id(), jobRole.authMajor(), input); + GenerateResponse response = analysisLaunchService.launchMaterials(authUser.id(), stored); return new Analysis(response.getJobId(), MaterialsSaveResponse.ANALYSIS_ACCEPTED); } catch (RuntimeException e) { log.error("자료는 저장했지만 분석을 요청하지 못했습니다. userId={}", authUser.id(), e); @@ -172,7 +167,10 @@ private static boolean sameAsSaved(ValidatedMaterials next, MaterialsResponse sa .toList(); return nextDocuments.equals(savedDocuments) && next.gitUrls().equals(saved.gitUrls()) - && nextSupplements.equals(saved.supplements()); + && nextSupplements.equals(saved.supplements()) + && Objects.equals(next.jobTitle(), saved.jobTitle()) + && next.coverLetters().stream().map(c -> new MaterialsResponse.CoverLetter(c.question(), c.answer())) + .toList().equals(saved.coverLetters()); } private static List toEntries(List supplements) { diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java index 0b53e3af..5e56f371 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java @@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.DocumentInput; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.CoverLetterInput; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.Reason; @@ -49,6 +50,11 @@ @RequiredArgsConstructor public class MaterialsValidator { + static final int MAX_JOB_TITLE = 30; + static final int MAX_COVER_LETTERS = 5; + static final int MAX_COVER_LETTER_QUESTION = 200; + static final int MAX_COVER_LETTER_ANSWER = 2_000; + private static final Pattern CONTROL_CHARACTER = Pattern.compile("\\p{Cntrl}"); static final int MAX_DOCUMENTS = 5; static final int MAX_TOTAL_PAGES = 50; static final long MAX_FILE_BYTES = 50L * 1024 * 1024; @@ -79,7 +85,8 @@ public class MaterialsValidator { /** 검사를 통과한 저장 내용. 문서는 요청 순서 그대로다. 다 쓰면 {@link #deleteTempFiles()}를 부른다. */ public record ValidatedMaterials(JobRole jobRole, List documents, - List gitUrls, List supplements) { + List gitUrls, List supplements, + String jobTitle, List coverLetters) { public void deleteTempFiles() { documents.forEach(document -> MaterialTempFiles.deleteQuietly(document.file())); @@ -103,15 +110,24 @@ public ValidatedMaterials validate(MaterialsPayload payload, Map tempFiles = new ArrayList<>(); boolean passed = false; try { + String jobTitle = validateJobTitle(job, payload.jobTitle(), errors); + List coverLetterErrors = new ArrayList<>(); + List coverLetters = validateCoverLetters(job, payload.coverLetters(), coverLetterErrors); List inputs = payload.documents() == null ? List.of() : payload.documents(); List documents = validateDocuments(job, inputs, files, saved, errors, tempFiles); + if (job.materialRule() == JobRole.MaterialRule.NON_RESUME_OR_COVER_LETTER + && inputs.stream().noneMatch(input -> input != null && !"RESUME".equals(input.type())) + && coverLetters.isEmpty()) { + errors.add(error("materials", Reason.MATERIAL_REQUIRED, null)); + } List gitUrls = validateGithub(job, payload.gitUrls(), errors); List supplements = validateSupplements(job, payload.supplements(), inputs.size(), errors); + errors.addAll(coverLetterErrors); if (!errors.isEmpty()) { throw new MaterialValidationException(errors); } - ValidatedMaterials validated = new ValidatedMaterials(job, documents, gitUrls, supplements); + ValidatedMaterials validated = new ValidatedMaterials(job, documents, gitUrls, supplements, jobTitle, coverLetters); passed = true; return validated; } finally { @@ -122,10 +138,69 @@ public ValidatedMaterials validate(MaterialsPayload payload, Map errors) { + String title = requested == null ? null : requested.strip(); + if (title != null && title.isEmpty()) { + title = null; + } + if (job.requiresJobTitle()) { + if (title == null) { + errors.add(error("jobTitle", Reason.JOB_TITLE_REQUIRED, null)); + } else if (title.length() > MAX_JOB_TITLE || CONTROL_CHARACTER.matcher(title).find()) { + errors.add(error("jobTitle", Reason.JOB_TITLE_INVALID, null)); + } + } else if (title != null) { + errors.add(error("jobTitle", Reason.JOB_TITLE_NOT_ALLOWED, null)); + } + return job.requiresJobTitle() ? title : null; + } + + private List validateCoverLetters(JobRole job, List requested, + List errors) { + List entries = new ArrayList<>(); + List positions = new ArrayList<>(); + if (requested != null) { + for (int i = 0; i < requested.size(); i++) { + CoverLetterInput entry = requested.get(i); + String question = entry == null || entry.question() == null ? "" : entry.question().strip(); + String answer = entry == null || entry.answer() == null ? "" : entry.answer().strip(); + if (!question.isEmpty() || !answer.isEmpty()) { + entries.add(new CoverLetterInput(question, answer)); + positions.add(i); + } + } + } + if (entries.isEmpty()) { + return List.of(); + } + if (!job.coverLettersAllowed()) { + errors.add(error("coverLetters", Reason.COVER_LETTER_NOT_ALLOWED, null)); + return List.of(); + } + if (entries.size() > MAX_COVER_LETTERS) { + errors.add(error("coverLetters", Reason.COVER_LETTER_INVALID, null)); + return List.of(); + } + List valid = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + CoverLetterInput entry = entries.get(i); + if (entry.question().isEmpty() || entry.answer().isEmpty() + || entry.question().length() > MAX_COVER_LETTER_QUESTION + || entry.answer().length() > MAX_COVER_LETTER_ANSWER) { + errors.add(error("coverLetters[" + positions.get(i) + "]", Reason.COVER_LETTER_INVALID, null)); + } else { + valid.add(entry); + } + } + return List.copyOf(valid); + } + private List validateDocuments(JobRole job, List inputs, Map files, MaterialsResponse saved, List errors, List tempFiles) { if (inputs.isEmpty()) { - errors.add(error("documents", Reason.NO_DOCUMENT, null)); + if (job.materialRule() == JobRole.MaterialRule.REQUIRED_TYPE) { + errors.add(error("documents", Reason.NO_DOCUMENT, null)); + } return List.of(); } if (inputs.size() > MAX_DOCUMENTS) { @@ -182,7 +257,8 @@ private List validateDocuments(JobRole job, List i errors.add(error("documents", Reason.TOO_MANY_PAGES, String.valueOf(totalPages))); } } - if (inputs.stream().noneMatch(input -> job.isRequiredType(input.type()))) { + if (job.materialRule() == JobRole.MaterialRule.REQUIRED_TYPE + && inputs.stream().noneMatch(input -> job.isRequiredType(input.type()))) { errors.add(error("documents", Reason.REQUIRED_DOC_MISSING, String.join(",", job.requiredTypes()))); } return documents; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java index e1a865e7..4ef2bc55 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java @@ -55,8 +55,9 @@ public MetaDataResponse dataUpload(String token, // metaData 반환. 원본은 인증 서버가 들고 있어 사용자 토큰을 그대로 넘겨 받아온다. + // fileUrl은 포트폴리오 자리다. 첫 문서가 이력서·경력기술서일 수 있으니 순서가 아니라 PORTFOLIO 종류로 고른다. public MetaDataResponse getMetaData(String token) { - return authServerClient.getMetaData(token); + return getMaterials(token).toAnalysisInput(); } // 직무·문서·GitHub·보완 입력 반환. 옛 업로드 경로로 올린 PDF는 PORTFOLIO 문서 한 건으로 바꾼다. diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java index c7b901cd..c628456e 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java @@ -1,15 +1,13 @@ package repit.repit_api_server.domain.metadata.support; -import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; - import java.util.List; import java.util.Locale; import java.util.Optional; /** - * 직무 명세(비개발 직군 명세 01 §3.1). 직무 코드로 GitHub 규칙, 받는 문서 종류, 보완 입력 여부를 찾는다. + * 직무 명세(비개발·일반 직군 명세 01 §3.1). 직무 코드로 GitHub 규칙, 받는 문서 종류, 보완 입력 여부를 찾는다. * - *

직무 값을 해석하는 코드는 여기에 모은다. 일반 직군이 붙으면 대분류 조회가 이 자리에 더해진다(01 §8.2). + *

직무 값을 해석하는 코드는 여기에 모은다. */ public enum JobRole { FRONTEND(JobFamily.DEV, GithubRule.REQUIRED, List.of("PORTFOLIO"), List.of(), false), @@ -26,15 +24,31 @@ public enum JobRole { PRODUCT_MANAGER(JobFamily.NON_DEV, GithubRule.HIDDEN, List.of("PRD", "POLICY_DOC", "REQUIREMENT_SPEC"), List.of("PORTFOLIO", "SCREEN_SPEC"), true), IT_CONSULTANT(JobFamily.NON_DEV, GithubRule.HIDDEN, - List.of("CAREER_DESCRIPTION", "PORTFOLIO"), List.of("PROPOSAL", "PROJECT_REPORT", "REQUIREMENT_SPEC"), true); + List.of("CAREER_DESCRIPTION", "PORTFOLIO"), List.of("PROPOSAL", "PROJECT_REPORT", "REQUIREMENT_SPEC"), true), + GEN_STRATEGY(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER), + GEN_HR_ADMIN(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER), + GEN_FINANCE(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER), + GEN_MARKETING(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER), + GEN_SALES(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER), + GEN_DESIGN(JobFamily.GENERAL, GithubRule.HIDDEN, List.of(), + List.of("PORTFOLIO", "WORK_SAMPLE"), false, true, MaterialRule.NON_RESUME_OR_COVER_LETTER); /** 모든 직무에서 선택으로 올릴 수 있는 종류. 필수에 이미 있으면 필수로 센다. */ private static final List COMMON_TYPES = List.of("RESUME", "CAREER_DESCRIPTION"); - public enum JobFamily { DEV, NON_DEV } + public enum JobFamily { DEV, NON_DEV, GENERAL } public enum GithubRule { REQUIRED, OPTIONAL, HIDDEN } + public enum MaterialRule { REQUIRED_TYPE, NON_RESUME_OR_COVER_LETTER } + + private final boolean coverLettersAllowed; + private final MaterialRule materialRule; private final JobFamily family; private final GithubRule githubRule; private final List requiredTypes; @@ -43,6 +57,13 @@ public enum GithubRule { REQUIRED, OPTIONAL, HIDDEN } JobRole(JobFamily family, GithubRule githubRule, List requiredTypes, List optionalTypes, boolean supplementsAllowed) { + this(family, githubRule, requiredTypes, optionalTypes, supplementsAllowed, false, MaterialRule.REQUIRED_TYPE); + } + + JobRole(JobFamily family, GithubRule githubRule, List requiredTypes, List optionalTypes, + boolean supplementsAllowed, boolean coverLettersAllowed, MaterialRule materialRule) { + this.coverLettersAllowed = coverLettersAllowed; + this.materialRule = materialRule; this.family = family; this.githubRule = githubRule; this.requiredTypes = requiredTypes; @@ -77,29 +98,25 @@ public boolean keepsMaterialsTo(JobRole next) { } return switch (family) { case DEV -> next.family == JobFamily.DEV; - case NON_DEV -> false; + case NON_DEV, GENERAL -> false; }; } - /** - * 지금 /profile 요청 모양(포트폴리오 문서 + GitHub)으로 맞는 분석이 나오는 직무인지. - * 비개발은 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지 분석하지 않는다. 분석을 시작하는 모든 길이 이 판단을 쓴다. - */ - public boolean supportsCurrentProfileRequest() { - return switch (family) { - case DEV -> true; - case NON_DEV -> false; - }; + /** 인증 서버 저장값(MAJOR_ 접두사). */ + public String authMajor() { + return "MAJOR_" + name(); } - /** 인증 서버 저장값(MAJOR_ 접두사)으로 판단한다. 직무가 없거나 모르는 값은 백엔드로 본다(가정 A-7). */ - public static boolean supportsCurrentProfileRequest(String userMajor) { - return find(UserJobMajors.normalize(userMajor)).map(JobRole::supportsCurrentProfileRequest).orElse(true); + public boolean requiresJobTitle() { + return family == JobFamily.GENERAL; } - /** 인증 서버 저장값(MAJOR_ 접두사). */ - public String authMajor() { - return "MAJOR_" + name(); + public boolean coverLettersAllowed() { + return coverLettersAllowed; + } + + public MaterialRule materialRule() { + return materialRule; } public JobFamily family() { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java index 669619de..277b9565 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java @@ -28,6 +28,11 @@ public class FeedbackMultiRequest { private String sessionId; private String interviewId; private String userId; + // 면접 시작 시점 기술 면접관의 직무와 직무명. 현재 사용자 프로필을 다시 읽지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobRole; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; // 면접에 참여한 면접관 전원. 1~6명. private List personas; // 진행 순서대로. ORIGINAL + FOLLOW를 모두 담는다. diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java index f7bd50bd..1d715bfc 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java @@ -22,6 +22,11 @@ public class FeedbackSoloRequest { private String sessionId; private String interviewId; private String userId; + // 면접 시작 시점 기술 면접관의 직무와 직무명. 현재 사용자 프로필을 다시 읽지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobRole; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; private String personaType; // 면접관 어조. 성향(personaType)과 독립된 축이다. private String personaTone; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java index 0a271b6f..78b24b88 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java @@ -30,6 +30,7 @@ import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; import repit.repit_api_server.domain.userdata.question.entity.QuestionEntity; import repit.repit_api_server.domain.userdata.question.entity.enums.Type; @@ -321,12 +322,15 @@ private Transcript loadTranscript(InterviewEntity interview) { private FeedbackSoloRequest toSoloRequest(InterviewEntity interview, Transcript transcript) { PersonaEntity persona = soloPersonaOf(interview); + PersonaEntity techPersona = persona != null && persona.getRole() == Role.TECH ? persona : null; return FeedbackSoloRequest.builder() .interviewStatus(interview.getStatus().name()) .sessionId(interview.getSessionId()) .interviewId(String.valueOf(interview.getInterviewId())) .userId(String.valueOf(interview.getUserId())) + .jobRole(enumName(techPersona == null ? null : techPersona.getMajor())) + .jobTitle(techPersona == null ? null : techPersona.getJobTitle()) .personaType(enumName(persona == null ? null : persona.getType())) .personaTone(enumName(persona == null ? null : persona.getTone())) .company(interview.getCompanySnapshot()) @@ -355,12 +359,18 @@ private FeedbackSoloRequest toSoloRequest(InterviewEntity interview, Transcript private FeedbackMultiRequest toMultiRequest(InterviewEntity interview, Transcript transcript) { List members = orderedPersonas(interview.getInterviewId()); Map personaByQuestion = resolveQuestionPersonas(transcript, members.getFirst().getPersonaId()); + PersonaEntity techPersona = members.stream() + .filter(persona -> persona.getRole() == Role.TECH) + .findFirst() + .orElse(null); return FeedbackMultiRequest.builder() .interviewStatus(interview.getStatus().name()) .sessionId(interview.getSessionId()) .interviewId(String.valueOf(interview.getInterviewId())) .userId(String.valueOf(interview.getUserId())) + .jobRole(enumName(techPersona == null ? null : techPersona.getMajor())) + .jobTitle(techPersona == null ? null : techPersona.getJobTitle()) .personas(members.stream() .map(persona -> FeedbackMultiRequest.Persona.builder() .personaId(String.valueOf(persona.getPersonaId())) diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java index 4288b3e8..ac5c2ba2 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java @@ -62,6 +62,9 @@ public class ChatInterviewPrepareRequest { * 비워 보내면 면접이 열리지 않으므로 넘기는 쪽에서 채운다 — {@code ChatInterviewHandoffService} 참고. */ private Major major; + // 대표 면접관에 저장된 면접 시작 시점의 일반 직군 직무명. + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; // 면접 난이도. private Level level; /** diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPersonaSummary.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPersonaSummary.java index 69bb3242..b25a927d 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPersonaSummary.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPersonaSummary.java @@ -29,7 +29,7 @@ public static InterviewPersonaSummary of(PersonaEntity persona) { } return new InterviewPersonaSummary( persona.getPersonaId(), - persona.getPersonaName(), + persona.getDisplayName(), persona.getRole(), persona.getType(), persona.getLevel(), diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java index e4b40272..d7ed2ef5 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java @@ -13,6 +13,10 @@ import java.util.Optional; public interface InterviewRepository extends JpaRepository { + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select i from InterviewEntity i where i.interviewId = :interviewId") + Optional findForJobSnapshot(@Param("interviewId") Long interviewId); + List findAllByUserId(Long userId); Optional findBySessionId(String sessionId); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java index 7e2fb96b..584f12b0 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java @@ -66,6 +66,7 @@ public ChatInterviewResponse deliver(QuestionTailorEntity tailor) { .personality(persona.getType()) .tone(persona.getTone()) .major(persona.getMajor() == null ? FALLBACK_MAJOR : persona.getMajor()) + .jobTitle(persona.getJobTitle()) .level(persona.getLevel()) .questions(toQuestions(tailor, defaultPersonaId(interview, persona), personas.stream().map(PersonaEntity::getPersonaId).collect(Collectors.toSet()))) @@ -110,7 +111,7 @@ private List interviewers(InterviewEntity interview) { private ChatInterviewPrepareRequest.Persona toPersona(PersonaEntity persona) { return ChatInterviewPrepareRequest.Persona.builder() .personaId(persona.getPersonaId()) - .personaName(persona.getPersonaName()) + .personaName(persona.getDisplayName()) .role(persona.getRole()) // 대표 설정과 달리 대체값을 채우지 않는다. 비기술 면접관은 비어 있는 것이 맞다. .major(persona.getMajor()) diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java new file mode 100644 index 00000000..d4508593 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java @@ -0,0 +1,76 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import repit.repit_api_server.domain.metadata.support.JobRole; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; +import repit.repit_api_server.domain.userdata.question.repository.QuestionTailorRepository; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.exception.BusinessException; + +/** 첫 준비 접수의 일반 직무를 면접 전용 면접관으로 보존한다(G-A31). */ +@Service +@RequiredArgsConstructor +public class InterviewJobSnapshotService { + private final InterviewRepository interviews; + private final InterviewPersonaRepository members; + private final PersonaRepository personas; + private final QuestionTailorRepository tailors; + private final jakarta.persistence.EntityManager entityManager; + + @Transactional + public InterviewEntity capture(AuthUser user, Long interviewId) { + InterviewEntity interview = interviews.findForJobSnapshot(interviewId) + .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); + // OSIV가 앞선 조회의 managed 엔티티를 보관해도 잠금 획득 뒤 최신 FK를 다시 읽는다. + entityManager.refresh(interview, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE); + if (!user.id().equals(interview.getUserId())) { + throw BusinessException.forbidden("본인의 면접만 시작할 수 있습니다."); + } + if (interview.getStatus() != Status.IN_PROGRESS + || tailors.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).isPresent()) { + return interview; + } + InterviewPersonaEntity link = interview.getMode() == InterviewMode.MULTI + ? members.findAllByInterviewIdOrderByPersonaOrderAsc(interviewId).stream().findFirst().orElse(null) : null; + Long personaId = link == null ? interview.getPersonaId() : link.getPersonaId(); + PersonaEntity source = personas.findById(personaId) + .orElseThrow(() -> BusinessException.notFound("페르소나를 찾을 수 없습니다")); + if (source.isInterviewSnapshot()) { + return interview; + } + JobRole job = JobRole.find(UserJobMajors.normalize(user.user().getMajor())).orElse(JobRole.BACKEND); + if (!job.requiresJobTitle()) { + return interview; + } + String title = user.user().getJobTitle(); + if (title == null || title.isBlank() || title.strip().length() > 30 || title.matches("(?s).*\\p{Cntrl}.*")) { + throw BusinessException.unprocessable("일반 직군의 직무명을 먼저 저장해 주세요."); + } + if (source.getRole() != Role.TECH) { + throw BusinessException.unprocessable("일반 직군에는 전문 면접관이 필요합니다."); + } + PersonaEntity snapshot = personas.save(source.toBuilder().personaId(null) + .personaName("__interview_snapshot_" + interviewId).snapshotSourceName(source.getPersonaName()) + .major(Major.valueOf(job.name())).jobTitle(title.strip()).interviewSnapshot(true).build()); + if (link == null) { + interview.setPersonaId(snapshot.getPersonaId()); + interviews.save(interview); + } else { + link.setPersonaId(snapshot.getPersonaId()); + members.save(link); + } + return interview; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java index 7770e8e0..890467fa 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java @@ -8,7 +8,6 @@ import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.company.service.CompanySnapshotFactory; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; -import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.request.SaveInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.response.ChatAnswerResponse; @@ -60,7 +59,6 @@ public class InterviewService { private static final Logger log = LoggerFactory.getLogger(InterviewService.class); - static final String NON_DEV_INTERVIEW_NOT_SUPPORTED = "비개발 직무는 아직 면접을 준비할 수 없습니다."; private final InterviewRepository interviewRepository; private final QuestionRepository questionRepository; @@ -71,6 +69,7 @@ public class InterviewService { private final InterviewPersonaRepository interviewPersonaRepository; private final AnalysisLaunchService analysisLaunchService; private final CompanySnapshotFactory companySnapshotFactory; + private final InterviewJobSnapshotService interviewJobSnapshotService; /** * 면접을 만든다. {@link InterviewCreateService}가 트랜잭션을 열고 부른다 — N:1 면접관 명단·기업 스냅샷·요청 키가 @@ -82,6 +81,9 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req } PersonaEntity persona = findPersona(request); + if (persona.isInterviewSnapshot()) { + throw BusinessException.unprocessable("면접 전용 면접관은 새 면접에서 선택할 수 없습니다."); + } InterviewEntity interview = InterviewEntity.builder() .userId(userId) @@ -114,6 +116,9 @@ private InterviewResponse createMultiInterview(Long userId, List requested Map personas = personaRepository.findAllById(personaIds).stream() .collect(Collectors.toMap(PersonaEntity::getPersonaId, Function.identity())); for (Long personaId : personaIds) { + if (personas.containsKey(personaId) && personas.get(personaId).isInterviewSnapshot()) { + throw BusinessException.unprocessable("면접 전용 면접관은 새 면접에서 선택할 수 없습니다."); + } if (!personas.containsKey(personaId)) { throw BusinessException.notFound("페르소나를 찾을 수 없습니다: " + personaId); } @@ -227,7 +232,11 @@ public InterviewPrepareResponse prepareInterview(AuthUser authUser, Long intervi if (!authUser.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 시작할 수 있습니다."); } - requireSupportedJob(authUser); + if (repit.repit_api_server.domain.metadata.support.JobRole.find( + repit.repit_api_server.domain.userdata.persona.support.UserJobMajors.normalize(authUser.user().getMajor())) + .map(repit.repit_api_server.domain.metadata.support.JobRole::requiresJobTitle).orElse(false)) { + interview = interviewJobSnapshotService.capture(authUser, interviewId); + } // 쓸 수 있는 종합 데이터가 없으면(예전 분석만 있거나, 분석 서버가 지금 것을 거부했으면) 종합 데이터부터 요청한다. // 질문 준비는 그 분석을 기다린다. analysisLaunchService.launchIfMissing(authUser); @@ -248,7 +257,6 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi if (!authUser.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 다시 준비할 수 있습니다."); } - requireSupportedJob(authUser); // 처음 시작할 때 종합 데이터를 요청하지 못했거나, 그 사이 분석 서버가 지금 것을 거부했으면 여기서 요청한다. analysisLaunchService.launchIfMissing(authUser); @@ -256,16 +264,6 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi return InterviewPrepareResponse.of(tailor, interview); } - /** - * 비개발 직무면 면접 준비를 422로 막는다. 지금 직무에 맞는 분석은 아직 만들 수 없고(분석 서버 새 요청 모양 C-3 전), - * 막지 않으면 개발 직무였을 때 남은 분석으로 개발 질문을 준비한다. - */ - private static void requireSupportedJob(AuthUser authUser) { - if (!JobRole.supportsCurrentProfileRequest(authUser.user().getMajor())) { - throw BusinessException.unprocessable(NON_DEV_INTERVIEW_NOT_SUPPORTED); - } - } - /** * 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다. * diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/controller/PersonaController.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/controller/PersonaController.java index be8cf455..d81abf65 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/controller/PersonaController.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/controller/PersonaController.java @@ -24,7 +24,7 @@ public class PersonaController { public ApiResponse savePersona( @AuthenticationPrincipal AuthUser authUser, @RequestBody PersonaRequest request) { - return ApiResponse.created(personaService.createPersona(request, authUser.user().getMajor())); + return ApiResponse.created(personaService.createPersona(request, authUser.user().getMajor(), authUser.user().getJobTitle())); } @GetMapping("/getById") diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java index f07f9aac..d0c877a9 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java @@ -1,6 +1,7 @@ package repit.repit_api_server.domain.userdata.persona.dto.response; import lombok.*; +import com.fasterxml.jackson.annotation.JsonInclude; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; import repit.repit_api_server.domain.userdata.persona.entity.enums.InterviewTone; @@ -18,6 +19,8 @@ public class PersonaResponse { private String personaName; private Role role; private Major major; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; private Type type; private InterviewTone tone; private Level level; @@ -33,9 +36,10 @@ public class PersonaResponse { public static PersonaResponse from(PersonaEntity persona) { return new PersonaResponse( persona.getPersonaId(), - persona.getPersonaName(), + persona.getDisplayName(), persona.getRole(), persona.getMajor(), + persona.getJobTitle(), persona.getType(), persona.getTone(), persona.getLevel(), diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java index 5af6663b..d62fa598 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java @@ -15,7 +15,7 @@ @Entity @Table(name = "persona") @Getter -@Builder +@Builder(toBuilder = true) @AllArgsConstructor @NoArgsConstructor public class PersonaEntity { @@ -36,6 +36,20 @@ public class PersonaEntity { @Enumerated(EnumType.STRING) private Major major; + // 면접 시작 시점의 일반 직군 세부 직무명. + @Column(name = "job_title", length = 30) + private String jobTitle; + + @Column(name = "interview_snapshot", nullable = false) + private boolean interviewSnapshot; + + @Column(name = "snapshot_source_name") + private String snapshotSourceName; + + public String getDisplayName() { + return interviewSnapshot && snapshotSourceName != null ? snapshotSourceName : personaName; + } + @Enumerated(EnumType.STRING) @Column(nullable = false) private Type type; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Major.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Major.java index 204d8d94..75a50b6a 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Major.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Major.java @@ -1,6 +1,19 @@ package repit.repit_api_server.domain.userdata.persona.entity.enums; public enum Major { + FRONTEND, BACKEND, - FRONTEND + WEB_PUBLISHER, + DATA_ANALYST, + DBA_INFRA, + QA_ENGINEER, + SECURITY, + PRODUCT_MANAGER, + IT_CONSULTANT, + GEN_STRATEGY, + GEN_HR_ADMIN, + GEN_FINANCE, + GEN_MARKETING, + GEN_SALES, + GEN_DESIGN } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java index 0552fd27..206af7d4 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import repit.repit_api_server.domain.userdata.persona.dto.request.PersonaRequest; +import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.userdata.persona.dto.response.PersonaResponse; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; @@ -21,7 +22,7 @@ public class PersonaService { /** * @param userMajor 로그인 사용자의 직무. 기술 면접관 전공은 요청값이 아니라 이 값으로 정한다. */ - public PersonaResponse createPersona(PersonaRequest request, String userMajor) { + public PersonaResponse createPersona(PersonaRequest request, String userMajor, String userJobTitle) { // 직책과 난이도에는 기본값을 두지 않는다. 조용히 채워 넣으면 인사·CEO 면접관이 // 기술 면접관으로 저장되고, 저장된 뒤에는 무엇이 요청값이고 무엇이 기본값인지 구분할 방법이 없다. if (request.getRole() == null) { @@ -34,6 +35,9 @@ public PersonaResponse createPersona(PersonaRequest request, String userMajor) { if (request.getTone() == null) { throw BusinessException.unprocessable("면접관 어조(tone)를 지정해주세요."); } + if (request.getPersonaName() != null && request.getPersonaName().startsWith("__interview_snapshot_")) { + throw BusinessException.unprocessable("예약된 면접관 이름입니다."); + } Role role = request.getRole(); // DB CHECK와 같은 범위. 번호와 성별의 관계는 Web과 채팅 서버의 카탈로그가 맞추므로 보지 않는다. Integer voiceIndex = request.getVoiceIndex(); @@ -48,6 +52,8 @@ public PersonaResponse createPersona(PersonaRequest request, String userMajor) { .tone(request.getTone()) .level(request.getLevel()) .major(role == Role.TECH ? UserJobMajors.toPersonaMajor(userMajor) : null) + .jobTitle(role == Role.TECH && JobRole.find(UserJobMajors.normalize(userMajor)) + .map(JobRole::requiresJobTitle).orElse(false) ? userJobTitle : null) .career(request.getCareer()) .gender(request.getGender()) .imageUrl(blankToNull(request.getImageUrl())) @@ -62,12 +68,14 @@ public PersonaResponse createPersona(PersonaRequest request, String userMajor) { public PersonaResponse getPersonaById(Long id) { PersonaEntity persona = personaRepository.findById(id) + .filter(value -> !value.isInterviewSnapshot()) .orElseThrow(() -> BusinessException.notFound("페르소나를 찾을 수 없습니다")); return PersonaResponse.from(persona); } public PersonaResponse getPersonaByName(String name) { PersonaEntity persona = personaRepository.findByPersonaName(name) + .filter(value -> !value.isInterviewSnapshot()) .orElseThrow(() -> BusinessException.notFound("페르소나를 찾을 수 없습니다: " + name)); return PersonaResponse.from(persona); } @@ -84,6 +92,7 @@ public List getAllPersona(Role role) { : personaRepository.findAllByRole(role); return personas.stream() + .filter(persona -> !persona.isInterviewSnapshot()) .map(PersonaResponse::from) .toList(); } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajors.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajors.java index 1782745e..55b6ab8b 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajors.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajors.java @@ -10,7 +10,7 @@ * 인증 서버의 사용자 직무 값을 다룬다. * *

기술 면접관의 전공은 사용자가 고르지 않고 사용자 직무를 따른다. 면접관 전공은 - * BACKEND / FRONTEND 두 가지뿐이라, 그 밖의 직무는 가까운 값이나 대체값으로 맞춘다. + * 개발·비개발·일반 직군 15개 코드를 그대로 유지한다. */ public final class UserJobMajors { @@ -36,8 +36,7 @@ public static String normalize(String userMajor) { /** * 기술 면접관 전공. null을 돌려주지 않는다. * - *

WEB_PUBLISHER는 화면 개발 직무라 FRONTEND로 맞춘다. 직무가 없거나 옮길 수 없는 값은 - * BACKEND로 둔다. 마이페이지 직무 저장이 아직 없어 직무 없는 사용자가 정상 경로에 있으므로 막지 않는다. + *

직무가 없거나 알려지지 않은 값은 BACKEND로 둔다. */ public static Major toPersonaMajor(String userMajor) { String job = normalize(userMajor); @@ -45,13 +44,11 @@ public static Major toPersonaMajor(String userMajor) { log.info("사용자 직무가 없어 기술 면접관 전공을 BACKEND로 둡니다."); return Major.BACKEND; } - return switch (job) { - case "BACKEND" -> Major.BACKEND; - case "FRONTEND", "WEB_PUBLISHER" -> Major.FRONTEND; - default -> { - log.info("면접관 전공으로 옮길 수 없는 직무라 BACKEND로 둡니다. userMajor={}", userMajor); - yield Major.BACKEND; - } - }; + try { + return Major.valueOf(job); + } catch (IllegalArgumentException e) { + log.info("면접관 전공으로 옮길 수 없는 직무라 BACKEND로 둡니다. userMajor={}", userMajor); + return Major.BACKEND; + } } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java index a704549e..7b7073b1 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java @@ -27,6 +27,8 @@ public class QuestionTailorMultiRequest { private String userId; // 재작성 개인화 축. 둘 다 선택이라 없으면 비워 보낸다. private String jobRole; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; private String experienceLevel; // 기술 면접관. questionCount는 아래 questions 개수와 반드시 같아야 한다. private Persona techPersona; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java index 4d20564b..ed865c4b 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java @@ -34,6 +34,8 @@ public class QuestionTailorRequest { @AllArgsConstructor public static class Profile { private String jobRole; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String jobTitle; private String experienceLevel; private String personaType; // 면접관 어조. 성향(personaType)과 독립된 축이다. diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java index 00fe9ba0..11be7151 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java @@ -91,7 +91,8 @@ public static int setSize(InterviewMode mode) { * 꺼낸 세트와 그 세트가 나온 종합 데이터. * * @param questions 비어 있으면(null) 꺼낼 세트가 없어 다음 사이클을 기다려야 한다. - * @param major 종합 데이터를 만들 때 실어 보낸 전공. 사용자 정보가 없는 콜백에서 재작성 축으로 쓴다. + * @param major 종합 데이터를 만들 때 실어 보낸 직무(v1은 major, v2는 jobRole). 사용자 정보가 없는 콜백에서 + * 재작성 축으로 쓰고, 면접 직무와 자료 직무가 같은지 가리는 데도 쓴다. */ public record DrawnSet(String profileJobId, List questions, Object projectSummary, String major) { @@ -555,9 +556,22 @@ private DrawnSet drawn(AnalysisDataEntity profile, List set) .basedOn(question.getBasedOn()) .build()); } - Object major = resultField(profile, "profile") instanceof Map body ? body.get("major") : null; - return new DrawnSet(profile.getJobId(), questions, resultField(profile, "projectSummary"), - major instanceof String text ? text : null); + return new DrawnSet(profile.getJobId(), questions, resultField(profile, "projectSummary"), profileMajor(profile)); + } + + /** + * 종합 데이터에 실었던 직무. 개발 직군 결과(schemaVersion 1)는 major에, 비개발·일반 직군 결과(schemaVersion 2)는 + * jobRole에 담겨 온다. major만 읽으면 v2 결과에서는 늘 비어, 콜백에서 이어갈 때 재작성 축이 빠지고 + * 면접 직무와 자료 직무가 달라도 가려내지 못한다. + */ + private String profileMajor(AnalysisDataEntity profile) { + if (!(resultField(profile, "profile") instanceof Map body)) { + return null; + } + if (body.get("major") instanceof String major && !major.isBlank()) { + return major; + } + return body.get("jobRole") instanceof String jobRole && !jobRole.isBlank() ? jobRole : null; } private Object resultField(AnalysisDataEntity data, String key) { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java index e944b379..d3ff043a 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java @@ -40,6 +40,7 @@ import repit.repit_api_server.global.client.AiServerClient; import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.response.UserResponse; +import repit.repit_api_server.domain.metadata.support.JobRole; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; @@ -115,7 +116,7 @@ public QuestionTailorEntity requestTailor(InterviewEntity interview, UserRespons return existing; } - return startTailor(interview, user.getMajor(), null); + return startTailor(interview, user.getMajor(), user.getJobTitle(), null); } /** @@ -150,7 +151,7 @@ public QuestionTailorEntity retryPreparation(InterviewEntity interview, UserResp deliverToChatServer(existing); return existing; } - return startTailor(interview, user.getMajor(), null); + return startTailor(interview, user.getMajor(), user.getJobTitle(), null); } private QuestionTailorEntity latestTailor(Long interviewId) { @@ -163,7 +164,7 @@ private QuestionTailorEntity latestTailor(Long interviewId) { * @param userMajor 사용자 전공. 콜백에서 이어갈 때는 사용자 정보가 없어 종합 데이터에 실었던 값을 쓴다. * @param target 사이클을 기다리던 준비 건. 있으면 새로 만들지 않고 이 건을 채운다. */ - private QuestionTailorEntity startTailor(InterviewEntity interview, String userMajor, QuestionTailorEntity target) { + private QuestionTailorEntity startTailor(InterviewEntity interview, String userMajor, String userJobTitle, QuestionTailorEntity target) { QuestionPoolService.DrawnSet drawn = questionPoolService.takeSet( interview.getUserId(), interview.getMode(), interview.getInterviewId()); @@ -180,6 +181,18 @@ private QuestionTailorEntity startTailor(InterviewEntity interview, String userM return waitForCycle(interview, tailor); } + PersonaEntity representative = interview.getMode() == InterviewMode.MULTI + ? orderedPersonas(interview.getInterviewId()).getFirst() + : personaRepository.findById(interview.getPersonaId()).orElse(null); + String major = userMajor != null ? userMajor : drawn.major(); + if (representative != null && representative.isInterviewSnapshot()) { + major = representative.getMajor().name(); + userJobTitle = representative.getJobTitle(); + if (drawn.major() != null && !major.equals(UserJobMajors.normalize(drawn.major()))) { + throw BusinessException.conflict("면접 직무와 분석 자료의 직무가 다릅니다. 자료 분석 후 새 면접을 시작해 주세요."); + } + } + // 콜백이 접수 응답보다 먼저 올 수 있다. 분석 서버를 부르기 전에 이 건을 저장하고, 콜백 주소에 그 id를 실어 // 보낸다 — 콜백은 접수 응답의 jobId 없이도 이 건을 찾는다. 이 메서드는 트랜잭션 밖에서 불려 저장이 곧바로 // 커밋되므로, 분석 서버가 부르기 전에 다른 트랜잭션의 콜백 처리에서도 보인다. @@ -190,12 +203,11 @@ private QuestionTailorEntity startTailor(InterviewEntity interview, String userM tailor.setSourceQuestions(drawn.questions()); tailor = questionTailorRepository.save(tailor); - String major = userMajor != null ? userMajor : drawn.major(); QuestionTailorAcceptedResponse accepted; try { accepted = interview.getMode() == InterviewMode.MULTI - ? requestMultiTailor(interview, major, drawn, tailor.getTailorId()) - : requestSoloTailor(interview, major, drawn, tailor.getTailorId()); + ? requestMultiTailor(interview, major, userJobTitle, drawn, tailor.getTailorId()) + : requestSoloTailor(interview, major, userJobTitle, drawn, tailor.getTailorId()); } catch (RuntimeException e) { // 접수되지 않은 새 건은 남기지 않는다. 남기면 면접을 다시 시작해도 이 건에 막혀 제한 시간까지 기다린다. // 그사이 콜백이 와서 결과가 들어갔으면 지우지 않는다. 사이클을 기다리던 건은 부르는 쪽이 실패로 닫는다. @@ -222,9 +234,9 @@ static String withTailorId(String callbackUrl, Long tailorId) { .toUriString(); } - private QuestionTailorAcceptedResponse requestSoloTailor(InterviewEntity interview, String major, + private QuestionTailorAcceptedResponse requestSoloTailor(InterviewEntity interview, String major, String jobTitle, QuestionPoolService.DrawnSet drawn, Long tailorId) { - QuestionTailorRequest.Profile profile = resolveProfile(major, interview.getPersonaId()); + QuestionTailorRequest.Profile profile = resolveProfile(major, jobTitle, interview.getPersonaId()); return aiServerClient.tailorQuestions(QuestionTailorRequest.builder() .interviewId(String.valueOf(interview.getInterviewId())) @@ -266,7 +278,7 @@ private QuestionTailorEntity waitForCycle(InterviewEntity interview, QuestionTai * 따라간다 — 면접관 2·3·4명이 각각 네·여섯·여덟 문항이다. 인원 범위는 * {@link MultiInterviewPanel}에 있다. */ - private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interview, String major, + private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interview, String major, String jobTitle, QuestionPoolService.DrawnSet drawn, Long tailorId) { List members = orderedPersonas(interview.getInterviewId()); PersonaEntity tech = members.getFirst(); @@ -279,6 +291,7 @@ private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interv .interviewId(String.valueOf(interview.getInterviewId())) .userId(String.valueOf(interview.getUserId())) .jobRole(resolveJobRole(major)) + .jobTitle(resolveJobTitle(major, jobTitle)) // persona.career는 면접관 설정이지 지원자 경력이 아니다. 지원자 경력은 아직 수집하지 않는다. .experienceLevel(null) .techPersona(toRequestPersona(tech, techQuestions.size())) @@ -447,6 +460,11 @@ private String resolveJobRole(String userMajor) { return UserJobMajors.normalize(userMajor); } + private String resolveJobTitle(String userMajor, String jobTitle) { + return JobRole.find(resolveJobRole(userMajor)).map(JobRole::requiresJobTitle).orElse(false) + ? jobTitle : null; + } + private QuestionTailorRequest.Question toRequestQuestion(TailoredQuestionResponse question) { return QuestionTailorRequest.Question.builder() .id(question.getId()) @@ -462,7 +480,7 @@ private QuestionTailorRequest.Question toRequestQuestion(TailoredQuestionRespons * 세 축이 모두 비면 분석 서버가 실패 콜백(422)을 보낸다. * 콜백까지 갔다 오면 면접 시작이 그만큼 늦어지므로 요청 전에 막는다. */ - private QuestionTailorRequest.Profile resolveProfile(String userMajor, Long personaId) { + private QuestionTailorRequest.Profile resolveProfile(String userMajor, String jobTitle, Long personaId) { PersonaEntity persona = personaRepository.findById(personaId).orElse(null); String jobRole = resolveJobRole(userMajor); @@ -475,6 +493,7 @@ private QuestionTailorRequest.Profile resolveProfile(String userMajor, Long pers return QuestionTailorRequest.Profile.builder() .jobRole(jobRole) + .jobTitle(resolveJobTitle(userMajor, jobTitle)) // persona.career는 면접관 설정이지 지원자 경력이 아니다. 지원자 경력은 아직 수집하지 않는다. .experienceLevel(null) .personaType(personaType) @@ -655,7 +674,7 @@ private QuestionTailorEntity resumeWaiting(QuestionTailorEntity tailor) { InterviewEntity interview = interviewRepository.findById(tailor.getInterviewId()) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); // 콜백에는 사용자 정보가 없다. 재작성 축의 전공은 종합 데이터에 실었던 값으로 대신한다. - return startTailor(interview, null, tailor); + return startTailor(interview, null, null, tailor); } catch (RuntimeException e) { log.error("질문 사이클을 받았지만 면접 준비를 이어가지 못했습니다. tailorId={}, interviewId={}", tailor.getTailorId(), tailor.getInterviewId(), e); diff --git a/src/main/java/repit/repit_api_server/global/response/UserResponse.java b/src/main/java/repit/repit_api_server/global/response/UserResponse.java index 188c9e31..891cbc35 100644 --- a/src/main/java/repit/repit_api_server/global/response/UserResponse.java +++ b/src/main/java/repit/repit_api_server/global/response/UserResponse.java @@ -16,6 +16,7 @@ public class UserResponse { private String nickname; private String username; private String major; + private String jobTitle; private String provider; private String role; private LocalDateTime createAt; diff --git a/src/main/resources/db/migration/V39__widen_persona_major.sql b/src/main/resources/db/migration/V39__widen_persona_major.sql new file mode 100644 index 00000000..3582e6fd --- /dev/null +++ b/src/main/resources/db/migration/V39__widen_persona_major.sql @@ -0,0 +1,3 @@ +ALTER TABLE persona DROP CONSTRAINT persona_major_check; +ALTER TABLE persona ADD CONSTRAINT persona_major_check CHECK (major IN ( + 'FRONTEND', 'BACKEND', 'WEB_PUBLISHER', 'DATA_ANALYST', 'DBA_INFRA', 'QA_ENGINEER', 'SECURITY', 'PRODUCT_MANAGER', 'IT_CONSULTANT')); diff --git a/src/main/resources/db/migration/V40__general_job_persona.sql b/src/main/resources/db/migration/V40__general_job_persona.sql new file mode 100644 index 00000000..eca8f7d4 --- /dev/null +++ b/src/main/resources/db/migration/V40__general_job_persona.sql @@ -0,0 +1,10 @@ +ALTER TABLE persona DROP CONSTRAINT persona_major_check; +ALTER TABLE persona ADD CONSTRAINT persona_major_check CHECK (major IN ( + 'FRONTEND', 'BACKEND', 'WEB_PUBLISHER', 'DATA_ANALYST', 'DBA_INFRA', 'QA_ENGINEER', 'SECURITY', 'PRODUCT_MANAGER', 'IT_CONSULTANT', 'GEN_STRATEGY', 'GEN_HR_ADMIN', 'GEN_FINANCE', 'GEN_MARKETING', 'GEN_SALES', 'GEN_DESIGN')); + +ALTER TABLE persona ADD COLUMN job_title VARCHAR(30) NULL; +COMMENT ON COLUMN persona.job_title IS '일반 직군 기술(첫 자리) 면접관의 지원자 직무명. 면접 시작 시점 값. 그 밖은 NULL'; + +ALTER TABLE persona ADD COLUMN interview_snapshot BOOLEAN NOT NULL DEFAULT false; +COMMENT ON COLUMN persona.interview_snapshot IS '일반 직군 면접 시작 시점 전용 면접관. 선택 목록에서는 숨김'; +ALTER TABLE persona ADD COLUMN snapshot_source_name VARCHAR(255) NULL; diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java index 2e0a2f62..6ea321ca 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java @@ -157,4 +157,26 @@ private static MockMultipartFile payloadPart() { .andExpect(jsonPath("$.errors[1].reason").value("TOO_MANY_PAGES")) .andExpect(jsonPath("$.errors[1].detail").value("52")); } + @Test + void 일반_직무명과_자소서_payload를_받고_응답에_함께_싣는다() throws Exception { + when(materialsService.replace(eq(authUser), any(), any())).thenReturn(new MaterialsSaveResponse( + new MaterialsResponse("GEN_MARKETING", List.of(), List.of(), List.of(), "퍼포먼스 마케터", + List.of(new MaterialsResponse.CoverLetter("역량", "캠페인 경험"))), + new MaterialsSaveResponse.Analysis("job-general", "accepted"))); + String payload = """ + {"jobRole":"GEN_MARKETING","jobTitle":"퍼포먼스 마케터","documents":[], + "gitUrls":[],"supplements":[],"coverLetters":[{"question":"역량","answer":"캠페인 경험"}]} + """; + mockMvc.perform(multipart("/api/v1/metaData/materials").file(new MockMultipartFile( + "payload", "", MediaType.APPLICATION_JSON_VALUE, payload.getBytes(StandardCharsets.UTF_8))) + .with(request -> { request.setMethod("PUT"); return request; })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.materials.jobTitle").value("퍼포먼스 마케터")) + .andExpect(jsonPath("$.materials.coverLetters[0].answer").value("캠페인 경험")); + var sent = ArgumentCaptor.forClass(MaterialsPayload.class); + verify(materialsService).replace(eq(authUser), sent.capture(), any()); + assertThat(sent.getValue().jobTitle()).isEqualTo("퍼포먼스 마케터"); + assertThat(sent.getValue().coverLetters()).containsExactly(new MaterialsPayload.CoverLetterInput("역량", "캠페인 경험")); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java index 2a52cef4..fe91f26f 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java @@ -78,13 +78,13 @@ class MetaDataControllerTest { } @Test - void 분석할_수_없는_직무면_옛_업로드도_자료를_저장하기_전에_422() throws Exception { - doThrow(BusinessException.unprocessable("비개발 직무는 아직 자료 분석을 지원하지 않습니다.")) - .when(analysisLaunchService).requireSupportedJob(any()); - - assertThatThrownBy(() -> controller.upload(USER, - new MockMultipartFile("file", "a.pdf", "application/pdf", new byte[]{1}), List.of("https://github.com/a/b"))) - .isInstanceOf(BusinessException.class); + void 옛_업로드도_materials_저장과_분석을_재사용한다() throws Exception { + when(materialsService.replace(any(), any(), any())).thenReturn( + new repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse( + MaterialsResponse.empty(), new repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse.Analysis("job-1", "accepted"))); + var response = controller.upload(USER, + new MockMultipartFile("file", "a.pdf", "application/pdf", new byte[]{1}), List.of("https://github.com/a/b")); + assertThat(response.getBody().getJobId()).isEqualTo("job-1"); verify(metaService, never()).dataUpload(any(), any(), any()); } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequestSerializationTest.java new file mode 100644 index 00000000..e5c124ef --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/ProfileRequestSerializationTest.java @@ -0,0 +1,29 @@ +package repit.repit_api_server.domain.metadata.dto.request; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +class ProfileRequestSerializationTest { + @Test void IT는_일반_필드와_옛_필드를_보내지_않는다() { + var request = ProfileRequest.builder().jobRole("QA_ENGINEER") + .documents(List.of(new ProfileRequest.Document("TEST_PLAN", "https://s3/test.pdf", "테스트 계획.pdf"))) + .githubUrls(List.of()).supplements(List.of()).coverLetters(List.of()).callbackUrl("https://api/callback").build(); + var json = new ObjectMapper().valueToTree(request); + assertThat(json.has("jobTitle")).isFalse(); + assertThat(json.has("coverLetters")).isFalse(); + assertThat(json.has("major")).isFalse(); + assertThat(json.has("portfolioUrl")).isFalse(); + assertThat(json.get("documents").get(0).get("title").asText()).isEqualTo("테스트 계획.pdf"); + } + + @Test void 일반은_자소서와_직무명을_보낸다() { + var request = ProfileRequest.builder().jobRole("GEN_MARKETING").jobTitle("마케터") + .documents(List.of()).githubUrls(List.of()).supplements(List.of()) + .coverLetters(List.of(new ProfileRequest.CoverLetter("역량", "경험"))).build(); + var json = new ObjectMapper().valueToTree(request); + assertThat(json.get("jobTitle").asText()).isEqualTo("마케터"); + assertThat(json.get("coverLetters").get(0).get("answer").asText()).isEqualTo("경험"); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java b/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java index 64704680..1f823b5b 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java @@ -102,4 +102,23 @@ class MaterialsResponseTest { assertThat(MaterialsResponse.from(auth).toAnalysisInput().getFileUrl()).isNull(); } + @Test + void 일반_직무명과_자소서는_인증_서버_값을_순서대로_옮긴다() { + AuthMaterialsResponse auth = new AuthMaterialsResponse("MAJOR_GEN_MARKETING", List.of(), List.of(), + List.of(), null, "브랜드 마케터", List.of( + new AuthMaterialsResponse.CoverLetterEntry("지원 동기", "브랜드를 키웠습니다"), + new AuthMaterialsResponse.CoverLetterEntry("성과", "매출 증가"))); + + MaterialsResponse response = MaterialsResponse.from(auth); + + assertThat(response.jobRole()).isEqualTo("GEN_MARKETING"); + assertThat(response.jobTitle()).isEqualTo("브랜드 마케터"); + assertThat(response.coverLetters()).containsExactly( + new MaterialsResponse.CoverLetter("지원 동기", "브랜드를 키웠습니다"), + new MaterialsResponse.CoverLetter("성과", "매출 증가")); + assertThat(MaterialsResponse.empty().jobTitle()).isNull(); + assertThat(MaterialsResponse.empty().coverLetters()).isEmpty(); + assertThat(MaterialsResponse.from(new AuthMaterialsResponse("MAJOR_GEN_DESIGN", null, null, + null, null, "디자이너", null)).coverLetters()).isEmpty(); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java index 24342981..1ac2dd7c 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java @@ -125,7 +125,8 @@ private AuthUser authUser() { // 첫 문서(이력서)가 아니라 포트폴리오를 보낸다. 옛 metaData의 파일 주소는 보지 않는다. ArgumentCaptor sent = ArgumentCaptor.forClass(ProfileRequest.class); verify(aiServerClient).requestProfile(sent.capture()); - assertThat(sent.getValue().getPortfolioUrl()).isEqualTo("https://s3/portfolio.pdf"); + assertThat(sent.getValue().getDocuments()).extracting(ProfileRequest.Document::getUrl) + .containsExactly("https://s3/resume.pdf", "https://s3/portfolio.pdf"); assertThat(sent.getValue().getGithubUrls()).containsExactly("https://github.com/user/repo"); verify(metaService, never()).getMetaData(any()); verify(aiMetaDataService).registerJob(eq("job-1"), eq(OWNER_ID), any(LocalDateTime.class), eq(AnalysisResultType.PROFILE)); @@ -207,7 +208,7 @@ private AuthUser authUser() { @Test void 분석을_요청하고_그_실행을_접수한다() { - GenerateResponse response = service.launch(OWNER_ID, "MAJOR_BACKEND", metaData()); + GenerateResponse response = service.launchMaterials(OWNER_ID, savedMaterials()); assertThat(response.getJobId()).isEqualTo("job-1"); verify(aiMetaDataService).registerJob(eq("job-1"), eq(9L), any(LocalDateTime.class), eq(AnalysisResultType.PROFILE)); @@ -220,7 +221,7 @@ private AuthUser authUser() { */ @Test void 분석을_요청한_뒤에_곧바로_접수한다() { - service.launch(OWNER_ID, "MAJOR_BACKEND", metaData()); + service.launchMaterials(OWNER_ID, savedMaterials()); InOrder order = inOrder(aiServerClient, aiMetaDataService); order.verify(aiServerClient).requestProfile(any(ProfileRequest.class)); @@ -235,13 +236,14 @@ private AuthUser authUser() { void 콜백_주소를_설정에서_만든다() { ArgumentCaptor sent = ArgumentCaptor.forClass(ProfileRequest.class); - service.launch(OWNER_ID, "MAJOR_BACKEND", metaData()); + service.launchMaterials(OWNER_ID, savedMaterials()); verify(aiServerClient).requestProfile(sent.capture()); assertThat(sent.getValue().getCallbackUrl()).isEqualTo("https://api.repit.test/api/v1/ai/profile/callback"); - assertThat(sent.getValue().getPortfolioUrl()).isEqualTo("https://s3/portfolio.pdf"); + assertThat(sent.getValue().getDocuments()).extracting(ProfileRequest.Document::getUrl) + .containsExactly("https://s3/resume.pdf", "https://s3/portfolio.pdf"); assertThat(sent.getValue().getGithubUrls()).containsExactly("https://github.com/user/repo"); - assertThat(sent.getValue().getMajor()).isEqualTo("MAJOR_BACKEND"); + assertThat(sent.getValue().getJobRole()).isEqualTo("BACKEND"); } /** jobId가 없으면 구독도 조회도 할 수 없다. 성공으로 돌려주면 원인을 찾을 수 없다. */ @@ -251,7 +253,7 @@ private AuthUser authUser() { .status("rejected") .build()); - assertThatThrownBy(() -> service.launch(OWNER_ID, null, metaData())) + assertThatThrownBy(() -> service.launchMaterials(OWNER_ID, savedMaterials())) .isInstanceOf(ExternalApiException.class); } @@ -260,7 +262,7 @@ private AuthUser authUser() { void 접수에_실패해도_jobId는_돌려준다() { doThrowOnRegister(); - GenerateResponse response = service.launch(OWNER_ID, null, metaData()); + GenerateResponse response = service.launchMaterials(OWNER_ID, savedMaterials()); assertThat(response.getJobId()).isEqualTo("job-1"); } @@ -271,25 +273,26 @@ private void doThrowOnRegister() { } @Test - void 비개발_직무는_분석을_요청하지_않고_422() { - assertThatThrownBy(() -> service.launch(OWNER_ID, "MAJOR_QA_ENGINEER", metaData())) - .isInstanceOf(repit.repit_api_server.global.exception.BusinessException.class) - .hasMessage(AnalysisLaunchService.NON_DEV_NOT_SUPPORTED) - .extracting("status").hasToString("422 UNPROCESSABLE_CONTENT"); - verify(aiServerClient, never()).requestProfile(any()); + void 비개발_문서를_GitHub_없이_분석한다() { + service.launchMaterials(OWNER_ID, new MaterialsResponse("QA_ENGINEER", + List.of(new MaterialDocument(0, "TEST_PLAN", "https://s3/test.pdf", "test.pdf", 1)), List.of(), List.of())); + ArgumentCaptor sent = ArgumentCaptor.forClass(ProfileRequest.class); + verify(aiServerClient).requestProfile(sent.capture()); + assertThat(sent.getValue().getJobRole()).isEqualTo("QA_ENGINEER"); + assertThat(sent.getValue().getGithubUrls()).isEmpty(); } @Test - void 면접을_시작해도_비개발_직무는_자동_분석을_돌리지_않는다() { - UserResponse user = mock(UserResponse.class); - when(user.getId()).thenReturn(OWNER_ID); - when(user.getMajor()).thenReturn("MAJOR_QA_ENGINEER"); + void 일반_직군은_자소서만_있어도_자동_분석한다() { givenLatestProfile(null); - when(metaService.getMaterials("token-1")).thenReturn(savedMaterials()); - - service.launchIfMissing(new AuthUser(user, "token-1")); - - verify(aiServerClient, never()).requestProfile(any()); - verify(analysisDataRepository, never()).lockProfileLaunch(any()); + when(metaService.getMaterials("token-1")).thenReturn(new MaterialsResponse("GEN_MARKETING", + List.of(), List.of(), List.of(), "퍼포먼스 마케터", + List.of(new MaterialsResponse.CoverLetter("경험", "캠페인을 개선했습니다")))); + service.launchIfMissing(authUser()); + ArgumentCaptor sent = ArgumentCaptor.forClass(ProfileRequest.class); + verify(aiServerClient).requestProfile(sent.capture()); + assertThat(sent.getValue().getJobTitle()).isEqualTo("퍼포먼스 마케터"); + assertThat(sent.getValue().getCoverLetters()).hasSize(1); + assertThat(sent.getValue().getDocuments()).isEmpty(); } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java index d8f8fb8a..c6de2027 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java @@ -19,7 +19,7 @@ import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse; -import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; import repit.repit_api_server.domain.metadata.support.PdfInspector; @@ -97,7 +97,7 @@ private static AuthMaterialsResponse savedBackend() { AuthMaterialsRequest request = invocation.getArgument(1); return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); }); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_FRONTEND"), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenReturn(new GenerateResponse("job-1", "processing", null)); MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("FRONTEND", @@ -136,7 +136,7 @@ private MaterialsPayload backendWithNewPortfolio() { return uploadedFrom.size() == 1 ? "https://bucket/portfolio.pdf" : "https://bucket/resume.pdf"; }); authEchoesSavedRequest(); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenReturn(new GenerateResponse("job-2", "processing", null)); Map files = Map.of( "file0", new MockMultipartFile("file0", "포트폴리오.pdf", "application/pdf", TestPdfs.pdf(9, "portfolio")), @@ -153,9 +153,9 @@ private MaterialsPayload backendWithNewPortfolio() { new DocumentEntry("RESUME", "https://bucket/resume.pdf", "이력서.pdf", 2)), List.of("https://github.com/a/api"), List.of())); - ArgumentCaptor launched = ArgumentCaptor.forClass(MetaDataResponse.class); - verify(analysisLaunchService).launch(eq(7L), eq("MAJOR_BACKEND"), launched.capture()); - assertThat(launched.getValue().getFileUrl()).isEqualTo("https://bucket/portfolio.pdf"); + ArgumentCaptor launched = ArgumentCaptor.forClass(MaterialsResponse.class); + verify(analysisLaunchService).launchMaterials(eq(7L), launched.capture()); + assertThat(launched.getValue().documents()).extracting("type").containsExactly("PORTFOLIO", "RESUME"); assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-2", "accepted")); // 올린 뒤 임시 파일은 남지 않는다. assertThat(uploadedFrom).hasSize(2).allSatisfy(file -> assertThat(file).doesNotExist()); @@ -166,7 +166,7 @@ private MaterialsPayload backendWithNewPortfolio() { when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); when(storage.upload(any())).thenReturn("https://bucket/resume.pdf"); authEchoesSavedRequest(); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenReturn(new GenerateResponse("job-3", "processing", null)); Map files = Map.of( "file0", new MockMultipartFile("file0", "이력서.pdf", "application/pdf", TestPdfs.pdf(2, "resume"))); @@ -179,17 +179,19 @@ private MaterialsPayload backendWithNewPortfolio() { ArgumentCaptor sent = ArgumentCaptor.forClass(AuthMaterialsRequest.class); verify(authServerClient).putMaterials(eq(TOKEN), sent.capture()); assertThat(sent.getValue().documents()).extracting(DocumentEntry::type).containsExactly("RESUME", "PORTFOLIO"); - ArgumentCaptor launched = ArgumentCaptor.forClass(MetaDataResponse.class); - verify(analysisLaunchService).launch(eq(7L), eq("MAJOR_BACKEND"), launched.capture()); - assertThat(launched.getValue().getFileUrl()).isEqualTo(SAVED_URL); + ArgumentCaptor launched = ArgumentCaptor.forClass(MaterialsResponse.class); + verify(analysisLaunchService).launchMaterials(eq(7L), launched.capture()); + assertThat(launched.getValue().documents()).extracting("type").containsExactly("RESUME", "PORTFOLIO"); assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-3", "accepted")); } @Test - void 비개발_직무는_저장하지만_분석은_요청하지_않고_skipped() { + void 비개발_직무도_저장하고_분석을_요청한다() { when(authServerClient.getMaterials(TOKEN)).thenReturn(new AuthMaterialsResponse(null, List.of(), List.of(), List.of(), null)); when(storage.upload(any())).thenReturn("https://bucket/prd.pdf"); authEchoesSavedRequest(); + when(analysisLaunchService.launchMaterials(eq(7L), any())) + .thenReturn(new GenerateResponse("job-pm", "accepted", null)); Map files = Map.of( "file0", new MockMultipartFile("file0", "회원가입-PRD.pdf", "application/pdf", TestPdfs.pdf(9, "prd"))); @@ -199,8 +201,8 @@ private MaterialsPayload backendWithNewPortfolio() { assertThat(response.materials().jobRole()).isEqualTo("PRODUCT_MANAGER"); assertThat(response.materials().supplements()).hasSize(1); - assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis(null, "skipped")); - verifyNoInteractions(analysisLaunchService); + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-pm", "accepted")); + verify(analysisLaunchService).launchMaterials(eq(7L), any()); } /** 재시도 중 앞 시도가 커밋했을 수 있어 4xx도 저장되지 않았다고 확정하지 않는다. */ @@ -228,7 +230,7 @@ private MaterialsPayload backendWithNewPortfolio() { when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); when(authServerClient.putMaterials(eq(TOKEN), any())) .thenThrow(new ExternalApiException("요청이 너무 많습니다. (429)", HttpStatus.TOO_MANY_REQUESTS, null)); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenReturn(new GenerateResponse("job-4", "processing", null)); MaterialsSaveResponse response = service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio()); @@ -260,7 +262,7 @@ private MaterialsPayload backendWithNewPortfolio() { when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); when(authServerClient.putMaterials(eq(TOKEN), any())) .thenThrow(new ExternalApiException("인증 서버와 연결할 수 없습니다.", null, null)); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenReturn(new GenerateResponse("job-3", "processing", null)); MaterialsSaveResponse response = service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio()); @@ -347,7 +349,7 @@ private MaterialsPayload backendWithNewPortfolio() { AuthMaterialsRequest request = invocation.getArgument(1); return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); }); - when(analysisLaunchService.launch(eq(7L), anyString(), any())) + when(analysisLaunchService.launchMaterials(eq(7L), any())) .thenThrow(new ExternalApiException("분석 서버와 연결할 수 없습니다.", null, null)); MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("BACKEND", @@ -358,4 +360,69 @@ private MaterialsPayload backendWithNewPortfolio() { assertThat(response.materials().gitUrls()).hasSize(2); verify(storage, never()).deleteQuietly(anyString()); } + private AuthMaterialsResponse general(String title, String answer) { + return new AuthMaterialsResponse("MAJOR_GEN_MARKETING", List.of(), List.of(), List.of(), null, + title, List.of(new AuthMaterialsResponse.CoverLetterEntry("역량", answer))); + } + + private MaterialsPayload generalPayload(String title, String answer) { + return new MaterialsPayload("GEN_MARKETING", List.of(), List.of(), List.of(), title, + List.of(new MaterialsPayload.CoverLetterInput("역량", answer))); + } + + @Test + void 일반_자소서만_저장해_직무명과_함께_분석으로_전달한다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(null); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(inv -> { + AuthMaterialsRequest r = inv.getArgument(1); + return new AuthMaterialsResponse(r.major(), r.documents(), r.gitUrls(), r.supplements(), null, + r.jobTitle(), r.coverLetters()); + }); + when(analysisLaunchService.launchMaterials(eq(7L), any())) + .thenReturn(new GenerateResponse("job-general", "accepted", null)); + var result = service.replace(authUser, generalPayload(" 퍼포먼스 마케터 ", "경험"), Map.of()); + assertThat(result.materials().jobTitle()).isEqualTo("퍼포먼스 마케터"); + assertThat(result.materials().coverLetters()).containsExactly(new MaterialsResponse.CoverLetter("역량", "경험")); + var sent = ArgumentCaptor.forClass(MaterialsResponse.class); + verify(analysisLaunchService).launchMaterials(eq(7L), sent.capture()); + assertThat(sent.getValue()).isEqualTo(result.materials()); + verifyNoInteractions(storage); + } + + @Test + void 일반_같은_직무명과_자소서는_재분석하지_않는다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(general("마케터", "경험")); + var result = service.replace(authUser, generalPayload("마케터", "경험"), Map.of()); + assertThat(result.analysis()).isNull(); + verify(authServerClient, never()).putMaterials(anyString(), any()); + verifyNoInteractions(analysisLaunchService, storage); + } + + @Test + void 직무명만_바꾸거나_자소서_답을_바꾸면_다시_분석한다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(general("마케터", "경험")); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(inv -> { + AuthMaterialsRequest r = inv.getArgument(1); + return new AuthMaterialsResponse(r.major(), r.documents(), r.gitUrls(), r.supplements(), null, + r.jobTitle(), r.coverLetters()); + }); + when(analysisLaunchService.launchMaterials(eq(7L), any())) + .thenReturn(new GenerateResponse("job-general", "accepted", null)); + assertThat(service.replace(authUser, generalPayload("해외 마케터", "경험"), Map.of()).analysis()).isNotNull(); + assertThat(service.replace(authUser, generalPayload("마케터", "경험1"), Map.of()).analysis()).isNotNull(); + verify(analysisLaunchService, times(2)).launchMaterials(eq(7L), any()); + } + + @Test + void 타임아웃_재조회에서_직무명이나_자소서가_다르면_저장_성공으로_보지_않는다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(general("마케터", "경험")); + ExternalApiException failure = new ExternalApiException("timeout", null, null); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(failure); + assertThatThrownBy(() -> service.replace(authUser, generalPayload("해외 마케터", "경험"), Map.of())) + .isSameAs(failure); + assertThatThrownBy(() -> service.replace(authUser, generalPayload("마케터", "경험1"), Map.of())) + .isSameAs(failure); + verifyNoInteractions(analysisLaunchService); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java index 7964b611..a7b97951 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java @@ -9,6 +9,7 @@ import org.springframework.web.multipart.MultipartFile; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.DocumentInput; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.CoverLetterInput; import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.Reason; @@ -466,4 +467,106 @@ private static long countMaterialTempFiles() throws Exception { return stream.filter(path -> path.getFileName().toString().startsWith("repit-material-")).count(); } } + private static MaterialsPayload general(String title, List documents, List letters) { + return new MaterialsPayload("GEN_MARKETING", documents, List.of(), List.of(), title, letters); + } + + @Test + void 일반_직군은_자소서만으로_저장하고_공백과_빈_항목을_정리한다() { + ValidatedMaterials result = validator().validate(general(" 브랜드 마케터 ", List.of(), List.of( + new CoverLetterInput(" ", null), new CoverLetterInput(" 동기 ", " 경험 "))), files, NOTHING_SAVED); + + assertThat(result.jobTitle()).isEqualTo("브랜드 마케터"); + assertThat(result.documents()).isEmpty(); + assertThat(result.coverLetters()).containsExactly(new CoverLetterInput("동기", "경험")); + } + + @Test + void 일반_직군은_업무_산출물이나_경력기술서만으로도_저장한다() { + for (String type : List.of("WORK_SAMPLE", "CAREER_DESCRIPTION")) { + ValidatedMaterials result = validator().validate(general("마케터", + List.of(newDoc(type, "file0", TestPdfs.pdf(1, type))), List.of()), files, NOTHING_SAVED); + assertThat(result.documents()).extracting(MaterialsValidator.ValidDocument::type).containsExactly(type); + result.deleteTempFiles(); + } + } + + @Test + void 일반_직군은_이력서만_있거나_모두_비면_MATERIAL_REQUIRED만_반환한다() { + assertThat(errorsOf(general("마케터", List.of(), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("materials", Reason.MATERIAL_REQUIRED, null)); + assertThat(errorsOf(general("마케터", List.of(newDoc("RESUME", "file0", TestPdfs.pdf(1, "resume"))), + List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("materials", Reason.MATERIAL_REQUIRED, null)); + } + + @Test + void 일반_직무명은_필수이며_길이와_제어문자를_검사한다() { + List letters = List.of(new CoverLetterInput("동기", "경험")); + for (String title : java.util.Arrays.asList(null, "", " ")) { + assertThat(errorsOf(general(title, List.of(), letters), NOTHING_SAVED)) + .containsExactly(new MaterialError("jobTitle", Reason.JOB_TITLE_REQUIRED, null)); + } + for (String title : List.of("가".repeat(31), "브랜드\n마케터", "브랜드\t마케터")) { + assertThat(errorsOf(general(title, List.of(), letters), NOTHING_SAVED)) + .containsExactly(new MaterialError("jobTitle", Reason.JOB_TITLE_INVALID, null)); + } + assertThat(validator().validate(general("가".repeat(30), List.of(), letters), files, NOTHING_SAVED).jobTitle()) + .hasSize(30); + } + + @Test + void 자소서_오류는_원래_위치에_남기고_저장조건_오류_다음에_반환한다() { + assertThat(errorsOf(general("마케터", List.of(), List.of(new CoverLetterInput(" ", " "), + new CoverLetterInput(null, "답만"))), NOTHING_SAVED)) + .containsExactly(new MaterialError("materials", Reason.MATERIAL_REQUIRED, null), + new MaterialError("coverLetters[1]", Reason.COVER_LETTER_INVALID, null)); + assertThat(errorsOf(general("마케터", List.of(), Collections.nCopies(6, + new CoverLetterInput("문항", "답"))), NOTHING_SAVED)) + .containsExactly(new MaterialError("materials", Reason.MATERIAL_REQUIRED, null), + new MaterialError("coverLetters", Reason.COVER_LETTER_INVALID, null)); + for (CoverLetterInput letter : List.of(new CoverLetterInput("가".repeat(201), "답"), + new CoverLetterInput("문항", "가".repeat(2001)), new CoverLetterInput("문항", ""))) { + assertThat(errorsOf(general("마케터", List.of(), List.of(letter)), NOTHING_SAVED)) + .extracting(MaterialError::reason) + .containsExactly(Reason.MATERIAL_REQUIRED, Reason.COVER_LETTER_INVALID); + } + assertThat(validator().validate(general("마케터", List.of(), List.of( + new CoverLetterInput("가".repeat(200), "가".repeat(2000)))), files, NOTHING_SAVED).coverLetters()) + .hasSize(1); + } + + @Test + void 일반_검증_오류는_직무명_문서_저장조건_GitHub_보완_자소서_순서다() { + MaterialsPayload request = new MaterialsPayload("GEN_MARKETING", + List.of(newDoc("TEST_CASE", "file0", TestPdfs.pdf(1, "a"))), + List.of("https://github.com/a/b"), List.of(new SupplementInput("프로젝트", null, null, null, null, null)), + null, List.of(new CoverLetterInput("문항", ""))); + assertThat(errorsOf(request, NOTHING_SAVED)).extracting(MaterialError::reason) + .containsExactly(Reason.JOB_TITLE_REQUIRED, Reason.DOC_TYPE_NOT_ALLOWED, + Reason.GITHUB_NOT_ALLOWED, Reason.SUPPLEMENT_NOT_ALLOWED, Reason.COVER_LETTER_INVALID); + } + + @Test + void IT_직군은_직무명_자소서_업무산출물을_받지_않는다() { + MaterialsPayload request = new MaterialsPayload("QA_ENGINEER", + List.of(newDoc("TEST_PLAN", "file0", TestPdfs.pdf(1, "a")), + newDoc("WORK_SAMPLE", "file1", TestPdfs.pdf(1, "b"))), List.of(), List.of(), "테스터", + List.of(new CoverLetterInput("문항", "답"))); + assertThat(errorsOf(request, NOTHING_SAVED)).extracting(MaterialError::reason) + .containsExactly(Reason.JOB_TITLE_NOT_ALLOWED, Reason.DOC_TYPE_NOT_ALLOWED, Reason.COVER_LETTER_NOT_ALLOWED); + } + + @Test + void 일반_대분류_변경은_기존문서를_거절하고_같은_대분류는_허용한다() { + MaterialsResponse saved = new MaterialsResponse("GEN_MARKETING", + List.of(new MaterialDocument(0, "WORK_SAMPLE", SAVED_URL, "work.pdf", 1)), + List.of(), List.of(), "마케터", List.of()); + MaterialsPayload request = general("브랜드 마케터", List.of(existingDoc("WORK_SAMPLE", SAVED_URL)), List.of()); + assertThat(validator().validate(request, files, saved).documents()).hasSize(1); + MaterialsPayload changed = new MaterialsPayload("GEN_HR_ADMIN", request.documents(), List.of(), List.of(), + "채용 담당", List.of()); + assertThat(errorsOf(changed, saved)).extracting(MaterialError::reason) + .containsExactly(Reason.MATERIALS_RESET_REQUIRED); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MetaServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MetaServiceTest.java new file mode 100644 index 00000000..54fc7dee --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MetaServiceTest.java @@ -0,0 +1,51 @@ +package repit.repit_api_server.domain.metadata.service; + +import org.junit.jupiter.api.Test; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; +import repit.repit_api_server.global.client.AuthServerClient; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 옛 자료 조회(getMetaData)의 fileUrl은 포트폴리오 자리다. + * + *

문서 순서는 사용자가 정하므로 첫 문서가 이력서일 수 있다. 순서로 고르면 /getMetaData가 이력서를 포트폴리오로 + * 내보내고, /isPortfolio는 포트폴리오가 없는데도 true를 준다. + */ +class MetaServiceTest { + + private final AuthServerClient authServerClient = mock(AuthServerClient.class); + private final MetaService service = new MetaService(mock(S3Client.class), authServerClient); + + private void givenDocuments(AuthMaterialsResponse.DocumentEntry... documents) { + when(authServerClient.getMaterials("Bearer token")).thenReturn(new AuthMaterialsResponse( + "MAJOR_BACKEND", List.of(documents), List.of("https://github.com/a/b"), List.of(), null)); + } + + @Test + void 첫_문서가_이력서여도_포트폴리오_주소를_돌려준다() { + givenDocuments( + new AuthMaterialsResponse.DocumentEntry("RESUME", "https://s3/resume.pdf", "이력서.pdf", 2), + new AuthMaterialsResponse.DocumentEntry("PORTFOLIO", "https://s3/portfolio.pdf", "포트폴리오.pdf", 10)); + + MetaDataResponse metaData = service.getMetaData("Bearer token"); + + assertThat(metaData.getFileUrl()).isEqualTo("https://s3/portfolio.pdf"); + assertThat(metaData.getGitUrls()).containsExactly("https://github.com/a/b"); + } + + @Test + void 포트폴리오가_없으면_다른_문서가_있어도_fileUrl은_비어_있다() { + givenDocuments( + new AuthMaterialsResponse.DocumentEntry("RESUME", "https://s3/resume.pdf", "이력서.pdf", 2), + new AuthMaterialsResponse.DocumentEntry("CAREER_DESCRIPTION", "https://s3/career.pdf", "경력.pdf", 3)); + + assertThat(service.getMetaData("Bearer token").getFileUrl()).isNull(); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java index f4948337..f94105b4 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java @@ -12,10 +12,11 @@ class JobRoleTest { @Test - void 직무_코드는_명세의_9개다() { + void 직무_코드는_명세의_15개다() { assertThat(Arrays.stream(JobRole.values()).map(Enum::name)).containsExactly( "FRONTEND", "BACKEND", "WEB_PUBLISHER", - "DATA_ANALYST", "DBA_INFRA", "QA_ENGINEER", "SECURITY", "PRODUCT_MANAGER", "IT_CONSULTANT"); + "DATA_ANALYST", "DBA_INFRA", "QA_ENGINEER", "SECURITY", "PRODUCT_MANAGER", "IT_CONSULTANT", + "GEN_STRATEGY", "GEN_HR_ADMIN", "GEN_FINANCE", "GEN_MARKETING", "GEN_SALES", "GEN_DESIGN"); } @Test @@ -65,12 +66,30 @@ class JobRoleTest { } @Test - void 지금_분석_요청_모양으로_분석하는_것은_개발_직무뿐이고_직무가_없으면_백엔드로_본다() { - assertThat(JobRole.BACKEND.supportsCurrentProfileRequest()).isTrue(); - assertThat(JobRole.QA_ENGINEER.supportsCurrentProfileRequest()).isFalse(); - assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_WEB_PUBLISHER")).isTrue(); - assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_PRODUCT_MANAGER")).isFalse(); - assertThat(JobRole.supportsCurrentProfileRequest(null)).isTrue(); - assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_SOMETHING_OLD")).isTrue(); + void 일반_6직군은_공통_4문서와_자소서를_받고_GitHub와_보완_입력은_받지_않는다() { + assertThat(Arrays.stream(JobRole.values()).filter(JobRole::requiresJobTitle)) + .containsExactly(JobRole.GEN_STRATEGY, JobRole.GEN_HR_ADMIN, JobRole.GEN_FINANCE, + JobRole.GEN_MARKETING, JobRole.GEN_SALES, JobRole.GEN_DESIGN) + .allSatisfy(job -> { + assertThat(job.family()).isEqualTo(JobFamily.GENERAL); + assertThat(job.githubRule()).isEqualTo(GithubRule.HIDDEN); + assertThat(job.requiredTypes()).isEmpty(); + assertThat(job.supplementsAllowed()).isFalse(); + assertThat(job.coverLettersAllowed()).isTrue(); + assertThat(job.materialRule()).isEqualTo(JobRole.MaterialRule.NON_RESUME_OR_COVER_LETTER); + assertThat(job.allowsType("RESUME")).isTrue(); + assertThat(job.allowsType("CAREER_DESCRIPTION")).isTrue(); + assertThat(job.allowsType("PORTFOLIO")).isTrue(); + assertThat(job.allowsType("WORK_SAMPLE")).isTrue(); + assertThat(job.keepsMaterialsTo(job)).isTrue(); + }); + assertThat(Arrays.stream(JobRole.values()).filter(job -> !job.requiresJobTitle())) + .allSatisfy(job -> { + assertThat(job.allowsType("WORK_SAMPLE")).isFalse(); + assertThat(job.coverLettersAllowed()).isFalse(); + assertThat(job.materialRule()).isEqualTo(JobRole.MaterialRule.REQUIRED_TYPE); + }); + assertThat(JobRole.GEN_MARKETING.keepsMaterialsTo(JobRole.GEN_HR_ADMIN)).isFalse(); + assertThat(JobRole.GEN_MARKETING.keepsMaterialsTo(JobRole.BACKEND)).isFalse(); } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java index b57e932a..c83b5915 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java @@ -75,6 +75,20 @@ private FeedbackMultiRequest request() { }); } + @Test + void 직무와_직무명은_있을_때만_직렬화된다() { + contextRunner.run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + String empty = mapper.writeValueAsString(FeedbackMultiRequest.builder().build()); + assertThat(empty).doesNotContain("jobRole", "jobTitle"); + String it = mapper.writeValueAsString(FeedbackMultiRequest.builder().jobRole("DATA_ANALYST").build()); + assertThat(it).contains("\"jobRole\":\"DATA_ANALYST\"").doesNotContain("jobTitle"); + String general = mapper.writeValueAsString(FeedbackMultiRequest.builder() + .jobRole("GEN_MARKETING").jobTitle("브랜드 마케터").build()); + assertThat(general).contains("\"jobRole\":\"GEN_MARKETING\"", "\"jobTitle\":\"브랜드 마케터\""); + }); + } + @Test void 면접_상태는_interviewStatus_키로_직렬화된다() { contextRunner.run(context -> { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java index eeeff805..2636e3eb 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java @@ -54,6 +54,20 @@ class FeedbackSoloRequestSerializationTest { }); } + @Test + void 직무와_직무명은_있을_때만_직렬화된다() { + contextRunner.run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + String empty = mapper.writeValueAsString(FeedbackSoloRequest.builder().build()); + assertThat(empty).doesNotContain("jobRole", "jobTitle"); + String it = mapper.writeValueAsString(FeedbackSoloRequest.builder().jobRole("DATA_ANALYST").build()); + assertThat(it).contains("\"jobRole\":\"DATA_ANALYST\"").doesNotContain("jobTitle"); + String general = mapper.writeValueAsString(FeedbackSoloRequest.builder() + .jobRole("GEN_MARKETING").jobTitle("브랜드 마케터").build()); + assertThat(general).contains("\"jobRole\":\"GEN_MARKETING\"", "\"jobTitle\":\"브랜드 마케터\""); + }); + } + @Test void 면접_상태는_interviewStatus_키로_직렬화된다() { contextRunner.run(context -> { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java index 6e34bf77..3b948d3f 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java @@ -30,6 +30,7 @@ import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; import repit.repit_api_server.domain.userdata.persona.entity.enums.InterviewTone; import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; @@ -297,6 +298,71 @@ private FeedbackSoloRequest captureRequest() { return sent.getValue(); } + @Test + void SOLO는_저장된_전문_면접관의_직무와_직무명을_보낸다() { + when(personaRepository.findById(5L)).thenReturn(Optional.of(PersonaEntity.builder() + .personaId(5L).role(Role.TECH).major(Major.GEN_MARKETING).jobTitle("브랜드 마케터").build())); + + service.requestFeedback(USER_ID, 3L); + + FeedbackSoloRequest request = captureRequest(); + assertThat(request.getJobRole()).isEqualTo("GEN_MARKETING"); + assertThat(request.getJobTitle()).isEqualTo("브랜드 마케터"); + } + + @Test + void SOLO_비기술_면접관은_직무와_직무명을_보내지_않는다() { + when(personaRepository.findById(5L)).thenReturn(Optional.of(hrPersona())); + + service.requestFeedback(USER_ID, 3L); + + FeedbackSoloRequest request = captureRequest(); + assertThat(request.getJobRole()).isNull(); + assertThat(request.getJobTitle()).isNull(); + } + + @Test + void MULTI는_진행순_첫_TECH_면접관의_직무_스냅샷을_보낸다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.COMPLETED, null))); + when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(3L)).thenReturn(List.of( + InterviewPersonaEntity.builder().interviewId(3L).personaId(5L).personaOrder(0).build(), + InterviewPersonaEntity.builder().interviewId(3L).personaId(6L).personaOrder(1).build())); + PersonaEntity tech = PersonaEntity.builder().personaId(5L).role(Role.TECH) + .major(Major.GEN_DESIGN).jobTitle("제품 디자이너").build(); + // 저장소 조회 순서가 바뀌어도 진행 순서대로 복원한 TECH를 쓴다. + when(personaRepository.findAllById(List.of(5L, 6L))).thenReturn(List.of(hrPersona(), tech)); + + service.requestFeedback(USER_ID, 3L); + + ArgumentCaptor sent = ArgumentCaptor.forClass(FeedbackMultiRequest.class); + verify(aiServerClient).requestMultiFeedback(sent.capture()); + assertThat(sent.getValue().getJobRole()).isEqualTo("GEN_DESIGN"); + assertThat(sent.getValue().getJobTitle()).isEqualTo("제품 디자이너"); + } + + @Test + void 면접관_스냅샷이_없으면_직무와_직무명을_비운다() { + when(personaRepository.findById(5L)).thenReturn(Optional.empty()); + + service.requestFeedback(USER_ID, 3L); + + FeedbackSoloRequest request = captureRequest(); + assertThat(request.getJobRole()).isNull(); + assertThat(request.getJobTitle()).isNull(); + } + + @Test + void IT_면접관은_직무만_보내고_직무명은_비운다() { + when(personaRepository.findById(5L)).thenReturn(Optional.of(PersonaEntity.builder() + .personaId(5L).role(Role.TECH).major(Major.DATA_ANALYST).build())); + + service.requestFeedback(USER_ID, 3L); + + FeedbackSoloRequest request = captureRequest(); + assertThat(request.getJobRole()).isEqualTo("DATA_ANALYST"); + assertThat(request.getJobTitle()).isNull(); + } + @Test void 면접_내용을_우리_DB에서_읽는다() { service.requestFeedback(USER_ID, 3L); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequestSerializationTest.java index 9fd01bcd..60fe5fd2 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequestSerializationTest.java @@ -121,4 +121,17 @@ class ChatInterviewPrepareRequestSerializationTest { assertThat(json.get("mode").asString()).isEqualTo("MULTI"); } + @Test + void 일반_직군_직무명은_최상위에만_실린다() { + ChatInterviewPrepareRequest request = ChatInterviewPrepareRequest.builder() + .major(Major.GEN_DESIGN).jobTitle("제품 디자이너") + .personas(List.of(ChatInterviewPrepareRequest.Persona.builder().major(Major.GEN_DESIGN).build())) + .build(); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(request)); + + assertThat(json.get("jobTitle").asString()).isEqualTo("제품 디자이너"); + assertThat(json.get("personas").get(0).has("jobTitle")).isFalse(); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java index 9dc67343..2497d8df 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java @@ -184,6 +184,7 @@ private TailoredQuestionResponse question(int id, String content) { assertThat(sent.getPersonality()).isEqualTo(Type.METICULOUS); assertThat(sent.getTone()).isEqualTo(InterviewTone.PRESSURING); assertThat(sent.getMajor()).isEqualTo(Major.BACKEND); + assertThat(sent.getJobTitle()).isNull(); assertThat(sent.getLevel()).isEqualTo(Level.HARD); ChatInterviewPrepareRequest.Question question = sent.getQuestions().getFirst(); @@ -485,4 +486,33 @@ private TailoredQuestionResponse question(int id, String content) { verify(chatServerClient).prepareInterview(sentRequest.capture()); assertThat(JsonMapper.shared().valueToTree(sentRequest.getValue()).has("company")).isFalse(); } + @Test + void SOLO는_대표_면접관에_저장된_직무명을_넘긴다() { + PersonaEntity snapshot = PersonaEntity.builder().personaId(1L).role(Role.TECH) + .major(Major.GEN_MARKETING).jobTitle("브랜드 마케터").build(); + when(personaRepository.findById(1L)).thenReturn(Optional.of(snapshot)); + + service.deliver(tailor(true)); + + verify(chatServerClient).prepareInterview(sentRequest.capture()); + assertThat(sentRequest.getValue().getJobTitle()).isEqualTo("브랜드 마케터"); + } + + @Test + void MULTI는_진행순_대표_면접관의_직무명을_넘긴다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(null, InterviewMode.MULTI))); + PersonaEntity snapshot = PersonaEntity.builder().personaId(11L).role(Role.TECH) + .major(Major.GEN_DESIGN).jobTitle("제품 디자이너").build(); + multiMembers(snapshot, hrPersona()); + QuestionTailorEntity tailor = tailor(true); + tailor.setQuestions(List.of(TailoredQuestionResponse.builder().id(2).personaId(11L) + .question("사용자 경험 개선 사례를 알려주세요.").build())); + + service.deliver(tailor); + + verify(chatServerClient).prepareInterview(sentRequest.capture()); + assertThat(sentRequest.getValue().getMajor()).isEqualTo(Major.GEN_DESIGN); + assertThat(sentRequest.getValue().getJobTitle()).isEqualTo("제품 디자이너"); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotDatabaseTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotDatabaseTest.java new file mode 100644 index 00000000..ccc23183 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotDatabaseTest.java @@ -0,0 +1,77 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.*; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.response.UserResponse; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class InterviewJobSnapshotDatabaseTest { + @Autowired InterviewJobSnapshotService snapshots; + @Autowired InterviewRepository interviews; + @Autowired PersonaRepository personas; + @Autowired org.springframework.transaction.support.TransactionTemplate transactions; + @jakarta.persistence.PersistenceContext jakarta.persistence.EntityManager entityManager; + + AuthUser user(String title) { + var user = new UserResponse(); + ReflectionTestUtils.setField(user, "id", 87654321L); + ReflectionTestUtils.setField(user, "major", "MAJOR_GEN_MARKETING"); + ReflectionTestUtils.setField(user, "jobTitle", title); + return new AuthUser(user, "local-test"); + } + + @Test void 동시_최초_시작은_한_복제본만_확정하고_원본과_과거_면접을_보존한다() throws Exception { + PersonaEntity source = personas.saveAndFlush(PersonaEntity.builder().personaName("snapshot-test-" + UUID.randomUUID()) + .role(Role.TECH).major(Major.GEN_MARKETING).jobTitle("옛 직무명") + .type(Type.REALISTIC).tone(InterviewTone.GENTLE).level(Level.NORMAL).gender(Gender.MALE).build()); + InterviewEntity oldInterview = interviews.saveAndFlush(InterviewEntity.builder().userId(87654321L) + .personaId(source.getPersonaId()).sessionId(UUID.randomUUID().toString()).status(Status.COMPLETED).build()); + InterviewEntity current = interviews.saveAndFlush(InterviewEntity.builder().userId(87654321L) + .personaId(source.getPersonaId()).sessionId(UUID.randomUUID().toString()).status(Status.IN_PROGRESS).build()); + Long snapshotId = null; + try { + CountDownLatch start = new CountDownLatch(2); + var first = CompletableFuture.supplyAsync(() -> captureAfter(start, "퍼포먼스 마케터", current.getInterviewId())); + var second = CompletableFuture.supplyAsync(() -> captureAfter(start, "브랜드 마케터", current.getInterviewId())); + InterviewEntity a = first.get(), b = second.get(); + assertThat(a.getPersonaId()).isEqualTo(b.getPersonaId()).isNotEqualTo(source.getPersonaId()); + snapshotId = a.getPersonaId(); + var snapshot = personas.findById(snapshotId).orElseThrow(); + assertThat(snapshot.getJobTitle()).isIn("퍼포먼스 마케터", "브랜드 마케터"); + assertThat(snapshot.isInterviewSnapshot()).isTrue(); + assertThat(personas.findByPersonaName("__interview_snapshot_" + current.getInterviewId())).isPresent(); + assertThat(personas.findById(source.getPersonaId()).orElseThrow().getJobTitle()).isEqualTo("옛 직무명"); + assertThat(interviews.findById(oldInterview.getInterviewId()).orElseThrow().getPersonaId()).isEqualTo(source.getPersonaId()); + snapshots.capture(user("나중 변경"), current.getInterviewId()); + assertThat(personas.findById(snapshotId).orElseThrow().getJobTitle()).isEqualTo(snapshot.getJobTitle()); + } finally { + interviews.deleteById(current.getInterviewId()); + interviews.deleteById(oldInterview.getInterviewId()); + if (snapshotId != null) personas.deleteById(snapshotId); + personas.deleteById(source.getPersonaId()); + } + } + + InterviewEntity captureAfter(CountDownLatch start, String title, Long id) { + return transactions.execute(status -> { + // prepareInterview/OSIV처럼 두 요청 모두 갱신 전 대표 FK를 먼저 읽어 둔다. + entityManager.find(InterviewEntity.class, id); + start.countDown(); + try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + return snapshots.capture(user(title), id); + }); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java new file mode 100644 index 00000000..e8d75507 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java @@ -0,0 +1,109 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import repit.repit_api_server.domain.userdata.interview.entity.*; +import repit.repit_api_server.domain.userdata.interview.entity.enums.*; +import repit.repit_api_server.domain.userdata.interview.repository.*; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.*; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.question.repository.QuestionTailorRepository; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.response.UserResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class InterviewJobSnapshotServiceTest { + @Mock InterviewRepository interviews; + @Mock InterviewPersonaRepository members; + @Mock PersonaRepository personas; + @Mock QuestionTailorRepository tailors; + InterviewJobSnapshotService service; + + @BeforeEach void setup() { + service = new InterviewJobSnapshotService(interviews, members, personas, tailors, mock(jakarta.persistence.EntityManager.class)); + } + + AuthUser user(String title) { + UserResponse user = new UserResponse(); + ReflectionTestUtils.setField(user, "id", 7L); + ReflectionTestUtils.setField(user, "major", "MAJOR_GEN_MARKETING"); + ReflectionTestUtils.setField(user, "jobTitle", title); + return new AuthUser(user, "token"); + } + + PersonaEntity shared() { + return PersonaEntity.builder().personaId(5L).personaName("전문 면접관").role(Role.TECH) + .major(Major.GEN_MARKETING).jobTitle("옛 직무명").type(Type.REALISTIC) + .tone(InterviewTone.GENTLE).level(Level.NORMAL).gender(Gender.MALE).build(); + } + + InterviewEntity interview(long id) { + return InterviewEntity.builder().interviewId(id).userId(7L).personaId(5L) + .mode(InterviewMode.SOLO).status(Status.IN_PROGRESS).build(); + } + + @Test void 공유_면접관을_갱신하지_않고_면접별로_시작_값을_복제한다() { + PersonaEntity shared = shared(); + InterviewEntity first = interview(1L), second = interview(2L); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(first)); + when(interviews.findForJobSnapshot(2L)).thenReturn(Optional.of(second)); + when(personas.findById(5L)).thenReturn(Optional.of(shared)); + AtomicLong ids = new AtomicLong(10); + when(personas.save(any())).thenAnswer(inv -> { + PersonaEntity next = inv.getArgument(0); + return next.toBuilder().personaId(ids.incrementAndGet()).build(); + }); + service.capture(user("퍼포먼스 마케터"), 1L); + service.capture(user("브랜드 마케터"), 2L); + var saved = org.mockito.ArgumentCaptor.forClass(PersonaEntity.class); + verify(personas, times(2)).save(saved.capture()); + assertThat(saved.getAllValues()).extracting(PersonaEntity::getJobTitle) + .containsExactly("퍼포먼스 마케터", "브랜드 마케터"); + assertThat(shared.getJobTitle()).isEqualTo("옛 직무명"); + assertThat(first.getPersonaId()).isNotEqualTo(second.getPersonaId()); + assertThat(saved.getValue().getDisplayName()).isEqualTo("전문 면접관"); + } + + @Test void 접수_실패_뒤_다시_시작해도_이미_확정된_직무명을_바꾸지_않는다() { + InterviewEntity interview = interview(1L); interview.setPersonaId(11L); + PersonaEntity snapshot = shared().toBuilder().personaId(11L).interviewSnapshot(true) + .jobTitle("시작 시점").build(); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(personas.findById(11L)).thenReturn(Optional.of(snapshot)); + service.capture(user("나중 직무"), 1L); + assertThat(snapshot.getJobTitle()).isEqualTo("시작 시점"); + verify(personas, never()).save(any()); + } + + @Test void 다대일은_첫_자리_링크만_면접_전용으로_바꾼다() { + InterviewEntity interview = interview(1L); interview.setMode(InterviewMode.MULTI); interview.setPersonaId(null); + InterviewPersonaEntity link = InterviewPersonaEntity.builder().interviewId(1L).personaId(5L).personaOrder(0).build(); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(members.findAllByInterviewIdOrderByPersonaOrderAsc(1L)).thenReturn(List.of(link)); + when(personas.findById(5L)).thenReturn(Optional.of(shared())); + when(personas.save(any())).thenAnswer(inv -> ((PersonaEntity) inv.getArgument(0)).toBuilder().personaId(11L).build()); + service.capture(user("마케터"), 1L); + assertThat(link.getPersonaId()).isEqualTo(11L); + verify(members).save(link); + verify(interviews, never()).save(any()); + } + + @Test void 다른_사용자는_잠근_면접의_스냅샷을_만들_수_없다() { + InterviewEntity interview = interview(1L); interview.setUserId(99L); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + assertThatThrownBy(() -> service.capture(user("마케터"), 1L)).hasMessageContaining("본인의"); + verifyNoInteractions(personas, members); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java index 6db4509b..016d308c 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java @@ -69,7 +69,7 @@ class InterviewServiceChatRecordTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, null); + interviewPersonaRepository, null, null, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java index 55135ef5..c0d48457 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java @@ -72,7 +72,7 @@ class InterviewServiceCreateTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, companySnapshotFactory); + interviewPersonaRepository, null, companySnapshotFactory, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); when(personaRepository.findById(1L)).thenReturn(Optional.of(persona())); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java index 2abe3ccd..16875c43 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java @@ -65,7 +65,7 @@ class InterviewServiceLookupTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, null); + interviewPersonaRepository, null, null, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java index 070d1bba..66d775bf 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java @@ -73,7 +73,7 @@ class InterviewServiceMultiCreateTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, null); + interviewPersonaRepository, null, null, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); when(interviewRepository.save(any(InterviewEntity.class))).thenAnswer(invocation -> { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePersonaTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePersonaTest.java index 856c950d..669960e1 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePersonaTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePersonaTest.java @@ -76,7 +76,7 @@ class InterviewServicePersonaTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, null); + interviewPersonaRepository, null, null, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); } @Test diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java index 7cfa848d..d38cb47c 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java @@ -67,13 +67,15 @@ class InterviewServicePrepareTest { @Mock private CompanySnapshotFactory companySnapshotFactory; + @Mock private InterviewJobSnapshotService snapshotService; + private InterviewService service; @BeforeEach void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, analysisLaunchService, companySnapshotFactory); + interviewPersonaRepository, analysisLaunchService, companySnapshotFactory, snapshotService); when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(InterviewEntity.builder() .interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-1").build())); } @@ -86,34 +88,12 @@ private static AuthUser userWithMajor(String major) { } @Test - void 개발에서_비개발_직무로_바꾼_사용자는_남은_개발_분석으로_면접을_준비하지_않고_422() { - AuthUser pm = userWithMajor("MAJOR_PRODUCT_MANAGER"); - - assertThatThrownBy(() -> service.prepareInterview(pm, INTERVIEW_ID)) - .isInstanceOf(BusinessException.class) - .hasMessage(InterviewService.NON_DEV_INTERVIEW_NOT_SUPPORTED) - .extracting("status").hasToString("422 UNPROCESSABLE_CONTENT"); - verify(analysisLaunchService, never()).launchIfMissing(any()); - verify(questionTailorService, never()).requestTailor(any(), any()); - } - - @Test - void 비개발_직무는_면접_준비를_다시_시도해도_422() { - AuthUser qa = userWithMajor("MAJOR_QA_ENGINEER"); - - assertThatThrownBy(() -> service.retryPreparation(qa, INTERVIEW_ID)) - .isInstanceOf(BusinessException.class) - .hasMessage(InterviewService.NON_DEV_INTERVIEW_NOT_SUPPORTED); - verify(analysisLaunchService, never()).launchIfMissing(any()); - verify(questionTailorService, never()).retryPreparation(any(), any()); - } - - @Test - void 개발_직무와_직무를_고른_적_없는_사용자는_그대로_면접을_준비한다() { + void 모든_직군과_직무를_고른_적_없는_사용자는_면접을_준비한다() { when(questionTailorService.requestTailor(any(), any())).thenReturn(QuestionTailorEntity.builder() .interviewId(INTERVIEW_ID).userId(USER_ID).status(TailorStatus.WAITING).build()); - for (String major : new String[]{"MAJOR_FRONTEND", null}) { + when(snapshotService.capture(any(), any())).thenAnswer(inv -> interviewRepository.findById(INTERVIEW_ID).orElseThrow()); + for (String major : new String[]{"MAJOR_FRONTEND", "MAJOR_PRODUCT_MANAGER", "MAJOR_GEN_MARKETING", null}) { AuthUser user = userWithMajor(major); service.prepareInterview(user, INTERVIEW_ID); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java index ebca434f..f92fbc59 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java @@ -74,7 +74,7 @@ class InterviewServiceSaveResultTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null, null); + interviewPersonaRepository, null, null, org.mockito.Mockito.mock(InterviewJobSnapshotService.class)); when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() .interviewId(3L) diff --git a/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java index 00869f20..1c16b3bc 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java @@ -66,7 +66,7 @@ private PersonaRequest request(Role role, Major major) { @Test void 요청한_직책이_그대로_저장된다() { - service.createPersona(request(Role.HR, null), "MAJOR_BACKEND"); + service.createPersona(request(Role.HR, null), "MAJOR_BACKEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getRole()).isEqualTo(Role.HR); @@ -76,7 +76,7 @@ private PersonaRequest request(Role role, Major major) { @Test void 직책을_안_보내면_422다() { - assertThatThrownBy(() -> service.createPersona(request(null, Major.BACKEND), "MAJOR_BACKEND")) + assertThatThrownBy(() -> service.createPersona(request(null, Major.BACKEND), "MAJOR_BACKEND", null)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -88,7 +88,7 @@ private PersonaRequest request(Role role, Major major) { /** Web이 보낸 전공이 사용자 직무를 덮어쓰면 엉뚱한 직무 기준으로 면접이 진행된다. */ @Test void 기술_면접관_전공은_요청값이_아니라_사용자_직무다() { - service.createPersona(request(Role.TECH, Major.BACKEND), "MAJOR_FRONTEND"); + service.createPersona(request(Role.TECH, Major.BACKEND), "MAJOR_FRONTEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getRole()).isEqualTo(Role.TECH); @@ -97,33 +97,56 @@ private PersonaRequest request(Role role, Major major) { @Test void 기술_면접관은_전공을_안_보내도_저장된다() { - service.createPersona(request(Role.TECH, null), "MAJOR_BACKEND"); + service.createPersona(request(Role.TECH, null), "MAJOR_BACKEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getMajor()).isEqualTo(Major.BACKEND); } @Test - void 웹퍼블리셔_사용자의_기술_면접관은_프론트엔드다() { - service.createPersona(request(Role.TECH, null), "WEB_PUBLISHER"); + void 웹퍼블리셔_사용자의_기술_면접관은_웹퍼블리셔다() { + service.createPersona(request(Role.TECH, null), "WEB_PUBLISHER", null); verify(personaRepository).save(saved.capture()); - assertThat(saved.getValue().getMajor()).isEqualTo(Major.FRONTEND); + assertThat(saved.getValue().getMajor()).isEqualTo(Major.WEB_PUBLISHER); } @ParameterizedTest @NullSource - @ValueSource(strings = "DATA_ANALYST") + @ValueSource(strings = "UNKNOWN") void 직무가_없거나_옮길_수_없으면_백엔드다(String userMajor) { - service.createPersona(request(Role.TECH, null), userMajor); + service.createPersona(request(Role.TECH, null), userMajor, null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getMajor()).isEqualTo(Major.BACKEND); } + @Test + void 일반_직군의_기술_면접관에_직무명을_저장하고_응답한다() { + var response = service.createPersona(request(Role.TECH, null), "MAJOR_GEN_MARKETING", "브랜드 마케터"); + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getMajor()).isEqualTo(Major.GEN_MARKETING); + assertThat(saved.getValue().getJobTitle()).isEqualTo("브랜드 마케터"); + assertThat(response.getJobTitle()).isEqualTo("브랜드 마케터"); + } + + @Test + void 일반_직군도_인사_면접관에_직무명을_저장하지_않는다() { + service.createPersona(request(Role.HR, null), "MAJOR_GEN_MARKETING", "브랜드 마케터"); + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getJobTitle()).isNull(); + } + + @Test + void IT_직군의_기술_면접관은_직무명을_저장하지_않는다() { + service.createPersona(request(Role.TECH, null), "MAJOR_DATA_ANALYST", "분석가"); + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getJobTitle()).isNull(); + } + @Test void 비기술_면접관은_사용자_직무와_관계없이_전공이_없다() { - service.createPersona(request(Role.HR, null), "MAJOR_FRONTEND"); + service.createPersona(request(Role.HR, null), "MAJOR_FRONTEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getMajor()).isNull(); @@ -131,7 +154,7 @@ private PersonaRequest request(Role role, Major major) { @Test void 요청한_난이도가_그대로_저장된다() { - service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND"); + service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getLevel()).isEqualTo(Level.HARD); @@ -139,7 +162,7 @@ private PersonaRequest request(Role role, Major major) { @Test void 요청한_어조가_그대로_저장된다() { - service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND"); + service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND", null); verify(personaRepository).save(saved.capture()); // 성향이 FRIENDLY라고 어조까지 부드럽게 채워지면 안 된다. 두 축은 독립이다. @@ -151,7 +174,7 @@ private PersonaRequest request(Role role, Major major) { PersonaRequest request = request(Role.CEO, null); request.setTone(null); - assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND")) + assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND", null)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -165,7 +188,7 @@ private PersonaRequest request(Role role, Major major) { PersonaRequest request = request(Role.HR, null); request.setVoiceIndex(voiceIndex); - assertThat(service.createPersona(request, "MAJOR_BACKEND").getVoiceIndex()).isEqualTo(voiceIndex); + assertThat(service.createPersona(request, "MAJOR_BACKEND", null).getVoiceIndex()).isEqualTo(voiceIndex); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getVoiceIndex()).isEqualTo(voiceIndex); } @@ -173,7 +196,7 @@ private PersonaRequest request(Role role, Major major) { /** 음성 번호는 선택이다. 비우면 채팅 서버가 기본 음성을 쓴다. */ @Test void 음성_번호_없이도_저장된다() { - service.createPersona(request(Role.HR, null), "MAJOR_BACKEND"); + service.createPersona(request(Role.HR, null), "MAJOR_BACKEND", null); verify(personaRepository).save(saved.capture()); assertThat(saved.getValue().getVoiceIndex()).isNull(); @@ -185,7 +208,7 @@ private PersonaRequest request(Role role, Major major) { PersonaRequest request = request(Role.HR, null); request.setVoiceIndex(voiceIndex); - assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND")) + assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND", null)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -198,7 +221,7 @@ private PersonaRequest request(Role role, Major major) { PersonaRequest request = request(Role.CEO, null); request.setLevel(null); - assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND")) + assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND", null)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceLookupTest.java b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceLookupTest.java index 9f6d9aaf..b0081524 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceLookupTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceLookupTest.java @@ -105,4 +105,15 @@ private PersonaEntity persona() { assertThat(service.getAllPersona(null)).hasSize(1); verify(personaRepository, never()).findAllByRole(Role.HR); } + @Test + void 면접_전용_스냅샷은_선택_목록과_직접_조회에서_숨긴다() { + PersonaEntity snapshot = persona().toBuilder().personaId(99L).interviewSnapshot(true).build(); + when(personaRepository.findAll()).thenReturn(List.of(persona(), snapshot)); + when(personaRepository.findById(99L)).thenReturn(Optional.of(snapshot)); + when(personaRepository.findByPersonaName("전용")).thenReturn(Optional.of(snapshot)); + assertThat(service.getAllPersona(null)).extracting(value -> value.getPersonaId()).containsExactly(5L); + assertThatThrownBy(() -> service.getPersonaById(99L)).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.getPersonaByName("전용")).isInstanceOf(BusinessException.class); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajorsTest.java b/src/test/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajorsTest.java index 618131e7..24dbd949 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajorsTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/persona/support/UserJobMajorsTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; @@ -16,14 +17,20 @@ class UserJobMajorsTest { "MAJOR_FRONTEND, FRONTEND, FRONTEND", "FRONTEND, FRONTEND, FRONTEND", "' major_frontend ', FRONTEND, FRONTEND", - "WEB_PUBLISHER, WEB_PUBLISHER, FRONTEND", - "DATA_ANALYST, DATA_ANALYST, BACKEND", + "WEB_PUBLISHER, WEB_PUBLISHER, WEB_PUBLISHER", + "DATA_ANALYST, DATA_ANALYST, DATA_ANALYST", }) void 직무를_정규화하고_면접관_전공으로_옮긴다(String userMajor, String normalized, Major major) { assertThat(UserJobMajors.normalize(userMajor)).isEqualTo(normalized); assertThat(UserJobMajors.toPersonaMajor(userMajor)).isEqualTo(major); } + @ParameterizedTest + @EnumSource(Major.class) + void 모든_직군_코드를_전공으로_그대로_쓴다(Major major) { + assertThat(UserJobMajors.toPersonaMajor("MAJOR_" + major.name())).isEqualTo(major); + } + /** 직무가 없는 사용자도 면접을 시작할 수 있어야 한다. 예외 대신 BACKEND로 둔다. */ @ParameterizedTest @NullSource diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequestSerializationTest.java index e4642189..745e08a0 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequestSerializationTest.java @@ -46,6 +46,24 @@ class QuestionTailorRequestSerializationTest { assertThat(json).contains("\"callbackUrl\":"); assertThat(json).doesNotContain("expected_answer"); assertThat(json).doesNotContain("based_on"); + assertThat(json).doesNotContain("jobTitle"); }); } + @Test + void 일반_직군_직무명은_SOLO_profile과_MULTI_최상위에_직렬화한다() { + contextRunner.run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + var solo = QuestionTailorRequest.builder().profile(QuestionTailorRequest.Profile.builder() + .jobRole("GEN_MARKETING").jobTitle("브랜드 마케터").build()).build(); + var multi = QuestionTailorMultiRequest.builder() + .jobRole("GEN_MARKETING").jobTitle("브랜드 마케터").build(); + assertThat(mapper.readTree(mapper.writeValueAsString(solo)).path("profile").path("jobTitle").asText()) + .isEqualTo("브랜드 마케터"); + assertThat(mapper.readTree(mapper.writeValueAsString(multi)).path("jobTitle").asText()) + .isEqualTo("브랜드 마케터"); + assertThat(mapper.writeValueAsString(QuestionTailorMultiRequest.builder().jobRole("BACKEND").build())) + .doesNotContain("jobTitle"); + }); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceTest.java index c26f638f..fba8d353 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceTest.java @@ -163,6 +163,39 @@ private void givenActive(QuestionCycleEntity active, List un verify(aiServerClient, never()).requestQuestionCycle(any()); } + /** + * 비개발·일반 직군 결과(schemaVersion 2)에는 major가 없고 jobRole만 있다. major만 읽으면 직무가 늘 비어, + * 콜백에서 이어갈 때 재작성 축이 빠지고 면접 직무와 자료 직무가 달라도 가려내지 못한다. + */ + @Test + void 비개발_직군_종합_데이터는_jobRole을_직무로_읽는다() { + AnalysisDataEntity profile = AnalysisDataEntity.builder() + .jobId(PROFILE_JOB) + .userId(USER_ID) + .status(AnalysisStatus.SUCCEEDED) + .resultType(AnalysisResultType.PROFILE) + .result(Map.of( + "profile", Map.of( + "schemaVersion", 2, + "jobRole", "QA_ENGINEER", + "overview", "결제 화면 회귀 테스트를 맡은 QA", + "sources", List.of(), + "materials", List.of(), + "claimChecks", List.of(), + "repositories", List.of()), + "projectSummary", Map.of("overview", "결제 화면 회귀 테스트를 맡은 QA"))) + .build(); + when(analysisDataRepository.findLatestCompleted(USER_ID, AnalysisResultType.PROFILE)) + .thenReturn(Optional.of(profile)); + when(analysisDataRepository.findTopByUserIdAndResultTypeOrderByCreatedAtDesc(USER_ID, AnalysisResultType.PROFILE)) + .thenReturn(Optional.of(profile)); + givenActive(cycle(1L, 1, CycleStatus.ACTIVE), unusedSets(1L, 1, 2, 3)); + + QuestionPoolService.DrawnSet drawn = service.takeSet(USER_ID, InterviewMode.SOLO, 3L); + + assertThat(drawn.major()).isEqualTo("QA_ENGINEER"); + } + @Test void 두_번째_세트를_꺼내면_다음_사이클을_미리_요청한다() { givenActive(cycle(1L, 1, CycleStatus.ACTIVE), unusedSets(1L, 2, 3)); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java index fa05191f..6e7a75bc 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java @@ -249,6 +249,33 @@ private void givenMembers(Long... personaIds) { assertThat(sent.getValue().getProfile().getJobRole()).isEqualTo("WEB_PUBLISHER"); } + @Test + void 일반_직군은_현재_사용자_직무명을_전달한다() { + when(user.getMajor()).thenReturn("MAJOR_GEN_MARKETING"); + when(user.getJobTitle()).thenReturn("브랜드 마케터"); + when(personaRepository.findById(11L)).thenReturn(Optional.of(persona(11L, Role.TECH))); + + service.requestTailor(interview(InterviewMode.SOLO), user); + + ArgumentCaptor sent = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(sent.capture()); + assertThat(sent.getValue().getProfile().getJobRole()).isEqualTo("GEN_MARKETING"); + assertThat(sent.getValue().getProfile().getJobTitle()).isEqualTo("브랜드 마케터"); + } + + @Test + void IT_직군은_사용자_직무명이_있어도_전달하지_않는다() { + when(user.getMajor()).thenReturn("MAJOR_BACKEND"); + when(user.getJobTitle()).thenReturn("백엔드 개발자"); + when(personaRepository.findById(11L)).thenReturn(Optional.of(persona(11L, Role.TECH))); + + service.requestTailor(interview(InterviewMode.SOLO), user); + + ArgumentCaptor sent = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(sent.capture()); + assertThat(sent.getValue().getProfile().getJobTitle()).isNull(); + } + /** 인증 서버는 MAJOR_ 접두사를 붙여 내려준다. 떼지 않으면 분석 서버가 모르는 직군이 넘어간다. */ @Test void 일대일은_면접관_전공이_없으면_사용자_전공을_접두사_없이_쓴다() { @@ -514,4 +541,36 @@ private InterviewEntity interview(InterviewMode mode, CompanySnapshot snapshot) verify(aiServerClient).tailorQuestionsMulti(multi.capture()); assertThat(mapper.readTree(mapper.writeValueAsString(multi.getValue())).has("company")).isFalse(); } + @Test + void 스냅샷과_질문_직무가_다르면_PENDING을_남기지_않는다() { + when(personaRepository.findById(any())).thenReturn(Optional.of(PersonaEntity.builder() + .personaId(11L).role(Role.TECH).major(Major.GEN_MARKETING) + .jobTitle("마케터").interviewSnapshot(true).type(Type.REALISTIC).tone(InterviewTone.GENTLE).build())); + when(questionPoolService.takeSet(eq(7L), any(), eq(3L))).thenReturn( + new QuestionPoolService.DrawnSet("analysis-1", List.of(), null, "BACKEND")); + assertThatThrownBy(() -> service.requestTailor(interview(InterviewMode.SOLO), user)) + .isInstanceOf(BusinessException.class).hasMessageContaining("직무가 다릅니다"); + verify(questionTailorRepository, never()).save(any()); + verify(aiServerClient, never()).tailorQuestions(any()); + } + + /** 일반 직군 자료(v2)의 직무는 jobRole에서 온다. 다른 일반 직군 자료로 만든 세트로 면접을 열지 않는다. */ + @Test + void 일반_직군_스냅샷과_v2_자료_직무가_다르면_409로_막는다() { + when(user.getMajor()).thenReturn("MAJOR_GEN_MARKETING"); + when(personaRepository.findById(any())).thenReturn(Optional.of(PersonaEntity.builder() + .personaId(11L).role(Role.TECH).major(Major.GEN_MARKETING) + .jobTitle("마케터").interviewSnapshot(true).type(Type.REALISTIC).tone(InterviewTone.GENTLE).build())); + when(questionPoolService.takeSet(eq(7L), any(), eq(3L))).thenReturn( + new QuestionPoolService.DrawnSet("analysis-1", List.of(), null, "GEN_HR_ADMIN")); + + assertThatThrownBy(() -> service.requestTailor(interview(InterviewMode.SOLO), user)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("직무가 다릅니다") + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.CONFLICT); + verify(questionTailorRepository, never()).save(any()); + verify(aiServerClient, never()).tailorQuestions(any()); + } + } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java index 86e52806..185b8385 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java @@ -192,6 +192,30 @@ private UserResponse user() { assertThat(sent.getValue().getProfile().getJobRole()).isEqualTo("BACKEND"); } + /** 비개발 직군은 면접 스냅샷이 없다. 콜백에서 이어갈 때 직무는 종합 데이터(v2 jobRole)에서만 온다. */ + @Test + void 비개발_직군_대기를_이어가면_종합_데이터의_직무를_재작성_축으로_보낸다() { + QuestionTailorEntity waiting = waitingTailor(); + when(questionPoolService.applyCycleResult(any(), eq(5L), eq(1))) + .thenReturn(new QuestionPoolService.CycleOutcome(5L, 7L, InterviewMode.SOLO, true, false)); + when(questionTailorRepository.findAllByUserIdAndModeAndStatus(7L, InterviewMode.SOLO, TailorStatus.WAITING)) + .thenReturn(List.of(waiting)); + when(questionTailorRepository.claimResume(eq(1L), any())).thenReturn(1); + when(personaRepository.findById(11L)).thenReturn(Optional.of(PersonaEntity.builder() + .personaId(11L).role(Role.TECH).major(Major.QA_ENGINEER) + .type(Type.REALISTIC).tone(InterviewTone.DIRECT).build())); + when(questionPoolService.takeSet(7L, InterviewMode.SOLO, 3L)) + .thenReturn(new QuestionPoolService.DrawnSet("profile-1", SET.questions(), null, "QA_ENGINEER")); + + service.handleCycleCallback(QuestionCycleCallbackRequest.builder().jobId("cycle-job").status("succeeded").build(), 5L, 1); + + ArgumentCaptor sent = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(sent.capture()); + assertThat(sent.getValue().getProfile().getJobRole()).isEqualTo("QA_ENGINEER"); + // 비개발 IT 직무는 직무명을 받지 않는다. + assertThat(sent.getValue().getProfile().getJobTitle()).isNull(); + } + @Test void 다른_쪽이_먼저_이어간_준비는_건드리지_않는다() { when(questionPoolService.applyCycleResult(any(), eq(5L), eq(1))) @@ -342,4 +366,21 @@ private QuestionTailorEntity givenResumeFails(int claimExpiration) { assertThat(waiting.getQuestions()).isNull(); verify(sseNotifier).sendFinal(eq("profile-1"), eq(SseNotifier.INTERVIEW_PREPARATION_FAILED), any()); } + @Test + void 일반_대기_재개는_사용자_변경값_대신_면접_스냅샷_직무명을_쓴다() { + var snapshot = PersonaEntity.builder().personaId(11L).role(Role.TECH).major(Major.GEN_MARKETING) + .jobTitle("시작 시점 마케터").interviewSnapshot(true) + .type(Type.REALISTIC).tone(InterviewTone.DIRECT).build(); + when(personaRepository.findById(11L)).thenReturn(Optional.of(snapshot)); + var general = new QuestionPoolService.DrawnSet("profile-1", SET.questions(), null, "GEN_MARKETING"); + when(questionPoolService.takeSet(7L, InterviewMode.SOLO, 3L)).thenReturn(WAITING, general); + when(questionPoolService.settled(7L, InterviewMode.SOLO)).thenReturn(true); + when(questionTailorRepository.claimResume(eq(1L), any())).thenReturn(1); + service.requestTailor(interview(), user()); + ArgumentCaptor sent = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(sent.capture()); + assertThat(sent.getValue().getProfile().getJobRole()).isEqualTo("GEN_MARKETING"); + assertThat(sent.getValue().getProfile().getJobTitle()).isEqualTo("시작 시점 마케터"); + } + }