diff --git a/docs/interview-answer-mode.md b/docs/interview-answer-mode.md new file mode 100644 index 00000000..f38ff0f6 --- /dev/null +++ b/docs/interview-answer-mode.md @@ -0,0 +1,22 @@ +# 면접 답변 방식 계약 (#107) + +면접 생성의 선택 필드 `answerMode`는 `TEXT` 또는 `VOICE`입니다. `VOICE`는 현재 웹의 영상/음성 답변을 뜻합니다. 생략·null은 기존 클라이언트 호환을 위해 허용하고 기록에도 null로 저장합니다. 기존 기록에 답변 방식을 추정해 채우지 않습니다. + +```json +{"personaId": 1, "answerMode": "TEXT"} +``` + +- SOLO와 MULTI 생성 모두 저장합니다. +- 생성 응답 및 단건·목록 면접 조회의 `answerMode`로 그대로 반환합니다. +- 완료 기록 저장·피드백 재채점은 답변 방식을 변경하지 않습니다. +- 기존 요청의 나머지 필드는 그대로이며 잘못된 enum 문자열은 400입니다. +- `Idempotency-Key`가 같은데 TEXT↔VOICE를 바꾸면 409 `IDEMPOTENCY_KEY_REUSED`입니다. +- 필드가 없거나 null인 요청의 canonical 해시는 기존과 같습니다. 이미 제출한 요청은 본문을 바꾸지 않고 재전송해야 합니다. +- `V37__add_interview_answer_mode.sql`은 nullable 컬럼과 값 제약만 추가합니다. 선행 기업 계약 PR #106의 V36 뒤에 적용합니다. +- Chat·Analysis 요청/콜백 및 점수 산식·음성 분석 디스패치에는 영향이 없습니다. + +웹은 TEXT의 음성 섹션을 숨기고, VOICE에는 녹음 실패 상태도 보여 줍니다. 기존 null 기록은 음성 조회의 녹음 유무로 표시를 보완하며 조회 장애를 TEXT로 단정하지 않습니다. + +## 검증 + +격리 PostgreSQL 16에서 `./gradlew test` 877개 통과(실패·오류·건너뜀 0). 추가 회귀는 SOLO/MULTI × TEXT/VOICE의 생성·멱등 재전송·완료 후 단건/목록 조회·실제 컬럼 저장, 변경된 답변 방식의 409, 변경 전 해시 재현과 null 유지, JSON enum의 허용/거절·문자열 응답을 확인합니다. V37 SQL을 이전 스키마의 기존 행에 적용해 null이 유지되는 것도 격리 스키마에서 확인했습니다. diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java index b025833e..164fc9d3 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java @@ -4,13 +4,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; import java.util.List; /** * 면접 생성 요청. * - *

면접이 고르는 것은 면접관뿐이라 그것만 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서 + *

면접관과 기업 선택, 답변 방식을 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서 * 전공·타입·경력·성별까지 받는 것처럼 보였지만, 실제로는 이름만 쓰고 나머지는 버렸다. * *

{@code personaId}가 있으면 그것을 쓰고, 없으면 {@code personaName}으로 찾는다. 이름은 바뀔 수 있는 @@ -33,4 +34,10 @@ public class CreateInterviewRequest { // 순서를 그대로 쓴다. private List personaIds; private Long companyId; + // 생략한 기존 클라이언트는 null로 남긴다. + private AnswerMode answerMode; + + public CreateInterviewRequest(Long personaId, String personaName, List personaIds, Long companyId) { + this(personaId, personaName, personaIds, companyId, null); + } } 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 new file mode 100644 index 00000000..69bb3242 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPersonaSummary.java @@ -0,0 +1,40 @@ +package repit.repit_api_server.domain.userdata.interview.dto.response; + +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +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.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; + +/** + * 면접 응답에 붙는 대표 면접관. 목록·상세 화면이 성향·난이도·면접관 이름을 여기서 읽는다. + * + *

1:1은 그 면접의 면접관, N:1은 진행 순서 맨 앞 면접관이다. 피드백 응답의 성향·난이도와 + * 같은 규칙이다. 이름은 저장된 값 그대로다 — 1:1 면접관 이름 끝의 유일성 접미사는 화면이 뗀다. + */ +public record InterviewPersonaSummary( + Long id, + String personaName, + Role personaRole, + Type type, + Level level, + InterviewTone tone, + String imageUrl +) { + + /** 면접관이 없으면(지워졌거나 걸려 있지 않으면) null이다. */ + public static InterviewPersonaSummary of(PersonaEntity persona) { + if (persona == null) { + return null; + } + return new InterviewPersonaSummary( + persona.getPersonaId(), + persona.getPersonaName(), + persona.getRole(), + persona.getType(), + persona.getLevel(), + persona.getTone(), + persona.getImageUrl() + ); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java index d8118345..cf02ec7e 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java @@ -7,7 +7,9 @@ import repit.repit_api_server.domain.company.dto.CompanyBadge; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import java.time.LocalDateTime; import java.util.List; @@ -24,27 +26,32 @@ public class InterviewResponse { private Long personaId; // N:1 면접관 목록. 진행 순서대로다. private List personaIds; + // 대표 면접관. 1:1은 그 면접관, N:1은 진행 순서 맨 앞 면접관이다. 면접관이 없으면 비어 있다. + private InterviewPersonaSummary persona; private String sessionId; private Status status; private LocalDateTime createdAt; // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. private CompanyBadge company; + private AnswerMode answerMode; - public static InterviewResponse from(InterviewEntity interview) { - return from(interview, List.of()); - } - - public static InterviewResponse from(InterviewEntity interview, List personaIds) { + /** + * @param personaIds N:1 면접관 목록. 1:1은 빈 목록이다. + * @param persona 대표 면접관. 없으면 null이다. + */ + public static InterviewResponse from(InterviewEntity interview, List personaIds, PersonaEntity persona) { return new InterviewResponse( interview.getInterviewId(), interview.getUserId(), interview.getMode(), interview.getPersonaId(), personaIds, + InterviewPersonaSummary.of(persona), interview.getSessionId(), interview.getStatus(), interview.getCreatedAt(), - CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()) + CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()), + interview.getAnswerMode() ); } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java index 2d59c401..c67a241d 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java @@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes; import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import java.time.LocalDateTime; @@ -35,6 +36,10 @@ public class InterviewEntity { @Builder.Default private InterviewMode mode = InterviewMode.SOLO; + @Enumerated(EnumType.STRING) + @Column(length = 16) + private AnswerMode answerMode; + @Column(nullable = false, unique = true, length = 64) private String sessionId; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/AnswerMode.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/AnswerMode.java new file mode 100644 index 00000000..91058e0f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/AnswerMode.java @@ -0,0 +1,7 @@ +package repit.repit_api_server.domain.userdata.interview.entity.enums; + +/** 면접에서 선택한 답변 방식. VOICE는 영상/음성 답변을 함께 뜻한다. */ +public enum AnswerMode { + TEXT, + VOICE +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java index 2a41b3ab..ed5742d0 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java @@ -35,6 +35,8 @@ static String of(CreateInterviewRequest request) { canonical.put("personaName", request.getPersonaName()); } canonical.put("companyId", request.getCompanyId()); + // 필드 없는 기존 제출의 해시는 바꾸지 않아 재전송을 보존한다. + if (request.getAnswerMode() != null) canonical.put("answerMode", request.getAnswerMode().name()); return sha256(JsonMapper.shared().writeValueAsString(canonical)); } 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 241ec7af..7770e8e0 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 @@ -23,6 +23,7 @@ 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.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; @@ -77,7 +78,7 @@ public class InterviewService { */ public InterviewResponse createInterview(Long userId, CreateInterviewRequest request) { if (request.getPersonaIds() != null && !request.getPersonaIds().isEmpty()) { - return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId()); + return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId(), request.getAnswerMode()); } PersonaEntity persona = findPersona(request); @@ -85,6 +86,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req InterviewEntity interview = InterviewEntity.builder() .userId(userId) .mode(InterviewMode.SOLO) + .answerMode(request.getAnswerMode()) .personaId(persona.getPersonaId()) .status(Status.IN_PROGRESS) .sessionId(UUID.randomUUID().toString()) @@ -93,7 +95,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req .build(); InterviewEntity saved = interviewRepository.save(interview); - return InterviewResponse.from(saved); + return InterviewResponse.from(saved, List.of(), persona); } /** @@ -103,7 +105,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req * 이 순서를 따르고, 꼬리질문이 부모 질문 바로 뒤에 삽입되므로 한 면접관의 질문 묶음이 끝나야 * 다음 면접관으로 넘어간다. */ - private InterviewResponse createMultiInterview(Long userId, List requestedIds, Long companyId) { + private InterviewResponse createMultiInterview(Long userId, List requestedIds, Long companyId, AnswerMode answerMode) { List personaIds = new ArrayList<>(new LinkedHashSet<>(requestedIds)); if (personaIds.size() != requestedIds.size()) { throw BusinessException.unprocessable("같은 면접관을 두 번 지정할 수 없습니다."); @@ -122,6 +124,7 @@ private InterviewResponse createMultiInterview(Long userId, List requested InterviewEntity saved = interviewRepository.save(InterviewEntity.builder() .userId(userId) .mode(InterviewMode.MULTI) + .answerMode(answerMode) .status(Status.IN_PROGRESS) .sessionId(UUID.randomUUID().toString()) .companyId(companyId) @@ -138,7 +141,9 @@ private InterviewResponse createMultiInterview(Long userId, List requested } interviewPersonaRepository.saveAll(members); - return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList()); + // 대표 면접관은 진행 순서 맨 앞이다. 조회 응답과 같은 규칙이다. + return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList(), + ordered.getFirst()); } /** @@ -261,9 +266,47 @@ private static void requireSupportedJob(AuthUser authUser) { } } + /** + * 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다. + * + *

N:1 면접관 명단과 면접관은 면접마다 따로 읽지 않고 한 번에 읽어 면접별로 나눈다. + * 그러지 않으면 목록이 길어지는 만큼 쿼리가 늘어난다. + */ public List getAllInterviewsByUserId(Long userId) { - return interviewRepository.findAllByUserId(userId).stream() - .map(interview -> InterviewResponse.from(interview, personaIdsOf(interview))) + List interviews = interviewRepository.findAllByUserId(userId); + + List multiInterviewIds = interviews.stream() + .filter(interview -> interview.getMode() == InterviewMode.MULTI) + .map(InterviewEntity::getInterviewId) + .toList(); + Map> personaIdsByInterview = multiInterviewIds.isEmpty() + ? Map.of() + : interviewPersonaRepository + .findAllByInterviewIdInOrderByInterviewIdAscPersonaOrderAsc(multiInterviewIds).stream() + .collect(Collectors.groupingBy(InterviewPersonaEntity::getInterviewId, + Collectors.mapping(InterviewPersonaEntity::getPersonaId, Collectors.toList()))); + + Map representativeIdByInterview = new HashMap<>(); + for (InterviewEntity interview : interviews) { + Long personaId = representativePersonaId(interview, + personaIdsByInterview.getOrDefault(interview.getInterviewId(), List.of())); + if (personaId != null) { + representativeIdByInterview.put(interview.getInterviewId(), personaId); + } + } + Map personaById = representativeIdByInterview.isEmpty() + ? Map.of() + : personaRepository.findAllById(new LinkedHashSet<>(representativeIdByInterview.values())).stream() + .collect(Collectors.toMap(PersonaEntity::getPersonaId, Function.identity())); + + return interviews.stream() + .map(interview -> { + Long representativeId = representativeIdByInterview.get(interview.getInterviewId()); + return InterviewResponse.from( + interview, + personaIdsByInterview.getOrDefault(interview.getInterviewId(), List.of()), + representativeId == null ? null : personaById.get(representativeId)); + }) .toList(); } @@ -271,7 +314,25 @@ public InterviewResponse getInterviewById(Long userId, Long interviewId) { InterviewEntity interview = interviewRepository.findById(interviewId) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); verifyOwner(interview, userId); - return InterviewResponse.from(interview, personaIdsOf(interview)); + List personaIds = personaIdsOf(interview); + Long representativeId = representativePersonaId(interview, personaIds); + PersonaEntity persona = representativeId == null + ? null + : personaRepository.findById(representativeId).orElse(null); + return InterviewResponse.from(interview, personaIds, persona); + } + + /** + * 면접을 대표하는 면접관 id. 1:1은 그 면접의 면접관, N:1은 진행 순서 맨 앞 면접관이다. + * 피드백 응답이 성향·난이도를 대표 한 명으로 읽는 것과 같은 규칙이다. 걸린 면접관이 없으면 null이다. + * + * @param multiPersonaIds 진행 순서대로 정렬된 N:1 면접관 명단. 1:1은 빈 목록이다. + */ + private static Long representativePersonaId(InterviewEntity interview, List multiPersonaIds) { + if (interview.getMode() == InterviewMode.MULTI) { + return multiPersonaIds.isEmpty() ? null : multiPersonaIds.getFirst(); + } + return interview.getPersonaId(); } /** diff --git a/src/main/resources/db/migration/V37__add_interview_answer_mode.sql b/src/main/resources/db/migration/V37__add_interview_answer_mode.sql new file mode 100644 index 00000000..26beef4a --- /dev/null +++ b/src/main/resources/db/migration/V37__add_interview_answer_mode.sql @@ -0,0 +1,3 @@ +ALTER TABLE interview ADD COLUMN answer_mode varchar(16); +ALTER TABLE interview ADD CONSTRAINT ck_interview_answer_mode + CHECK (answer_mode IS NULL OR answer_mode IN ('TEXT', 'VOICE')); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java index 1c6d052f..e1c024de 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java @@ -96,8 +96,21 @@ void tearDown() { } private static InterviewResponse response(Long interviewId) { - return new InterviewResponse(interviewId, USER_ID, InterviewMode.SOLO, 1L, List.of(), "session", - Status.IN_PROGRESS, LocalDateTime.now(), new CompanyBadge(12L, "샘플커머스")); + return new InterviewResponse(interviewId, USER_ID, InterviewMode.SOLO, 1L, List.of(), null, "session", + Status.IN_PROGRESS, LocalDateTime.now(), new CompanyBadge(12L, "샘플커머스"), null); + } + + @Test + void 답변방식_enum을_받고_잘못된_값은_거절한다() throws Exception { + for (String mode : List.of("TEXT", "VOICE")) { + mockMvc.perform(create("{\"personaId\":1,\"answerMode\":\"" + mode + "\"}")) + .andExpect(status().isOk()); + } + mockMvc.perform(create("{\"personaId\":1,\"answerMode\":null}")) + .andExpect(status().isOk()); + mockMvc.perform(create("{\"personaId\":1,\"answerMode\":\"INVALID\"}")) + .andExpect(status().isBadRequest()); + } private static MockHttpServletRequestBuilder create(String body) { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequestDeserializationTest.java index a77692f5..992a61db 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequestDeserializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequestDeserializationTest.java @@ -5,6 +5,9 @@ import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import tools.jackson.databind.ObjectMapper; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; import static org.assertj.core.api.Assertions.assertThat; @@ -36,6 +39,21 @@ class CreateInterviewRequestDeserializationTest { }); } + @Test + void 답변방식은_문자열로_왕복하고_생략한_옛_요청은_null이다() { + contextRunner.run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + for (AnswerMode mode : AnswerMode.values()) { + CreateInterviewRequest request = mapper.readValue("{\"personaId\":1,\"answerMode\":\"" + mode + "\"}", CreateInterviewRequest.class); + assertThat(request.getAnswerMode()).isEqualTo(mode); + InterviewResponse response = InterviewResponse.from(InterviewEntity.builder().answerMode(mode).build(), java.util.List.of(), null); + assertThat(mapper.readTree(mapper.writeValueAsString(response)).get("answerMode").asText()).isEqualTo(mode.name()); + } + assertThat(mapper.readValue("{\"personaId\":1}", CreateInterviewRequest.class).getAnswerMode()).isNull(); + assertThat(mapper.readValue("{\"personaId\":1,\"answerMode\":null}", CreateInterviewRequest.class).getAnswerMode()).isNull(); + }); + } + @Test void personaId만_보내는_요청도_받는다() { contextRunner.run(context -> { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponseSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponseSerializationTest.java new file mode 100644 index 00000000..54edafbd --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponseSerializationTest.java @@ -0,0 +1,83 @@ +package repit.repit_api_server.domain.userdata.interview.dto.response; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +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.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; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 웹이 읽는 persona 키 이름이 그대로 나가야 한다. 이름이 어긋나면 화면은 조용히 "미제공"이 된다. */ +class InterviewResponseSerializationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)); + + private static final InterviewEntity INTERVIEW = InterviewEntity.builder() + .interviewId(56L) + .userId(7L) + .personaId(56L) + .mode(InterviewMode.SOLO) + .sessionId("sess-56") + .status(Status.COMPLETED) + .build(); + + @Test + void 대표_면접관을_persona_객체로_내보낸다() { + PersonaEntity persona = PersonaEntity.builder() + .personaId(56L) + .personaName("김도윤-4898e87d") + .role(Role.TECH) + .type(Type.FRIENDLY) + .level(Level.EASY) + .tone(InterviewTone.GENTLE) + .career(3) + .gender(Gender.MALE) + .imageUrl("/img/56.svg") + .build(); + + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + JsonNode json = objectMapper.valueToTree(InterviewResponse.from(INTERVIEW, List.of(), persona)); + JsonNode node = json.get("persona"); + + assertThat(node.propertyNames()).containsExactlyInAnyOrder( + "id", "personaName", "personaRole", "type", "level", "tone", "imageUrl"); + assertThat(node.get("id").asLong()).isEqualTo(56L); + assertThat(node.get("personaName").asString()).isEqualTo("김도윤-4898e87d"); + assertThat(node.get("personaRole").asString()).isEqualTo("TECH"); + assertThat(node.get("type").asString()).isEqualTo("FRIENDLY"); + assertThat(node.get("level").asString()).isEqualTo("EASY"); + assertThat(node.get("tone").asString()).isEqualTo("GENTLE"); + assertThat(node.get("imageUrl").asString()).isEqualTo("/img/56.svg"); + // 기존 필드는 그대로 남는다. + assertThat(json.get("personaId").asLong()).isEqualTo(56L); + }); + } + + @Test + void 면접관이_없으면_persona는_null이다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + JsonNode json = objectMapper.valueToTree(InterviewResponse.from(INTERVIEW, List.of(), null)); + + assertThat(json.has("persona")).isTrue(); + assertThat(json.get("persona").isNull()).isTrue(); + }); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java index d5406bf3..85c2c11f 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java @@ -22,6 +22,8 @@ import repit.repit_api_server.domain.company.repository.CompanySourceRepository; import repit.repit_api_server.domain.company.service.CompanySnapshotFactory; import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; @@ -61,6 +63,8 @@ class InterviewCreateIdempotencyTest { @Autowired private InterviewCreateService createService; @Autowired + private InterviewService interviewService; + @Autowired private CompanySnapshotFactory companySnapshotFactory; @Autowired private InterviewRepository interviewRepository; @@ -195,6 +199,56 @@ private static void assertError(Runnable call, HttpStatus status, String code) { }); } + @Test + void 두_면접유형의_답변방식이_생성_재전송_완료후_단건_목록에_보존된다() { + for (boolean multiMode : List.of(false, true)) { + for (AnswerMode mode : AnswerMode.values()) { + CreateInterviewRequest request = multiMode ? multi(null) : solo(null); + request.setAnswerMode(mode); + String key = UUID.randomUUID().toString(); + InterviewResponse created = createService.create(userId, key, request); + assertThat(created.getAnswerMode()).isEqualTo(mode); + assertThat(createService.create(userId, key, request).getInterviewId()).isEqualTo(created.getInterviewId()); + assertThat(createService.create(userId, key, request).getAnswerMode()).isEqualTo(mode); + assertThat(jdbcTemplate.queryForObject("select answer_mode from interview where interview_id = ?", + String.class, created.getInterviewId())).isEqualTo(mode.name()); + jdbcTemplate.update("update interview set status = 'COMPLETED' where interview_id = ?", created.getInterviewId()); + InterviewResponse loaded = interviewService.getInterviewById(userId, created.getInterviewId()); + assertThat(loaded.getAnswerMode()).isEqualTo(mode); + assertThat(loaded.getStatus()).isEqualTo(Status.COMPLETED); + assertThat(interviewService.getAllInterviewsByUserId(userId)).anySatisfy(item -> { + assertThat(item.getInterviewId()).isEqualTo(created.getInterviewId()); + assertThat(item.getAnswerMode()).isEqualTo(mode); + }); + request.setAnswerMode(mode == AnswerMode.TEXT ? AnswerMode.VOICE : AnswerMode.TEXT); + assertError(() -> createService.create(userId, key, request), + HttpStatus.CONFLICT, ErrorCodes.IDEMPOTENCY_KEY_REUSED); + } + } + } + + @Test + void 이전_답변방식_없는_요청키의_해시와_null_기록을_유지한다() { + CreateInterviewRequest request = solo(null); + String key = UUID.randomUUID().toString(); + // 새 필드가 생기기 전 canonical 본문으로 저장한 요청 키도 같은 면접을 복구해야 한다. + String canonical = "{\"companyId\":null,\"mode\":\"SOLO\",\"personaId\":" + techId + "}"; + String oldHash; + try { + oldHash = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + InterviewResponse created = createService.create(userId, key, request); + jdbcTemplate.update("update interview_create_request set request_hash = ? where user_id = ? and idempotency_key = ?::uuid", + oldHash, userId, key); + assertThat(createService.create(userId, key, request).getInterviewId()).isEqualTo(created.getInterviewId()); + assertThat(interviewService.getInterviewById(userId, created.getInterviewId()).getAnswerMode()).isNull(); + request.setAnswerMode(AnswerMode.TEXT); + assertError(() -> createService.create(userId, key, request), HttpStatus.CONFLICT, ErrorCodes.IDEMPOTENCY_KEY_REUSED); + } + @Test void 같은_키_같은_설정이면_처음_만든_면접을_돌려준다() { String key = UUID.randomUUID().toString(); 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 new file mode 100644 index 00000000..856c950d --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePersonaTest.java @@ -0,0 +1,227 @@ +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; +import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewPersonaSummary; +import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; +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.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.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.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; +import repit.repit_api_server.domain.userdata.question.service.QuestionTailorService; +import repit.repit_api_server.global.client.ChatServerClient; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyIterable; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 면접 조회 응답의 대표 면접관(persona). + * + *

웹 목록·상세는 성향·난이도·면접관 이름을 여기서 읽는다. 피드백이 아직 없는 면접도 + * 면접관은 정해져 있으므로 값이 나와야 한다. 1:1은 그 면접관, N:1은 진행 순서 맨 앞, + * 면접관이 없으면 null이다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class InterviewServicePersonaTest { + + @Mock + private InterviewRepository interviewRepository; + @Mock + private QuestionRepository questionRepository; + @Mock + private ChatServerClient chatServerClient; + @Mock + private AnswerRepository answerRepository; + @Mock + private PersonaRepository personaRepository; + @Mock + private QuestionTailorService questionTailorService; + @Mock + private InterviewPersonaRepository interviewPersonaRepository; + + private InterviewService service; + + private static final Long USER_ID = 7L; + + @BeforeEach + void setUp() { + service = new InterviewService(interviewRepository, questionRepository, chatServerClient, + answerRepository, personaRepository, questionTailorService, + interviewPersonaRepository, null, null); + } + + @Test + void 일대일_단건은_그_면접의_면접관을_싣는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(solo(3L, 56L))); + when(personaRepository.findById(56L)).thenReturn(Optional.of( + persona(56L, "김도윤-4898e87d", Role.TECH, Type.FRIENDLY, Level.EASY, InterviewTone.GENTLE))); + + InterviewPersonaSummary persona = service.getInterviewById(USER_ID, 3L).getPersona(); + + // 이름은 저장값 그대로다. 접미사는 화면이 뗀다. + assertThat(persona).isEqualTo(new InterviewPersonaSummary(56L, "김도윤-4898e87d", Role.TECH, + Type.FRIENDLY, Level.EASY, InterviewTone.GENTLE, "/img/56.svg")); + } + + @Test + void N대1_단건은_진행_순서_맨_앞_면접관을_싣는다() { + when(interviewRepository.findById(4L)).thenReturn(Optional.of(multi(4L))); + when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(4L)).thenReturn(List.of( + member(4L, 11L, 0), member(4L, 12L, 1))); + when(personaRepository.findById(11L)).thenReturn(Optional.of( + persona(11L, "박기술", Role.TECH, Type.METICULOUS, Level.HARD, InterviewTone.DIRECT))); + + InterviewResponse response = service.getInterviewById(USER_ID, 4L); + + assertThat(response.getPersonaIds()).containsExactly(11L, 12L); + assertThat(response.getPersona()).isEqualTo(new InterviewPersonaSummary(11L, "박기술", Role.TECH, + Type.METICULOUS, Level.HARD, InterviewTone.DIRECT, "/img/11.svg")); + verify(personaRepository, never()).findById(12L); + } + + @Test + void 면접관이_걸려_있지_않으면_null이다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(solo(3L, null))); + when(interviewRepository.findById(4L)).thenReturn(Optional.of(multi(4L))); + when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(4L)).thenReturn(List.of()); + + assertThat(service.getInterviewById(USER_ID, 3L).getPersona()).isNull(); + assertThat(service.getInterviewById(USER_ID, 4L).getPersona()).isNull(); + verify(personaRepository, never()).findById(any()); + } + + @Test + void 면접관이_지워졌으면_null이다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(solo(3L, 56L))); + when(personaRepository.findById(56L)).thenReturn(Optional.empty()); + + assertThat(service.getInterviewById(USER_ID, 3L).getPersona()).isNull(); + } + + /** + * 목록은 면접관을 면접마다 읽지 않고 한 번에 읽는다. N:1 명단도 마찬가지다. + * 1:1·N:1·면접관 없음이 섞여 있어도 각자 맞는 값이 붙어야 한다. + */ + @Test + void 목록은_면접관을_한_번에_읽어_면접마다_대표_면접관을_싣는다() { + when(interviewRepository.findAllByUserId(USER_ID)).thenReturn(List.of( + solo(1L, 56L), multi(2L), solo(3L, null), multi(4L))); + when(interviewPersonaRepository.findAllByInterviewIdInOrderByInterviewIdAscPersonaOrderAsc(List.of(2L, 4L))) + .thenReturn(List.of(member(2L, 11L, 0), member(2L, 12L, 1))); + Map stored = Map.of( + 56L, persona(56L, "김도윤-4898e87d", Role.TECH, Type.FRIENDLY, Level.EASY, InterviewTone.GENTLE), + 11L, persona(11L, "박기술", Role.TECH, Type.METICULOUS, Level.HARD, InterviewTone.DIRECT), + 12L, persona(12L, "이인사", Role.HR, Type.METICULOUS, Level.HARD, InterviewTone.DIRECT)); + when(personaRepository.findAllById(anyIterable())).thenAnswer(invocation -> { + Iterable ids = invocation.getArgument(0); + return StreamSupport.stream(ids.spliterator(), false) + .map(stored::get) + .filter(java.util.Objects::nonNull) + .toList(); + }); + + Map byId = service.getAllInterviewsByUserId(USER_ID).stream() + .collect(Collectors.toMap(InterviewResponse::getInterviewId, Function.identity())); + + assertThat(byId.get(1L).getPersona().id()).isEqualTo(56L); + assertThat(byId.get(1L).getPersona().level()).isEqualTo(Level.EASY); + assertThat(byId.get(1L).getPersonaIds()).isEmpty(); + assertThat(byId.get(2L).getPersona().id()).isEqualTo(11L); + assertThat(byId.get(2L).getPersonaIds()).containsExactly(11L, 12L); + assertThat(byId.get(3L).getPersona()).isNull(); + // 명단이 비어 있는 N:1도 깨지지 않고 비어 나간다. + assertThat(byId.get(4L).getPersona()).isNull(); + assertThat(byId.get(4L).getPersonaIds()).isEmpty(); + + verify(personaRepository, times(1)).findAllById(anyIterable()); + verify(personaRepository, never()).findById(any()); + verify(interviewPersonaRepository, times(1)) + .findAllByInterviewIdInOrderByInterviewIdAscPersonaOrderAsc(any()); + verify(interviewPersonaRepository, never()).findAllByInterviewIdOrderByPersonaOrderAsc(any()); + } + + @Test + void 대표_면접관이_하나도_없으면_면접관을_읽지_않는다() { + when(interviewRepository.findAllByUserId(USER_ID)).thenReturn(List.of(solo(1L, null))); + + assertThat(service.getAllInterviewsByUserId(USER_ID)) + .singleElement() + .extracting(InterviewResponse::getPersona) + .isNull(); + verify(personaRepository, never()).findAllById(anyIterable()); + verify(interviewPersonaRepository, never()) + .findAllByInterviewIdInOrderByInterviewIdAscPersonaOrderAsc(any()); + } + + private static InterviewEntity solo(Long interviewId, Long personaId) { + return InterviewEntity.builder() + .interviewId(interviewId) + .userId(USER_ID) + .personaId(personaId) + .mode(InterviewMode.SOLO) + .sessionId("sess-" + interviewId) + .status(Status.COMPLETED) + .build(); + } + + private static InterviewEntity multi(Long interviewId) { + return InterviewEntity.builder() + .interviewId(interviewId) + .userId(USER_ID) + .mode(InterviewMode.MULTI) + .sessionId("sess-" + interviewId) + .status(Status.COMPLETED) + .build(); + } + + private static InterviewPersonaEntity member(Long interviewId, Long personaId, int order) { + return InterviewPersonaEntity.builder() + .interviewId(interviewId) + .personaId(personaId) + .personaOrder(order) + .build(); + } + + private static PersonaEntity persona(Long id, String name, Role role, Type type, Level level, InterviewTone tone) { + return PersonaEntity.builder() + .personaId(id) + .personaName(name) + .role(role) + .type(type) + .level(level) + .tone(tone) + .career(3) + .gender(Gender.MALE) + .imageUrl("/img/" + id + ".svg") + .build(); + } +}