diff --git a/docs/gimin-#36-document-indexing-status.md b/docs/gimin-#36-document-indexing-status.md new file mode 100644 index 0000000..5a9786c --- /dev/null +++ b/docs/gimin-#36-document-indexing-status.md @@ -0,0 +1,204 @@ +# Issue #36 문서 인덱싱 상태 조회 API 설계 + +## 1. 목적 + +문서 업로드는 원본 파일과 `PENDING` 임베딩 작업을 생성한 뒤 즉시 응답한다. 실제 파싱, 청킹, +임베딩은 비동기로 진행되므로 클라이언트가 현재 검색 가능한 버전과 처리 중인 버전을 구분해서 +확인할 수 있는 조회 API가 필요하다. + +```http +GET /api/documents/{documentId}/status +Authorization: Bearer {token} +``` + +이번 이슈는 상태를 조회만 한다. Worker 실행, 상태 변경, 인덱싱 완료, 실패·재시도는 포함하지 않는다. + +## 2. 핵심 응답 계약 + +### 최초 버전 처리 중 + +최초 업로드에서는 `documents.current_version_id`가 Version 1을 가리키더라도 Version 1이 아직 +검색 가능한 상태는 아니다. 따라서 `currentVersion`은 `null`이고 Version 1은 +`processingVersion`으로 반환한다. + +```json +{ + "documentId": 10, + "documentStatus": "UPLOADED", + "currentVersion": null, + "processingVersion": { + "versionNo": 1, + "status": "UPLOADED", + "jobStatus": "PENDING" + } +} +``` + +### 새 버전 처리 중 + +Version 2가 처리되는 동안에는 기존 `INDEXED` Version 1을 검색 가능 버전으로 유지한다. + +```json +{ + "documentId": 10, + "documentStatus": "INDEXED", + "currentVersion": { + "versionNo": 1, + "status": "INDEXED" + }, + "processingVersion": { + "versionNo": 2, + "status": "PARSING", + "jobStatus": "PROCESSING" + } +} +``` + +### 처리 완료 + +처리 중 Version이 없으면 `processingVersion`은 `null`이다. + +```json +{ + "documentId": 10, + "documentStatus": "INDEXED", + "currentVersion": { + "versionNo": 2, + "status": "INDEXED" + }, + "processingVersion": null +} +``` + +## 3. 상태 판정 + +`currentVersion`은 `documents.current_version_id`가 가리키는 Version이 `INDEXED`일 때만 반환한다. +최초 Version이 `UPLOADED`, `PARSING`, `CHUNKED`, `EMBEDDING` 또는 `FAILED`이면 검색 가능한 +버전이 아니므로 `null`이다. + +처리 중 Version 상태는 기존 부분 유니크 인덱스의 조건과 동일하다. + +```text +UPLOADED +PARSING +CHUNKED +EMBEDDING +``` + +처리 중 Version에 연결된 활성 Job 상태는 다음 둘 중 하나다. + +```text +PENDING +PROCESSING +``` + +처리 중 Version은 있는데 활성 Job이 없거나 활성 Job이 중복되어 조회 행이 여러 개라면 정상 상태로 +숨기지 않고 `INDEXING_STATUS_INCONSISTENT` 오류로 처리한다. + +## 4. 조회 일관성 + +Document, 현재 Version, 처리 중 Version, EmbeddingJob을 각각 순차 조회하면 Worker가 상태를 바꾸는 +중간에 서로 다른 시점의 값이 섞일 수 있다. + +```text +Version 조회: PARSING +Job 조회: INDEXED +``` + +이를 방지하기 위해 하나의 JPQL Projection 쿼리로 다음 관계를 함께 조회한다. + +```text +Document +LEFT JOIN current DocumentVersion +LEFT JOIN processing DocumentVersion +LEFT JOIN active EmbeddingJob +``` + +Projection은 조회에 필요한 ID, 문서 상태, 버전 번호, 버전 상태, Job 상태만 선택한다. Entity 전체를 +Controller에 노출하지 않으며 조회 과정에서 Dirty Checking 대상 상태를 변경하지 않는다. + +## 5. 권한 + +상태 조회에는 기존 `PermissionQueryService.canReadDocument()`를 사용한다. + +```text +OWNER +PUBLIC +USER_CACHE +ROLE +DEPARTMENT +``` + +읽기 권한이 없으면 `PERMISSION_DENIED`를 반환하고 상태 Projection 조회를 실행하지 않는다. 존재하지 +않는 문서는 `DOCUMENT_NOT_FOUND`, soft delete된 문서도 `DOCUMENT_NOT_FOUND`로 처리한다. + +API는 기존 Security 설정의 `anyRequest().authenticated()` 적용을 받으므로 별도 Security 경로 변경은 +필요하지 않다. + +## 6. 구현 구조 + +```text +DocumentQueryController +→ DocumentQueryService + → PermissionQueryService.canReadDocument() + → DocumentRepository.findDocumentStatus() + → DocumentStatusConverter +→ DocumentStatusResponse +``` + +응답 DTO는 현재 검색 가능한 버전과 처리 중 버전의 필드 차이를 명확히 하기 위해 분리한다. + +```text +DocumentStatusResponse +├─ CurrentVersionStatusResponse +└─ ProcessingVersionStatusResponse +``` + +`ProcessingVersionStatusResponse`만 `jobStatus`를 포함한다. 외부 클라이언트가 내부 작업을 직접 +조작하지 않으므로 Version ID와 Job ID는 이번 응답에 포함하지 않는다. + +## 7. 오류 응답 + +| 상황 | HTTP | 오류 코드 | +|---|---:|---| +| 인증되지 않은 요청 | 401 | `COMMON-007` | +| 문서 없음 또는 삭제된 문서 | 404 | `DOCUMENT-001` | +| 문서 읽기 권한 없음 | 403 | `ROLE-002` | +| Version과 Job 상태 불일치 | 500 | `DOCUMENT-STATUS-001` | + +상태 불일치 오류는 외부에 DB 상세를 노출하지 않고 문서 ID와 조회 행 개수만 서버 오류 로그에 남긴다. + +## 8. 제외 범위와 후속 계약 + +- 인덱싱 완료 시 `current_version_id`를 교체하는 기능 +- 실패한 Version과 Job 상태 전환 +- 자동 재시도 및 Lock 만료 복구 +- FAILED Version 수동 재처리 +- 실패 Version 상세 조회 +- Worker, 파서, 청커, 임베딩 서버 호출 + +후속 인덱싱 완료 기능은 새 Version을 `INDEXED`로 만들고 `current_version_id`를 교체한다. 이 API는 +변경된 DB 상태를 같은 응답 계약으로 그대로 반환한다. 실패 상세는 실패·재시도 기능에서 별도 필드나 +조회 계약으로 확장한다. + +## 9. 테스트 + +- 최초 Version 처리 중 `currentVersion=null` +- Version 1 `INDEXED` 상태에서 `processingVersion=null` +- Version 1 검색 가능 상태를 유지하면서 Version 2 처리 상태 반환 +- 처리 중 Version과 활성 Job 상태 함께 반환 +- 처리 중 Version에 활성 Job이 없으면 상태 불일치 오류 +- 활성 Job이 중복되면 상태 불일치 오류 +- 읽기 권한 없음, 문서 없음, 삭제 문서 오류 +- 인증된 요청과 미인증 요청의 Controller 응답 +- 실제 OpenSQL 스키마에서 Projection 쿼리 검증 + +## 10. 완료 기준 + +- 읽기 권한이 있는 사용자가 문서 상태를 조회할 수 있다. +- 검색 가능한 `INDEXED` Version만 `currentVersion`으로 반환한다. +- 처리 중 Version과 Job 상태를 하나의 조회 스냅샷으로 반환한다. +- 새 Version 처리 중 기존 검색 가능 Version이 유지된다. +- 상태 불일치를 정상 응답으로 숨기지 않는다. +- 조회 과정에서 Document, Version, Job 상태를 변경하지 않는다. +- Repository, Service, Converter, Controller 테스트와 전체 빌드가 통과한다. diff --git a/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java b/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java new file mode 100644 index 0000000..79bc8ae --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java @@ -0,0 +1,40 @@ +package com.opensource.docgrid.domain.document.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Document", description = "문서 관련 API") +@RestController +@RequestMapping("/api/documents") +@RequiredArgsConstructor +public class DocumentQueryController { + + private final DocumentQueryService documentQueryService; + + @Operation( + summary = "문서 인덱싱 상태 조회", + description = "현재 검색 가능한 INDEXED 버전과 처리 중인 버전 및 임베딩 작업 상태를 함께 조회합니다. " + + "최초 버전이 아직 처리 중이면 currentVersion은 null입니다. 문서 읽기 권한이 필요합니다." + ) + @GetMapping("/{documentId}/status") + public ResponseEntity> getDocumentStatus( + @PathVariable Long documentId, + @Parameter(hidden = true) @CurrentUser Long userId + ) { + return ResponseUtils.ok(documentQueryService.getDocumentStatus(userId, documentId)); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverter.java b/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverter.java new file mode 100644 index 0000000..d8ca9ce --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverter.java @@ -0,0 +1,39 @@ +package com.opensource.docgrid.domain.document.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.document.dto.response.CurrentVersionStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.ProcessingVersionStatusResponse; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; + +@Component +public class DocumentStatusConverter { + + public DocumentStatusResponse toResponse(DocumentStatusProjection projection) { + CurrentVersionStatusResponse currentVersion = null; + if (projection.getCurrentVersionStatus() == DocumentVersionStatus.INDEXED) { + currentVersion = new CurrentVersionStatusResponse( + projection.getCurrentVersionNo(), + projection.getCurrentVersionStatus() + ); + } + + ProcessingVersionStatusResponse processingVersion = null; + if (projection.getProcessingVersionNo() != null) { + processingVersion = new ProcessingVersionStatusResponse( + projection.getProcessingVersionNo(), + projection.getProcessingVersionStatus(), + projection.getProcessingJobStatus() + ); + } + + return new DocumentStatusResponse( + projection.getDocumentId(), + projection.getDocumentStatus(), + currentVersion, + processingVersion + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentVersionStatusResponse.java b/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentVersionStatusResponse.java new file mode 100644 index 0000000..2f35e47 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentVersionStatusResponse.java @@ -0,0 +1,12 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "현재 검색 가능한 문서 버전 상태") +public record CurrentVersionStatusResponse( + @Schema(description = "문서 버전 번호") int versionNo, + @Schema(description = "문서 버전 상태", example = "INDEXED") DocumentVersionStatus status +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentStatusResponse.java b/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentStatusResponse.java new file mode 100644 index 0000000..5f5e1ba --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentStatusResponse.java @@ -0,0 +1,16 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import com.opensource.docgrid.domain.document.enums.DocumentStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "문서 인덱싱 상태") +public record DocumentStatusResponse( + @Schema(description = "문서 ID") Long documentId, + @Schema(description = "문서 상태") DocumentStatus documentStatus, + @Schema(description = "현재 검색 가능한 버전. 아직 검색 가능한 버전이 없으면 null", nullable = true) + CurrentVersionStatusResponse currentVersion, + @Schema(description = "현재 처리 중인 버전. 처리 중인 버전이 없으면 null", nullable = true) + ProcessingVersionStatusResponse processingVersion +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/dto/response/ProcessingVersionStatusResponse.java b/src/main/java/com/opensource/docgrid/domain/document/dto/response/ProcessingVersionStatusResponse.java new file mode 100644 index 0000000..d17d3fd --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/dto/response/ProcessingVersionStatusResponse.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "현재 처리 중인 문서 버전과 임베딩 작업 상태") +public record ProcessingVersionStatusResponse( + @Schema(description = "문서 버전 번호") int versionNo, + @Schema(description = "문서 버전 상태", example = "PARSING") DocumentVersionStatus status, + @Schema(description = "임베딩 작업 상태", example = "PROCESSING") EmbeddingJobStatus jobStatus +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index dcd768d..1c9b0eb 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.document.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Lock; @@ -11,6 +13,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; // A담당자 영역 — B담당자는 존재 확인 등 읽기 전용으로만 사용 public interface DocumentRepository extends JpaRepository { @@ -18,4 +22,28 @@ public interface DocumentRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT d FROM Document d WHERE d.id = :documentId") Optional findByIdForUpdate(@Param("documentId") Long documentId); + + @Query(""" + SELECT d.id AS documentId, + d.status AS documentStatus, + cv.versionNo AS currentVersionNo, + cv.status AS currentVersionStatus, + pv.versionNo AS processingVersionNo, + pv.status AS processingVersionStatus, + ej.status AS processingJobStatus + FROM Document d + LEFT JOIN d.currentVersion cv + LEFT JOIN DocumentVersion pv + ON pv.document = d + AND pv.status IN :processingVersionStatuses + LEFT JOIN EmbeddingJob ej + ON ej.documentVersion = pv + AND ej.status IN :activeJobStatuses + WHERE d.id = :documentId + """) + List findDocumentStatus( + @Param("documentId") Long documentId, + @Param("processingVersionStatuses") Collection processingVersionStatuses, + @Param("activeJobStatuses") Collection activeJobStatuses + ); } diff --git a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentStatusProjection.java b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentStatusProjection.java new file mode 100644 index 0000000..b9a276f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentStatusProjection.java @@ -0,0 +1,22 @@ +package com.opensource.docgrid.domain.document.repository; + +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; + +public interface DocumentStatusProjection { + + Long getDocumentId(); + + DocumentStatus getDocumentStatus(); + + Integer getCurrentVersionNo(); + + DocumentVersionStatus getCurrentVersionStatus(); + + Integer getProcessingVersionNo(); + + DocumentVersionStatus getProcessingVersionStatus(); + + EmbeddingJobStatus getProcessingJobStatus(); +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java b/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java new file mode 100644 index 0000000..3b66c53 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java @@ -0,0 +1,83 @@ +package com.opensource.docgrid.domain.document.service.query; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; +import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DocumentQueryService { + + private static final Set PROCESSING_VERSION_STATUSES = EnumSet.of( + DocumentVersionStatus.UPLOADED, + DocumentVersionStatus.PARSING, + DocumentVersionStatus.CHUNKED, + DocumentVersionStatus.EMBEDDING + ); + private static final Set ACTIVE_JOB_STATUSES = EnumSet.of( + EmbeddingJobStatus.PENDING, + EmbeddingJobStatus.PROCESSING + ); + + private final DocumentRepository documentRepository; + private final PermissionQueryService permissionQueryService; + private final DocumentStatusConverter documentStatusConverter; + + public DocumentStatusResponse getDocumentStatus(Long userId, Long documentId) { + if (!permissionQueryService.canReadDocument(userId, documentId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + List rows = documentRepository.findDocumentStatus( + documentId, + PROCESSING_VERSION_STATUSES, + ACTIVE_JOB_STATUSES + ); + if (rows.isEmpty()) { + throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); + } + if (rows.size() != 1 || isInconsistent(rows.get(0))) { + log.error("문서 인덱싱 상태가 일관되지 않습니다. documentId={}, rowCount={}", documentId, rows.size()); + throw new DocGridException(ErrorCode.INDEXING_STATUS_INCONSISTENT); + } + + DocumentStatusProjection projection = rows.get(0); + if (projection.getDocumentStatus() == DocumentStatus.DELETED) { + throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); + } + return documentStatusConverter.toResponse(projection); + } + + private boolean isInconsistent(DocumentStatusProjection projection) { + boolean hasCurrentVersionNo = projection.getCurrentVersionNo() != null; + boolean hasCurrentVersionStatus = projection.getCurrentVersionStatus() != null; + if (hasCurrentVersionNo != hasCurrentVersionStatus) { + return true; + } + + boolean hasProcessingVersionNo = projection.getProcessingVersionNo() != null; + boolean hasProcessingVersionStatus = projection.getProcessingVersionStatus() != null; + boolean hasProcessingJobStatus = projection.getProcessingJobStatus() != null; + return hasProcessingVersionNo != hasProcessingVersionStatus + || hasProcessingVersionNo != hasProcessingJobStatus; + } +} 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 36b19eb..3ac2b94 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -52,6 +52,9 @@ public enum ErrorCode { DOCUMENT_VERSION_TYPE_MISMATCH( HttpStatus.BAD_REQUEST, "DOCUMENT-VERSION-004", "기존 문서와 다른 파일 형식은 업로드할 수 없습니다." ), + INDEXING_STATUS_INCONSISTENT( + HttpStatus.INTERNAL_SERVER_ERROR, "DOCUMENT-STATUS-001", "문서 인덱싱 상태를 조회할 수 없습니다." + ), EMPTY_FILE(HttpStatus.BAD_REQUEST, "DOCUMENT-FILE-001", "빈 파일은 업로드할 수 없습니다."), FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "DOCUMENT-FILE-002", "파일 크기 제한을 초과했습니다."), UNSUPPORTED_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "DOCUMENT-FILE-003", "지원하지 않는 파일 확장자입니다."), diff --git a/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java b/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java new file mode 100644 index 0000000..d15dbd7 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java @@ -0,0 +1,119 @@ +package com.opensource.docgrid.domain.document.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +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.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.opensource.docgrid.domain.document.dto.response.CurrentVersionStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.ProcessingVersionStatusResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@WebMvcTest(DocumentQueryController.class) +@DisplayName("DocumentQueryController 테스트") +class DocumentQueryControllerTest { + + private static final String STATUS_URL = "/api/documents/{documentId}/status"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DocumentQueryService documentQueryService; + + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("인증된 사용자가 현재 버전과 처리 중 버전 상태를 조회한다") + void getDocumentStatus_returnsCurrentAndProcessingVersions() throws Exception { + DocumentStatusResponse response = new DocumentStatusResponse( + 10L, + DocumentStatus.INDEXED, + new CurrentVersionStatusResponse(1, DocumentVersionStatus.INDEXED), + new ProcessingVersionStatusResponse( + 2, DocumentVersionStatus.PARSING, EmbeddingJobStatus.PROCESSING + ) + ); + given(documentQueryService.getDocumentStatus(20L, 10L)).willReturn(response); + + mockMvc.perform(get(STATUS_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.documentId").value(10)) + .andExpect(jsonPath("$.data.documentStatus").value("INDEXED")) + .andExpect(jsonPath("$.data.currentVersion.versionNo").value(1)) + .andExpect(jsonPath("$.data.currentVersion.status").value("INDEXED")) + .andExpect(jsonPath("$.data.processingVersion.versionNo").value(2)) + .andExpect(jsonPath("$.data.processingVersion.status").value("PARSING")) + .andExpect(jsonPath("$.data.processingVersion.jobStatus").value("PROCESSING")); + } + + @Test + @DisplayName("최초 버전 처리 중에는 currentVersion을 null로 반환한다") + void getDocumentStatus_returnsNullCurrentVersion_when_initialVersionIsProcessing() throws Exception { + DocumentStatusResponse response = new DocumentStatusResponse( + 10L, + DocumentStatus.UPLOADED, + null, + new ProcessingVersionStatusResponse( + 1, DocumentVersionStatus.UPLOADED, EmbeddingJobStatus.PENDING + ) + ); + given(documentQueryService.getDocumentStatus(20L, 10L)).willReturn(response); + + mockMvc.perform(get(STATUS_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.currentVersion").doesNotExist()) + .andExpect(jsonPath("$.data.processingVersion.versionNo").value(1)) + .andExpect(jsonPath("$.data.processingVersion.jobStatus").value("PENDING")); + } + + @Test + @DisplayName("읽기 권한이 없으면 403을 반환한다") + void getDocumentStatus_returnsForbidden_when_readPermissionIsDenied() throws Exception { + given(documentQueryService.getDocumentStatus(20L, 10L)) + .willThrow(new DocGridException(ErrorCode.PERMISSION_DENIED)); + + mockMvc.perform(get(STATUS_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ROLE-002")); + } + + @Test + @DisplayName("인증되지 않은 사용자는 401을 반환한다") + void getDocumentStatus_returnsUnauthorized_when_userIsNotAuthenticated() throws Exception { + mockMvc.perform(get(STATUS_URL, 10L)) + .andExpect(status().isUnauthorized()); + } + + private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) { + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + "user", "password", List.of() + ); + authentication.setDetails(userId); + return authentication; + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverterTest.java b/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverterTest.java new file mode 100644 index 0000000..28c89c6 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentStatusConverterTest.java @@ -0,0 +1,82 @@ +package com.opensource.docgrid.domain.document.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +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 com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentStatusConverter 테스트") +class DocumentStatusConverterTest { + + @InjectMocks + private DocumentStatusConverter converter; + + @Mock + private DocumentStatusProjection projection; + + @Test + @DisplayName("검색 가능한 현재 버전과 처리 중 버전을 함께 변환한다") + void toResponse_convertsCurrentAndProcessingVersions() { + given(projection.getDocumentId()).willReturn(10L); + given(projection.getDocumentStatus()).willReturn(DocumentStatus.INDEXED); + given(projection.getCurrentVersionNo()).willReturn(1); + given(projection.getCurrentVersionStatus()).willReturn(DocumentVersionStatus.INDEXED); + given(projection.getProcessingVersionNo()).willReturn(2); + given(projection.getProcessingVersionStatus()).willReturn(DocumentVersionStatus.PARSING); + given(projection.getProcessingJobStatus()).willReturn(EmbeddingJobStatus.PROCESSING); + + DocumentStatusResponse response = converter.toResponse(projection); + + assertThat(response.documentId()).isEqualTo(10L); + assertThat(response.documentStatus()).isEqualTo(DocumentStatus.INDEXED); + assertThat(response.currentVersion().versionNo()).isEqualTo(1); + assertThat(response.currentVersion().status()).isEqualTo(DocumentVersionStatus.INDEXED); + assertThat(response.processingVersion().versionNo()).isEqualTo(2); + assertThat(response.processingVersion().status()).isEqualTo(DocumentVersionStatus.PARSING); + assertThat(response.processingVersion().jobStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + } + + @Test + @DisplayName("최초 버전이 처리 중이면 검색 가능한 현재 버전을 null로 변환한다") + void toResponse_returnsNullCurrentVersion_when_initialVersionIsProcessing() { + given(projection.getDocumentId()).willReturn(10L); + given(projection.getDocumentStatus()).willReturn(DocumentStatus.UPLOADED); + given(projection.getCurrentVersionStatus()).willReturn(DocumentVersionStatus.UPLOADED); + given(projection.getProcessingVersionNo()).willReturn(1); + given(projection.getProcessingVersionStatus()).willReturn(DocumentVersionStatus.UPLOADED); + given(projection.getProcessingJobStatus()).willReturn(EmbeddingJobStatus.PENDING); + + DocumentStatusResponse response = converter.toResponse(projection); + + assertThat(response.currentVersion()).isNull(); + assertThat(response.processingVersion().versionNo()).isEqualTo(1); + assertThat(response.processingVersion().jobStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + } + + @Test + @DisplayName("처리 중인 버전이 없으면 processingVersion을 null로 변환한다") + void toResponse_returnsNullProcessingVersion_when_noVersionIsProcessing() { + given(projection.getDocumentId()).willReturn(10L); + given(projection.getDocumentStatus()).willReturn(DocumentStatus.INDEXED); + given(projection.getCurrentVersionNo()).willReturn(1); + given(projection.getCurrentVersionStatus()).willReturn(DocumentVersionStatus.INDEXED); + given(projection.getProcessingVersionNo()).willReturn(null); + + DocumentStatusResponse response = converter.toResponse(projection); + + assertThat(response.currentVersion()).isNotNull(); + assertThat(response.processingVersion()).isNull(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentStatusRepositoryTest.java b/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentStatusRepositoryTest.java new file mode 100644 index 0000000..ee3ac3e --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentStatusRepositoryTest.java @@ -0,0 +1,184 @@ +package com.opensource.docgrid.domain.document.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; + +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.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.enums.DocumentSourceType; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.enums.VisibilityType; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("문서 상태 Projection Repository 테스트") +class DocumentStatusRepositoryTest { + + private static final EnumSet PROCESSING_VERSION_STATUSES = EnumSet.of( + DocumentVersionStatus.UPLOADED, + DocumentVersionStatus.PARSING, + DocumentVersionStatus.CHUNKED, + DocumentVersionStatus.EMBEDDING + ); + private static final EnumSet ACTIVE_JOB_STATUSES = EnumSet.of( + EmbeddingJobStatus.PENDING, + EmbeddingJobStatus.PROCESSING + ); + + @Autowired private DocumentRepository documentRepository; + @Autowired private DocumentVersionRepository documentVersionRepository; + @Autowired private EmbeddingJobRepository embeddingJobRepository; + @Autowired private EmbeddingModelRepository embeddingModelRepository; + @Autowired private UserRepository userRepository; + @Autowired private EntityManager entityManager; + + @Test + @DisplayName("최초 버전 처리 중에는 같은 버전을 current와 processing 상태로 조회한다") + void findDocumentStatus_returnsInitialProcessingVersion() { + User owner = saveOwner(); + Document document = saveDocument(owner, DocumentStatus.UPLOADED); + DocumentVersion version = saveVersion(document, owner, 1, DocumentVersionStatus.UPLOADED); + document.updateCurrentVersion(version); + saveJob(version, EmbeddingJobStatus.PENDING); + flushAndClear(); + + DocumentStatusProjection result = findSingleStatus(document.getId()); + + assertThat(result.getDocumentStatus()).isEqualTo(DocumentStatus.UPLOADED); + assertThat(result.getCurrentVersionNo()).isEqualTo(1); + assertThat(result.getCurrentVersionStatus()).isEqualTo(DocumentVersionStatus.UPLOADED); + assertThat(result.getProcessingVersionNo()).isEqualTo(1); + assertThat(result.getProcessingVersionStatus()).isEqualTo(DocumentVersionStatus.UPLOADED); + assertThat(result.getProcessingJobStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + } + + @Test + @DisplayName("새 버전 처리 중에는 기존 INDEXED 버전과 처리 중 버전을 함께 조회한다") + void findDocumentStatus_returnsCurrentAndProcessingVersions() { + User owner = saveOwner(); + Document document = saveDocument(owner, DocumentStatus.INDEXED); + DocumentVersion currentVersion = saveVersion(document, owner, 1, DocumentVersionStatus.INDEXED); + document.updateCurrentVersion(currentVersion); + DocumentVersion processingVersion = saveVersion(document, owner, 2, DocumentVersionStatus.PARSING); + saveJob(processingVersion, EmbeddingJobStatus.PROCESSING); + flushAndClear(); + + DocumentStatusProjection result = findSingleStatus(document.getId()); + + assertThat(result.getCurrentVersionNo()).isEqualTo(1); + assertThat(result.getCurrentVersionStatus()).isEqualTo(DocumentVersionStatus.INDEXED); + assertThat(result.getProcessingVersionNo()).isEqualTo(2); + assertThat(result.getProcessingVersionStatus()).isEqualTo(DocumentVersionStatus.PARSING); + assertThat(result.getProcessingJobStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + } + + @Test + @DisplayName("처리 중 버전이 없으면 processing 상태를 null로 조회한다") + void findDocumentStatus_returnsNullProcessingStatus_when_indexingIsComplete() { + User owner = saveOwner(); + Document document = saveDocument(owner, DocumentStatus.INDEXED); + DocumentVersion currentVersion = saveVersion(document, owner, 1, DocumentVersionStatus.INDEXED); + document.updateCurrentVersion(currentVersion); + flushAndClear(); + + DocumentStatusProjection result = findSingleStatus(document.getId()); + + assertThat(result.getCurrentVersionNo()).isEqualTo(1); + assertThat(result.getCurrentVersionStatus()).isEqualTo(DocumentVersionStatus.INDEXED); + assertThat(result.getProcessingVersionNo()).isNull(); + assertThat(result.getProcessingVersionStatus()).isNull(); + assertThat(result.getProcessingJobStatus()).isNull(); + } + + private User saveOwner() { + return userRepository.save( + User.builder() + .email("document-status-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("문서 상태 테스트 사용자") + .status(UserStatus.ACTIVE) + .build() + ); + } + + private Document saveDocument(User owner, DocumentStatus status) { + return documentRepository.save( + Document.builder() + .owner(owner) + .title("문서 상태 테스트") + .documentType(DocumentType.TXT) + .sourceType(DocumentSourceType.UPLOAD) + .status(status) + .visibility(VisibilityType.PRIVATE) + .build() + ); + } + + private DocumentVersion saveVersion( + Document document, + User owner, + int versionNo, + DocumentVersionStatus status + ) { + return documentVersionRepository.save( + DocumentVersion.builder() + .document(document) + .versionNo(versionNo) + .titleSnapshot(document.getTitle()) + .status(status) + .createdBy(owner) + .build() + ); + } + + private void saveJob(DocumentVersion version, EmbeddingJobStatus status) { + EmbeddingModel model = embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue().get(0); + embeddingJobRepository.save( + EmbeddingJob.builder() + .documentVersion(version) + .embeddingModel(model) + .status(status) + .priority(0) + .maxRetryCount(3) + .build() + ); + } + + private DocumentStatusProjection findSingleStatus(Long documentId) { + List rows = documentRepository.findDocumentStatus( + documentId, + PROCESSING_VERSION_STATUSES, + ACTIVE_JOB_STATUSES + ); + assertThat(rows).hasSize(1); + return rows.get(0); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java new file mode 100644 index 0000000..23767af --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java @@ -0,0 +1,142 @@ +package com.opensource.docgrid.domain.document.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.util.List; + +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 com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; +import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentQueryService 테스트") +class DocumentQueryServiceTest { + + private static final Long USER_ID = 10L; + private static final Long DOCUMENT_ID = 20L; + + @InjectMocks + private DocumentQueryService service; + + @Mock private DocumentRepository documentRepository; + @Mock private PermissionQueryService permissionQueryService; + @Mock private DocumentStatusConverter documentStatusConverter; + @Mock private DocumentStatusProjection projection; + + @Test + @DisplayName("읽기 권한이 있고 상태가 일관되면 문서 상태를 반환한다") + void getDocumentStatus_returnsResponse_when_statusIsConsistent() { + DocumentStatusResponse expected = new DocumentStatusResponse( + DOCUMENT_ID, DocumentStatus.INDEXED, null, null + ); + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findDocumentStatus( + org.mockito.ArgumentMatchers.eq(DOCUMENT_ID), anyCollection(), anyCollection() + )).willReturn(List.of(projection)); + given(projection.getCurrentVersionNo()).willReturn(1); + given(projection.getCurrentVersionStatus()).willReturn(DocumentVersionStatus.INDEXED); + given(projection.getProcessingVersionNo()).willReturn(null); + given(projection.getProcessingVersionStatus()).willReturn(null); + given(projection.getProcessingJobStatus()).willReturn(null); + given(documentStatusConverter.toResponse(projection)).willReturn(expected); + + DocumentStatusResponse result = service.getDocumentStatus(USER_ID, DOCUMENT_ID); + + assertThat(result).isEqualTo(expected); + } + + @Test + @DisplayName("문서 읽기 권한이 없으면 상태를 조회하지 않고 403 예외가 발생한다") + void getDocumentStatus_throws_when_readPermissionIsDenied() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(false); + + assertThatThrownBy(() -> service.getDocumentStatus(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + then(documentRepository).should(never()).findDocumentStatus( + org.mockito.ArgumentMatchers.anyLong(), anyCollection(), anyCollection() + ); + } + + @Test + @DisplayName("상태 조회 결과가 없으면 문서 없음 예외가 발생한다") + void getDocumentStatus_throws_when_documentDoesNotExist() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findDocumentStatus( + org.mockito.ArgumentMatchers.eq(DOCUMENT_ID), anyCollection(), anyCollection() + )).willReturn(List.of()); + + assertThatThrownBy(() -> service.getDocumentStatus(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); + } + + @Test + @DisplayName("처리 중 버전에 활성 Job이 없으면 상태 불일치 예외가 발생한다") + void getDocumentStatus_throws_when_processingJobIsMissing() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findDocumentStatus( + org.mockito.ArgumentMatchers.eq(DOCUMENT_ID), anyCollection(), anyCollection() + )).willReturn(List.of(projection)); + given(projection.getCurrentVersionNo()).willReturn(null); + given(projection.getCurrentVersionStatus()).willReturn(null); + given(projection.getProcessingVersionNo()).willReturn(2); + given(projection.getProcessingVersionStatus()).willReturn(DocumentVersionStatus.PARSING); + given(projection.getProcessingJobStatus()).willReturn(null); + + assertThatThrownBy(() -> service.getDocumentStatus(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INDEXING_STATUS_INCONSISTENT); + } + + @Test + @DisplayName("활성 Job이 중복되어 조회 행이 여러 개면 상태 불일치 예외가 발생한다") + void getDocumentStatus_throws_when_multipleStatusRowsExist() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findDocumentStatus( + org.mockito.ArgumentMatchers.eq(DOCUMENT_ID), anyCollection(), anyCollection() + )).willReturn(List.of(projection, projection)); + + assertThatThrownBy(() -> service.getDocumentStatus(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INDEXING_STATUS_INCONSISTENT); + } + + @Test + @DisplayName("삭제된 문서는 문서 없음 예외가 발생한다") + void getDocumentStatus_throws_when_documentIsDeleted() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findDocumentStatus( + org.mockito.ArgumentMatchers.eq(DOCUMENT_ID), anyCollection(), anyCollection() + )).willReturn(List.of(projection)); + given(projection.getDocumentStatus()).willReturn(DocumentStatus.DELETED); + given(projection.getCurrentVersionNo()).willReturn(null); + given(projection.getCurrentVersionStatus()).willReturn(null); + given(projection.getProcessingVersionNo()).willReturn(null); + given(projection.getProcessingVersionStatus()).willReturn(null); + given(projection.getProcessingJobStatus()).willReturn(null); + + assertThatThrownBy(() -> service.getDocumentStatus(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); + } +}