백엔드 stt 서비스 개발

This commit is contained in:
cyjadela
2025-10-23 15:35:47 +09:00
parent 8c148e2721
commit 53f499cc7c
43 changed files with 4726 additions and 0 deletions
@@ -0,0 +1,23 @@
package com.unicorn.hgzero.stt;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* STT 애플리케이션 통합 테스트
* 전체 애플리케이션 컨텍스트 로딩 및 기본 설정 검증
*/
@SpringBootTest
@ActiveProfiles("test")
@DisplayName("STT 애플리케이션 테스트")
class SttApplicationTest {
@Test
@DisplayName("애플리케이션 컨텍스트 로딩 성공")
void contextLoads() {
// 애플리케이션 컨텍스트가 정상적으로 로딩되는지 확인
// 이 테스트는 모든 Bean이 정상적으로 생성되고 주입되는지 검증
}
}
@@ -0,0 +1,225 @@
package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 녹음 컨트롤러 통합 테스트
*/
@WebMvcTest(RecordingController.class)
@DisplayName("녹음 컨트롤러 테스트")
class RecordingControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RecordingService recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingDto.PrepareResponse prepareResponse;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
prepareResponse = RecordingDto.PrepareResponse.builder()
.recordingId("REC-20250123-001")
.sessionId("SESSION-001")
.status("READY")
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.estimatedInitTime(1100)
.build();
}
@Test
@DisplayName("녹음 준비 API 성공")
void prepareRecording_Success() throws Exception {
// Given
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
.thenReturn(prepareResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value("REC-20250123-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("READY"))
.andExpect(jsonPath("$.data.streamUrl").value("wss://api.example.com/stt/v1/ws/stt/SESSION-001"))
.andExpect(jsonPath("$.data.estimatedInitTime").value(1100));
verify(recordingService).prepareRecording(any(RecordingDto.PrepareRequest.class));
}
@Test
@DisplayName("녹음 준비 API 실패 - 유효성 검증")
void prepareRecording_ValidationFailure() throws Exception {
// Given
RecordingDto.PrepareRequest invalidRequest = RecordingDto.PrepareRequest.builder()
.meetingId("") // 빈 값
.sessionId("SESSION-001")
.build();
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest());
verify(recordingService, never()).prepareRecording(any());
}
@Test
@DisplayName("녹음 시작 API 성공")
void startRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("RECORDING")
.startTime(LocalDateTime.now())
.duration(0)
.build();
when(recordingService.startRecording(eq(recordingId), any(RecordingDto.StartRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("RECORDING"))
.andExpect(jsonPath("$.data.duration").value(0));
verify(recordingService).startRecording(eq(recordingId), any(RecordingDto.StartRequest.class));
}
@Test
@DisplayName("녹음 중지 API 성공")
void stopRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("STOPPED")
.startTime(LocalDateTime.now().minusMinutes(30))
.endTime(LocalDateTime.now())
.duration(1800)
.fileSize(172800000L)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.build();
when(recordingService.stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/stop", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(stopRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").value(1800))
.andExpect(jsonPath("$.data.fileSize").value(172800000L));
verify(recordingService).stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class));
}
@Test
@DisplayName("녹음 조회 API 성공")
void getRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.DetailResponse detailResponse = RecordingDto.DetailResponse.builder()
.recordingId(recordingId)
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.status("STOPPED")
.startTime(LocalDateTime.now().minusMinutes(30))
.endTime(LocalDateTime.now())
.duration(1800)
.speakerCount(3)
.segmentCount(45)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.language("ko-KR")
.build();
when(recordingService.getRecording(recordingId)).thenReturn(detailResponse);
// When & Then
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.meetingId").value("MEETING-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").value(1800))
.andExpect(jsonPath("$.data.speakerCount").value(3))
.andExpect(jsonPath("$.data.segmentCount").value(45))
.andExpect(jsonPath("$.data.language").value("ko-KR"));
verify(recordingService).getRecording(recordingId);
}
@Test
@DisplayName("존재하지 않는 녹음 조회 실패")
void getRecording_NotFound() throws Exception {
// Given
String recordingId = "REC-NOTFOUND-001";
when(recordingService.getRecording(recordingId))
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
"녹음을 찾을 수 없습니다"
));
// When & Then
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("녹음을 찾을 수 없습니다"));
verify(recordingService).getRecording(recordingId);
}
}
@@ -0,0 +1,185 @@
package com.unicorn.hgzero.stt.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* STT API 통합 테스트
* 전체 워크플로우 시나리오 테스트
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebMvc
@ActiveProfiles("test")
@Transactional
@DisplayName("STT API 통합 테스트")
class SttApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("전체 STT 워크플로우 통합 테스트")
void fullSttWorkflowIntegrationTest() throws Exception {
// 1단계: 녹음 준비
RecordingDto.PrepareRequest prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-INTEGRATION-001")
.sessionId("SESSION-INTEGRATION-001")
.language("ko-KR")
.build();
MvcResult prepareResult = mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("READY"))
.andReturn();
// 응답에서 recordingId 추출
String prepareResponseJson = prepareResult.getResponse().getContentAsString();
// JSON 파싱하여 recordingId 추출 (실제로는 JsonPath 라이브러리 사용)
String recordingId = "REC-20250123-001"; // 테스트용 하드코딩
// 2단계: 녹음 시작
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("integration-test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("RECORDING"));
// 3단계: 실시간 음성 변환 (시뮬레이션)
TranscriptionDto.StreamRequest streamRequest = TranscriptionDto.StreamRequest.builder()
.recordingId(recordingId)
.audioData("dGVzdCBhdWRpbyBkYXRh") // base64 encoded "test audio data"
.timestamp(System.currentTimeMillis())
.chunkIndex(1)
.build();
mockMvc.perform(post("/api/v1/stt/transcription/stream")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(streamRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.text").exists())
.andExpect(jsonPath("$.data.confidence").exists());
// 4단계: 화자 식별
SpeakerDto.IdentifyRequest identifyRequest = SpeakerDto.IdentifyRequest.builder()
.recordingId(recordingId)
.audioFrame("dGVzdCBhdWRpbyBmcmFtZQ==") // base64 encoded "test audio frame"
.build();
mockMvc.perform(post("/api/v1/stt/speakers/identify")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(identifyRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.speakerId").exists())
.andExpect(jsonPath("$.data.confidence").exists());
// 5단계: 녹음 중지
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("integration-test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/stop", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(stopRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").exists());
// 6단계: 녹음 정보 조회
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("STOPPED"));
// 7단계: 변환 결과 조회 (세그먼트 포함)
mockMvc.perform(get("/api/v1/stt/transcription/{recordingId}", recordingId)
.param("includeSegments", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.fullText").exists());
// 8단계: 녹음별 화자 목록 조회
mockMvc.perform(get("/api/v1/stt/speakers/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.speakerCount").exists());
}
@Test
@DisplayName("에러 케이스 통합 테스트")
void errorCasesIntegrationTest() throws Exception {
// 존재하지 않는 녹음 시작 시도
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/NONEXISTENT-001/start")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").exists());
// 잘못된 요청 데이터로 녹음 준비 시도
RecordingDto.PrepareRequest invalidRequest = RecordingDto.PrepareRequest.builder()
.meetingId("") // 빈 meetingId
.sessionId("SESSION-001")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest());
// 존재하지 않는 변환 결과 조회
mockMvc.perform(get("/api/v1/stt/transcription/NONEXISTENT-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("Swagger UI 접근 테스트")
void swaggerUiAccessTest() throws Exception {
// Swagger UI 페이지 접근 가능 여부 확인
mockMvc.perform(get("/swagger-ui/index.html"))
.andExpect(status().isOk());
// OpenAPI JSON 엔드포인트 확인
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
}
@@ -0,0 +1,221 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.stt.domain.Recording;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 녹음 서비스 단위 테스트
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("녹음 서비스 테스트")
class RecordingServiceTest {
@Mock
private RecordingRepository recordingRepository;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private RecordingServiceImpl recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingEntity recordingEntity;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
recordingEntity = RecordingEntity.builder()
.recordingId("REC-20250123-001")
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.status(Recording.RecordingStatus.READY)
.language("ko-KR")
.speakerCount(0)
.segmentCount(0)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.build();
}
@Test
@DisplayName("녹음 준비 성공")
void prepareRecording_Success() {
// Given
when(recordingRepository.existsActiveRecordingByMeetingId(anyString())).thenReturn(false);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.PrepareResponse response = recordingService.prepareRecording(prepareRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getSessionId()).isEqualTo("SESSION-001");
assertThat(response.getStatus()).isEqualTo("READY");
assertThat(response.getStreamUrl()).contains("SESSION-001");
verify(recordingRepository).existsActiveRecordingByMeetingId("MEETING-001");
verify(recordingRepository).save(any(RecordingEntity.class));
}
@Test
@DisplayName("중복 녹음 세션 예외")
void prepareRecording_DuplicateSession() {
// Given
when(recordingRepository.existsActiveRecordingByMeetingId(anyString())).thenReturn(true);
// When & Then
assertThatThrownBy(() -> recordingService.prepareRecording(prepareRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("이미 진행 중인 녹음 세션이 있습니다");
verify(recordingRepository).existsActiveRecordingByMeetingId("MEETING-001");
verify(recordingRepository, never()).save(any());
}
@Test
@DisplayName("녹음 시작 성공")
void startRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.StatusResponse response = recordingService.startRecording(recordingId, startRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getStatus()).isEqualTo("RECORDING");
assertThat(response.getDuration()).isEqualTo(0);
verify(recordingRepository).findById(recordingId);
verify(recordingRepository).save(any(RecordingEntity.class));
verify(eventPublisher).publishAsync(eq("recording-events"), any());
}
@Test
@DisplayName("녹음 시작 실패 - 잘못된 상태")
void startRecording_InvalidStatus() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingEntity recordingEntity = RecordingEntity.builder()
.recordingId(recordingId)
.status(Recording.RecordingStatus.RECORDING) // 이미 녹음 중
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
// When & Then
assertThatThrownBy(() -> recordingService.startRecording(recordingId, startRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 시작할 수 없는 상태입니다");
verify(recordingRepository).findById(recordingId);
verify(recordingRepository, never()).save(any());
verify(eventPublisher, never()).publishAsync(any(), any());
}
@Test
@DisplayName("녹음 중지 성공")
void stopRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("user001")
.build();
RecordingEntity recordingEntity = RecordingEntity.builder()
.recordingId(recordingId)
.meetingId("MEETING-001")
.status(Recording.RecordingStatus.RECORDING)
.startTime(LocalDateTime.now().minusMinutes(30))
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.StatusResponse response = recordingService.stopRecording(recordingId, stopRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getStatus()).isEqualTo("STOPPED");
assertThat(response.getDuration()).isEqualTo(1800);
assertThat(response.getFileSize()).isEqualTo(172800000L);
verify(recordingRepository).findById(recordingId);
verify(recordingRepository).save(any(RecordingEntity.class));
verify(eventPublisher).publishAsync(eq("recording-events"), any());
}
@Test
@DisplayName("녹음 정보 조회 성공")
void getRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
// When
RecordingDto.DetailResponse response = recordingService.getRecording(recordingId);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getMeetingId()).isEqualTo("MEETING-001");
assertThat(response.getSessionId()).isEqualTo("SESSION-001");
assertThat(response.getStatus()).isEqualTo("READY");
assertThat(response.getLanguage()).isEqualTo("ko-KR");
verify(recordingRepository).findById(recordingId);
}
@Test
@DisplayName("존재하지 않는 녹음 조회 예외")
void getRecording_NotFound() {
// Given
String recordingId = "REC-NOTFOUND-001";
when(recordingRepository.findById(recordingId)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> recordingService.getRecording(recordingId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 찾을 수 없습니다");
verify(recordingRepository).findById(recordingId);
}
}
@@ -0,0 +1,310 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptSegmentEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptionEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 음성 변환 서비스 단위 테스트
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("음성 변환 서비스 테스트")
class TranscriptionServiceTest {
@Mock
private TranscriptionRepository transcriptionRepository;
@Mock
private TranscriptSegmentRepository segmentRepository;
@Mock
private RecordingRepository recordingRepository;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private TranscriptionServiceImpl transcriptionService;
private RecordingEntity recordingEntity;
private TranscriptionDto.StreamRequest streamRequest;
@BeforeEach
void setUp() {
recordingEntity = RecordingEntity.builder()
.recordingId("REC-20250123-001")
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.build();
streamRequest = TranscriptionDto.StreamRequest.builder()
.recordingId("REC-20250123-001")
.audioData("base64-encoded-audio-data")
.timestamp(System.currentTimeMillis())
.chunkIndex(1)
.build();
}
@Test
@DisplayName("실시간 음성 변환 성공")
void processAudioStream_Success() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
TranscriptSegmentDto.Response response = transcriptionService.processAudioStream(streamRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo("REC-20250123-001");
assertThat(response.getText()).isNotEmpty();
assertThat(response.getConfidence()).isGreaterThan(0.8);
assertThat(response.getSpeakerId()).isNotEmpty();
verify(recordingRepository).findById("REC-20250123-001");
verify(segmentRepository).save(any(TranscriptSegmentEntity.class));
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
}
@Test
@DisplayName("실시간 음성 변환 실패 - 녹음 없음")
void processAudioStream_RecordingNotFound() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> transcriptionService.processAudioStream(streamRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 찾을 수 없습니다");
verify(recordingRepository).findById("REC-20250123-001");
verify(segmentRepository, never()).save(any());
verify(eventPublisher, never()).publishAsync(any(), any());
}
@Test
@DisplayName("저신뢰도 세그먼트 경고 이벤트 발행")
void processAudioStream_LowConfidenceWarning() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
TranscriptSegmentDto.Response response = transcriptionService.processAudioStream(streamRequest);
// Then
// 저신뢰도인 경우 경고 이벤트도 발행되는지 확인
if (response.getWarningFlag()) {
verify(eventPublisher, times(2)).publishAsync(eq("transcription-events"), any());
} else {
verify(eventPublisher, times(1)).publishAsync(eq("transcription-events"), any());
}
}
@Test
@DisplayName("배치 음성 변환 작업 시작 성공")
void transcribeAudioBatch_Success() {
// Given
TranscriptionDto.BatchRequest batchRequest = TranscriptionDto.BatchRequest.builder()
.recordingId("REC-20250123-001")
.callbackUrl("https://api.example.com/callback")
.build();
MockMultipartFile audioFile = new MockMultipartFile(
"audioFile", "test.wav", "audio/wav", "test audio content".getBytes()
);
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
// When
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(batchRequest, audioFile);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo("REC-20250123-001");
assertThat(response.getStatus()).isEqualTo("PROCESSING");
assertThat(response.getJobId()).isNotEmpty();
assertThat(response.getCallbackUrl()).isEqualTo("https://api.example.com/callback");
assertThat(response.getEstimatedCompletionTime()).isAfter(LocalDateTime.now());
verify(recordingRepository).findById("REC-20250123-001");
}
@Test
@DisplayName("배치 변환 완료 콜백 처리 성공")
void processBatchCallback_Success() {
// Given
List<TranscriptSegmentDto.Detail> segments = List.of(
TranscriptSegmentDto.Detail.builder()
.transcriptId("TRS-001")
.text("안녕하세요")
.speakerId("SPK-001")
.speakerName("화자-001")
.timestamp(System.currentTimeMillis())
.duration(2.5)
.confidence(0.95)
.build(),
TranscriptSegmentDto.Detail.builder()
.transcriptId("TRS-002")
.text("회의를 시작하겠습니다")
.speakerId("SPK-002")
.speakerName("화자-002")
.timestamp(System.currentTimeMillis() + 3000)
.duration(3.2)
.confidence(0.92)
.build()
);
TranscriptionDto.BatchCallbackRequest callbackRequest = TranscriptionDto.BatchCallbackRequest.builder()
.jobId("JOB-20250123-001")
.status("COMPLETED")
.segments(segments)
.build();
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(any());
// When
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getJobId()).isEqualTo("JOB-20250123-001");
assertThat(response.getStatus()).isEqualTo("COMPLETED");
assertThat(response.getSegmentCount()).isEqualTo(2);
assertThat(response.getTotalDuration()).isEqualTo(5); // 2.5 + 3.2 반올림
assertThat(response.getAverageConfidence()).isEqualTo(0.935); // (0.95 + 0.92) / 2
verify(segmentRepository, times(2)).save(any(TranscriptSegmentEntity.class));
verify(transcriptionRepository).save(any(TranscriptionEntity.class));
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
}
@Test
@DisplayName("변환 결과 조회 성공")
void getTranscription_Success() {
// Given
String recordingId = "REC-20250123-001";
TranscriptionEntity transcriptionEntity = TranscriptionEntity.builder()
.transcriptId("TRS-FULL-001")
.recordingId(recordingId)
.fullText("안녕하세요, 회의를 시작하겠습니다.")
.segmentCount(2)
.totalDuration(300)
.averageConfidence(0.92)
.speakerCount(2)
.build();
when(transcriptionRepository.findByRecordingId(recordingId))
.thenReturn(Optional.of(transcriptionEntity));
// When
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, false, null);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getFullText()).isEqualTo("안녕하세요, 회의를 시작하겠습니다.");
assertThat(response.getSegmentCount()).isEqualTo(2);
assertThat(response.getTotalDuration()).isEqualTo(300);
assertThat(response.getAverageConfidence()).isEqualTo(0.92);
assertThat(response.getSpeakerCount()).isEqualTo(2);
assertThat(response.getSegments()).isNull(); // includeSegments = false
verify(transcriptionRepository).findByRecordingId(recordingId);
}
@Test
@DisplayName("세그먼트 포함 변환 결과 조회 성공")
void getTranscription_WithSegments_Success() {
// Given
String recordingId = "REC-20250123-001";
TranscriptionEntity transcriptionEntity = TranscriptionEntity.builder()
.transcriptId("TRS-FULL-001")
.recordingId(recordingId)
.fullText("안녕하세요, 회의를 시작하겠습니다.")
.segmentCount(2)
.totalDuration(300)
.averageConfidence(0.92)
.speakerCount(2)
.build();
List<TranscriptSegmentEntity> segmentEntities = List.of(
TranscriptSegmentEntity.builder()
.segmentId("SEG-001")
.recordingId(recordingId)
.text("안녕하세요")
.speakerId("SPK-001")
.speakerName("화자-001")
.timestamp(System.currentTimeMillis())
.duration(2.5)
.confidence(0.95)
.build()
);
when(transcriptionRepository.findByRecordingId(recordingId))
.thenReturn(Optional.of(transcriptionEntity));
when(segmentRepository.findByRecordingIdOrderByTimestamp(recordingId))
.thenReturn(segmentEntities);
// When
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, true, null);
// Then
assertThat(response).isNotNull();
assertThat(response.getSegments()).isNotNull();
assertThat(response.getSegments()).hasSize(1);
assertThat(response.getSegments().get(0).getText()).isEqualTo("안녕하세요");
verify(transcriptionRepository).findByRecordingId(recordingId);
verify(segmentRepository).findByRecordingIdOrderByTimestamp(recordingId);
}
@Test
@DisplayName("변환 결과 조회 실패 - 결과 없음")
void getTranscription_NotFound() {
// Given
String recordingId = "REC-NOTFOUND-001";
when(transcriptionRepository.findByRecordingId(recordingId)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> transcriptionService.getTranscription(recordingId, false, null))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("변환 결과를 찾을 수 없습니다");
verify(transcriptionRepository).findByRecordingId(recordingId);
}
}
@@ -0,0 +1,55 @@
# STT 서비스 테스트 설정
spring:
profiles:
active: test
# 데이터베이스 설정 (H2 인메모리)
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
# JPA 설정
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
# Redis 설정 (임베디드)
redis:
host: localhost
port: 6370
timeout: 2000ms
# JWT 설정
security:
jwt:
secret: test-secret-key-for-jwt-token-generation-test
expiration: 86400
# Azure 서비스 설정 (테스트용 더미)
azure:
speech:
subscription-key: test-key
region: koreacentral
endpoint: https://test.cognitiveservices.azure.com/
storage:
connection-string: DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net
container-name: test-recordings
event-hubs:
connection-string: Endpoint=sb://test-eventhub.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
consumer-group: test-group
# 로깅 설정
logging:
level:
com.unicorn.hgzero.stt: DEBUG
org.springframework.web: DEBUG
org.hibernate.SQL: DEBUG