Skip to content

Commit 38f2e54

Browse files
committed
feat: 답변 멱등키 + Sprint 2 도메인 단위 테스트
- SPRINT2_PLAN decision #4: POST /api/sessions/{id}/messages 에 Idempotency-Key 헤더 처리 (interview_messages.idempotency_key 컬럼 + Flyway V5 partial unique index). SSE 재연결 자동 재시도 시 동일 키면 기존 답변 반환. - 단위 테스트 (Mockito): - SessionServiceTest: create/start/end + analyzed 컨텍스트 링크 + non-analyzed 거부 - InterviewMessageServiceTest: submitAnswer + 멱등키 중복 차단 + 직전 메시지 검증 - QuestionsCallbackServiceTest: POOL 첫 질문 INSERT/SSE, FOLLOWUP 자동종료, 중복 messageId skip - AiRequestLogServiceTest: record + invalid status fallback
1 parent e5226af commit 38f2e54

9 files changed

Lines changed: 490 additions & 8 deletions

File tree

backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ public List<MessageResult> list(Long userId, Long sessionId) {
3434
}
3535

3636
@Transactional
37-
public MessageResult submitAnswer(Long userId, Long sessionId, String content) {
37+
public MessageResult submitAnswer(Long userId, Long sessionId, String content, String idempotencyKey) {
3838
InterviewSession session = ownedSession(userId, sessionId);
39+
40+
// SPRINT2_PLAN decision #4: SSE 재연결 자동 재시도 중복 차단
41+
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
42+
var existing = messageRepository.findBySession_IdAndIdempotencyKey(sessionId, idempotencyKey);
43+
if (existing.isPresent()) {
44+
return MessageResult.of(existing.get());
45+
}
46+
}
47+
3948
if (session.getStatus() != SessionStatus.IN_PROGRESS) {
4049
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
4150
}
@@ -48,7 +57,8 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content) {
4857
}
4958
int nextSeq = latest.getSequenceNumber() + 1;
5059
InterviewMessage answer = messageRepository.save(
51-
InterviewMessage.interviewee(session, nextSeq, content, latest)
60+
InterviewMessage.interviewee(session, nextSeq, content, latest,
61+
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
5262
);
5363

5464
events.publishEvent(new AnswerSubmittedEvent(

backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,32 @@ public class InterviewMessage extends BaseTimeEntity {
6262
@Enumerated(EnumType.STRING)
6363
private MessageStatus status = MessageStatus.CREATED;
6464

65+
@Column(name = "idempotency_key", length = 64)
66+
private String idempotencyKey;
67+
6568
private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role,
66-
String content, InterviewMessage parentMessage) {
69+
String content, InterviewMessage parentMessage,
70+
String idempotencyKey) {
6771
this.session = session;
6872
this.sequenceNumber = sequenceNumber;
6973
this.role = role;
7074
this.content = content;
7175
this.parentMessage = parentMessage;
76+
this.idempotencyKey = idempotencyKey;
7277
}
7378

7479
public static InterviewMessage interviewer(InterviewSession session, int seq, String content) {
75-
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null);
80+
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null, null);
7681
}
7782

7883
public static InterviewMessage followup(InterviewSession session, int seq, String content,
7984
InterviewMessage parent) {
80-
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent);
85+
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, null);
8186
}
8287

8388
public static InterviewMessage interviewee(InterviewSession session, int seq, String content,
84-
InterviewMessage parent) {
85-
return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent);
89+
InterviewMessage parent, String idempotencyKey) {
90+
return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent, idempotencyKey);
8691
}
8792

8893
public void markStatus(MessageStatus newStatus) {

backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ public interface InterviewMessageRepository extends JpaRepository<InterviewMessa
1111
Optional<InterviewMessage> findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId);
1212

1313
long countBySession_Id(Long sessionId);
14+
15+
Optional<InterviewMessage> findBySession_IdAndIdempotencyKey(Long sessionId, String idempotencyKey);
1416
}

backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.springframework.web.bind.annotation.PathVariable;
1818
import org.springframework.web.bind.annotation.PostMapping;
1919
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestHeader;
2021
import org.springframework.web.bind.annotation.RequestMapping;
2122
import org.springframework.web.bind.annotation.ResponseStatus;
2223
import org.springframework.web.bind.annotation.RestController;
@@ -62,10 +63,11 @@ public List<MessageResponse> list(
6263
public MessageResponse submit(
6364
@AuthenticationPrincipal UserPrincipal principal,
6465
@PathVariable Long sessionId,
66+
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
6567
@Valid @RequestBody MessageSubmitRequest request
6668
) {
6769
return MessageResponse.from(
68-
messageService.submitAnswer(principal.userId(), sessionId, request.content())
70+
messageService.submitAnswer(principal.userId(), sessionId, request.content(), idempotencyKey)
6971
);
7072
}
7173
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- US-19 답변 제출 멱등 처리 (SPRINT2_PLAN decision #4)
2+
-- POST /api/sessions/{id}/messages 에서 SSE 재연결 후 자동 재시도 시 중복 INSERT 차단.
3+
-- session 단위로 unique — 동일 키여도 다른 세션이면 별도 답변.
4+
5+
ALTER TABLE interview_messages
6+
ADD COLUMN idempotency_key VARCHAR(64);
7+
8+
CREATE UNIQUE INDEX uq_interview_messages_session_idem_key
9+
ON interview_messages (session_id, idempotency_key)
10+
WHERE idempotency_key IS NOT NULL;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.stackup.stackup.log.ai.application;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.verify;
6+
import static org.mockito.Mockito.when;
7+
8+
import com.stackup.stackup.log.ai.application.dto.AiRequestLogCommand;
9+
import com.stackup.stackup.log.ai.domain.AiRequestLog;
10+
import com.stackup.stackup.log.ai.domain.AiRequestLogRepository;
11+
import com.stackup.stackup.log.ai.domain.AiRequestStatus;
12+
import com.stackup.stackup.session.domain.InterviewSessionRepository;
13+
import com.stackup.stackup.user.domain.User;
14+
import com.stackup.stackup.user.domain.UserRepository;
15+
import java.util.Optional;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.api.extension.ExtendWith;
18+
import org.mockito.ArgumentCaptor;
19+
import org.mockito.InjectMocks;
20+
import org.mockito.Mock;
21+
import org.mockito.junit.jupiter.MockitoExtension;
22+
import org.springframework.test.util.ReflectionTestUtils;
23+
24+
@ExtendWith(MockitoExtension.class)
25+
class AiRequestLogServiceTest {
26+
27+
@Mock AiRequestLogRepository logRepository;
28+
@Mock UserRepository userRepository;
29+
@Mock InterviewSessionRepository sessionRepository;
30+
@InjectMocks AiRequestLogService service;
31+
32+
@Test
33+
void record_savesLogWithResolvedUserAndSession() {
34+
User user = User.createGithubUser(1L, "u", null, null, "t");
35+
ReflectionTestUtils.setField(user, "id", 1L);
36+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
37+
when(sessionRepository.findById(10L)).thenReturn(Optional.empty());
38+
39+
service.record(new AiRequestLogCommand(
40+
1L, 10L, "analyze.document", "gemini-3.1-pro",
41+
123, 456, 1500, "SUCCESS", null
42+
));
43+
44+
ArgumentCaptor<AiRequestLog> cap = ArgumentCaptor.forClass(AiRequestLog.class);
45+
verify(logRepository).save(cap.capture());
46+
AiRequestLog saved = cap.getValue();
47+
assertThat(saved.getStatus()).isEqualTo(AiRequestStatus.SUCCESS);
48+
assertThat(saved.getRequestType()).isEqualTo("analyze.document");
49+
assertThat(saved.getInputTokens()).isEqualTo(123);
50+
}
51+
52+
@Test
53+
void record_fallsBackToFailedOnUnknownStatus() {
54+
service.record(new AiRequestLogCommand(
55+
null, null, "generate.followup", "gemini-flash",
56+
10, 20, 800, "BOGUS", null
57+
));
58+
59+
ArgumentCaptor<AiRequestLog> cap = ArgumentCaptor.forClass(AiRequestLog.class);
60+
verify(logRepository).save(cap.capture());
61+
assertThat(cap.getValue().getStatus()).isEqualTo(AiRequestStatus.FAILED);
62+
}
63+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.stackup.stackup.session.application;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.Mockito.never;
7+
import static org.mockito.Mockito.verify;
8+
import static org.mockito.Mockito.when;
9+
10+
import com.stackup.stackup.common.exception.DomainException;
11+
import com.stackup.stackup.session.application.dto.MessageResult;
12+
import com.stackup.stackup.session.application.event.AnswerSubmittedEvent;
13+
import com.stackup.stackup.session.domain.InterviewMessage;
14+
import com.stackup.stackup.session.domain.InterviewMessageRepository;
15+
import com.stackup.stackup.session.domain.InterviewSession;
16+
import com.stackup.stackup.session.domain.InterviewSessionRepository;
17+
import com.stackup.stackup.session.domain.InterviewType;
18+
import com.stackup.stackup.session.domain.JobCategory;
19+
import com.stackup.stackup.session.domain.SessionMode;
20+
import com.stackup.stackup.user.domain.User;
21+
import java.util.Optional;
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.ExtendWith;
24+
import org.mockito.InjectMocks;
25+
import org.mockito.Mock;
26+
import org.mockito.junit.jupiter.MockitoExtension;
27+
import org.springframework.context.ApplicationEventPublisher;
28+
import org.springframework.test.util.ReflectionTestUtils;
29+
30+
@ExtendWith(MockitoExtension.class)
31+
class InterviewMessageServiceTest {
32+
33+
@Mock InterviewSessionRepository sessionRepository;
34+
@Mock InterviewMessageRepository messageRepository;
35+
@Mock ApplicationEventPublisher events;
36+
@InjectMocks InterviewMessageService service;
37+
38+
@Test
39+
void submitAnswer_insertsIntervieweeAndPublishesEvent() {
40+
InterviewSession session = sessionInProgress(10L);
41+
InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?");
42+
ReflectionTestUtils.setField(question, "id", 100L);
43+
44+
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
45+
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question));
46+
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> {
47+
InterviewMessage m = inv.getArgument(0);
48+
ReflectionTestUtils.setField(m, "id", 200L);
49+
return m;
50+
});
51+
52+
MessageResult result = service.submitAnswer(1L, 10L, "answer text", null);
53+
54+
assertThat(result.id()).isEqualTo(200L);
55+
assertThat(result.sequenceNumber()).isEqualTo(2);
56+
verify(events).publishEvent(any(AnswerSubmittedEvent.class));
57+
}
58+
59+
@Test
60+
void submitAnswer_returnsExistingWhenIdempotencyKeyHit() {
61+
InterviewSession session = sessionInProgress(10L);
62+
InterviewMessage prior = InterviewMessage.interviewee(session, 2, "prior", null, "key-abc");
63+
ReflectionTestUtils.setField(prior, "id", 200L);
64+
65+
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
66+
when(messageRepository.findBySession_IdAndIdempotencyKey(10L, "key-abc"))
67+
.thenReturn(Optional.of(prior));
68+
69+
MessageResult result = service.submitAnswer(1L, 10L, "retry", "key-abc");
70+
71+
assertThat(result.id()).isEqualTo(200L);
72+
verify(messageRepository, never()).save(any(InterviewMessage.class));
73+
verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class));
74+
}
75+
76+
@Test
77+
void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() {
78+
InterviewSession session = sessionInProgress(10L);
79+
InterviewMessage prior = InterviewMessage.interviewee(session, 1, "prev answer", null, null);
80+
81+
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
82+
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(prior));
83+
84+
assertThatThrownBy(() -> service.submitAnswer(1L, 10L, "x", null))
85+
.isInstanceOf(DomainException.class);
86+
}
87+
88+
private InterviewSession sessionInProgress(Long id) {
89+
User user = User.createGithubUser(1L, "u", null, null, "t");
90+
ReflectionTestUtils.setField(user, "id", 1L);
91+
InterviewSession s = InterviewSession.create(
92+
user, "t", null, SessionMode.ONLINE,
93+
InterviewType.TECHNICAL, JobCategory.BACKEND, 5, 30
94+
);
95+
ReflectionTestUtils.setField(s, "id", id);
96+
s.start();
97+
return s;
98+
}
99+
}

0 commit comments

Comments
 (0)