diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 251f5baa..fc7f379e 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -552,6 +552,30 @@ docker compose up -d (`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에 텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시 확인한다(완료된 답변을 실패로 되돌리지 않는다). +- **꼬리질문 콜백 유실 복구 본 구현**: 스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저 + INSERT 하고 `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 **실패를 보고**하면 + `applyFollowupFailed` 가 처리하지만, **콜백 자체가 오지 않으면**(AI 크래시·DLQ 격리·브로커 + 단절) 그 경로를 아무도 타지 못한다 — placeholder 가 CREATED 로 남고, 답할 질문이 없어 + 면접이 그 자리에서 멈춘다(세션 시간 초과로 통째로 끝날 때까지). 음성 답변의 + `(transcribing)` 과 정확히 같은 구멍인데 질문 쪽에만 대응이 없었다. 운영에서 1건 발생 + (messageId=444, sessionId=93 — 28일째 CREATED, 해당 세션은 시간초과로 조기 종료됨). + `StaleFollowupSweeper`(기본 2분 주기)가 `interview.followup.stale-generation-minutes` + (기본 3분)를 넘긴 placeholder 를 찾아 `QuestionsCallbackService.failStaleFollowup` 을 부른다. + - **콜백 경로와 같은 코드를 탄다**(`markFollowupFailedAndAdvance`) — 실패 문구 확정 + + `SESSION_MESSAGE` 발행 + `advanceToNextGeneral`. "어떻게 실패했는지"에 따라 사용자가 + 보는 결과가 갈리면 안 된다. 이벤트 reason 만 `FOLLOWUP_TIMEOUT` 으로 구분한다. + - 확정 직전 상태를 다시 확인한다 — 스위퍼 조회 이후 콜백이 도착했거나(완성된 질문을 + 실패로 되돌리면 안 된다) 세션이 종료됐을 수 있다. + - **실패로 확정된 질문은 TTS 를 요청하지 않는다**(`SessionTtsRequester` 가드). 실패 사실은 + 화면에 보여야 하므로 SSE 는 그대로 나가는데, `QuestionPersistedEvent` 에 가드가 없어 + Gemini TTS 가 "질문 생성에 실패했습니다"를 음성으로 만들고 있었다 — 운영 2건 + (messageId=434·437, `tts_status=SUCCEEDED`). 면접관이 오류 문구를 읽고, 대화 기록의 + 재생 버튼도 그걸 들려준다. + - 두 스위퍼의 조회 JPQL 은 `InterviewMessageSweepQueryTest`(`@PostgresRepositoryTest`)로 + 검증한다. **멈춘 면접을 푸는 유일한 장치인데 지금까지 테스트가 없었다** — 넓으면 정상 + 진행 중인 턴을 실패로 되돌리고, 좁으면 조용히 0건이 되어 스위퍼가 있으나 마나가 되는데 + 둘 다 배포 전에는 신호가 없다(§15.1). `createdAt` 은 감사 필드라 못 바꾸므로 cutoff 를 + 움직여 양방향으로 확인한다. - **STT 실패 재전사 본 구현**: STT 가 실패하면 `failVoiceTranscription()` 이 content 를 실패 문구로 덮고 FAILED 로 확정해 턴을 풀어 줬는데, **오디오는 S3 에 그대로 남아 있는데 꺼낼 경로가 없어** 사용자가 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이 diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index b801c21e..a84e036a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -328,11 +328,50 @@ private void applyFollowupFailed(InterviewSession session, QuestionsCallbackPayl publishErrorEvent(session, "FOLLOWUP", payload); return; } + markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_FAILED"); + } + + /** + * 꼬리질문 콜백을 영영 못 받은 placeholder 를 실패로 확정하고 면접을 진행시킨다. + * + *

{@link StaleFollowupSweeper} 가 메시지마다 호출한다. AI 가 status=FAILED 콜백을 + * 보내 준 경우는 {@link #applyFollowupFailed} 가 처리하지만, 콜백 자체가 오지 않으면 + * (AI 크래시·DLQ 격리·브로커 단절) 그 경로를 타지 못한다 — 음성 답변의 + * `(transcribing)` 과 정확히 같은 구멍이고, 여기가 그 대응이다. + * + *

확정 직전 상태를 다시 확인한다: 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있고, + * 완성된 질문을 실패로 되돌리면 사용자가 받은 질문이 사라진다. + */ + @Transactional + public void failStaleFollowup(Long messageId) { + InterviewMessage placeholder = messageRepository.findById(messageId).orElse(null); + if (placeholder == null) { + return; + } + if (placeholder.getStatus() != MessageStatus.CREATED + || !InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(placeholder.getContent())) { + return; + } + InterviewSession session = placeholder.getSession(); + // 스위퍼 조회 이후 세션이 끝났을 수 있다(시간초과·수동종료). 종료 세션에 질문을 + // 더하지 않는 것은 콜백 경로의 terminal 가드와 같은 원칙. + if (session == null || session.isDeleted() || session.getStatus().isTerminal()) { + return; + } + log.warn("follow-up stuck in generation — no callback arrived. sessionId={}, messageId={}", + session.getId(), messageId); + markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_TIMEOUT"); + } + + // 실패 확정 + 화면 갱신 + 다음 일반질문. 콜백 경로와 스위퍼 경로가 같은 코드를 타야 + // "어떻게 실패했는지"에 따라 사용자가 보는 결과가 갈리지 않는다. + private void markFollowupFailedAndAdvance(InterviewSession session, InterviewMessage placeholder, + String reason) { placeholder.failFollowup(); InterviewMessage message = messageRepository.save(placeholder); - publishQuestionEvents(session, message, "FOLLOWUP_FAILED"); - log.info("callback.questions FOLLOWUP marked failed, advancing to next general. sessionId={}, msg={}", - session.getId(), message.getId()); + publishQuestionEvents(session, message, reason); + log.info("follow-up marked failed({}), advancing to next general. sessionId={}, msg={}", + reason, session.getId(), message.getId()); advanceToNextGeneral(session.getId()); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java index ac5dd3cc..c88af363 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java @@ -8,6 +8,7 @@ import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.MessageStatus; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,6 +37,15 @@ public void onQuestionPersisted(QuestionPersistedEvent event) { log.warn("generate.tts skipped — message not found. messageId={}", event.messageId()); return; } + // 실패로 확정된 질문은 읽어 주지 않는다. FOLLOWUP 생성 실패/유실도 이 이벤트를 타는데 + // (실패 사실을 화면에 보여야 하므로 SSE 는 그대로 나간다), 그대로 두면 Gemini TTS 를 + // 한 번 써서 "질문 생성에 실패했습니다"를 음성으로 만든다 — 실제로 운영에 2건 있다 + // (messageId=434·437, tts_status=SUCCEEDED). 면접관이 오류 문구를 말하는 셈이고, + // 대화 기록의 재생 버튼도 그걸 들려준다. + if (message.getStatus() == MessageStatus.FAILED) { + log.info("generate.tts skipped — message is FAILED. messageId={}", event.messageId()); + return; + } InterviewSession session = message.getSession(); GenerateTtsPayload payload = new GenerateTtsPayload( session.getId(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java new file mode 100644 index 00000000..8f43887a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java @@ -0,0 +1,71 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 꼬리질문 콜백을 영영 못 받은 placeholder 를 정리한다. + * + *

스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저 INSERT 하고 + * `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 실패를 보고하면 + * {@code QuestionsCallbackService.applyFollowupFailed} 가 실패 문구로 확정하고 다음 + * 일반질문으로 넘어가지만, 콜백 자체가 오지 않으면(AI 크래시·DLQ 격리·브로커 단절) + * 그 경로를 아무도 타지 못한다. placeholder 는 CREATED 인 채로 남고, 화면에는 "(생성 중)" + * 만 떠 있으며, 답할 질문이 없으니 면접이 그 자리에서 멈춘다 — 세션 시간 초과로 통째로 + * 끝날 때까지. + * + *

음성 답변의 `(transcribing)` 이 정확히 같은 구멍이었고 {@link StaleTranscriptionSweeper} + * 로 막았다. 이건 그 짝이다. 운영에서 1건 발생했다(messageId=444, sessionId=93 — + * 28일째 CREATED 로 남아 있었고 그 세션은 시간초과로 조기 종료됐다). + */ +@Component +@RequiredArgsConstructor +public class StaleFollowupSweeper { + + private static final Logger log = LoggerFactory.getLogger(StaleFollowupSweeper.class); + + private final InterviewMessageRepository messageRepository; + private final QuestionsCallbackService callbackService; + + // 이 시간이 지나도 확정되지 않으면 유실로 본다. 정상 생성은 flash 타임아웃(10초) + RAG(1.5초) + // 범위에서 끝나고, AI 쪽에 메시지 단위 재시도가 없어 실패는 곧장 DLQ 로 간다 — + // 3분이면 정상 케이스를 자를 위험 없이 넉넉하다. + @Value("${interview.followup.stale-generation-minutes:3}") + private long staleAfterMinutes = 3; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Scheduled( + fixedDelayString = "${interview.followup.sweep-interval-ms:120000}", + initialDelayString = "${interview.followup.sweep-initial-delay-ms:90000}") + public void sweep() { + Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes)); + List stale = messageRepository.findStaleFollowupPlaceholders( + InterviewMessage.FOLLOWUP_GENERATING_TEXT, before); + if (stale.isEmpty()) { + return; + } + int recovered = 0; + for (InterviewMessage m : stale) { + try { + // 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다. + callbackService.failStaleFollowup(m.getId()); + recovered++; + } catch (RuntimeException e) { + log.warn("stale follow-up recovery failed. messageId={}", m.getId(), e); + } + } + log.info("stale follow-up sweeper recovered {} of {} stuck placeholder(s)", + recovered, stale.size()); + } +} 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 97b51cb5..a17d3a22 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 @@ -45,6 +45,21 @@ public interface InterviewMessageRepository extends JpaRepository findStaleTranscribing(@Param("pendingText") String pendingText, @Param("before") java.time.Instant before); + // AI 꼬리질문 콜백을 기다리다 멈춰 있는 placeholder. 음성 쪽 findStaleTranscribing 과 + // 같은 성격이다 — callback.questions(FOLLOWUP) 이 유실되면 "(생성 중)" 인 채로 영구히 남고 + // 면접이 그 자리에서 멈춘다. + @Query(""" + select m from InterviewMessage m + where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWER + and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED + and m.content = :generatingText + and m.createdAt < :before + and m.session.deleted = false + and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS + """) + List findStaleFollowupPlaceholders(@Param("generatingText") String generatingText, + @Param("before") java.time.Instant before); + // 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해. List findByParentMessage_IdIn(List parentMessageIds); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index 6d6d3b23..2ec83d88 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -586,6 +586,77 @@ void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() { verify(poolRepository, never()).findFirstBySessionIdAndUsedFalseOrderByIdxAsc(any()); } + // ── 콜백 유실 복구 (StaleFollowupSweeper 경로) ────────────────────────────── + + @Test + void failStaleFollowup_marksFailedAndAdvances() { + InterviewSession session = sessionFixture(40L, SessionStatus.IN_PROGRESS); + InterviewMessage placeholder = InterviewMessage.followupPlaceholder( + session, 3, parentMessageFixture(session)); + ReflectionTestUtils.setField(placeholder, "id", 444L); + + when(messageRepository.findById(444L)).thenReturn(Optional.of(placeholder)); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + when(sessionRepository.findById(40L)).thenReturn(Optional.of(session)); + when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(40L)).thenReturn(Optional.empty()); + when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1); + + service.failStaleFollowup(444L); + + // AI 가 실패를 보고한 경우와 같은 결과여야 한다 — 어떻게 실패했는지에 따라 + // 사용자가 보는 화면이 갈리면 안 된다. + assertThat(placeholder.getContent()) + .isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT); + assertThat(placeholder.getStatus()) + .isEqualTo(com.stackup.stackup.session.domain.MessageStatus.FAILED); + assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); + } + + // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다. 완성된 질문을 실패로 되돌리면 + // 사용자가 받은 질문이 화면에서 사라진다. + @Test + void failStaleFollowup_skipsWhenCallbackArrivedMeanwhile() { + InterviewSession session = sessionFixture(41L, SessionStatus.IN_PROGRESS); + InterviewMessage placeholder = InterviewMessage.followupPlaceholder( + session, 3, parentMessageFixture(session)); + ReflectionTestUtils.setField(placeholder, "id", 445L); + placeholder.completeFollowup("뒤늦게 도착한 진짜 꼬리질문?", false); + + when(messageRepository.findById(445L)).thenReturn(Optional.of(placeholder)); + + service.failStaleFollowup(445L); + + assertThat(placeholder.getContent()).isEqualTo("뒤늦게 도착한 진짜 꼬리질문?"); + verify(messageRepository, never()).save(any(InterviewMessage.class)); + verify(events, never()).publishEvent(any()); + } + + // 스위퍼 조회 이후 세션이 시간초과로 끝났을 수 있다 — 종료 세션에 질문을 더하지 않는다. + @Test + void failStaleFollowup_skipsWhenSessionAlreadyTerminal() { + InterviewSession session = sessionFixture(42L, SessionStatus.IN_PROGRESS); + InterviewMessage placeholder = InterviewMessage.followupPlaceholder( + session, 3, parentMessageFixture(session)); + ReflectionTestUtils.setField(placeholder, "id", 446L); + ReflectionTestUtils.setField(session, "status", SessionStatus.COMPLETED); + + when(messageRepository.findById(446L)).thenReturn(Optional.of(placeholder)); + + service.failStaleFollowup(446L); + + assertThat(placeholder.getContent()).isEqualTo(InterviewMessage.FOLLOWUP_GENERATING_TEXT); + verify(events, never()).publishEvent(any()); + } + + @Test + void failStaleFollowup_isNoopWhenMessageMissing() { + when(messageRepository.findById(447L)).thenReturn(Optional.empty()); + + service.failStaleFollowup(447L); + + verify(events, never()).publishEvent(any()); + } + private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List questions) { QuestionsCallbackPayload payload = new QuestionsCallbackPayload( sessionId, "POOL", questions, null, null, null, null, null, null diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java index c93d7a02..ce67cbac 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java @@ -51,6 +51,20 @@ void publishesGenerateTts_whenQuestionPersisted() { org.assertj.core.api.Assertions.assertThat(captor.getValue().text()).isEqualTo("당신의 프로젝트에서..."); } + // 꼬리질문 생성 실패/유실도 QuestionPersistedEvent 를 탄다(실패 사실을 화면에 보여야 + // 하므로). 그대로 두면 Gemini TTS 를 써서 "질문 생성에 실패했습니다"를 음성으로 만들고 + // 면접관이 오류 문구를 읽는다 — 운영에 2건 있었다(messageId=434·437). + @Test + void skips_whenMessageIsFailed() { + InterviewMessage message = org.mockito.Mockito.mock(InterviewMessage.class); + when(message.getStatus()).thenReturn(com.stackup.stackup.session.domain.MessageStatus.FAILED); + when(messageRepository.findById(43L)).thenReturn(Optional.of(message)); + + requester.onQuestionPersisted(new QuestionPersistedEvent(1L, 7L, 43L)); + + verify(publisher, never()).publishToAi(any(), any(), any()); + } + @Test void skips_whenMessageNotFound() { when(messageRepository.findById(99L)).thenReturn(Optional.empty()); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java b/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java new file mode 100644 index 00000000..05058809 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java @@ -0,0 +1,107 @@ +package com.stackup.stackup.session.application; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.user.domain.User; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * `callback.questions(FOLLOWUP)` 이 유실되면 꼬리질문이 `(생성 중)` 으로 남고 답할 질문이 + * 없어 면접이 멈춘다. 음성 답변의 `(transcribing)` 과 같은 구멍이다. + */ +@ExtendWith(MockitoExtension.class) +class StaleFollowupSweeperTest { + + @Mock InterviewMessageRepository messageRepository; + @Mock QuestionsCallbackService callbackService; + @InjectMocks StaleFollowupSweeper sweeper; + + @Test + void 멈춘_placeholder_를_복구한다() { + ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L); + when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class))) + .thenReturn(List.of(placeholder(444L), placeholder(445L))); + + sweeper.sweep(); + + verify(callbackService).failStaleFollowup(444L); + verify(callbackService).failStaleFollowup(445L); + } + + // sentinel 문자열이 어긋나면 조회가 조용히 0건이 되고 스위퍼가 아무 일도 안 한다. + @Test + void 조회는_생성중_sentinel_과_임계시각으로_한다() { + ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L); + when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class))) + .thenReturn(List.of()); + + Instant before = Instant.now(); + sweeper.sweep(); + + ArgumentCaptor text = ArgumentCaptor.forClass(String.class); + ArgumentCaptor cutoff = ArgumentCaptor.forClass(Instant.class); + verify(messageRepository).findStaleFollowupPlaceholders(text.capture(), cutoff.capture()); + org.assertj.core.api.Assertions.assertThat(text.getValue()) + .isEqualTo(InterviewMessage.FOLLOWUP_GENERATING_TEXT); + org.assertj.core.api.Assertions.assertThat(cutoff.getValue()) + .isBefore(before.minusSeconds(170)); + verifyNoInteractions(callbackService); + } + + // 한 건이 터져도 나머지는 정리돼야 한다 — 멈춘 면접이 남는 게 이 스위퍼가 막으려는 것이다. + @Test + void 한_건이_실패해도_나머지를_계속_처리한다() { + ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L); + when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class))) + .thenReturn(List.of(placeholder(444L), placeholder(445L))); + doThrow(new IllegalStateException("boom")).when(callbackService).failStaleFollowup(444L); + + sweeper.sweep(); + + verify(callbackService).failStaleFollowup(445L); + } + + @Test + void 대상이_없으면_아무것도_하지_않는다() { + ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L); + when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class))) + .thenReturn(List.of()); + + sweeper.sweep(); + + verify(callbackService, never()).failStaleFollowup(any()); + } + + private InterviewMessage placeholder(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession session = InterviewSession.create( + user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null); + ReflectionTestUtils.setField(session, "id", 10L); + session.start(); + + InterviewMessage parent = InterviewMessage.interviewer(session, 1, "질문"); + InterviewMessage m = InterviewMessage.followupPlaceholder(session, 3, parent); + ReflectionTestUtils.setField(m, "id", id); + return m; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java new file mode 100644 index 00000000..a92eb456 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java @@ -0,0 +1,130 @@ +package com.stackup.stackup.session.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 두 스위퍼의 조회 쿼리를 실제 PostgreSQL 에서 검증한다. + * + *

이 쿼리들은 멈춘 면접을 푸는 유일한 장치인데 지금까지 아무 테스트도 없었다. + * 기본 테스트 프로파일은 리포지토리를 목으로 대체하므로 JPQL 이 검증되지 않는다 + * (backend/CLAUDE.md §15.1). 너무 넓게 잡으면 정상 진행 중인 질문·답변을 실패로 + * 되돌리고, 너무 좁게 잡으면 조용히 0건이 되어 스위퍼가 있으나 마나가 된다 — + * 둘 다 배포 전에는 아무 신호도 주지 않는다. + * + *

`createdAt` 은 감사 필드라 테스트에서 못 바꾼다. 대신 cutoff 를 움직여 + * `createdAt < :before` 를 양방향으로 확인한다. + */ +@PostgresRepositoryTest +class InterviewMessageSweepQueryTest { + + private static final Instant FUTURE = Instant.now().plusSeconds(600); + private static final Instant PAST = Instant.now().minusSeconds(600); + + @Autowired UserRepository userRepository; + @Autowired InterviewSessionRepository sessionRepository; + @Autowired InterviewMessageRepository messageRepository; + + @Test + void 멈춘_꼬리질문만_고른다() { + User user = userRepository.save(User.createGithubUser(98101L, "sweep-followup", null, null, "t")); + InterviewSession live = startedSession(user); + InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?")); + + InterviewMessage stuck = messageRepository.save( + InterviewMessage.followupPlaceholder(live, 2, question)); + + // 대조군 1: 콜백이 도착해 확정된 꼬리질문. + InterviewMessage done = InterviewMessage.followupPlaceholder(live, 3, question); + done.completeFollowup("진짜 꼬리질문?", false); + messageRepository.save(done); + + // 대조군 2: 이미 실패로 확정된 꼬리질문(두 번 처리하면 안 된다). + InterviewMessage alreadyFailed = InterviewMessage.followupPlaceholder(live, 4, question); + alreadyFailed.failFollowup(); + messageRepository.save(alreadyFailed); + + // 대조군 3: 전사 대기 중인 음성 답변 — 다른 스위퍼의 몫이다. + messageRepository.save(InterviewMessage.voiceInterviewee(live, 5, question, "idem-a")); + + List found = messageRepository.findStaleFollowupPlaceholders( + InterviewMessage.FOLLOWUP_GENERATING_TEXT, FUTURE); + + assertThat(found).extracting(InterviewMessage::getId).containsExactly(stuck.getId()); + } + + @Test + void 멈춘_음성답변만_고른다() { + User user = userRepository.save(User.createGithubUser(98102L, "sweep-voice", null, null, "t")); + InterviewSession live = startedSession(user); + InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?")); + + InterviewMessage stuck = messageRepository.save( + InterviewMessage.voiceInterviewee(live, 2, question, "idem-b")); + + // 대조군: 전사가 도착한 답변, 그리고 꼬리질문 placeholder(다른 스위퍼의 몫). + InterviewMessage done = InterviewMessage.voiceInterviewee(live, 3, question, "idem-c"); + done.completeWithTranscript("전사된 답변입니다"); + messageRepository.save(done); + messageRepository.save(InterviewMessage.followupPlaceholder(live, 4, question)); + + List found = messageRepository.findStaleTranscribing( + InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, FUTURE); + + assertThat(found).extracting(InterviewMessage::getId).containsExactly(stuck.getId()); + } + + // cutoff 가 실제로 걸리는지. 이게 안 걸리면 방금 만든 placeholder 를 즉시 실패시킨다. + @Test + void 임계시각_이전에_만들어진_것만_고른다() { + User user = userRepository.save(User.createGithubUser(98103L, "sweep-cutoff", null, null, "t")); + InterviewSession live = startedSession(user); + InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?")); + messageRepository.save(InterviewMessage.followupPlaceholder(live, 2, question)); + messageRepository.save(InterviewMessage.voiceInterviewee(live, 3, question, "idem-d")); + + assertThat(messageRepository.findStaleFollowupPlaceholders( + InterviewMessage.FOLLOWUP_GENERATING_TEXT, PAST)).isEmpty(); + assertThat(messageRepository.findStaleTranscribing( + InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, PAST)).isEmpty(); + } + + // 끝난 세션·삭제된 세션에는 손대지 않는다 — 스위퍼가 종료된 면접을 사후 변조하면 안 된다. + @Test + void 종료되거나_삭제된_세션은_제외한다() { + User user = userRepository.save(User.createGithubUser(98104L, "sweep-terminal", null, null, "t")); + + InterviewSession ended = startedSession(user); + InterviewMessage endedQ = messageRepository.save(InterviewMessage.interviewer(ended, 1, "질문?")); + messageRepository.save(InterviewMessage.followupPlaceholder(ended, 2, endedQ)); + messageRepository.save(InterviewMessage.voiceInterviewee(ended, 3, endedQ, "idem-e")); + ended.end(); + sessionRepository.save(ended); + + InterviewSession removed = startedSession(user); + InterviewMessage removedQ = messageRepository.save(InterviewMessage.interviewer(removed, 1, "질문?")); + messageRepository.save(InterviewMessage.followupPlaceholder(removed, 2, removedQ)); + messageRepository.save(InterviewMessage.voiceInterviewee(removed, 3, removedQ, "idem-f")); + removed.markDeleted(); + sessionRepository.save(removed); + + assertThat(messageRepository.findStaleFollowupPlaceholders( + InterviewMessage.FOLLOWUP_GENERATING_TEXT, FUTURE)).isEmpty(); + assertThat(messageRepository.findStaleTranscribing( + InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, FUTURE)).isEmpty(); + } + + private InterviewSession startedSession(User user) { + InterviewSession s = InterviewSession.create( + user, "면접", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); + s.start(); + return sessionRepository.save(s); + } +}