diff --git a/docs/design/kangcheolung-#44-search-embedding-query-logging.md b/docs/design/kangcheolung-#44-search-embedding-query-logging.md new file mode 100644 index 00000000..b5ed066a --- /dev/null +++ b/docs/design/kangcheolung-#44-search-embedding-query-logging.md @@ -0,0 +1,180 @@ +# #44 검색 블록 — 질문 임베딩 + 검색 요청 로깅 (F-SEARCH-01/02/03) + +closes #44 + +## 배경 + +벡터 검색의 첫 단계는 사용자의 검색어를 임베딩 모델과 동일한 차원의 벡터로 변환하는 것이다. +이번 이슈에서는 Python 사이드카 서버(`/embed`)를 호출해 질문을 1024차원 벡터로 변환하는 서비스(F-SEARCH-01/02)와, +검색 요청 자체를 `search_queries` 테이블에 `PROCESSING → SUCCESS/FAILED` 흐름으로 로그 남기는 서비스(F-SEARCH-03)를 구현한다. + +이 이슈는 서비스 레이어 "부품" 구현에 집중하며, `POST /search` API 조립은 Issue 5에서 진행한다. + +--- + +## 작업 내용 + +### 1. 기존 파일 수정 + +#### `ResultStatus.java` + +`PROCESSING` 값 추가. 검색 요청 저장 시 초기 상태로 사용한다. + +```java +public enum ResultStatus { + PROCESSING, // 추가 + SUCCESS, + FAILED +} +``` + +DB는 `VARCHAR(20)` CHECK 없음 → Flyway 마이그레이션 불필요. + +#### `ErrorCode.java` + +검색 블록 에러 코드 2종 추가. + +| 코드 | HTTP | 설명 | +|------|------|------| +| `EMBEDDING_SERVER_UNAVAILABLE` | 503 | Python 서버 타임아웃/연결 실패 | +| `EMBEDDING_DIMENSION_MISMATCH` | 500 | 응답 차원 ≠ 모델 설정 차원 | + +#### `application.yml` + +```yaml +embedding: + server: + base-url: ${EMBEDDING_SERVER_URL:http://localhost:8000} +``` + +#### `SearchQuery.java` + +dirty checking 기반 상태 갱신 메서드 추가. + +```java +public void updateToSuccess(int latencyMs) { + this.status = ResultStatus.SUCCESS; + this.latencyMs = latencyMs; +} + +public void updateToFailed(String errorMessage) { + this.status = ResultStatus.FAILED; + this.errorMessage = errorMessage; +} +``` + +명시적 `save()` 없이 트랜잭션 종료 시점에 dirty checking으로 자동 반영된다. + +--- + +### 2. EmbeddingServerConfig — RestClient Bean 등록 + +`global/config/EmbeddingServerConfig.java` + +```java +@Bean("embeddingRestClient") +public RestClient embeddingRestClient() { ... } +``` + +- JDK HttpClient 기반, connectTimeout = readTimeout = 5s +- `@Qualifier("embeddingRestClient")`로 주입해 다른 RestClient Bean과 충돌 방지 + +--- + +### 3. Python 서버 통신 DTO + +| 클래스 | 역할 | +|--------|------| +| `EmbedRequest(String text)` | `POST /embed` 요청 바디 | +| `EmbedServerResponse(float[] vector, int dimension)` | 응답 파싱 | +| `EmbedResult(EmbeddingModel model, float[] vector)` | 서비스 간 전달용 내부 record | + +`EmbedResult`는 Issue 5에서 `SearchQueryCommandService.createProcessing()`에 그대로 전달된다. + +--- + +### 4. QueryEmbeddingService (F-SEARCH-01/02) + +`domain/embedding/service/query/QueryEmbeddingService.java` + +**`embed(String text)` 처리 흐름:** + +``` +1. EmbeddingModelQueryService.getActiveModel() + └─ 0건 → EMBEDDING_MODEL_NOT_CONFIGURED (500) ← 기존 구현 재사용 + └─ 2건 이상 → MULTIPLE_ACTIVE_EMBEDDING_MODELS (500) ← 기존 구현 재사용 + +2. POST /embed { "text": text } 호출 + └─ RestClientException (타임아웃/연결 실패) → EMBEDDING_SERVER_UNAVAILABLE (503) + +3. 응답 차원 검증 + └─ response.dimension() != model.getDimension() → EMBEDDING_DIMENSION_MISMATCH (500) + +4. EmbedResult(model, vector) 반환 +``` + +- `@Transactional` 없음 — DB 접근 없이 외부 HTTP 호출만 수행 +- `RestClientException`으로 타임아웃/연결 거부를 통합 처리 → Spring 본체 격리 보장 + +--- + +### 5. SearchQueryCommandService (F-SEARCH-03) + +`domain/search/service/command/SearchQueryCommandService.java` + +#### `createProcessing()` + +``` +- user, collection(nullable), queryText, model, vector, topK 받아서 +- searchType = VECTOR 고정 (MVP) +- status = PROCESSING 으로 save +- 저장된 SearchQuery 반환 (query_id가 이후 search_results / RAG 블록의 부모 키) +``` + +#### `markSuccess(SearchQuery, int latencyMs)` + +``` +- status → SUCCESS +- latencyMs 갱신 +- dirty checking 자동 반영 (명시적 save 없음) +``` + +#### `markFailed(SearchQuery, String errorMessage)` + +``` +- status → FAILED +- errorMessage 갱신 +- dirty checking 자동 반영 +- 임베딩 서버 장애, 권한 오류 등 검색 전 단계 실패도 여기서 처리 +``` + +--- + +## 에러 케이스 정리 + +| 상황 | 예외 | HTTP | +|------|------|------| +| active 임베딩 모델 없음 | `EMBEDDING_MODEL_NOT_CONFIGURED` | 500 | +| active 임베딩 모델 2개 이상 | `MULTIPLE_ACTIVE_EMBEDDING_MODELS` | 500 | +| Python 서버 타임아웃/연결 실패 | `EMBEDDING_SERVER_UNAVAILABLE` | 503 | +| 응답 차원 불일치 | `EMBEDDING_DIMENSION_MISMATCH` | 500 | +| 빈 검색어 | `@NotBlank` 검증 (Controller 레이어, Issue 5에서 처리) | 400 | + +--- + +## 설계 결정 + +**`EmbedResult` 내부 record 분리** +`QueryEmbeddingService`가 `(model, vector)`를 함께 반환하도록 설계했다. +Issue 5에서 `SearchQueryCommandService.createProcessing()`을 호출할 때 model과 vector를 분리 전달하지 않아도 되므로 조립 코드가 단순해진다. + +**`markSuccess` / `markFailed`에 명시적 save 없음** +`SearchQueryCommandService`는 클래스 레벨 `@Transactional`이고, `SearchQuery`는 이미 영속 상태이므로 dirty checking으로 충분하다. +신규 엔티티 생성(`createProcessing`)에만 `save()`를 사용한다. + +**503 격리 원칙** +Python 서버 장애 시 `EMBEDDING_SERVER_UNAVAILABLE(503)`로 응답하되, Spring 애플리케이션 자체는 정상 동작을 유지한다. +`RestClientException`을 try-catch로 잡아 DocGridException으로 변환하는 방식으로 격리한다. + +**`search_type = VECTOR` 고정** +MVP는 dense vector 검색만 지원한다. `KEYWORD`, `HYBRID`는 Issue 명세상 2단계 확장 예정이므로 현재는 상수로 고정한다. diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/EmbedResult.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/EmbedResult.java new file mode 100644 index 00000000..a2ee43c1 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/EmbedResult.java @@ -0,0 +1,6 @@ +package com.opensource.docgrid.domain.embedding.dto; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; + +public record EmbedResult(EmbeddingModel model, float[] vector) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/EmbedRequest.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/EmbedRequest.java new file mode 100644 index 00000000..157dbf2a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/EmbedRequest.java @@ -0,0 +1,4 @@ +package com.opensource.docgrid.domain.embedding.dto.request; + +public record EmbedRequest(String text) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbedServerResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbedServerResponse.java new file mode 100644 index 00000000..5c365b3d --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbedServerResponse.java @@ -0,0 +1,4 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +public record EmbedServerResponse(float[] vector, int dimension) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingService.java new file mode 100644 index 00000000..e0b2f736 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingService.java @@ -0,0 +1,58 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClient; + +import com.opensource.docgrid.domain.embedding.dto.EmbedResult; +import com.opensource.docgrid.domain.embedding.dto.request.EmbedRequest; +import com.opensource.docgrid.domain.embedding.dto.response.EmbedServerResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class QueryEmbeddingService { + + private final EmbeddingModelQueryService embeddingModelQueryService; + private final RestClient restClient; + + public QueryEmbeddingService( + EmbeddingModelQueryService embeddingModelQueryService, + @Qualifier("embeddingRestClient") RestClient restClient + ) { + this.embeddingModelQueryService = embeddingModelQueryService; + this.restClient = restClient; + } + + public EmbedResult embed(String text) { + EmbeddingModel activeModel = embeddingModelQueryService.getActiveModel(); + + EmbedServerResponse response; + try { + response = restClient.post() + .uri("/embed") + .body(new EmbedRequest(text)) + .retrieve() + .body(EmbedServerResponse.class); + } catch (RestClientException e) { + log.error("임베딩 서버 호출 실패: {}", e.getMessage()); + throw new DocGridException(ErrorCode.EMBEDDING_SERVER_UNAVAILABLE); + } + + if (response == null + || response.vector() == null + || response.vector().length != activeModel.getDimension() + || response.dimension() != activeModel.getDimension()) { + log.error("임베딩 차원 불일치: expected={}, actual={}", + activeModel.getDimension(), response == null ? "null" : response.dimension()); + throw new DocGridException(ErrorCode.EMBEDDING_DIMENSION_MISMATCH); + } + + return new EmbedResult(activeModel, response.vector()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java b/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java index 43779357..95a0af37 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java +++ b/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java @@ -102,6 +102,16 @@ public class SearchQuery extends BaseEntity { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; + public void updateToSuccess(int latencyMs) { + this.status = ResultStatus.SUCCESS; + this.latencyMs = latencyMs; + } + + public void updateToFailed(String errorMessage) { + this.status = ResultStatus.FAILED; + this.errorMessage = errorMessage; + } + @Builder public SearchQuery(User user, DocumentCollection collection, String queryText, EmbeddingModel queryEmbeddingModel, float[] queryVector, SearchType searchType, int topK, diff --git a/src/main/java/com/opensource/docgrid/domain/search/enums/ResultStatus.java b/src/main/java/com/opensource/docgrid/domain/search/enums/ResultStatus.java index 703c84f8..3b2b9a57 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/enums/ResultStatus.java +++ b/src/main/java/com/opensource/docgrid/domain/search/enums/ResultStatus.java @@ -4,6 +4,7 @@ * 검색/RAG 응답 처리 결과 상태. */ public enum ResultStatus { + PROCESSING, SUCCESS, FAILED } diff --git a/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java b/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java new file mode 100644 index 00000000..df24792e --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java @@ -0,0 +1,8 @@ +package com.opensource.docgrid.domain.search.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.search.entity.SearchQuery; + +public interface SearchQueryRepository extends JpaRepository { +} diff --git a/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandService.java b/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandService.java new file mode 100644 index 00000000..1e51afe0 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandService.java @@ -0,0 +1,51 @@ +package com.opensource.docgrid.domain.search.service.command; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.enums.SearchType; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Transactional +@Service +@RequiredArgsConstructor +public class SearchQueryCommandService { + + private final SearchQueryRepository searchQueryRepository; + + public SearchQuery createProcessing( + User user, + DocumentCollection collection, + String queryText, + EmbeddingModel model, + float[] vector, + int topK + ) { + SearchQuery searchQuery = SearchQuery.builder() + .user(user) + .collection(collection) + .queryText(queryText) + .queryEmbeddingModel(model) + .queryVector(vector) + .searchType(SearchType.VECTOR) + .topK(topK) + .status(ResultStatus.PROCESSING) + .build(); + return searchQueryRepository.save(searchQuery); + } + + public void markSuccess(SearchQuery searchQuery, int latencyMs) { + searchQuery.updateToSuccess(latencyMs); + } + + public void markFailed(SearchQuery searchQuery, String errorMessage) { + searchQuery.updateToFailed(errorMessage); + } +} diff --git a/src/main/java/com/opensource/docgrid/global/config/EmbeddingServerConfig.java b/src/main/java/com/opensource/docgrid/global/config/EmbeddingServerConfig.java new file mode 100644 index 00000000..343c4999 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/global/config/EmbeddingServerConfig.java @@ -0,0 +1,31 @@ +package com.opensource.docgrid.global.config; + +import java.net.http.HttpClient; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +public class EmbeddingServerConfig { + + @Value("${embedding.server.base-url}") + private String baseUrl; + + @Bean("embeddingRestClient") + public RestClient embeddingRestClient() { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(5)); + + return RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(requestFactory) + .build(); + } +} diff --git a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 3ac2b940..891866eb 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -91,6 +91,18 @@ public enum ErrorCode { HttpStatus.INTERNAL_SERVER_ERROR, "EMBEDDING-MODEL-002", "사용 가능한 임베딩 모델이 여러 개 설정되어 있습니다." + ), + + // SEARCH + EMBEDDING_SERVER_UNAVAILABLE( + HttpStatus.SERVICE_UNAVAILABLE, + "SEARCH-001", + "임베딩 서버를 사용할 수 없습니다." + ), + EMBEDDING_DIMENSION_MISMATCH( + HttpStatus.INTERNAL_SERVER_ERROR, + "SEARCH-002", + "임베딩 차원이 설정된 모델과 일치하지 않습니다." ); private final HttpStatus httpStatus; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebcc3413..fd2bfb88 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,3 +27,7 @@ server: jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION:3600} + +embedding: + server: + base-url: ${EMBEDDING_SERVER_URL:http://localhost:8000} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingServiceTest.java new file mode 100644 index 00000000..095e6934 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/QueryEmbeddingServiceTest.java @@ -0,0 +1,89 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; + +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.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import com.opensource.docgrid.domain.embedding.dto.EmbedResult; +import com.opensource.docgrid.domain.embedding.dto.response.EmbedServerResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("QueryEmbeddingService 단위 테스트") +class QueryEmbeddingServiceTest { + + @Mock private EmbeddingModelQueryService embeddingModelQueryService; + @Mock private RestClient restClient; + @Mock(answer = Answers.RETURNS_SELF) private RestClient.RequestBodyUriSpec requestBodyUriSpec; + @Mock private RestClient.ResponseSpec responseSpec; + + private QueryEmbeddingService queryEmbeddingService; + + @BeforeEach + void setUp() { + queryEmbeddingService = new QueryEmbeddingService(embeddingModelQueryService, restClient); + doReturn(requestBodyUriSpec).when(restClient).post(); + doReturn(responseSpec).when(requestBodyUriSpec).retrieve(); + } + + @Test + @DisplayName("정상 케이스: 텍스트를 1024차원 벡터로 변환한다") + void embed_success() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + float[] vector = new float[EmbeddingModelFixture.DIMENSION]; + EmbedServerResponse serverResponse = new EmbedServerResponse(vector, EmbeddingModelFixture.DIMENSION); + + given(embeddingModelQueryService.getActiveModel()).willReturn(model); + given(responseSpec.body(EmbedServerResponse.class)).willReturn(serverResponse); + + EmbedResult result = queryEmbeddingService.embed("검색어"); + + assertThat(result.vector()).hasSize(EmbeddingModelFixture.DIMENSION); + assertThat(result.model()).isEqualTo(model); + } + + @Test + @DisplayName("차원 불일치: 응답 차원이 모델 차원과 다르면 EMBEDDING_DIMENSION_MISMATCH 예외가 발생한다") + void embed_dimensionMismatch_throwsException() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + EmbedServerResponse serverResponse = new EmbedServerResponse(new float[768], 768); + + given(embeddingModelQueryService.getActiveModel()).willReturn(model); + given(responseSpec.body(EmbedServerResponse.class)).willReturn(serverResponse); + + assertThatThrownBy(() -> queryEmbeddingService.embed("검색어")) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.EMBEDDING_DIMENSION_MISMATCH); + } + + @Test + @DisplayName("서버 장애: RestClientException 발생 시 EMBEDDING_SERVER_UNAVAILABLE 예외가 발생한다") + void embed_serverUnavailable_throwsException() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + + given(embeddingModelQueryService.getActiveModel()).willReturn(model); + given(responseSpec.body(EmbedServerResponse.class)) + .willThrow(new ResourceAccessException("Connection refused")); + + assertThatThrownBy(() -> queryEmbeddingService.embed("검색어")) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.EMBEDDING_SERVER_UNAVAILABLE); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/search/fixture/SearchQueryFixture.java b/src/test/java/com/opensource/docgrid/domain/search/fixture/SearchQueryFixture.java new file mode 100644 index 00000000..5ed5db8f --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/search/fixture/SearchQueryFixture.java @@ -0,0 +1,29 @@ +package com.opensource.docgrid.domain.search.fixture; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.enums.SearchType; + +public class SearchQueryFixture { + + public static final String QUERY_TEXT = "검색 테스트 질문"; + public static final int TOP_K = 5; + public static final float[] VECTOR = new float[1024]; + + private SearchQueryFixture() { + } + + public static SearchQuery createProcessing() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + return SearchQuery.builder() + .queryText(QUERY_TEXT) + .queryEmbeddingModel(model) + .queryVector(VECTOR) + .searchType(SearchType.VECTOR) + .topK(TOP_K) + .status(ResultStatus.PROCESSING) + .build(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandServiceTest.java new file mode 100644 index 00000000..8cf9336b --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchQueryCommandServiceTest.java @@ -0,0 +1,77 @@ +package com.opensource.docgrid.domain.search.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +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; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.enums.SearchType; +import com.opensource.docgrid.domain.search.fixture.SearchQueryFixture; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SearchQueryCommandService 단위 테스트") +class SearchQueryCommandServiceTest { + + @InjectMocks + private SearchQueryCommandService searchQueryCommandService; + + @Mock + private SearchQueryRepository searchQueryRepository; + + @Test + @DisplayName("createProcessing: PROCESSING 상태와 VECTOR 타입으로 SearchQuery를 저장한다") + void createProcessing_savesWithProcessingStatus() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + given(searchQueryRepository.save(any(SearchQuery.class))).willAnswer(i -> i.getArgument(0)); + + searchQueryCommandService.createProcessing( + null, null, SearchQueryFixture.QUERY_TEXT, + model, SearchQueryFixture.VECTOR, SearchQueryFixture.TOP_K + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + then(searchQueryRepository).should(times(1)).save(captor.capture()); + + SearchQuery saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(ResultStatus.PROCESSING); + assertThat(saved.getSearchType()).isEqualTo(SearchType.VECTOR); + assertThat(saved.getTopK()).isEqualTo(SearchQueryFixture.TOP_K); + assertThat(saved.getQueryText()).isEqualTo(SearchQueryFixture.QUERY_TEXT); + } + + @Test + @DisplayName("markSuccess: status가 SUCCESS로 갱신되고 latencyMs가 저장된다") + void markSuccess_updatesStatusAndLatency() { + SearchQuery searchQuery = SearchQueryFixture.createProcessing(); + + searchQueryCommandService.markSuccess(searchQuery, 120); + + assertThat(searchQuery.getStatus()).isEqualTo(ResultStatus.SUCCESS); + assertThat(searchQuery.getLatencyMs()).isEqualTo(120); + } + + @Test + @DisplayName("markFailed: status가 FAILED로 갱신되고 errorMessage가 저장된다") + void markFailed_updatesStatusAndErrorMessage() { + SearchQuery searchQuery = SearchQueryFixture.createProcessing(); + + searchQueryCommandService.markFailed(searchQuery, "임베딩 서버 연결 실패"); + + assertThat(searchQuery.getStatus()).isEqualTo(ResultStatus.FAILED); + assertThat(searchQuery.getErrorMessage()).isEqualTo("임베딩 서버 연결 실패"); + } +}