From ba2799b0cbb5d52e9e3c580ce511fea3bc74bdb1 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 29 May 2026 14:42:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(session):=20=EB=A9=B4=EC=A0=91=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20+=20Core/AI=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/GenerateFollowupPayload.java | 10 ++ .../dto/GenerateQuestionsPayload.java | 12 ++ .../dto/QuestionsCallbackEnvelope.java | 15 ++ .../dto/QuestionsCallbackPayload.java | 19 +++ .../session/domain/InterviewMessage.java | 46 ++++++ .../domain/InterviewMessageRepository.java | 8 + .../session/domain/InterviewSession.java | 68 ++++++++ .../domain/InterviewSessionRepository.java | 6 + .../domain/SessionContextRepository.java | 3 + .../dto/QuestionsCallbackPayloadTest.java | 44 +++++ .../session/domain/InterviewMessageTest.java | 116 +++++++++++++ .../session/domain/InterviewSessionTest.java | 152 ++++++++++++++++++ 12 files changed, 499 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayloadTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/domain/InterviewSessionTest.java diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java new file mode 100644 index 00000000..9401be7f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java new file mode 100644 index 00000000..d7e79a3a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java @@ -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 documentIds, + int maxQuestions +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java new file mode 100644 index 00000000..48cf538b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java @@ -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 +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java new file mode 100644 index 00000000..e4907ca9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java @@ -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 answerEvaluation, // FOLLOWUP / END 일 때: { specificity, logic, structure } + Map voiceAnalysis // 음성 모드, 텍스트 면접에선 null +) { + public boolean isFirst() { return "FIRST".equals(kind); } + public boolean isFollowup() { return "FOLLOWUP".equals(kind); } + public boolean isEnd() { return "END".equals(kind); } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index c175add6..816aadef 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -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; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java index 075483db..69e80169 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java @@ -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 { List 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 findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId); } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 6f2dfdac..296480c5 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -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(); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index b098f659..b53c323a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -2,6 +2,8 @@ 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 { @@ -9,4 +11,8 @@ public interface InterviewSessionRepository extends JpaRepository findByUser_Id(Long userId); Optional findByIdAndUser_Id(Long id, Long userId); + + Page findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Optional findByIdAndIsDeletedFalse(Long id); } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java index 6334bbec..13f4f02d 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java @@ -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 { Optional findBySession_Id(Long sessionId); + + List findAllBySession_Id(Long sessionId); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayloadTest.java b/backend/src/test/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayloadTest.java new file mode 100644 index 00000000..6ad9a0eb --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayloadTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageTest.java new file mode 100644 index 00000000..06b88422 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageTest.java @@ -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; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/InterviewSessionTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewSessionTest.java new file mode 100644 index 00000000..3cddada8 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewSessionTest.java @@ -0,0 +1,152 @@ +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 InterviewSessionTest { + + @Test + void create_initializes_ready_with_explicit_values() { + User user = userStub(1L); + InterviewSession s = InterviewSession.create( + user, "Title", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60 + ); + assertThat(s.getStatus()).isEqualTo(SessionStatus.READY); + assertThat(s.getTotalQuestionCount()).isZero(); + assertThat(s.getStartedAt()).isNull(); + } + + @Test + void create_applies_null_defaults_for_max_questions_and_max_duration() { + User user = userStub(1L); + InterviewSession s = InterviewSession.create( + user, "Title", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, null, null + ); + assertThat(s.getMaxQuestions()).isEqualTo(10); + assertThat(s.getMaxDurationMinutes()).isEqualTo(60); + } + + @Test + void create_throws_when_user_null() { + assertThatThrownBy(() -> InterviewSession.create( + null, null, null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void create_throws_when_mode_null() { + assertThatThrownBy(() -> InterviewSession.create( + userStub(1L), null, null, null, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void create_throws_when_interviewType_null() { + assertThatThrownBy(() -> InterviewSession.create( + userStub(1L), null, null, SessionMode.ONLINE, null, + JobCategory.BACKEND, 10, 60)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void create_throws_when_jobCategory_null() { + assertThatThrownBy(() -> InterviewSession.create( + userStub(1L), null, null, SessionMode.ONLINE, InterviewType.TECHNICAL, + null, 10, 60)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void markInProgress_transitions_to_in_progress_and_sets_startedAt() { + InterviewSession s = newReady(); + s.markInProgress(); + assertThat(s.getStatus()).isEqualTo(SessionStatus.IN_PROGRESS); + assertThat(s.getStartedAt()).isNotNull(); + } + + @Test + void markInProgress_fails_when_not_ready() { + InterviewSession s = newReady(); + s.markInProgress(); + assertThatThrownBy(s::markInProgress) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void incrementQuestionCount_advances_counter() { + InterviewSession s = newInProgress(); + s.incrementQuestionCount(); + assertThat(s.getTotalQuestionCount()).isEqualTo(1); + } + + @Test + void end_completed_when_in_progress() { + InterviewSession s = newInProgress(); + s.end(); + assertThat(s.getStatus()).isEqualTo(SessionStatus.COMPLETED); + assertThat(s.getEndedAt()).isNotNull(); + } + + @Test + void end_fails_when_not_in_progress() { + InterviewSession s = newReady(); + assertThatThrownBy(s::end).isInstanceOf(IllegalStateException.class); + } + + @Test + void isMaxReached_returns_true_when_question_count_equals_max() { + InterviewSession s = newInProgress(); + for (int i = 0; i < 10; i++) s.incrementQuestionCount(); + assertThat(s.isMaxReached()).isTrue(); + } + + @Test + void cancel_transitions_from_ready() { + InterviewSession s = newReady(); + s.cancel(); + assertThat(s.getStatus()).isEqualTo(SessionStatus.CANCELLED); + assertThat(s.getEndedAt()).isNotNull(); + } + + @Test + void cancel_fails_when_already_completed() { + InterviewSession s = newInProgress(); + s.end(); + assertThatThrownBy(s::cancel).isInstanceOf(IllegalStateException.class); + } + + @Test + void cancel_fails_when_already_cancelled() { + InterviewSession s = newReady(); + s.cancel(); + assertThatThrownBy(s::cancel).isInstanceOf(IllegalStateException.class); + } + + // helpers + private InterviewSession newReady() { + return InterviewSession.create(userStub(1L), null, null, + SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, 10, 60); + } + + private InterviewSession newInProgress() { + InterviewSession s = newReady(); + s.markInProgress(); + return s; + } + + 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; + } +}