diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java new file mode 100644 index 0000000..132f6d4 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java @@ -0,0 +1,74 @@ +package io.shinhanlife.dat.biz.mcp.docs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네 + * 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class ArchitectureDocumentContractTest { + + private static final Path ARCHITECTURE = Path.of("docs", "architecture.md"); + private static final Path MAIN_PACKAGE = + Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp"); + /** + * 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다. + */ + private static final Pattern TABLE_ROW = + Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|"); + + /** + * 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다. + */ + @Test + void everyDocumentedClassPathStillExists() throws IOException { + List documented = documentedTypes(); + + // 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다. + assertThat(documented) + .withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(10); + + List missing = documented.stream().filter(type -> !sourceExists(type)).toList(); + + assertThat(missing) + .withFailMessage( + "architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n" + + "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.", + missing) + .isEmpty(); + } + + /** + * 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다. + */ + private List documentedTypes() throws IOException { + try (Stream lines = Files.lines(ARCHITECTURE)) { + return lines.map(TABLE_ROW::matcher) + .filter(Matcher::find) + .map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2))) + .distinct() + .toList(); + } + } + + /** + * 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다. + */ + private boolean sourceExists(DocumentedType type) { + return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java")); + } + + private record DocumentedType(String name, String packagePath) { + } +}