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();
+ }
+}