From a3d3e4bdb914858847cba5d510569d20c83b22f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 13 Aug 2026 17:19:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=C2=B7=EB=B3=B8=EB=AC=B8=C2=B7=EC=9B=90=EB=B3=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DocumentQueryController.java | 77 +++++++++ .../converter/DocumentDetailConverter.java | 57 +++++++ .../CurrentDocumentVersionResponse.java | 24 +++ .../dto/response/DocumentContentResponse.java | 17 ++ .../dto/response/DocumentDetailResponse.java | 32 ++++ .../repository/DocumentRepository.java | 16 ++ .../service/DocumentFileDownload.java | 13 ++ .../document/service/DocumentFileService.java | 44 +++++ .../service/query/DocumentFileSnapshot.java | 15 ++ .../service/query/DocumentQueryService.java | 141 ++++++++++++++++ .../docgrid/global/exception/ErrorCode.java | 5 + .../DocumentQueryControllerTest.java | 105 ++++++++++++ .../DocumentDetailConverterTest.java | 64 ++++++++ .../service/DocumentFileServiceTest.java | 66 ++++++++ .../query/DocumentQueryServiceTest.java | 153 ++++++++++++++++++ 15 files changed, 829 insertions(+) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverter.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentDocumentVersionResponse.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentContentResponse.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentDetailResponse.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileDownload.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileService.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentFileSnapshot.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverterTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/document/service/DocumentFileServiceTest.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java b/backend/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java index 1068157..5c600bb 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java @@ -1,6 +1,13 @@ package com.opensource.docgrid.domain.document.controller; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -9,9 +16,13 @@ import org.springframework.web.bind.annotation.RestController; import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.document.dto.response.DocumentContentResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.service.DocumentFileDownload; +import com.opensource.docgrid.domain.document.service.DocumentFileService; import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; import com.opensource.docgrid.global.common.response.PageResponse; @@ -22,6 +33,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; @Tag(name = "Document", description = "문서 관련 API") @@ -32,6 +44,7 @@ public class DocumentQueryController { private final DocumentQueryService documentQueryService; + private final DocumentFileService documentFileService; @Operation( summary = "내 문서 목록 조회", @@ -50,6 +63,59 @@ public ResponseEntity>> getMyD return ResponseUtils.ok(documentQueryService.getMyDocuments(userId, status, page, size)); } + @Operation( + summary = "문서 상세 조회", + description = "문서 Metadata, 소유자와 현재 버전 정보를 조회합니다. 추출 본문과 원본 파일은 포함하지 않습니다. " + + "문서 읽기 권한이 필요하며 삭제된 문서는 조회할 수 없습니다." + ) + @GetMapping("/{documentId}") + public ResponseEntity> getDocumentDetail( + @PathVariable Long documentId, + @Parameter(hidden = true) @CurrentUser Long userId + ) { + return ResponseUtils.ok(documentQueryService.getDocumentDetail(userId, documentId)); + } + + @Operation( + summary = "문서 추출 본문 조회", + description = "현재 버전의 Chunk 중복을 제거하고 페이지·섹션 순서대로 복원한 정규화 Text 전체를 반환합니다. " + + "원본 PDF·DOCX의 Layout, Image와 Font는 포함하지 않으며 문서 읽기 권한이 필요합니다." + ) + @GetMapping("/{documentId}/content") + public ResponseEntity> getDocumentContent( + @PathVariable Long documentId, + @Parameter(hidden = true) @CurrentUser Long userId + ) { + return ResponseUtils.ok(documentQueryService.getDocumentContent(userId, documentId)); + } + + @Operation( + summary = "문서 원본 파일 조회", + description = "현재 버전의 원본 PDF·DOCX·TXT 파일을 반환합니다. disposition은 inline 또는 attachment이며 " + + "기본값 inline은 브라우저 표시, attachment는 다운로드에 사용합니다. 문서 읽기 권한이 필요합니다." + ) + @GetMapping("/{documentId}/file") + public ResponseEntity getDocumentFile( + @PathVariable Long documentId, + @RequestParam(defaultValue = "inline") + @Pattern(regexp = "inline|attachment", message = "disposition은 inline 또는 attachment여야 합니다.") + String disposition, + @Parameter(hidden = true) @CurrentUser Long userId + ) { + // 1. Service가 권한을 검증하고 현재 버전의 원본 Byte와 안전한 파일 Metadata를 반환한다. + DocumentFileDownload download = documentFileService.getDocumentFile(userId, documentId); + + // 2. 저장소 내부 위치는 숨기고 브라우저 표시·다운로드에 필요한 표준 Header만 설정한다. + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(resolveMediaType(download.contentType())); + headers.setContentLength(download.fileSize()); + headers.setContentDisposition(ContentDisposition.builder(disposition) + .filename(download.originalFilename(), StandardCharsets.UTF_8) + .build()); + headers.setCacheControl("no-store"); + return ResponseEntity.ok().headers(headers).body(download.content()); + } + @Operation( summary = "문서 인덱싱 상태 조회", description = "현재 검색 가능한 INDEXED 버전과 처리 중인 버전 및 임베딩 작업 상태를 함께 조회합니다. " @@ -62,4 +128,15 @@ public ResponseEntity> getDocumentStatus( ) { return ResponseUtils.ok(documentQueryService.getDocumentStatus(userId, documentId)); } + + private MediaType resolveMediaType(String contentType) { + if (!StringUtils.hasText(contentType)) { + return MediaType.APPLICATION_OCTET_STREAM; + } + try { + return MediaType.parseMediaType(contentType); + } catch (InvalidMediaTypeException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverter.java new file mode 100644 index 0000000..df81610 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverter.java @@ -0,0 +1,57 @@ +package com.opensource.docgrid.domain.document.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.document.dto.response.CurrentDocumentVersionResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.entity.FileObject; + +/** + * 문서 Entity와 현재 버전 연관관계를 외부 상세 응답 DTO로 변환한다. + * 저장소 Bucket과 Object Key 같은 내부 파일 위치는 변환 경계 밖으로 노출하지 않는다. + */ +@Component +public class DocumentDetailConverter { + + public DocumentDetailResponse toResponse(Document document, boolean contentAvailable) { + return new DocumentDetailResponse( + document.getId(), + document.getTitle(), + document.getDescription(), + document.getDocumentType(), + document.getSourceType(), + document.getStatus(), + document.getVisibility(), + document.getOwner().getId(), + document.getOwner().getName(), + toCurrentVersionResponse(document.getCurrentVersion()), + contentAvailable, + document.getCreatedAt(), + document.getUpdatedAt() + ); + } + + private CurrentDocumentVersionResponse toCurrentVersionResponse(DocumentVersion documentVersion) { + if (documentVersion == null) { + return null; + } + + FileObject fileObject = documentVersion.getFileObject(); + return new CurrentDocumentVersionResponse( + documentVersion.getId(), + documentVersion.getVersionNo(), + documentVersion.getStatus(), + documentVersion.getOriginalFilename() != null + ? documentVersion.getOriginalFilename() + : fileObject != null ? fileObject.getOriginalFilename() : null, + documentVersion.getContentType() != null + ? documentVersion.getContentType() + : fileObject != null ? fileObject.getContentType() : null, + fileObject != null ? fileObject.getFileSize() : null, + documentVersion.getIndexedAt(), + documentVersion.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentDocumentVersionResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentDocumentVersionResponse.java new file mode 100644 index 0000000..b5429dc --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/CurrentDocumentVersionResponse.java @@ -0,0 +1,24 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 문서 상세 응답에서 현재 버전의 식별 정보와 원본 파일 Metadata만 노출한다. + * 처리 중인 별도 버전이나 저장소 내부 위치는 이 응답의 책임 범위에 포함하지 않는다. + */ +@Schema(description = "문서의 현재 버전 정보") +public record CurrentDocumentVersionResponse( + @Schema(description = "문서 버전 ID") Long documentVersionId, + @Schema(description = "버전 번호") int versionNo, + @Schema(description = "버전 상태") DocumentVersionStatus status, + @Schema(description = "원본 파일명") String originalFilename, + @Schema(description = "원본 파일 Content-Type") String contentType, + @Schema(description = "원본 파일 크기(Byte)") Long fileSize, + @Schema(description = "인덱싱 완료 시각") LocalDateTime indexedAt, + @Schema(description = "버전 생성 시각") LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentContentResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentContentResponse.java new file mode 100644 index 0000000..2828b1a --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentContentResponse.java @@ -0,0 +1,17 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 현재 문서 버전의 Chunk 중복을 제거해 복원한 정규화 Text 전체를 반환한다. + * 원본 PDF·DOCX의 Binary, Layout, Image와 Font 정보는 이 응답에 포함하지 않는다. + */ +@Schema(description = "문서에서 추출한 전체 텍스트") +public record DocumentContentResponse( + @Schema(description = "문서 ID") Long documentId, + @Schema(description = "본문을 복원한 문서 버전 ID") Long documentVersionId, + @Schema(description = "본문을 복원한 버전 번호") int versionNo, + @Schema(description = "중복을 제거하고 원래 순서로 복원한 전체 텍스트") String content, + @Schema(description = "본문 복원에 사용한 Chunk 수") int chunkCount +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentDetailResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentDetailResponse.java new file mode 100644 index 0000000..5e38321 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentDetailResponse.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import java.time.LocalDateTime; + +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.VisibilityType; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 문서 상세 화면에 필요한 Metadata와 현재 버전 요약을 반환한다. + * 추출 본문과 원본 파일 Byte는 별도 API 경계로 분리해 이 응답에 포함하지 않는다. + */ +@Schema(description = "문서 상세 정보") +public record DocumentDetailResponse( + @Schema(description = "문서 ID") Long documentId, + @Schema(description = "문서 제목") String title, + @Schema(description = "문서 설명") String description, + @Schema(description = "문서 형식") DocumentType documentType, + @Schema(description = "문서 생성 출처") DocumentSourceType sourceType, + @Schema(description = "문서 상태") DocumentStatus status, + @Schema(description = "공개 범위") VisibilityType visibility, + @Schema(description = "소유자 사용자 ID") Long ownerUserId, + @Schema(description = "소유자 이름") String ownerName, + @Schema(description = "현재 버전 정보, 현재 버전이 없으면 null") CurrentDocumentVersionResponse currentVersion, + @Schema(description = "현재 버전의 추출 본문 조회 가능 여부") boolean contentAvailable, + @Schema(description = "문서 생성 시각") LocalDateTime createdAt, + @Schema(description = "문서 마지막 수정 시각") LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index f9ef90f..fa5c9fd 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -45,6 +45,22 @@ public interface DocumentRepository extends JpaRepository { @Query("SELECT d FROM Document d LEFT JOIN FETCH d.currentVersion WHERE d.id = :documentId") Optional findByIdWithCurrentVersion(@Param("documentId") Long documentId); + /** + * 문서 상세·본문·원본 파일 조회에 필요한 소유자, 현재 버전과 파일 정보를 한 번에 조회한다. + * + * @param documentId 조회할 문서 식별자 + * @return 상세 조회에 필요한 연관관계가 초기화된 문서 + */ + @Query(""" + SELECT d + FROM Document d + JOIN FETCH d.owner + LEFT JOIN FETCH d.currentVersion currentVersion + LEFT JOIN FETCH currentVersion.fileObject + WHERE d.id = :documentId + """) + Optional findByIdWithDetail(@Param("documentId") Long documentId); + @Query(""" SELECT d.id AS documentId, d.status AS documentStatus, diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileDownload.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileDownload.java new file mode 100644 index 0000000..3ecf65b --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileDownload.java @@ -0,0 +1,13 @@ +package com.opensource.docgrid.domain.document.service; + +/** + * 권한 검증을 마친 문서 원본 Byte와 HTTP 응답에 필요한 파일 Metadata를 함께 전달한다. + * Content-Disposition 선택은 HTTP 계층의 책임이므로 이 값에 포함하지 않는다. + */ +public record DocumentFileDownload( + byte[] content, + String originalFilename, + String contentType, + long fileSize +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileService.java new file mode 100644 index 0000000..e00cadb --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/DocumentFileService.java @@ -0,0 +1,44 @@ +package com.opensource.docgrid.domain.document.service; + +import org.springframework.stereotype.Service; + +import com.opensource.docgrid.domain.document.service.query.DocumentFileSnapshot; +import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; +import com.opensource.docgrid.domain.document.storage.FileStorageService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 문서 원본 파일 조회 Snapshot과 Object Storage 읽기를 Transaction 밖에서 조정한다. + * 권한 및 현재 버전 선택은 Query Service에 위임하고 HTTP Header 조립은 Controller에 맡긴다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DocumentFileService { + + private final DocumentQueryService documentQueryService; + private final FileStorageService fileStorageService; + + public DocumentFileDownload getDocumentFile(Long userId, Long documentId) { + // 1. 짧은 DB Transaction에서 권한을 확인하고 현재 버전의 파일 위치를 Snapshot으로 고정한다. + DocumentFileSnapshot snapshot = documentQueryService.getDocumentFileSnapshot(userId, documentId); + + // 2. DB Transaction이 끝난 뒤 원본 전체를 읽어 Controller가 소유할 수 있는 Byte 배열로 반환한다. + byte[] content = fileStorageService.read(snapshot.storedFile()); + if (content.length != snapshot.fileSize()) { + log.error("원본 파일 크기가 Metadata와 일치하지 않습니다. expected={}, actual={}", + snapshot.fileSize(), content.length); + throw new DocGridException(ErrorCode.FILE_STORAGE_FAILED); + } + return new DocumentFileDownload( + content, + snapshot.originalFilename(), + snapshot.contentType(), + snapshot.fileSize() + ); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentFileSnapshot.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentFileSnapshot.java new file mode 100644 index 0000000..d5e11b8 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentFileSnapshot.java @@ -0,0 +1,15 @@ +package com.opensource.docgrid.domain.document.service.query; + +import com.opensource.docgrid.domain.document.storage.StoredFile; + +/** + * 원본 파일을 읽기 전에 짧은 DB Transaction에서 확정한 저장 위치와 응답 Metadata다. + * JPA Entity를 외부 저장소 I/O 구간으로 전달하지 않는 경계 역할만 담당한다. + */ +public record DocumentFileSnapshot( + StoredFile storedFile, + String originalFilename, + String contentType, + long fileSize +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java index 42659fe..bc0f290 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java @@ -11,15 +11,23 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.opensource.docgrid.domain.document.converter.DocumentDetailConverter; import com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; +import com.opensource.docgrid.domain.document.dto.response.DocumentContentResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentChunk; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.entity.FileObject; import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentChunkRepository; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; +import com.opensource.docgrid.domain.document.storage.StoredFile; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; import com.opensource.docgrid.global.common.response.PageResponse; @@ -45,6 +53,11 @@ public class DocumentQueryService { EmbeddingJobStatus.PENDING, EmbeddingJobStatus.PROCESSING ); + private static final Set CONTENT_EXPECTED_VERSION_STATUSES = EnumSet.of( + DocumentVersionStatus.CHUNKED, + DocumentVersionStatus.EMBEDDING, + DocumentVersionStatus.INDEXED + ); // 목록에는 삭제된 문서만 빼고 모두 노출한다. 인덱싱 중·실패한 문서도 진행 상황 확인 대상이다. private static final List LISTABLE_STATUSES = EnumSet.complementOf( EnumSet.of(DocumentStatus.DELETED) @@ -55,7 +68,9 @@ public class DocumentQueryService { ); private final DocumentRepository documentRepository; + private final DocumentChunkRepository documentChunkRepository; private final PermissionQueryService permissionQueryService; + private final DocumentDetailConverter documentDetailConverter; private final DocumentStatusConverter documentStatusConverter; private final DocumentSummaryConverter documentSummaryConverter; @@ -81,6 +96,78 @@ public PageResponse getMyDocuments( return PageResponse.from(documents, content); } + /** + * 읽기 가능한 문서의 Metadata와 현재 버전 요약을 반환한다. + */ + public DocumentDetailResponse getDocumentDetail(Long userId, Long documentId) { + // 1. 권한과 삭제 상태를 검증한 상세 조회용 Entity를 가져온다. + Document document = getReadableDocument(userId, documentId); + DocumentVersion currentVersion = document.getCurrentVersion(); + + // 2. 현재 버전에 저장된 Chunk Set이 있는지 별도 응답 상태로 계산한다. + boolean contentAvailable = currentVersion != null + && documentChunkRepository.existsByDocumentVersionId(currentVersion.getId()); + return documentDetailConverter.toResponse(document, contentAvailable); + } + + /** + * 현재 버전의 Chunk를 검증하고 중복을 제거해 정규화 Text 전체를 복원한다. + */ + public DocumentContentResponse getDocumentContent(Long userId, Long documentId) { + // 1. 읽기 가능한 문서와 현재 버전을 확정한다. + Document document = getReadableDocument(userId, documentId); + DocumentVersion currentVersion = document.getCurrentVersion(); + if (currentVersion == null) { + throw new DocGridException(ErrorCode.DOCUMENT_CONTENT_NOT_AVAILABLE); + } + + // 2. 저장 순서가 아니라 불변 Chunk Index 순서로 전체 Chunk Set을 조회한다. + List chunks = documentChunkRepository + .findAllByDocumentVersionIdOrderByChunkIndexAsc(currentVersion.getId()); + if (chunks.isEmpty()) { + if (CONTENT_EXPECTED_VERSION_STATUSES.contains(currentVersion.getStatus())) { + throw inconsistentChunks(currentVersion.getId(), "본문이 있어야 하는 상태지만 Chunk가 없습니다."); + } + throw new DocGridException(ErrorCode.DOCUMENT_CONTENT_NOT_AVAILABLE); + } + + // 3. 전역 Code Point Offset으로 중첩 구간과 Segment 사이 줄바꿈을 복원한다. + String content = restoreContent(currentVersion.getId(), chunks); + return new DocumentContentResponse( + document.getId(), + currentVersion.getId(), + currentVersion.getVersionNo(), + content, + chunks.size() + ); + } + + /** + * 원본 파일 I/O를 Transaction 밖에서 수행할 수 있도록 현재 버전의 저장 위치만 Snapshot으로 반환한다. + */ + public DocumentFileSnapshot getDocumentFileSnapshot(Long userId, Long documentId) { + // 1. 동일한 읽기 권한과 삭제 문서 차단 규칙을 원본 파일에도 적용한다. + Document document = getReadableDocument(userId, documentId); + DocumentVersion currentVersion = document.getCurrentVersion(); + FileObject fileObject = currentVersion != null ? currentVersion.getFileObject() : null; + if (currentVersion == null || fileObject == null) { + log.error("문서 현재 버전의 원본 파일 참조가 없습니다. documentId={}", documentId); + throw new DocGridException(ErrorCode.DOCUMENT_FILE_REFERENCE_MISSING); + } + + // 2. JPA Entity 대신 외부 저장소 조회에 필요한 불변 값만 Transaction 밖으로 전달한다. + return new DocumentFileSnapshot( + new StoredFile(fileObject.getBucketName(), fileObject.getObjectKey()), + currentVersion.getOriginalFilename() != null + ? currentVersion.getOriginalFilename() + : fileObject.getOriginalFilename(), + currentVersion.getContentType() != null + ? currentVersion.getContentType() + : fileObject.getContentType(), + fileObject.getFileSize() + ); + } + public DocumentStatusResponse getDocumentStatus(Long userId, Long documentId) { if (!permissionQueryService.canReadDocument(userId, documentId)) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); @@ -106,6 +193,60 @@ public DocumentStatusResponse getDocumentStatus(Long userId, Long documentId) { return documentStatusConverter.toResponse(projection); } + private Document getReadableDocument(Long userId, Long documentId) { + if (!permissionQueryService.canReadDocument(userId, documentId)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + Document document = documentRepository.findByIdWithDetail(documentId) + .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND)); + if (document.getStatus() == DocumentStatus.DELETED) { + throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); + } + return document; + } + + private String restoreContent(Long documentVersionId, List chunks) { + StringBuilder restored = new StringBuilder(); + int previousEnd = 0; + + for (int expectedIndex = 0; expectedIndex < chunks.size(); expectedIndex++) { + DocumentChunk chunk = chunks.get(expectedIndex); + int start = chunk.getCharStart(); + int end = chunk.getCharEnd(); + int[] codePoints = chunk.getChunkText().codePoints().toArray(); + + // 1. Index와 반열린 Code Point 범위가 Chunk Text와 일치해야 안전하게 중복을 제거할 수 있다. + if (chunk.getChunkIndex() != expectedIndex + || start < 0 + || end <= start + || end - start != codePoints.length + || (expectedIndex == 0 && start != 0)) { + throw inconsistentChunks(documentVersionId, "Chunk Index 또는 Offset이 올바르지 않습니다."); + } + + // 2. Segment 사이는 의도된 LF 한 칸만 허용하고 그보다 큰 유실 구간은 데이터 불일치로 차단한다. + if (start > previousEnd + 1 || end <= previousEnd) { + throw inconsistentChunks(documentVersionId, "Chunk 사이에 복원할 수 없는 범위가 있습니다."); + } + if (start == previousEnd + 1) { + restored.append('\n'); + } + + // 3. 이전 Chunk가 이미 포함한 Code Point만 건너뛰고 새 구간을 이어 붙인다. + int overlap = Math.max(0, previousEnd - start); + restored.append(new String(codePoints, overlap, codePoints.length - overlap)); + previousEnd = end; + } + return restored.toString(); + } + + private DocGridException inconsistentChunks(Long documentVersionId, String reason) { + log.error("문서 본문 Chunk 데이터가 일관되지 않습니다. documentVersionId={}, reason={}", + documentVersionId, reason); + return new DocGridException(ErrorCode.DOCUMENT_CHUNKS_INCONSISTENT); + } + private boolean isInconsistent(DocumentStatusProjection projection) { boolean hasCurrentVersionNo = projection.getCurrentVersionNo() != null; boolean hasCurrentVersionStatus = projection.getCurrentVersionStatus() != null; diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index ffb5c81..74c9732 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -102,6 +102,11 @@ public enum ErrorCode { "DOCUMENT-PARSING-002", "문서에서 처리할 텍스트를 찾을 수 없습니다." ), + DOCUMENT_CONTENT_NOT_AVAILABLE( + HttpStatus.CONFLICT, + "DOCUMENT-CONTENT-001", + "현재 문서 버전의 추출 본문을 아직 조회할 수 없습니다." + ), DOCUMENT_TEXT_DECODING_FAILED( HttpStatus.UNPROCESSABLE_ENTITY, "DOCUMENT-PARSING-003", diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java index d15dbd7..b18ad3b 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/controller/DocumentQueryControllerTest.java @@ -4,8 +4,12 @@ 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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -18,10 +22,17 @@ import org.springframework.test.web.servlet.MockMvc; import com.opensource.docgrid.domain.document.dto.response.CurrentVersionStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentContentResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; 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.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.document.service.DocumentFileDownload; +import com.opensource.docgrid.domain.document.service.DocumentFileService; import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.global.exception.DocGridException; @@ -32,6 +43,9 @@ class DocumentQueryControllerTest { private static final String STATUS_URL = "/api/documents/{documentId}/status"; + private static final String DETAIL_URL = "/api/documents/{documentId}"; + private static final String CONTENT_URL = "/api/documents/{documentId}/content"; + private static final String FILE_URL = "/api/documents/{documentId}/file"; @Autowired private MockMvc mockMvc; @@ -39,9 +53,100 @@ class DocumentQueryControllerTest { @MockitoBean private DocumentQueryService documentQueryService; + @MockitoBean + private DocumentFileService documentFileService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + @Test + @DisplayName("인증된 사용자가 본문과 파일을 제외한 문서 상세 정보를 조회한다") + void getDocumentDetail_returnsDocumentMetadata() throws Exception { + DocumentDetailResponse response = new DocumentDetailResponse( + 10L, + "운영 가이드", + "배포 절차", + DocumentType.PDF, + DocumentSourceType.UPLOAD, + DocumentStatus.INDEXED, + VisibilityType.PRIVATE, + 20L, + "소유자", + null, + true, + LocalDateTime.of(2026, 8, 1, 10, 0), + LocalDateTime.of(2026, 8, 2, 11, 0) + ); + given(documentQueryService.getDocumentDetail(20L, 10L)).willReturn(response); + + mockMvc.perform(get(DETAIL_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.documentId").value(10)) + .andExpect(jsonPath("$.data.title").value("운영 가이드")) + .andExpect(jsonPath("$.data.ownerName").value("소유자")) + .andExpect(jsonPath("$.data.contentAvailable").value(true)) + .andExpect(jsonPath("$.data.currentVersion").doesNotExist()); + } + + @Test + @DisplayName("인증된 사용자가 현재 버전에서 추출한 전체 텍스트를 조회한다") + void getDocumentContent_returnsRestoredText() throws Exception { + DocumentContentResponse response = new DocumentContentResponse( + 10L, 30L, 2, "첫 페이지\n둘째 페이지", 4 + ); + given(documentQueryService.getDocumentContent(20L, 10L)).willReturn(response); + + mockMvc.perform(get(CONTENT_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.documentVersionId").value(30)) + .andExpect(jsonPath("$.data.content").value("첫 페이지\n둘째 페이지")) + .andExpect(jsonPath("$.data.chunkCount").value(4)); + } + + @Test + @DisplayName("원본 파일 조회 기본값은 브라우저 표시용 inline 응답이다") + void getDocumentFile_returnsInlineFileByDefault() throws Exception { + byte[] fileContent = "%PDF-test".getBytes(StandardCharsets.UTF_8); + given(documentFileService.getDocumentFile(20L, 10L)).willReturn( + new DocumentFileDownload(fileContent, "운영 가이드.pdf", "application/pdf", fileContent.length) + ); + + mockMvc.perform(get(FILE_URL, 10L) + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/pdf")) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("inline;"))) + .andExpect(content().bytes(fileContent)); + } + + @Test + @DisplayName("attachment를 지정하면 원본 파일을 다운로드 응답으로 반환한다") + void getDocumentFile_returnsAttachment_whenRequested() throws Exception { + byte[] fileContent = "text".getBytes(StandardCharsets.UTF_8); + given(documentFileService.getDocumentFile(20L, 10L)).willReturn( + new DocumentFileDownload(fileContent, "guide.txt", "text/plain", fileContent.length) + ); + + mockMvc.perform(get(FILE_URL, 10L) + .param("disposition", "attachment") + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("attachment;"))); + } + + @Test + @DisplayName("지원하지 않는 disposition이면 400을 반환한다") + void getDocumentFile_returnsBadRequest_whenDispositionIsInvalid() throws Exception { + mockMvc.perform(get(FILE_URL, 10L) + .param("disposition", "preview") + .with(authentication(authenticationWithUserId(20L)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } + @Test @DisplayName("인증된 사용자가 현재 버전과 처리 중 버전 상태를 조회한다") void getDocumentStatus_returnsCurrentAndProcessingVersions() throws Exception { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverterTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverterTest.java new file mode 100644 index 0000000..eed0859 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/converter/DocumentDetailConverterTest.java @@ -0,0 +1,64 @@ +package com.opensource.docgrid.domain.document.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.entity.FileObject; +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.user.entity.User; + +/** + * 문서와 현재 버전 Entity가 저장소 내부 위치 없이 상세 응답으로 변환되는지 검증한다. + */ +@DisplayName("DocumentDetailConverter 테스트") +class DocumentDetailConverterTest { + + private final DocumentDetailConverter converter = new DocumentDetailConverter(); + + @Test + @DisplayName("문서 Metadata와 현재 버전 파일 정보를 상세 응답으로 변환한다") + void toResponse_convertsDocumentAndCurrentVersion() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + FileObject fileObject = mock(FileObject.class); + User owner = mock(User.class); + given(document.getId()).willReturn(1L); + given(document.getTitle()).willReturn("운영 가이드"); + given(document.getDocumentType()).willReturn(DocumentType.PDF); + given(document.getSourceType()).willReturn(DocumentSourceType.UPLOAD); + given(document.getStatus()).willReturn(DocumentStatus.INDEXED); + given(document.getVisibility()).willReturn(VisibilityType.PRIVATE); + given(document.getOwner()).willReturn(owner); + given(owner.getId()).willReturn(2L); + given(owner.getName()).willReturn("소유자"); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(3L); + given(currentVersion.getVersionNo()).willReturn(4); + given(currentVersion.getStatus()).willReturn(DocumentVersionStatus.INDEXED); + given(currentVersion.getFileObject()).willReturn(fileObject); + given(fileObject.getOriginalFilename()).willReturn("guide.pdf"); + given(fileObject.getContentType()).willReturn("application/pdf"); + given(fileObject.getFileSize()).willReturn(1024L); + + DocumentDetailResponse result = converter.toResponse(document, true); + + assertThat(result.documentId()).isEqualTo(1L); + assertThat(result.ownerUserId()).isEqualTo(2L); + assertThat(result.ownerName()).isEqualTo("소유자"); + assertThat(result.contentAvailable()).isTrue(); + assertThat(result.currentVersion().documentVersionId()).isEqualTo(3L); + assertThat(result.currentVersion().originalFilename()).isEqualTo("guide.pdf"); + assertThat(result.currentVersion().fileSize()).isEqualTo(1024L); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/DocumentFileServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/DocumentFileServiceTest.java new file mode 100644 index 0000000..7478261 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/DocumentFileServiceTest.java @@ -0,0 +1,66 @@ +package com.opensource.docgrid.domain.document.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.service.query.DocumentFileSnapshot; +import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; +import com.opensource.docgrid.domain.document.storage.FileStorageService; +import com.opensource.docgrid.domain.document.storage.StoredFile; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 원본 파일 조회가 DB Snapshot 이후 저장소 Byte를 결합하고 크기 불일치를 차단하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentFileService 테스트") +class DocumentFileServiceTest { + + private static final Long USER_ID = 10L; + private static final Long DOCUMENT_ID = 20L; + private static final StoredFile STORED_FILE = new StoredFile("documents", "objects/guide.pdf"); + + @InjectMocks + private DocumentFileService service; + + @Mock private DocumentQueryService documentQueryService; + @Mock private FileStorageService fileStorageService; + + @Test + @DisplayName("조회 Snapshot의 원본 파일을 읽어 다운로드 응답을 반환한다") + void getDocumentFile_returnsStorageContentAndMetadata() { + byte[] content = {1, 2, 3}; + given(documentQueryService.getDocumentFileSnapshot(USER_ID, DOCUMENT_ID)).willReturn( + new DocumentFileSnapshot(STORED_FILE, "guide.pdf", "application/pdf", content.length) + ); + given(fileStorageService.read(STORED_FILE)).willReturn(content); + + DocumentFileDownload result = service.getDocumentFile(USER_ID, DOCUMENT_ID); + + assertThat(result.content()).isSameAs(content); + assertThat(result.originalFilename()).isEqualTo("guide.pdf"); + assertThat(result.contentType()).isEqualTo("application/pdf"); + } + + @Test + @DisplayName("저장된 Byte 크기가 Metadata와 다르면 저장소 오류가 발생한다") + void getDocumentFile_throws_whenFileSizeIsInconsistent() { + given(documentQueryService.getDocumentFileSnapshot(USER_ID, DOCUMENT_ID)).willReturn( + new DocumentFileSnapshot(STORED_FILE, "guide.pdf", "application/pdf", 4L) + ); + given(fileStorageService.read(STORED_FILE)).willReturn(new byte[] {1, 2, 3}); + + assertThatThrownBy(() -> service.getDocumentFile(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.FILE_STORAGE_FAILED); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java index 6a3ad0e..deb28cf 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java @@ -10,6 +10,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,15 +25,23 @@ import com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; +import com.opensource.docgrid.domain.document.converter.DocumentDetailConverter; +import com.opensource.docgrid.domain.document.dto.response.DocumentContentResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentChunk; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.entity.FileObject; 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.document.repository.DocumentRepository; import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; +import com.opensource.docgrid.domain.document.repository.DocumentChunkRepository; +import com.opensource.docgrid.domain.document.storage.StoredFile; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; import com.opensource.docgrid.global.common.response.PageResponse; @@ -50,11 +59,140 @@ class DocumentQueryServiceTest { private DocumentQueryService service; @Mock private DocumentRepository documentRepository; + @Mock private DocumentChunkRepository documentChunkRepository; @Mock private PermissionQueryService permissionQueryService; + @Mock private DocumentDetailConverter documentDetailConverter; @Mock private DocumentStatusConverter documentStatusConverter; @Mock private DocumentSummaryConverter documentSummaryConverter; @Mock private DocumentStatusProjection projection; + @Test + @DisplayName("읽기 가능한 문서의 상세 정보와 본문 조회 가능 여부를 반환한다") + void getDocumentDetail_returnsResponseWithContentAvailability() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + DocumentDetailResponse expected = mock(DocumentDetailResponse.class); + givenReadableDocument(document); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(30L); + given(documentChunkRepository.existsByDocumentVersionId(30L)).willReturn(true); + given(documentDetailConverter.toResponse(document, true)).willReturn(expected); + + DocumentDetailResponse result = service.getDocumentDetail(USER_ID, DOCUMENT_ID); + + assertThat(result).isSameAs(expected); + then(documentDetailConverter).should().toResponse(document, true); + } + + @Test + @DisplayName("문서 읽기 권한이 없으면 상세 Entity를 조회하지 않고 403 예외가 발생한다") + void getDocumentDetail_throws_whenReadPermissionIsDenied() { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(false); + + assertThatThrownBy(() -> service.getDocumentDetail(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + then(documentRepository).should(never()).findByIdWithDetail(DOCUMENT_ID); + } + + @Test + @DisplayName("Chunk 중첩과 Segment 경계를 제거해 Unicode 전체 본문을 복원한다") + void getDocumentContent_restoresOverlapsAndSegmentLineFeed() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + givenReadableDocument(document); + given(document.getId()).willReturn(DOCUMENT_ID); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(30L); + given(currentVersion.getVersionNo()).willReturn(2); + List chunks = List.of( + chunk(0, "가나다라", 0, 4), + chunk(1, "다라마바", 2, 6), + chunk(2, "서울😀", 7, 10) + ); + given(documentChunkRepository.findAllByDocumentVersionIdOrderByChunkIndexAsc(30L)) + .willReturn(chunks); + + DocumentContentResponse result = service.getDocumentContent(USER_ID, DOCUMENT_ID); + + assertThat(result.content()).isEqualTo("가나다라마바\n서울😀"); + assertThat(result.documentVersionId()).isEqualTo(30L); + assertThat(result.versionNo()).isEqualTo(2); + assertThat(result.chunkCount()).isEqualTo(3); + } + + @Test + @DisplayName("본문 생성 전 상태에 Chunk가 없으면 조회 준비 전 예외가 발생한다") + void getDocumentContent_throws_whenContentIsNotAvailable() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + givenReadableDocument(document); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(30L); + given(currentVersion.getStatus()).willReturn(DocumentVersionStatus.PARSING); + given(documentChunkRepository.findAllByDocumentVersionIdOrderByChunkIndexAsc(30L)).willReturn(List.of()); + + assertThatThrownBy(() -> service.getDocumentContent(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_CONTENT_NOT_AVAILABLE); + } + + @Test + @DisplayName("INDEXED 버전에 Chunk가 없으면 데이터 불일치 예외가 발생한다") + void getDocumentContent_throws_whenIndexedChunksAreMissing() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + givenReadableDocument(document); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(30L); + given(currentVersion.getStatus()).willReturn(DocumentVersionStatus.INDEXED); + given(documentChunkRepository.findAllByDocumentVersionIdOrderByChunkIndexAsc(30L)).willReturn(List.of()); + + assertThatThrownBy(() -> service.getDocumentContent(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_CHUNKS_INCONSISTENT); + } + + @Test + @DisplayName("Chunk Offset이 Text 길이와 다르면 데이터 불일치 예외가 발생한다") + void getDocumentContent_throws_whenChunkOffsetsAreInvalid() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + givenReadableDocument(document); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getId()).willReturn(30L); + DocumentChunk invalidChunk = chunk(0, "본문", 0, 3); + given(documentChunkRepository.findAllByDocumentVersionIdOrderByChunkIndexAsc(30L)) + .willReturn(List.of(invalidChunk)); + + assertThatThrownBy(() -> service.getDocumentContent(USER_ID, DOCUMENT_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_CHUNKS_INCONSISTENT); + } + + @Test + @DisplayName("읽기 가능한 문서의 현재 버전 원본 위치를 Snapshot으로 반환한다") + void getDocumentFileSnapshot_returnsCurrentVersionFileMetadata() { + Document document = mock(Document.class); + DocumentVersion currentVersion = mock(DocumentVersion.class); + FileObject fileObject = mock(FileObject.class); + givenReadableDocument(document); + given(document.getCurrentVersion()).willReturn(currentVersion); + given(currentVersion.getFileObject()).willReturn(fileObject); + given(currentVersion.getOriginalFilename()).willReturn("guide.pdf"); + given(currentVersion.getContentType()).willReturn("application/pdf"); + given(fileObject.getBucketName()).willReturn("documents"); + given(fileObject.getObjectKey()).willReturn("objects/guide.pdf"); + given(fileObject.getFileSize()).willReturn(100L); + + DocumentFileSnapshot result = service.getDocumentFileSnapshot(USER_ID, DOCUMENT_ID); + + assertThat(result.storedFile()).isEqualTo(new StoredFile("documents", "objects/guide.pdf")); + assertThat(result.originalFilename()).isEqualTo("guide.pdf"); + assertThat(result.contentType()).isEqualTo("application/pdf"); + assertThat(result.fileSize()).isEqualTo(100L); + } + @Test @DisplayName("읽을 수 있는 문서를 페이지 응답으로 변환해 반환한다") void getMyDocuments_returnsPage_when_readableDocumentsExist() { @@ -237,4 +375,19 @@ private DocumentSummaryResponse summaryResponse() { null ); } + + private void givenReadableDocument(Document document) { + given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); + given(documentRepository.findByIdWithDetail(DOCUMENT_ID)).willReturn(Optional.of(document)); + given(document.getStatus()).willReturn(DocumentStatus.INDEXED); + } + + private DocumentChunk chunk(int index, String text, int start, int end) { + DocumentChunk chunk = mock(DocumentChunk.class); + given(chunk.getChunkIndex()).willReturn(index); + given(chunk.getChunkText()).willReturn(text); + given(chunk.getCharStart()).willReturn(start); + given(chunk.getCharEnd()).willReturn(end); + return chunk; + } }