Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stackup.stackup.session.application.dto;

public record GenerateFollowupPayload(
Long sessionId,
Long questionMessageId,
Long answerMessageId,
String answerText,
String audioS3Key // 텍스트 면접에서는 null
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.stackup.stackup.session.application.dto;

import java.util.List;

public record GenerateQuestionsPayload(
Long sessionId,
String interviewType,
String jobCategory,
List<Long> documentIds,
int maxQuestions
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.stackup.stackup.session.application.dto;

import com.stackup.stackup.common.messaging.MessageContext;
import java.time.Instant;

public record QuestionsCallbackEnvelope(
String messageId,
String messageType,
String version,
String traceId,
Instant publishedAt,
String publisher,
QuestionsCallbackPayload payload,
MessageContext context
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.stackup.stackup.session.application.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Map;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record QuestionsCallbackPayload(
Long sessionId,
String kind, // "FIRST" | "FOLLOWUP" | "END"
String category, // FIRST / FOLLOWUP 일 때: PROJECT_DEEP_DIVE / CS_FUNDAMENTAL 등
String question, // FIRST / FOLLOWUP 일 때: 질문 본문
Long parentMessageId, // FOLLOWUP 일 때: 직전 답변 메시지 id
Map<String, Object> answerEvaluation, // FOLLOWUP / END 일 때: { specificity, logic, structure }
Map<String, Object> voiceAnalysis // 음성 모드, 텍스트 면접에선 null
) {
public boolean isFirst() { return "FIRST".equals(kind); }
public boolean isFollowup() { return "FOLLOWUP".equals(kind); }
public boolean isEnd() { return "END".equals(kind); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,50 @@ public class InterviewMessage extends BaseTimeEntity {
@Column(nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private MessageStatus status = MessageStatus.CREATED;

public static InterviewMessage interviewer(
InterviewSession session, int sequence, String content, InterviewMessage parent
) {
if (session == null) {
throw new IllegalArgumentException("session must not be null");
}
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("content must not be null or blank");
}
if (sequence < 1) {
throw new IllegalArgumentException("sequence must be >= 1");
}

InterviewMessage m = new InterviewMessage();
m.session = session;
m.sequenceNumber = sequence;
m.role = MessageRole.INTERVIEWER;
m.content = content;
m.parentMessage = parent;
m.status = MessageStatus.CREATED;
return m;
}

public static InterviewMessage interviewee(
InterviewSession session, int sequence, String content, InterviewMessage parent
) {
if (session == null) {
throw new IllegalArgumentException("session must not be null");
}
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("content must not be null or blank");
}
if (sequence < 1) {
throw new IllegalArgumentException("sequence must be >= 1");
}

InterviewMessage m = new InterviewMessage();
m.session = session;
m.sequenceNumber = sequence;
m.role = MessageRole.INTERVIEWEE;
m.content = content;
m.parentMessage = parent;
m.status = MessageStatus.COMPLETED; // 답변은 작성 시점에 완료
return m;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package com.stackup.stackup.session.domain;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface InterviewMessageRepository extends JpaRepository<InterviewMessage, Long> {

List<InterviewMessage> findBySession_IdOrderBySequenceNumberAsc(Long sessionId);

@Query("select coalesce(max(m.sequenceNumber), 0) from InterviewMessage m where m.session.id = :sessionId")
int findMaxSequenceBySessionId(@Param("sessionId") Long sessionId);

Optional<InterviewMessage> findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,72 @@ public class InterviewSession extends BaseSoftDeleteEntity {

@Column(name = "ended_at")
private Instant endedAt;

public static InterviewSession create(
User user, String title, String memo,
SessionMode mode, InterviewType interviewType, JobCategory jobCategory,
Integer maxQuestions, Integer maxDurationMinutes
) {
if (user == null) {
throw new IllegalArgumentException("user must not be null");
}
if (mode == null) {
throw new IllegalArgumentException("mode must not be null");
}
if (interviewType == null) {
throw new IllegalArgumentException("interviewType must not be null");
}
if (jobCategory == null) {
throw new IllegalArgumentException("jobCategory must not be null");
}
InterviewSession s = new InterviewSession();
s.user = user;
s.title = title;
s.memo = memo;
s.mode = mode;
s.interviewType = interviewType;
s.jobCategory = jobCategory;
s.maxQuestions = maxQuestions == null ? 10 : maxQuestions;
s.maxDurationMinutes = maxDurationMinutes == null ? 60 : maxDurationMinutes;
s.status = SessionStatus.READY;
s.totalQuestionCount = 0;
return s;
}

public void markInProgress() {
if (this.status != SessionStatus.READY) {
throw new IllegalStateException("IN_PROGRESS 전이는 READY 상태에서만 가능합니다. 현재=" + this.status);
}
this.status = SessionStatus.IN_PROGRESS;
this.startedAt = Instant.now();
}

public void incrementQuestionCount() {
if (this.status != SessionStatus.IN_PROGRESS) {
throw new IllegalStateException("IN_PROGRESS 상태에서만 증가할 수 있습니다.");
}
this.totalQuestionCount = (this.totalQuestionCount == null ? 0 : this.totalQuestionCount) + 1;
}

public boolean isMaxReached() {
return this.totalQuestionCount != null
&& this.maxQuestions != null
&& this.totalQuestionCount >= this.maxQuestions;
}

public void end() {
if (this.status != SessionStatus.IN_PROGRESS) {
throw new IllegalStateException("IN_PROGRESS 상태에서만 종료할 수 있습니다. 현재=" + this.status);
}
this.status = SessionStatus.COMPLETED;
this.endedAt = Instant.now();
}

public void cancel() {
if (this.status == SessionStatus.COMPLETED || this.status == SessionStatus.CANCELLED) {
throw new IllegalStateException("취소할 수 없는 상태입니다. 현재=" + this.status);
}
this.status = SessionStatus.CANCELLED;
this.endedAt = Instant.now();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface InterviewSessionRepository extends JpaRepository<InterviewSession, Long> {

List<InterviewSession> findByUser_Id(Long userId);

Optional<InterviewSession> findByIdAndUser_Id(Long id, Long userId);

Page<InterviewSession> findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(Long userId, Pageable pageable);

Optional<InterviewSession> findByIdAndIsDeletedFalse(Long id);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.stackup.stackup.session.domain;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SessionContextRepository extends JpaRepository<SessionContext, Long> {

Optional<SessionContext> findBySession_Id(Long sessionId);

List<SessionContext> findAllBySession_Id(Long sessionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.stackup.stackup.session.application.dto;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class QuestionsCallbackPayloadTest {
private final ObjectMapper m = JsonMapper.builder().findAndAddModules().build();

@Test
void first_payload_round_trip() throws Exception {
String json = """
{"sessionId":99,"kind":"FIRST",
"category":"PROJECT_DEEP_DIVE","question":"Q1"}""";
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
assertThat(p.isFirst()).isTrue();
assertThat(p.category()).isEqualTo("PROJECT_DEEP_DIVE");
assertThat(p.question()).isEqualTo("Q1");
}

@Test
void followup_payload_round_trip() throws Exception {
String json = """
{"sessionId":99,"kind":"FOLLOWUP","parentMessageId":502,
"category":"CS_FUNDAMENTAL","question":"왜?",
"answerEvaluation":{"specificity":3.5}}""";
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
assertThat(p.isFollowup()).isTrue();
assertThat(p.parentMessageId()).isEqualTo(502L);
assertThat(p.question()).isEqualTo("왜?");
}

@Test
void end_payload_round_trip() throws Exception {
String json = """
{"sessionId":99,"kind":"END",
"answerEvaluation":{"specificity":4.1}}""";
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
assertThat(p.isEnd()).isTrue();
assertThat(p.question()).isNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.stackup.stackup.session.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.stackup.stackup.user.domain.User;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

class InterviewMessageTest {

// ── factory happy-path ─────────────────────────────────────────────────

@Test
void interviewer_creates_interviewer_message_with_sequence() {
InterviewSession s = newSession();
InterviewMessage m = InterviewMessage.interviewer(s, 1, "왜 PG 를 골랐나요?", null);
assertThat(m.getRole()).isEqualTo(MessageRole.INTERVIEWER);
assertThat(m.getSequenceNumber()).isEqualTo(1);
assertThat(m.getContent()).isEqualTo("왜 PG 를 골랐나요?");
assertThat(m.getStatus()).isEqualTo(MessageStatus.CREATED);
assertThat(m.getParentMessage()).isNull();
assertThat(m.getSession()).isSameAs(s);
}

@Test
void interviewee_creates_interviewee_message_with_parent() {
InterviewSession s = newSession();
InterviewMessage parent = InterviewMessage.interviewer(s, 1, "Q", null);
InterviewMessage answer = InterviewMessage.interviewee(s, 2, "A", parent);
assertThat(answer.getRole()).isEqualTo(MessageRole.INTERVIEWEE);
assertThat(answer.getParentMessage()).isSameAs(parent);
assertThat(answer.getStatus()).isEqualTo(MessageStatus.COMPLETED);
assertThat(answer.getSequenceNumber()).isEqualTo(2);
assertThat(answer.getContent()).isEqualTo("A");
}

// ── null / blank / range guards ────────────────────────────────────────

@Test
void interviewer_throws_when_session_null() {
assertThatThrownBy(() -> InterviewMessage.interviewer(null, 1, "Q", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("session");
}

@Test
void interviewer_throws_when_content_null() {
assertThatThrownBy(() -> InterviewMessage.interviewer(newSession(), 1, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("content");
}

@Test
void interviewer_throws_when_content_blank() {
assertThatThrownBy(() -> InterviewMessage.interviewer(newSession(), 1, " ", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("content");
}

@Test
void interviewer_throws_when_sequence_less_than_1() {
assertThatThrownBy(() -> InterviewMessage.interviewer(newSession(), 0, "Q", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("sequence");
}

@Test
void interviewee_throws_when_session_null() {
assertThatThrownBy(() -> InterviewMessage.interviewee(null, 1, "A", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("session");
}

@Test
void interviewee_throws_when_content_blank() {
assertThatThrownBy(() -> InterviewMessage.interviewee(newSession(), 1, "", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("content");
}

@Test
void interviewee_throws_when_content_null() {
InterviewSession s = newSession();
InterviewMessage parent = InterviewMessage.interviewer(s, 1, "Q", null);
assertThatThrownBy(() -> InterviewMessage.interviewee(s, 2, null, parent))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("content");
}

@Test
void interviewee_throws_when_sequence_less_than_1() {
assertThatThrownBy(() -> InterviewMessage.interviewee(newSession(), -1, "A", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("sequence");
}

// ── helpers ────────────────────────────────────────────────────────────

private InterviewSession newSession() {
User user = userStub(1L);
return InterviewSession.create(
user, "테스트 세션", null,
SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND,
10, 60
);
}

private User userStub(Long id) {
User user = User.createGithubUser(
123L, "stub-user", "stub@example.com", null, "encrypted-token"
);
ReflectionTestUtils.setField(user, "id", id);
return user;
}
}
Loading