diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java index 2a3509d3..023deeb9 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java @@ -1,19 +1,25 @@ package com.stackup.stackup.session.application; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.messaging.MessageContext; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload; import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageItem; +import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.VoiceAnalysisSummary; import com.stackup.stackup.session.application.event.SessionEndedEvent; 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.InterviewSessionRepository; -import com.stackup.stackup.session.domain.SessionContext; +import com.stackup.stackup.session.domain.MessageVoiceAnalysis; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.session.domain.SessionFeedbackRepository; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +36,7 @@ public class SessionFeedbackRequester { private static final Logger log = LoggerFactory.getLogger(SessionFeedbackRequester.class); + private static final ObjectMapper JSON = new ObjectMapper(); private final RabbitMessagePublisher publisher; private final RabbitMqProperties properties; @@ -37,6 +44,7 @@ public class SessionFeedbackRequester { private final InterviewMessageRepository messageRepository; private final SessionContextRepository contextRepository; private final SessionFeedbackRepository feedbackRepository; + private final MessageVoiceAnalysisRepository voiceAnalysisRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -66,7 +74,8 @@ public void onSessionEnded(SessionEndedEvent event) { session.getTotalQuestionCount(), event.reason(), messages, - contextDocumentIds + contextDocumentIds, + summarizeVoiceAnalysis(event.sessionId()) ); publisher.publishToAi( @@ -88,4 +97,56 @@ private MessageItem toItem(InterviewMessage m) { parentId ); } + + private VoiceAnalysisSummary summarizeVoiceAnalysis(Long sessionId) { + List analyses = voiceAnalysisRepository.findByMessage_Session_Id(sessionId); + if (analyses.isEmpty()) { + return new VoiceAnalysisSummary(0, null, 0.0, Map.of()); + } + + double speakingRateTotal = 0.0; + int speakingRateCount = 0; + double silenceTotal = 0.0; + Map fillerCounts = new TreeMap<>(); + + for (MessageVoiceAnalysis analysis : analyses) { + if (analysis.getSpeakingRateWpm() != null) { + speakingRateTotal += analysis.getSpeakingRateWpm(); + speakingRateCount++; + } + if (analysis.getSilenceDurationSec() != null) { + silenceTotal += analysis.getSilenceDurationSec(); + } + mergeFillerCounts(fillerCounts, analysis.getFillerWordCounts()); + } + + Double averageSpeakingRate = speakingRateCount == 0 ? null : speakingRateTotal / speakingRateCount; + return new VoiceAnalysisSummary(analyses.size(), averageSpeakingRate, silenceTotal, fillerCounts); + } + + private void mergeFillerCounts(Map totals, String fillerWordCountsJson) { + if (fillerWordCountsJson == null || fillerWordCountsJson.isBlank()) { + return; + } + try { + JsonNode root = JSON.readTree(fillerWordCountsJson); + if (!root.isObject()) { + return; + } + root.fields().forEachRemaining(entry -> { + String word = entry.getKey(); + JsonNode value = entry.getValue(); + if (word == null || word.isBlank() || !value.canConvertToInt()) { + return; + } + int count = value.asInt(); + if (count <= 0) { + return; + } + totals.merge(word, count, Integer::sum); + }); + } catch (Exception e) { + log.warn("filler_word_counts json parse failed. raw={}", fillerWordCountsJson, e); + } + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java index 84d5227f..ef59ea49 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java @@ -2,7 +2,7 @@ import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope; import com.stackup.stackup.session.application.dto.TtsCallbackPayload; @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,7 +28,7 @@ public class TtsCallbackService { private final InterviewMessageRepository messageRepository; private final ProcessedMessageRepository processedMessageRepository; - private final SseEventPublisher sseEventPublisher; + private final ApplicationEventPublisher events; @Transactional public void apply(TtsCallbackEnvelope envelope) { @@ -94,9 +95,9 @@ private void publishNotice(InterviewMessage message, String errorCode) { message.getTtsDurationSec(), errorCode ); - sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice); - sseEventPublisher.publishToUser(message.getSession().getUser().getId(), - SseEventType.SESSION_MESSAGE, notice); + events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.SESSION_MESSAGE, notice)); + events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(), + SseEventType.SESSION_MESSAGE, notice)); } private boolean isProcessed(String messageId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java index 5ff90679..ca0b03a7 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java @@ -1,25 +1,35 @@ package com.stackup.stackup.session.application.dto; import java.util.List; +import java.util.Map; -// generate.feedback envelope payload (Core → AI). -// AI 가 세션 메시지/평가/분석문서 컨텍스트 기반으로 종합 피드백을 생성. +// generate.feedback envelope payload (Core -> AI). +// AI generates comprehensive feedback from session messages, evaluation context, documents, and voice summary. public record GenerateFeedbackPayload( Long sessionId, String mode, String jobCategory, Integer totalQuestionCount, - String endReason, // USER_REQUEST | MAX_QUESTIONS_REACHED + String endReason, List messages, - List contextDocumentIds // pgvector RAG 검색용 — AI 가 search API 호출 시 사용 + List contextDocumentIds, + VoiceAnalysisSummary voiceAnalysisSummary ) { public record MessageItem( Long id, Integer sequenceNumber, - String role, // INTERVIEWER | INTERVIEWEE + String role, String content, Long parentMessageId ) { } + + public record VoiceAnalysisSummary( + int analyzedMessageCount, + Double averageSpeakingRateWpm, + Double totalSilenceDurationSec, + Map fillerWordCounts + ) { + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java index de840eca..fb933701 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java @@ -1,5 +1,6 @@ package com.stackup.stackup.session.domain; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,4 +9,6 @@ public interface MessageVoiceAnalysisRepository extends JpaRepository findByMessage_Id(Long messageId); boolean existsByMessage_Id(Long messageId); + + List findByMessage_Session_Id(Long sessionId); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java index f833d00f..c3d9d629 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java @@ -1,6 +1,7 @@ package com.stackup.stackup.session.presentation.dto; import com.stackup.stackup.session.application.dto.FeedbackResult; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; @@ -13,7 +14,9 @@ public record FeedbackResponse( Double communicationScore, String strengthsSummary, String weaknessesSummary, + @Schema(description = "Improvement keywords returned by AI. The current contract is a string list.") List improvementKeywords, + @Schema(description = "Stored report path when AI generates a detailed learning guide/report.") String reportFilePath, Instant createdAt ) { diff --git a/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java b/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java new file mode 100644 index 00000000..b7da0d77 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java @@ -0,0 +1,76 @@ +package com.stackup.stackup.document.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult; +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentStatus; +import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; +import com.stackup.stackup.resume.domain.Resume; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnalyzedDocumentCascadeListenerTest { + + @Mock AnalyzedDocumentRepository documentRepository; + @Mock ObjectStorageClient storage; + @InjectMocks AnalyzedDocumentCascadeListener listener; + + @Test + void resumeDeleted_softDeletesRelatedAnalyzedDocuments() { + AnalyzedDocument first = AnalyzedDocument.forResume(mock(Resume.class)); + AnalyzedDocument second = AnalyzedDocument.forResume(mock(Resume.class)); + when(documentRepository.findActiveByResumeIdAndOwner(10L, 1L)).thenReturn(List.of(first, second)); + + listener.on(new ResumeDeletedEvent(1L, 10L)); + + assertThat(first.isDeleted()).isTrue(); + assertThat(second.isDeleted()).isTrue(); + } + + @Test + void repositoryDeleted_softDeletesRelatedAnalyzedDocuments() { + AnalyzedDocument document = AnalyzedDocument.forRepository(mock(GithubRepository.class)); + when(documentRepository.findActiveByRepositoryIdAndOwner(20L, 1L)).thenReturn(List.of(document)); + + listener.on(new RepositoryDeletedEvent(1L, 20L)); + + assertThat(document.isDeleted()).isTrue(); + } + + @Test + void listForUser_usesActiveDocumentQuerySoDeletedDocumentsAreExcluded() { + AnalyzedDocumentQueryService queryService = new AnalyzedDocumentQueryService(documentRepository, storage); + AnalyzedDocument active = mockDocument(); + when(documentRepository.findActiveByOwner(1L)).thenReturn(List.of(active)); + + List results = queryService.listForUser(1L, null, null); + + assertThat(results).hasSize(1); + verify(documentRepository).findActiveByOwner(1L); + verify(documentRepository, never()).findByResume_User_IdOrRepository_User_Id(1L, 1L); + } + + private AnalyzedDocument mockDocument() { + AnalyzedDocument document = mock(AnalyzedDocument.class); + when(document.getTechStack()).thenReturn("[\"Java\"]"); + when(document.getEmbeddingChunkCount()).thenReturn(1); + when(document.getAnalysisStatus()).thenReturn(AnalysisStatus.ANALYZED); + when(document.getStatus()).thenReturn(DocumentStatus.ACTIVE); + return document; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java b/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java index eb0a7de3..0cd86cc1 100644 --- a/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java @@ -19,6 +19,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -60,4 +62,23 @@ void record_fallsBackToFailedOnUnknownStatus() { verify(logRepository).save(cap.capture()); assertThat(cap.getValue().getStatus()).isEqualTo(AiRequestStatus.FAILED); } + + @ParameterizedTest + @ValueSource(strings = { + "generate.questions", + "generate.followup", + "generate.feedback", + "stt.transcribe", + "tts.synthesize" + }) + void record_keepsAiWorkflowRequestType(String requestType) { + service.record(new AiRequestLogCommand( + null, null, requestType, "gemini-flash", + 10, 20, 800, "SUCCESS", null + )); + + ArgumentCaptor cap = ArgumentCaptor.forClass(AiRequestLog.class); + verify(logRepository).save(cap.capture()); + assertThat(cap.getValue().getRequestType()).isEqualTo(requestType); + } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index 6c1d72c8..9f39a6a9 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -11,20 +11,25 @@ import com.stackup.stackup.common.messaging.MessageContext; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload; +import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.VoiceAnalysisSummary; import com.stackup.stackup.session.application.event.SessionEndedEvent; 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.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageVoiceAnalysis; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.user.domain.User; import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.DisplayName; 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; @@ -39,6 +44,7 @@ class SessionFeedbackRequesterTest { @Mock InterviewMessageRepository messageRepository; @Mock SessionContextRepository contextRepository; @Mock SessionFeedbackRepository feedbackRepository; + @Mock MessageVoiceAnalysisRepository voiceAnalysisRepository; @InjectMocks SessionFeedbackRequester requester; @Test @@ -53,13 +59,60 @@ void onSessionEnded_publishesGenerateFeedback() { when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); when(messageRepository.findBySession_IdOrderBySequenceNumberAsc(50L)).thenReturn(List.of(q, a)); when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(voiceAnalysisRepository.findByMessage_Session_Id(50L)).thenReturn(List.of( + MessageVoiceAnalysis.of(a, 120.0, 1.5, "{\"um\":2,\"uh\":1}", 0.91), + MessageVoiceAnalysis.of(q, 180.0, 2.0, "{\"um\":3,\"like\":1}", 0.95) + )); RabbitMqProperties.RoutingKeyProperties rk = mockRoutingKeys(); when(properties.routingKeys()).thenReturn(rk); requester.onSessionEnded(new SessionEndedEvent(1L, 50L, "MAX_QUESTIONS_REACHED")); - verify(publisher).publishToAi(eq("generate.feedback"), any(GenerateFeedbackPayload.class), - any(MessageContext.class)); + ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(GenerateFeedbackPayload.class); + verify(publisher).publishToAi(eq("generate.feedback"), payloadCaptor.capture(), any(MessageContext.class)); + + GenerateFeedbackPayload payload = payloadCaptor.getValue(); + assertThat(payload.messages()).hasSize(2); + VoiceAnalysisSummary summary = payload.voiceAnalysisSummary(); + assertThat(summary.analyzedMessageCount()).isEqualTo(2); + assertThat(summary.averageSpeakingRateWpm()).isEqualTo(150.0); + assertThat(summary.totalSilenceDurationSec()).isEqualTo(3.5); + assertThat(summary.fillerWordCounts()).containsExactlyEntriesOf(java.util.Map.of( + "like", 1, + "uh", 1, + "um", 5 + )); + } + + @Test + @DisplayName("generate.feedback voice summary ignores malformed filler json but keeps aggregate metrics") + void onSessionEnded_ignoresMalformedFillerJson() { + InterviewSession session = sessionFixture(50L); + InterviewMessage a = InterviewMessage.interviewee(session, 1, "A", null, null); + ReflectionTestUtils.setField(a, "id", 101L); + + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(messageRepository.findBySession_IdOrderBySequenceNumberAsc(50L)).thenReturn(List.of(a)); + when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(voiceAnalysisRepository.findByMessage_Session_Id(50L)).thenReturn(List.of( + MessageVoiceAnalysis.of(a, null, null, "not-json", null) + )); + RabbitMqProperties.RoutingKeyProperties rk = mockRoutingKeys(); + when(properties.routingKeys()).thenReturn(rk); + + requester.onSessionEnded(new SessionEndedEvent(1L, 50L, "USER_REQUEST")); + + ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(GenerateFeedbackPayload.class); + verify(publisher).publishToAi(eq("generate.feedback"), payloadCaptor.capture(), any(MessageContext.class)); + + VoiceAnalysisSummary summary = payloadCaptor.getValue().voiceAnalysisSummary(); + assertThat(summary.analyzedMessageCount()).isEqualTo(1); + assertThat(summary.averageSpeakingRateWpm()).isNull(); + assertThat(summary.totalSilenceDurationSec()).isEqualTo(0.0); + assertThat(summary.fillerWordCounts()).isEmpty(); } @Test diff --git a/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java index d2e40ade..dd8ba1b7 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java @@ -2,15 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.MessageResult; import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope; @@ -28,6 +28,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -37,7 +38,7 @@ class TtsCallbackServiceTest { @Mock InterviewMessageRepository messageRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock SseEventPublisher sseEventPublisher; + @Mock ApplicationEventPublisher events; @InjectMocks TtsCallbackService service; @Test @@ -57,8 +58,10 @@ void apply_successStoresTtsFieldsWithoutChangingMessageStatus() { assertThat(question.getTtsAudioPath()).isEqualTo("interview/tts/50/100.mp3"); assertThat(question.getTtsDurationSec()).isEqualTo(2.4); assertThat(MessageResult.of(question).ttsAudioPath()).isEqualTo("interview/tts/50/100.mp3"); - verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); - verify(sseEventPublisher).publishToUser(eq(1L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(events).publishEvent(argThat(event -> + isNotice(event, RealtimeNotifyEvent.Channel.SESSION, 50L))); + verify(events).publishEvent(argThat(event -> + isNotice(event, RealtimeNotifyEvent.Channel.USER, 1L))); verify(processedMessageRepository).save(any(ProcessedMessage.class)); } @@ -78,7 +81,8 @@ void apply_failedMarksOnlyTtsFailedAndLeavesQuestionUsable() { assertThat(question.getTtsStatus()).isEqualTo(TtsStatus.FAILED); assertThat(question.getContent()).isEqualTo("Q?"); assertThat(question.getTtsAudioPath()).isNull(); - verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(events).publishEvent(argThat(event -> + isNotice(event, RealtimeNotifyEvent.Channel.SESSION, 50L))); verify(processedMessageRepository).save(any(ProcessedMessage.class)); } @@ -96,7 +100,7 @@ void apply_ignoresIntervieweeMessages() { ))); assertThat(answer.getTtsStatus()).isEqualTo(TtsStatus.NOT_REQUESTED); - verify(sseEventPublisher, never()).publishToSession(any(), any(), any()); + verify(events, never()).publishEvent(any()); verify(processedMessageRepository).save(any(ProcessedMessage.class)); } @@ -132,6 +136,15 @@ private TtsCallbackEnvelope envelope(String messageId, TtsCallbackPayload payloa null, "ai", payload, null); } + private boolean isNotice(Object event, RealtimeNotifyEvent.Channel channel, Long id) { + if (!(event instanceof RealtimeNotifyEvent realtimeNotifyEvent)) { + return false; + } + return realtimeNotifyEvent.channel() == channel + && realtimeNotifyEvent.id().equals(id) + && realtimeNotifyEvent.type() == SseEventType.SESSION_MESSAGE; + } + private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L);