diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java new file mode 100644 index 0000000..115b874 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java @@ -0,0 +1,112 @@ +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.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿 + * 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class PackageBoundaryContractTest { + + private static final Path MAIN_SOURCES = Path.of("src", "main", "java"); + /** + * 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다. + */ + private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dat/biz/mcp/transport/"; + + /** + * 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다. + */ + @Test + void servletApiStaysInsideTheTransportPackage() throws IOException { + List leaks = sourcesImporting("jakarta.servlet").stream() + .filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE)) + .toList(); + + assertThat(leaks) + .withFailMessage( + "jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n" + + "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.", + leaks) + .isEmpty(); + } + + /** + * 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다. + */ + @Test + void transportDoesNotReachIntoExecutionOrRegistry() throws IOException { + List violations = sourcesImportingAny(List.of( + "io.shinhanlife.dat.biz.mcp.execute.", + "io.shinhanlife.dat.biz.mcp.registry.")) + .stream() + .filter(path -> normalize(path).contains(TRANSPORT_PACKAGE)) + .toList(); + + assertThat(violations) + .withFailMessage( + "transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s", + violations) + .isEmpty(); + } + + /** + * main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다. + */ + private List sourcesImportingAny(List importPrefixes) throws IOException { + try (Stream paths = Files.walk(MAIN_SOURCES)) { + return paths.filter(path -> path.toString().endsWith(".java")) + .filter(path -> declaresAnyImport(path, importPrefixes)) + .toList(); + } + } + + /** + * main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다. + */ + private List sourcesImporting(String importPrefix) throws IOException { + try (Stream paths = Files.walk(MAIN_SOURCES)) { + return paths.filter(path -> path.toString().endsWith(".java")) + .filter(path -> declaresImport(path, importPrefix)) + .toList(); + } + } + + /** + * 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다. + */ + private boolean declaresImport(Path path, String importPrefix) { + try (Stream lines = Files.lines(path)) { + return lines.anyMatch(line -> line.startsWith("import " + importPrefix)); + } catch (IOException exception) { + throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception); + } + } + + /** + * 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다. + */ + private boolean declaresAnyImport(Path path, List importPrefixes) { + try (Stream lines = Files.lines(path)) { + return lines.anyMatch(line -> importPrefixes.stream() + .anyMatch(importPrefix -> line.startsWith("import " + importPrefix))); + } catch (IOException exception) { + throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception); + } + } + + /** + * OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다. + */ + private String normalize(Path path) { + return path.toString().replace('\\', '/'); + } +}