diff --git a/src/main/java/com/wooteco/wiki/graph/controller/CrewGraphController.java b/src/main/java/com/wooteco/wiki/graph/controller/CrewGraphController.java new file mode 100644 index 0000000..8423434 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/controller/CrewGraphController.java @@ -0,0 +1,30 @@ +package com.wooteco.wiki.graph.controller; + +import com.wooteco.wiki.global.common.ApiResponse; +import com.wooteco.wiki.global.common.ApiResponse.SuccessBody; +import com.wooteco.wiki.global.common.ApiResponseGenerator; +import com.wooteco.wiki.graph.dto.CrewGraphResponse; +import com.wooteco.wiki.graph.service.CrewGraphQueryService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/graph") +public class CrewGraphController { + + private final CrewGraphQueryService crewGraphQueryService; + + @Operation(summary = "크루 관계 그래프 조회", description = "기수에 속한 크루 문서 노드와 관계를 조회합니다.") + @GetMapping + public ApiResponse> findByGeneration( + @RequestParam String generation + ) { + CrewGraphResponse response = crewGraphQueryService.findByGeneration(generation); + return ApiResponseGenerator.success(response); + } +} diff --git a/src/main/java/com/wooteco/wiki/graph/dto/CrewGraphResponse.java b/src/main/java/com/wooteco/wiki/graph/dto/CrewGraphResponse.java new file mode 100644 index 0000000..f0418c1 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/dto/CrewGraphResponse.java @@ -0,0 +1,21 @@ +package com.wooteco.wiki.graph.dto; + +import java.util.List; + +public record CrewGraphResponse( + List nodes, + List edges +) { + + public CrewGraphResponse { + nodes = List.copyOf(nodes); + edges = List.copyOf(edges); + } + + public static CrewGraphResponse of( + List nodes, + List edges + ) { + return new CrewGraphResponse(nodes, edges); + } +} diff --git a/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeResponse.java b/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeResponse.java new file mode 100644 index 0000000..b6cd098 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeResponse.java @@ -0,0 +1,10 @@ +package com.wooteco.wiki.graph.dto; + +import java.util.UUID; + +public record GraphEdgeResponse( + UUID sourceDocumentUuid, + UUID targetDocumentUuid, + GraphEdgeType type +) { +} diff --git a/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeType.java b/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeType.java new file mode 100644 index 0000000..a57d455 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/dto/GraphEdgeType.java @@ -0,0 +1,5 @@ +package com.wooteco.wiki.graph.dto; + +public enum GraphEdgeType { + REFERENCE +} diff --git a/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeResponse.java b/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeResponse.java new file mode 100644 index 0000000..b8b204c --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeResponse.java @@ -0,0 +1,21 @@ +package com.wooteco.wiki.graph.dto; + +import java.util.UUID; + +public record GraphNodeResponse( + UUID documentUuid, + String title, + GraphNodeType type +) { + + public static GraphNodeResponse fromCrewDocument( + UUID documentUuid, + String title + ) { + return new GraphNodeResponse( + documentUuid, + title, + GraphNodeType.CREW + ); + } +} diff --git a/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeType.java b/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeType.java new file mode 100644 index 0000000..a92c503 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/dto/GraphNodeType.java @@ -0,0 +1,5 @@ +package com.wooteco.wiki.graph.dto; + +public enum GraphNodeType { + CREW +} diff --git a/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphQueryRepository.java b/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphQueryRepository.java new file mode 100644 index 0000000..e9206df --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphQueryRepository.java @@ -0,0 +1,8 @@ +package com.wooteco.wiki.graph.repository; + +import java.util.List; + +public interface CrewGraphQueryRepository { + + List findAllCrewDocumentsByGenerationTitle(String generationTitle); +} diff --git a/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphReadModel.java b/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphReadModel.java new file mode 100644 index 0000000..37203d5 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/repository/CrewGraphReadModel.java @@ -0,0 +1,10 @@ +package com.wooteco.wiki.graph.repository; + +import java.util.UUID; + +public record CrewGraphReadModel( + UUID documentUuid, + String title, + String contents +) { +} diff --git a/src/main/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractor.java b/src/main/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractor.java new file mode 100644 index 0000000..6d3b075 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractor.java @@ -0,0 +1,58 @@ +package com.wooteco.wiki.graph.service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +@Component +public class CrewDocumentReferenceExtractor { + + private static final String UUID_PATTERN = + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; + private static final Pattern INTERNAL_DOCUMENT_LINK_PATTERN = Pattern.compile( + "(?" + UUID_PATTERN + ")" + + "(?=$|[^A-Za-z0-9._~-])" + ); + + public List extract(String contents) { + if (contents == null || contents.isBlank()) { + return List.of(); + } + Set references = new LinkedHashSet<>(); + Matcher matcher = INTERNAL_DOCUMENT_LINK_PATTERN.matcher(contents); + while (matcher.find()) { + addReferenceIfValid(contents, matcher, references); + } + return List.copyOf(references); + } + + private void addReferenceIfValid( + String contents, + Matcher matcher, + Set references + ) { + if (isMarkdownImage(contents, matcher.start())) { + return; + } + references.add(UUID.fromString(matcher.group("uuid"))); + } + + private boolean isMarkdownImage( + String contents, + int linkStart + ) { + boolean startsAfterMarkdownLabel = linkStart >= 3 + && contents.charAt(linkStart - 1) == '(' + && contents.charAt(linkStart - 2) == ']'; + if (!startsAfterMarkdownLabel) { + return false; + } + int altTextStart = contents.lastIndexOf('[', linkStart - 2); + return altTextStart > 0 && contents.charAt(altTextStart - 1) == '!'; + } +} diff --git a/src/main/java/com/wooteco/wiki/graph/service/CrewGraphQueryService.java b/src/main/java/com/wooteco/wiki/graph/service/CrewGraphQueryService.java new file mode 100644 index 0000000..c4eeac2 --- /dev/null +++ b/src/main/java/com/wooteco/wiki/graph/service/CrewGraphQueryService.java @@ -0,0 +1,125 @@ +package com.wooteco.wiki.graph.service; + +import com.wooteco.wiki.global.exception.ErrorCode; +import com.wooteco.wiki.global.exception.WikiException; +import com.wooteco.wiki.graph.dto.CrewGraphResponse; +import com.wooteco.wiki.graph.dto.GraphEdgeResponse; +import com.wooteco.wiki.graph.dto.GraphEdgeType; +import com.wooteco.wiki.graph.dto.GraphNodeResponse; +import com.wooteco.wiki.graph.repository.CrewGraphQueryRepository; +import com.wooteco.wiki.graph.repository.CrewGraphReadModel; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CrewGraphQueryService { + + private final CrewGraphQueryRepository crewGraphQueryRepository; + private final CrewDocumentReferenceExtractor crewDocumentReferenceExtractor; + + @Transactional(readOnly = true) + public CrewGraphResponse findByGeneration(String generation) { + validateGeneration(generation); + List readModels = crewGraphQueryRepository.findAllCrewDocumentsByGenerationTitle(generation); + List nodes = createNodes(readModels); + List edges = createEdges(readModels); + return CrewGraphResponse.of(nodes, edges); + } + + private void validateGeneration(String generation) { + if (generation == null || generation.isBlank()) { + throw new WikiException(ErrorCode.VALIDATION_ERROR); + } + } + + private List createNodes(List readModels) { + List nodes = new ArrayList<>(); + for (CrewGraphReadModel readModel : readModels) { + GraphNodeResponse node = GraphNodeResponse.fromCrewDocument( + readModel.documentUuid(), + readModel.title() + ); + nodes.add(node); + } + return List.copyOf(nodes); + } + + private List createEdges(List readModels) { + Set nodeDocumentUuids = createNodeDocumentUuids(readModels); + Set edges = new HashSet<>(); + for (CrewGraphReadModel readModel : readModels) { + addReferenceEdges(readModel, nodeDocumentUuids, edges); + } + List sortedEdges = new ArrayList<>(edges); + sortedEdges.sort(Comparator + .comparing(GraphEdgeResponse::sourceDocumentUuid) + .thenComparing(GraphEdgeResponse::targetDocumentUuid)); + return List.copyOf(sortedEdges); + } + + private Set createNodeDocumentUuids(List readModels) { + Set documentUuids = new HashSet<>(); + for (CrewGraphReadModel readModel : readModels) { + documentUuids.add(readModel.documentUuid()); + } + return documentUuids; + } + + private void addReferenceEdges( + CrewGraphReadModel sourceDocument, + Set nodeDocumentUuids, + Set edges + ) { + List referencedDocumentUuids = crewDocumentReferenceExtractor.extract(sourceDocument.contents()); + for (UUID targetDocumentUuid : referencedDocumentUuids) { + addReferenceEdgeIfValid( + sourceDocument.documentUuid(), + targetDocumentUuid, + nodeDocumentUuids, + edges + ); + } + } + + private void addReferenceEdgeIfValid( + UUID sourceDocumentUuid, + UUID targetDocumentUuid, + Set nodeDocumentUuids, + Set edges + ) { + if (sourceDocumentUuid.equals(targetDocumentUuid)) { + return; + } + if (!nodeDocumentUuids.contains(targetDocumentUuid)) { + return; + } + GraphEdgeResponse edge = createReferenceEdge(sourceDocumentUuid, targetDocumentUuid); + edges.add(edge); + } + + private GraphEdgeResponse createReferenceEdge( + UUID sourceDocumentUuid, + UUID targetDocumentUuid + ) { + if (sourceDocumentUuid.compareTo(targetDocumentUuid) < 0) { + return new GraphEdgeResponse( + sourceDocumentUuid, + targetDocumentUuid, + GraphEdgeType.REFERENCE + ); + } + return new GraphEdgeResponse( + targetDocumentUuid, + sourceDocumentUuid, + GraphEdgeType.REFERENCE + ); + } +} diff --git a/src/main/java/com/wooteco/wiki/organizationdocument/repository/DocumentOrganizationLinkRepository.java b/src/main/java/com/wooteco/wiki/organizationdocument/repository/DocumentOrganizationLinkRepository.java index 366b8cb..42b7360 100644 --- a/src/main/java/com/wooteco/wiki/organizationdocument/repository/DocumentOrganizationLinkRepository.java +++ b/src/main/java/com/wooteco/wiki/organizationdocument/repository/DocumentOrganizationLinkRepository.java @@ -3,6 +3,8 @@ import com.wooteco.wiki.document.domain.CrewDocument; import com.wooteco.wiki.document.repository.GenerationCrewOrganizationReadModel; import com.wooteco.wiki.document.repository.GenerationCrewQueryRepository; +import com.wooteco.wiki.graph.repository.CrewGraphQueryRepository; +import com.wooteco.wiki.graph.repository.CrewGraphReadModel; import com.wooteco.wiki.organizationdocument.domain.DocumentOrganizationLink; import com.wooteco.wiki.organizationdocument.domain.OrganizationDocument; import java.util.List; @@ -13,7 +15,8 @@ public interface DocumentOrganizationLinkRepository extends JpaRepository, - GenerationCrewQueryRepository { + GenerationCrewQueryRepository, + CrewGraphQueryRepository { Optional findByCrewDocumentAndOrganizationDocument( CrewDocument crewDocument, @@ -53,5 +56,21 @@ List findAllByGenerationTitle( @Param("generationTitle") String generationTitle ); + @Override + @Query(""" + SELECT new com.wooteco.wiki.graph.repository.CrewGraphReadModel( + crewDocument.uuid, + crewDocument.title, + crewDocument.contents + ) + FROM DocumentOrganizationLink documentOrganizationLink + JOIN documentOrganizationLink.crewDocument crewDocument + WHERE documentOrganizationLink.organizationDocument.title = :generationTitle + ORDER BY crewDocument.title + """) + List findAllCrewDocumentsByGenerationTitle( + @Param("generationTitle") String generationTitle + ); + void deleteAllByCrewDocument(CrewDocument crewDocument); } diff --git a/src/test/java/com/wooteco/wiki/graph/controller/CrewGraphControllerTest.java b/src/test/java/com/wooteco/wiki/graph/controller/CrewGraphControllerTest.java new file mode 100644 index 0000000..5a20a3d --- /dev/null +++ b/src/test/java/com/wooteco/wiki/graph/controller/CrewGraphControllerTest.java @@ -0,0 +1,175 @@ +package com.wooteco.wiki.graph.controller; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +import com.wooteco.wiki.document.domain.CrewDocument; +import com.wooteco.wiki.document.fixture.CrewDocumentFixture; +import com.wooteco.wiki.document.repository.CrewDocumentRepository; +import com.wooteco.wiki.organizationdocument.domain.DocumentOrganizationLink; +import com.wooteco.wiki.organizationdocument.domain.OrganizationDocument; +import com.wooteco.wiki.organizationdocument.fixture.DocumentOrganizationLinkFixture; +import com.wooteco.wiki.organizationdocument.fixture.OrganizationDocumentFixture; +import com.wooteco.wiki.organizationdocument.repository.DocumentOrganizationLinkRepository; +import com.wooteco.wiki.organizationdocument.repository.OrganizationDocumentRepository; +import io.restassured.RestAssured; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class CrewGraphControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private CrewDocumentRepository crewDocumentRepository; + + @Autowired + private OrganizationDocumentRepository organizationDocumentRepository; + + @Autowired + private DocumentOrganizationLinkRepository documentOrganizationLinkRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Nested + @DisplayName("크루 그래프 조회 API를 호출할 때") + class FindByGeneration { + + @Test + @DisplayName("성공 응답에 기수와 일치하는 크루 문서 노드와 참조 간선을 담아 반환한다.") + void findByGeneration_success_byMatchingGenerationTitle() { + // given + UUID firstCrewUuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID secondCrewUuid = UUID.fromString("22222222-2222-2222-2222-222222222222"); + CrewDocument firstCrew = saveCrewDocument( + "가람(8기)", + "https://crew-wiki.site/wiki/22222222-2222-2222-2222-222222222222", + firstCrewUuid + ); + CrewDocument secondCrew = saveCrewDocument( + "나래(8기)", + "contents", + secondCrewUuid + ); + OrganizationDocument generation = saveOrganizationDocument("8기"); + saveLink(firstCrew, generation); + saveLink(secondCrew, generation); + + // when & then + RestAssured.given().log().all() + .queryParam("generation", "8기") + .when() + .get("/graph") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("data.nodes", hasSize(2)) + .body("data.nodes[0].documentUuid", equalTo(firstCrewUuid.toString())) + .body("data.nodes[0].title", equalTo("가람(8기)")) + .body("data.nodes[0].type", equalTo("CREW")) + .body("data.nodes[1].documentUuid", equalTo(secondCrewUuid.toString())) + .body("data.nodes[1].title", equalTo("나래(8기)")) + .body("data.nodes[1].type", equalTo("CREW")) + .body("data.edges", hasSize(1)) + .body("data.edges[0].sourceDocumentUuid", equalTo(firstCrewUuid.toString())) + .body("data.edges[0].targetDocumentUuid", equalTo(secondCrewUuid.toString())) + .body("data.edges[0].type", equalTo("REFERENCE")); + } + + @Test + @DisplayName("입력한 조직 제목과 정확히 일치하는 기수가 없으면 빈 그래프를 반환한다.") + void findByGeneration_success_byNoExactGenerationTitle() { + // given + CrewDocument crewDocument = saveCrewDocument("가람(8기)"); + OrganizationDocument generation = saveOrganizationDocument("8기"); + saveLink(crewDocument, generation); + + // when & then + RestAssured.given().log().all() + .queryParam("generation", "8") + .when() + .get("/graph") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("data.nodes", hasSize(0)) + .body("data.edges", hasSize(0)); + } + + @Test + @DisplayName("기수를 누락하면 검증 실패를 반환한다.") + void findByGeneration_fail_byMissingGeneration() { + // when & then + assertValidationError("/graph"); + } + + @Test + @DisplayName("기수가 빈 문자열이면 검증 실패를 반환한다.") + void findByGeneration_fail_byEmptyGeneration() { + // when & then + assertValidationError("/graph?generation="); + } + } + + private void assertValidationError(String path) { + RestAssured.given().log().all() + .when() + .get(path) + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("code", equalTo("VALIDATION_ERROR")); + } + + private CrewDocument saveCrewDocument(String title) { + return saveCrewDocument(title, "contents", UUID.randomUUID()); + } + + private CrewDocument saveCrewDocument( + String title, + String contents, + UUID uuid + ) { + CrewDocument crewDocument = CrewDocumentFixture.createCrewDocument( + title, + contents, + "writer", + 10L, + uuid + ); + return crewDocumentRepository.save(crewDocument); + } + + private OrganizationDocument saveOrganizationDocument(String title) { + OrganizationDocument organizationDocument = OrganizationDocumentFixture.create( + title, + "contents", + "writer", + 10L, + UUID.randomUUID() + ); + return organizationDocumentRepository.save(organizationDocument); + } + + private void saveLink( + CrewDocument crewDocument, + OrganizationDocument organizationDocument + ) { + DocumentOrganizationLink link = DocumentOrganizationLinkFixture.create( + crewDocument, + organizationDocument + ); + documentOrganizationLinkRepository.save(link); + } +} diff --git a/src/test/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractorTest.java b/src/test/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractorTest.java new file mode 100644 index 0000000..a365e11 --- /dev/null +++ b/src/test/java/com/wooteco/wiki/graph/service/CrewDocumentReferenceExtractorTest.java @@ -0,0 +1,95 @@ +package com.wooteco.wiki.graph.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CrewDocumentReferenceExtractorTest { + + private static final UUID FIRST_DOCUMENT_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID SECOND_DOCUMENT_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private final CrewDocumentReferenceExtractor crewDocumentReferenceExtractor = + new CrewDocumentReferenceExtractor(); + + @Nested + @DisplayName("크루 문서 참조 UUID를 추출할 때") + class Extract { + + @Test + @DisplayName("정식 주소로 작성한 Markdown 링크와 일반 URL에서 UUID를 추출한다.") + void extract_success_byCanonicalLinks() { + // given + String contents = """ + [첫 번째 크루](https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111) + 관련 문서: https://crew-wiki.site/wiki/22222222-2222-2222-2222-222222222222 + """; + + // when + List references = crewDocumentReferenceExtractor.extract(contents); + + // then + assertThat(references).containsExactly(FIRST_DOCUMENT_UUID, SECOND_DOCUMENT_UUID); + } + + @Test + @DisplayName("동일한 문서를 여러 번 참조하면 UUID를 한 번만 반환한다.") + void extract_success_byDuplicateLinks() { + // given + String contents = """ + https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111 + [같은 크루](https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111) + """; + + // when + List references = crewDocumentReferenceExtractor.extract(contents); + + // then + assertThat(references).containsExactly(FIRST_DOCUMENT_UUID); + } + + @Test + @DisplayName("외부 주소와 이미지 및 유효하지 않은 문서 경로는 제외한다.") + void extract_success_byInvalidLinks() { + // given + String contents = """ + https://example.com/wiki/11111111-1111-1111-1111-111111111111 + https://api.crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111 + https://www.crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111 + https://dev.crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111 + http://localhost:3000/wiki/11111111-1111-1111-1111-111111111111 + /wiki/11111111-1111-1111-1111-111111111111 + ![이미지](https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111) + /wiki/not-a-uuid + /document/uuid/11111111-1111-1111-1111-111111111111 + 11111111-1111-1111-1111-111111111111 + https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111suffix + """; + + // when + List references = crewDocumentReferenceExtractor.extract(contents); + + // then + assertThat(references).isEmpty(); + } + + @Test + @DisplayName("본문이 없거나 공백이면 빈 목록을 반환한다.") + void extract_success_byEmptyContents() { + // when + List nullContentsReferences = crewDocumentReferenceExtractor.extract(null); + List blankContentsReferences = crewDocumentReferenceExtractor.extract(" "); + + // then + assertSoftly(softly -> { + softly.assertThat(nullContentsReferences).isEmpty(); + softly.assertThat(blankContentsReferences).isEmpty(); + }); + } + } +} diff --git a/src/test/java/com/wooteco/wiki/graph/service/CrewGraphQueryServiceTest.java b/src/test/java/com/wooteco/wiki/graph/service/CrewGraphQueryServiceTest.java new file mode 100644 index 0000000..9a923a9 --- /dev/null +++ b/src/test/java/com/wooteco/wiki/graph/service/CrewGraphQueryServiceTest.java @@ -0,0 +1,243 @@ +package com.wooteco.wiki.graph.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.wooteco.wiki.document.domain.CrewDocument; +import com.wooteco.wiki.document.fixture.CrewDocumentFixture; +import com.wooteco.wiki.document.repository.CrewDocumentRepository; +import com.wooteco.wiki.global.exception.ErrorCode; +import com.wooteco.wiki.global.exception.WikiException; +import com.wooteco.wiki.graph.dto.CrewGraphResponse; +import com.wooteco.wiki.graph.dto.GraphEdgeResponse; +import com.wooteco.wiki.graph.dto.GraphEdgeType; +import com.wooteco.wiki.graph.dto.GraphNodeResponse; +import com.wooteco.wiki.graph.dto.GraphNodeType; +import com.wooteco.wiki.organizationdocument.domain.DocumentOrganizationLink; +import com.wooteco.wiki.organizationdocument.domain.OrganizationDocument; +import com.wooteco.wiki.organizationdocument.fixture.DocumentOrganizationLinkFixture; +import com.wooteco.wiki.organizationdocument.fixture.OrganizationDocumentFixture; +import com.wooteco.wiki.organizationdocument.repository.DocumentOrganizationLinkRepository; +import com.wooteco.wiki.organizationdocument.repository.OrganizationDocumentRepository; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class CrewGraphQueryServiceTest { + + @Autowired + private CrewGraphQueryService crewGraphQueryService; + + @Autowired + private CrewDocumentRepository crewDocumentRepository; + + @Autowired + private OrganizationDocumentRepository organizationDocumentRepository; + + @Autowired + private DocumentOrganizationLinkRepository documentOrganizationLinkRepository; + + @Nested + @DisplayName("기수별 크루 그래프를 조회할 때") + class FindByGeneration { + + @Test + @DisplayName("요청한 기수 조직과 연결된 크루 문서만 노드로 반환한다.") + void findByGeneration_success_byMatchingGenerationTitle() { + // given + OrganizationDocument eighthGeneration = saveOrganizationDocument("8기"); + OrganizationDocument seventhGeneration = saveOrganizationDocument("7기"); + CrewDocument eighthCrew = saveCrewDocument("가람(8기)"); + CrewDocument anotherEighthCrew = saveCrewDocument("나래(8기)"); + CrewDocument seventhCrew = saveCrewDocument("다온(7기)"); + saveLink(eighthCrew, eighthGeneration); + saveLink(anotherEighthCrew, eighthGeneration); + saveLink(seventhCrew, seventhGeneration); + + // when + CrewGraphResponse response = crewGraphQueryService.findByGeneration("8기"); + + // then + assertSoftly(softly -> { + softly.assertThat(response.nodes()) + .extracting(GraphNodeResponse::documentUuid) + .containsExactly(eighthCrew.getUuid(), anotherEighthCrew.getUuid()); + softly.assertThat(response.nodes()) + .extracting(GraphNodeResponse::title) + .containsExactly("가람(8기)", "나래(8기)"); + softly.assertThat(response.nodes()) + .extracting(GraphNodeResponse::type) + .containsOnly(GraphNodeType.CREW); + softly.assertThat(response.edges()).isEmpty(); + }); + } + + @Test + @DisplayName("같은 기수 크루 문서의 참조를 중복 없는 무방향 간선으로 반환한다.") + void findByGeneration_success_byCrewDocumentReferences() { + // given + UUID firstCrewUuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID secondCrewUuid = UUID.fromString("22222222-2222-2222-2222-222222222222"); + OrganizationDocument generation = saveOrganizationDocument("8기"); + CrewDocument firstCrew = saveCrewDocument( + "가람(8기)", + """ + [나래](https://crew-wiki.site/wiki/22222222-2222-2222-2222-222222222222) + https://crew-wiki.site/wiki/22222222-2222-2222-2222-222222222222 + """, + firstCrewUuid + ); + CrewDocument secondCrew = saveCrewDocument( + "나래(8기)", + "https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111", + secondCrewUuid + ); + saveLink(firstCrew, generation); + saveLink(secondCrew, generation); + + // when + CrewGraphResponse response = crewGraphQueryService.findByGeneration("8기"); + + // then + assertSoftly(softly -> { + softly.assertThat(response.nodes()).hasSize(2); + softly.assertThat(response.edges()) + .containsExactly(new GraphEdgeResponse( + firstCrewUuid, + secondCrewUuid, + GraphEdgeType.REFERENCE + )); + }); + } + + @Test + @DisplayName("현재 기수의 다른 크루 문서를 가리키지 않는 참조는 간선에서 제외한다.") + void findByGeneration_success_byInvalidReferenceTargets() { + // given + UUID sourceCrewUuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID otherGenerationCrewUuid = UUID.fromString("22222222-2222-2222-2222-222222222222"); + UUID missingDocumentUuid = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID organizationDocumentUuid = UUID.fromString("44444444-4444-4444-4444-444444444444"); + OrganizationDocument eighthGeneration = saveOrganizationDocument("8기"); + OrganizationDocument seventhGeneration = saveOrganizationDocument( + "7기", + organizationDocumentUuid + ); + CrewDocument sourceCrew = saveCrewDocument( + "가람(8기)", + """ + https://crew-wiki.site/wiki/11111111-1111-1111-1111-111111111111 + https://crew-wiki.site/wiki/22222222-2222-2222-2222-222222222222 + https://crew-wiki.site/wiki/33333333-3333-3333-3333-333333333333 + https://crew-wiki.site/wiki/44444444-4444-4444-4444-444444444444 + """, + sourceCrewUuid + ); + CrewDocument otherGenerationCrew = saveCrewDocument( + "나래(7기)", + "contents", + otherGenerationCrewUuid + ); + saveLink(sourceCrew, eighthGeneration); + saveLink(otherGenerationCrew, seventhGeneration); + + // when + CrewGraphResponse response = crewGraphQueryService.findByGeneration("8기"); + + // then + assertSoftly(softly -> { + softly.assertThat(response.nodes()) + .extracting(GraphNodeResponse::documentUuid) + .containsExactly(sourceCrewUuid); + softly.assertThat(response.edges()).isEmpty(); + }); + } + + @Test + @DisplayName("요청한 기수 조직이 없으면 빈 그래프를 반환한다.") + void findByGeneration_success_byMissingGenerationOrganization() { + // when + CrewGraphResponse response = crewGraphQueryService.findByGeneration("8기"); + + // then + assertSoftly(softly -> { + softly.assertThat(response.nodes()).isEmpty(); + softly.assertThat(response.edges()).isEmpty(); + }); + } + + @Test + @DisplayName("기수가 공백이면 검증 예외가 발생한다.") + void findByGeneration_fail_byBlankGeneration() { + // when & then + assertThatThrownBy(() -> crewGraphQueryService.findByGeneration(" ")) + .isInstanceOf(WikiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + @DisplayName("기수가 null이면 검증 예외가 발생한다.") + void findByGeneration_fail_byNullGeneration() { + // when & then + assertThatThrownBy(() -> crewGraphQueryService.findByGeneration(null)) + .isInstanceOf(WikiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + } + + private CrewDocument saveCrewDocument(String title) { + return saveCrewDocument(title, "contents", UUID.randomUUID()); + } + + private CrewDocument saveCrewDocument( + String title, + String contents, + UUID uuid + ) { + CrewDocument crewDocument = CrewDocumentFixture.createCrewDocument( + title, + contents, + "writer", + 10L, + uuid + ); + return crewDocumentRepository.save(crewDocument); + } + + private OrganizationDocument saveOrganizationDocument(String title) { + return saveOrganizationDocument(title, UUID.randomUUID()); + } + + private OrganizationDocument saveOrganizationDocument( + String title, + UUID uuid + ) { + OrganizationDocument organizationDocument = OrganizationDocumentFixture.create( + title, + "contents", + "writer", + 10L, + uuid + ); + return organizationDocumentRepository.save(organizationDocument); + } + + private void saveLink( + CrewDocument crewDocument, + OrganizationDocument organizationDocument + ) { + DocumentOrganizationLink link = DocumentOrganizationLinkFixture.create( + crewDocument, + organizationDocument + ); + documentOrganizationLinkRepository.save(link); + } +}