From db4ffeee390b3d6dc83e3f8d214b8f7f4eb8241c Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:49:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=20=EC=9D=8C=EC=84=B1=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9A=94=EC=95=BD=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/SessionFeedbackRequester.java | 65 ++++++++++++++++++- .../dto/GenerateFeedbackPayload.java | 20 ++++-- .../MessageVoiceAnalysisRepository.java | 3 + .../presentation/dto/FeedbackResponse.java | 3 + .../SessionFeedbackRequesterTest.java | 57 +++++++++++++++- 5 files changed, 139 insertions(+), 9 deletions(-) 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/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/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 From c83449c0e6f9a5a611e7d4268b18e48edbea93f2 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:49:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?teat:=20=EB=B6=84=EC=84=9D=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=82=AD=EC=A0=9C=20=EC=A0=95=EC=B1=85=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalyzedDocumentCascadeListenerTest.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java 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; + } +} From fed1845a253f2ff23ccc7b91bbacec74dcb03764 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:49:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20AI=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AiRequestLogServiceTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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); + } } From a32175fdbfdbad6a5bbeee8ab4e6fc7ac8c94659 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:50:30 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20TTS=20=EC=BD=9C=EB=B0=B1=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=9C=ED=96=89=20=EA=B2=BD=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/TtsCallbackService.java | 11 ++++---- .../application/TtsCallbackServiceTest.java | 27 ++++++++++++++----- 2 files changed, 26 insertions(+), 12 deletions(-) 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/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);