diff --git a/src/test/java/io/github/randomcodespace/iq/detector/config/KubernetesDetectorExpandedTest.java b/src/test/java/io/github/randomcodespace/iq/detector/config/KubernetesDetectorExpandedTest.java new file mode 100644 index 00000000..bb4f83ef --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/config/KubernetesDetectorExpandedTest.java @@ -0,0 +1,450 @@ +package io.github.randomcodespace.iq.detector.config; + +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.model.EdgeKind; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Expanded branch-coverage tests for KubernetesDetector. + * Targets the 69% missed-line gap reported by SonarCloud. + */ +class KubernetesDetectorExpandedTest { + + private final KubernetesDetector detector = new KubernetesDetector(); + + // ── Helper to build the minimal parsedData map for a single-doc YAML ── + private DetectorContext singleDoc(Map docContent) { + Map parsedData = Map.of( + "type", "yaml", + "data", docContent + ); + return new DetectorContext("k8s/manifest.yaml", "yaml", "", parsedData, null); + } + + private DetectorContext multiDoc(List> docs) { + Map parsedData = Map.of( + "type", "yaml_multi", + "documents", docs + ); + return new DetectorContext("k8s/multi.yaml", "yaml", "", parsedData, null); + } + + // ── Null / empty parsedData ── + + @Test + void returnsEmptyWhenNoParsedData() { + DetectorContext ctx = new DetectorContext("k8s/empty.yaml", "yaml", ""); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty()); + } + + @Test + void returnsEmptyWhenNullParsedData() { + DetectorContext ctx = new DetectorContext("k8s/null.yaml", "yaml", "", null, null); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty()); + } + + @Test + void returnsEmptyForUnknownType() { + Map parsedData = Map.of("type", "json", "data", Map.of("kind", "Deployment")); + DetectorContext ctx = new DetectorContext("k8s/unknown.yaml", "yaml", "", parsedData, null); + assertTrue(detector.detect(ctx).nodes().isEmpty()); + } + + // ── ConfigMap ── + + @Test + void detectsConfigMap() { + DetectorContext ctx = singleDoc(Map.of( + "kind", "ConfigMap", + "metadata", Map.of("name", "app-config", "namespace", "default"), + "spec", Map.of() + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.INFRA_RESOURCE)); + } + + // ── Secret ── + + @Test + void detectsSecret() { + DetectorContext ctx = singleDoc(Map.of( + "kind", "Secret", + "metadata", Map.of("name", "db-secret", "namespace", "prod"), + "spec", Map.of() + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } + + // ── StatefulSet ── + + @Test + void detectsStatefulSet() { + Map podSpec = Map.of( + "containers", List.of( + Map.of("name", "db", "image", "postgres:15") + ) + ); + Map spec = Map.of( + "selector", Map.of("matchLabels", Map.of("app", "postgres")), + "template", Map.of( + "metadata", Map.of("labels", Map.of("app", "postgres")), + "spec", podSpec + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "StatefulSet", + "metadata", Map.of("name", "postgres", "namespace", "data"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.INFRA_RESOURCE)); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.CONFIG_KEY)); + } + + // ── DaemonSet ── + + @Test + void detectsDaemonSet() { + Map spec = Map.of( + "selector", Map.of("matchLabels", Map.of("app", "monitor")), + "template", Map.of( + "metadata", Map.of("labels", Map.of("app", "monitor")), + "spec", Map.of("containers", List.of( + Map.of("name", "agent", "image", "monitoring:latest") + )) + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "DaemonSet", + "metadata", Map.of("name", "monitor-agent"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } + + // ── Job ── + + @Test + void detectsJob() { + Map spec = Map.of( + "template", Map.of( + "spec", Map.of("containers", List.of( + Map.of("name", "migrator", "image", "migration:1.0") + )) + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Job", + "metadata", Map.of("name", "db-migration"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.CONFIG_KEY)); + } + + // ── CronJob ── + + @Test + void detectsCronJob() { + Map innerSpec = Map.of( + "template", Map.of( + "spec", Map.of("containers", List.of( + Map.of("name", "reporter", "image", "reporter:2.0") + )) + ) + ); + Map spec = Map.of( + "schedule", "0 * * * *", + "jobTemplate", Map.of("spec", innerSpec) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "CronJob", + "metadata", Map.of("name", "daily-report"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.CONFIG_KEY)); + } + + // ── Pod ── + + @Test + void detectsPod() { + Map spec = Map.of( + "containers", List.of( + Map.of("name", "web", "image", "nginx:1.21") + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Pod", + "metadata", Map.of("name", "single-pod"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.CONFIG_KEY)); + } + + // ── Container with ports and env vars ── + + @Test + void detectsContainerWithPortsAndEnvVars() { + Map spec = Map.of( + "template", Map.of( + "spec", Map.of("containers", List.of( + Map.of( + "name", "api", + "image", "api:latest", + "ports", List.of( + Map.of("containerPort", 8080, "protocol", "TCP") + ), + "env", List.of( + Map.of("name", "DB_URL", "value", "jdbc:..."), + Map.of("name", "API_KEY", "value", "secret") + ) + ) + )) + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Deployment", + "metadata", Map.of("name", "api-deploy", "namespace", "default"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + var containerNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.CONFIG_KEY) + .findFirst(); + assertTrue(containerNode.isPresent()); + assertNotNull(containerNode.get().getProperties().get("ports")); + assertNotNull(containerNode.get().getProperties().get("env_vars")); + } + + // ── Container with init containers ── + + @Test + void detectsInitContainers() { + Map spec = Map.of( + "template", Map.of( + "spec", Map.of( + "containers", List.of(Map.of("name", "main", "image", "app:1.0")), + "initContainers", List.of(Map.of("name", "init", "image", "busybox")) + ) + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Deployment", + "metadata", Map.of("name", "app-with-init"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + // Should detect both main + init containers as CONFIG_KEY nodes + long containerNodes = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.CONFIG_KEY) + .count(); + assertEquals(2, containerNodes); + } + + // ── Ingress ── + + @Test + void detectsIngressWithRules() { + Map spec = Map.of( + "rules", List.of( + Map.of("host", "api.example.com", + "http", Map.of("paths", List.of( + Map.of("path", "/api", + "backend", Map.of( + "service", Map.of("name", "api-svc", "port", Map.of("number", 80)) + )) + ))) + ) + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Ingress", + "metadata", Map.of("name", "main-ingress"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } + + @Test + void detectsIngressWithDefaultBackend() { + Map spec = Map.of( + "defaultBackend", Map.of( + "service", Map.of("name", "default-svc", "port", Map.of("number", 80)) + ), + "rules", List.of() + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Ingress", + "metadata", Map.of("name", "default-ingress"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } + + // ── Ingress -> Service -> Deployment cross-resource edges ── + + @Test + void detectsIngressToServiceEdge() { + List> docs = List.of( + Map.of( + "kind", "Ingress", + "metadata", Map.of("name", "web-ingress", "namespace", "default"), + "spec", Map.of( + "rules", List.of( + Map.of("http", Map.of("paths", List.of( + Map.of("backend", Map.of("service", + Map.of("name", "web-svc"))) + ))) + ) + ) + ), + Map.of( + "kind", "Service", + "metadata", Map.of("name", "web-svc", "namespace", "default"), + "spec", Map.of("selector", Map.of("app", "web")) + ) + ); + DetectorContext ctx = multiDoc(docs); + DetectorResult result = detector.detect(ctx); + assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.CONNECTS_TO)); + } + + // ── Namespace ── + + @Test + void detectsNamespaceResource() { + DetectorContext ctx = singleDoc(Map.of( + "kind", "Namespace", + "metadata", Map.of("name", "production"), + "spec", Map.of() + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } + + // ── Default namespace when not specified ── + + @Test + void usesDefaultNamespaceWhenMissing() { + DetectorContext ctx = singleDoc(Map.of( + "kind", "Service", + "metadata", Map.of("name", "my-svc"), + "spec", Map.of() + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + // FQN should include "default" namespace + String fqn = result.nodes().get(0).getFqn(); + assertTrue(fqn.contains("default")); + } + + // ── Metadata with labels and annotations ── + + @Test + void preservesLabelsAndAnnotations() { + Map meta = Map.of( + "name", "labeled-deploy", + "labels", Map.of("app", "myapp", "env", "prod"), + "annotations", Map.of("prometheus.io/scrape", "true") + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Deployment", + "metadata", meta, + "spec", Map.of() + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + var node = result.nodes().get(0); + assertNotNull(node.getProperties().get("labels")); + assertNotNull(node.getProperties().get("annotations")); + } + + // ── Non-K8s yaml_multi document gets filtered ── + + @Test + void filtersNonK8sDocumentsInMultiDoc() { + List> docs = List.of( + Map.of("kind", "Deployment", + "metadata", Map.of("name", "app"), + "spec", Map.of()), + Map.of("name", "not-k8s", "version", "1.0") // no kind field + ); + DetectorContext ctx = multiDoc(docs); + DetectorResult result = detector.detect(ctx); + // Only the Deployment should be detected + long k8sNodes = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.INFRA_RESOURCE) + .count(); + assertEquals(1, k8sNodes); + } + + // ── Determinism ── + + @Test + void isDeterministic() { + List> docs = List.of( + Map.of( + "kind", "Deployment", + "metadata", Map.of("name", "api", "namespace", "default"), + "spec", Map.of( + "selector", Map.of("matchLabels", Map.of("app", "api")), + "template", Map.of( + "metadata", Map.of("labels", Map.of("app", "api")), + "spec", Map.of("containers", List.of( + Map.of("name", "api", "image", "api:1.0") + )) + ) + ) + ), + Map.of( + "kind", "Service", + "metadata", Map.of("name", "api-svc", "namespace", "default"), + "spec", Map.of("selector", Map.of("app", "api")) + ) + ); + DetectorContext ctx = multiDoc(docs); + DetectorResult r1 = detector.detect(ctx); + DetectorResult r2 = detector.detect(ctx); + assertEquals(r1.nodes().size(), r2.nodes().size()); + assertEquals(r1.edges().size(), r2.edges().size()); + } + + // ── Old-style backend.serviceName (pre-networking.k8s.io/v1) ── + + @Test + void detectsIngressWithOldStyleServiceName() { + Map spec = Map.of( + "backend", Map.of("serviceName", "legacy-svc", "servicePort", 80), + "rules", List.of() + ); + DetectorContext ctx = singleDoc(Map.of( + "kind", "Ingress", + "metadata", Map.of("name", "legacy-ingress"), + "spec", spec + )); + DetectorResult result = detector.detect(ctx); + assertFalse(result.nodes().isEmpty()); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/java/JavaDetectorsBranchCoverageTest.java b/src/test/java/io/github/randomcodespace/iq/detector/java/JavaDetectorsBranchCoverageTest.java new file mode 100644 index 00000000..9bc1349e --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/java/JavaDetectorsBranchCoverageTest.java @@ -0,0 +1,1412 @@ +package io.github.randomcodespace.iq.detector.java; + +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.detector.DetectorTestUtils; +import io.github.randomcodespace.iq.model.EdgeKind; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Branch-coverage-focused tests for Java detectors targeting SonarCloud coverage gaps. + * Targets: ClassHierarchyDetector, SpringRestDetector, SpringSecurityDetector, + * JpaEntityDetector, PublicApiDetector, ConfigDefDetector, MicronautDetector. + */ +class JavaDetectorsBranchCoverageTest { + + private static DetectorContext ctx(String language, String content) { + return DetectorTestUtils.contextFor(language, content); + } + + // ======================================================================== + // ClassHierarchyDetector + // ======================================================================== + @Nested + class ClassHierarchyDetectorBranches { + private final ClassHierarchyDetector d = new ClassHierarchyDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Test.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyOnEmpty() { + assertTrue(d.detect(new DetectorContext("Test.java", "java", "")).nodes().isEmpty()); + } + + @Test + void detectsSimpleClass() { + String code = """ + public class Foo {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(NodeKind.CLASS, r.nodes().get(0).getKind()); + } + + @Test + void detectsAbstractClass() { + String code = """ + public abstract class AbstractFoo {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(NodeKind.ABSTRACT_CLASS, r.nodes().get(0).getKind()); + } + + @Test + void detectsInterface() { + String code = """ + public interface IFoo {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(NodeKind.INTERFACE, r.nodes().get(0).getKind()); + } + + @Test + void detectsEnum() { + String code = """ + public enum Color { RED, GREEN, BLUE } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(NodeKind.ENUM, r.nodes().get(0).getKind()); + } + + @Test + void detectsAnnotationType() { + String code = """ + public @interface MyAnnotation {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(NodeKind.ANNOTATION_TYPE, r.nodes().get(0).getKind()); + } + + @Test + void detectsClassExtends() { + String code = """ + public class Derived extends Base {} + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.EXTENDS)); + assertEquals("Base", r.nodes().get(0).getProperties().get("superclass")); + } + + @Test + void detectsClassImplementsMultiple() { + String code = """ + public class FooImpl implements Runnable, Serializable {} + """; + var r = d.detect(ctx("java", code)); + long implEdges = r.edges().stream().filter(e -> e.getKind() == EdgeKind.IMPLEMENTS).count(); + assertEquals(2, implEdges); + } + + @Test + void detectsClassExtendsAndImplements() { + String code = """ + public class ServiceImpl extends BaseService implements IService, Cloneable {} + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.EXTENDS)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.IMPLEMENTS)); + } + + @Test + void detectsInterfaceExtendsInterface() { + String code = """ + public interface IExtended extends IBase {} + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.EXTENDS)); + } + + @Test + void detectsInterfaceExtendsMultiple() { + String code = """ + public interface IFull extends IBase1, IBase2, IBase3 {} + """; + var r = d.detect(ctx("java", code)); + long extendsEdges = r.edges().stream().filter(e -> e.getKind() == EdgeKind.EXTENDS).count(); + assertEquals(3, extendsEdges); + } + + @Test + void detectsEnumImplementingInterface() { + String code = """ + public enum Status implements Displayable {} + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.IMPLEMENTS)); + } + + @Test + void detectsFinalClass() { + String code = """ + public final class ImmutableFoo {} + """; + var r = d.detect(ctx("java", code)); + assertEquals(NodeKind.CLASS, r.nodes().get(0).getKind()); + assertEquals(true, r.nodes().get(0).getProperties().get("is_final")); + } + + @Test + void detectsInnerClassAndOuterClass() { + String code = """ + public class Outer { + public class Inner {} + } + """; + var r = d.detect(ctx("java", code)); + // Both outer and inner class should be detected + assertTrue(r.nodes().size() >= 2); + } + + @Test + void detectsPrivateAndProtectedVisibility() { + String code = """ + public class Outer { + private class PrivateInner {} + protected class ProtectedInner {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> "private".equals(n.getProperties().get("visibility")))); + assertTrue(r.nodes().stream().anyMatch(n -> "protected".equals(n.getProperties().get("visibility")))); + } + + @Test + void detectsPackagePrivateClass() { + String code = """ + class PackagePrivateFoo {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("package-private", r.nodes().get(0).getProperties().get("visibility")); + } + + @Test + void detectsFullyQualifiedWithPackage() { + String code = """ + package com.example.model; + public class MyModel extends BaseModel implements Serializable {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + // FQN should include package + String fqn = r.nodes().get(0).getFqn(); + assertNotNull(fqn); + assertTrue(fqn.contains("MyModel")); + } + + @Test + void isDeterministic() { + String code = """ + public class Foo extends Bar implements Baz, Qux {} + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("java.class_hierarchy", d.getName()); + } + + @Test + void getSupportedLanguages() { + assertTrue(d.getSupportedLanguages().contains("java")); + } + + // ---- Regex fallback branch: provide un-parseable content ---- + @Test + void regexFallback_simpleClass() { + // Use a string that won't parse as valid Java + String code = "public class BrokenFoo\n"; // no body, forces regex fallback + var r = d.detect(ctx("java", code)); + // Might get a node from regex + assertNotNull(r); + } + + @Test + void regexFallback_abstractClass() { + String code = "abstract class AbstractBar extends Something {\n}"; + var r = d.detect(ctx("java", code)); + assertNotNull(r); + } + + @Test + void regexFallback_interfaceExtendsMultiple() { + String code = "public interface IFull extends A, B, C {\n}"; + var r = d.detect(ctx("java", code)); + assertNotNull(r); + } + + @Test + void regexFallback_enum() { + String code = "public enum Color implements Serializable {\nRED, GREEN\n}"; + var r = d.detect(ctx("java", code)); + assertNotNull(r); + } + + @Test + void regexFallback_annotationType() { + String code = "public @interface MyAnnotation {\n}"; + var r = d.detect(ctx("java", code)); + assertNotNull(r); + } + } + + // ======================================================================== + // SpringRestDetector + // ======================================================================== + @Nested + class SpringRestDetectorBranches { + private final SpringRestDetector d = new SpringRestDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Ctrl.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyOnEmpty() { + assertTrue(d.detect(new DetectorContext("Ctrl.java", "java", "")).nodes().isEmpty()); + } + + @Test + void detectsGetMapping() { + String code = """ + @RestController + public class UserController { + @GetMapping("/users") + public List list() { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("GET", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsPostMapping() { + String code = """ + @RestController + public class UserController { + @PostMapping("/users") + public User create(@RequestBody User u) { return u; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("POST", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsPutMapping() { + String code = """ + @RestController + public class UserController { + @PutMapping("/users/{id}") + public User update(@PathVariable Long id, @RequestBody User u) { return u; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("PUT", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsDeleteMapping() { + String code = """ + @RestController + public class UserController { + @DeleteMapping("/users/{id}") + public void delete(@PathVariable Long id) {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("DELETE", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsPatchMapping() { + String code = """ + @RestController + public class UserController { + @PatchMapping("/users/{id}") + public User patch(@PathVariable Long id) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("PATCH", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsRequestMappingWithMethodGet() { + String code = """ + @Controller + public class SearchController { + @RequestMapping(value = "/search", method = RequestMethod.GET) + public String search() { return "results"; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("GET", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsRequestMappingNoMethod() { + String code = """ + @Controller + public class ApiController { + @RequestMapping("/api") + public String api() { return "ok"; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("ALL", r.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsClassLevelRequestMapping() { + String code = """ + @RestController + @RequestMapping("/api/v1") + public class ApiV1Controller { + @GetMapping("/items") + public List items() { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + String path = (String) r.nodes().get(0).getProperties().get("path"); + assertTrue(path.contains("/api/v1")); + assertTrue(path.contains("/items")); + } + + @Test + void detectsProducesAndConsumes() { + String code = """ + @RestController + public class MediaController { + @PostMapping(value = "/data", produces = "application/json", consumes = "application/json") + public String process() { return "{}"; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertNotNull(r.nodes().get(0).getProperties().get("produces")); + assertNotNull(r.nodes().get(0).getProperties().get("consumes")); + } + + @Test + void skipsModelAttributeMethods() { + String code = """ + @Controller + public class FormController { + @ModelAttribute + public User populateUser() { return new User(); } + @GetMapping("/form") + public String showForm() { return "form"; } + } + """; + var r = d.detect(ctx("java", code)); + // Only the @GetMapping method should be an endpoint, not @ModelAttribute + assertEquals(1, r.nodes().stream().filter(n -> n.getKind() == NodeKind.ENDPOINT).count()); + } + + @Test + void skipsExceptionHandlerMethods() { + String code = """ + @RestController + public class ApiController { + @ExceptionHandler(Exception.class) + @GetMapping("/error") + public String error() { return "error"; } + @GetMapping("/ok") + public String ok() { return "ok"; } + } + """; + var r = d.detect(ctx("java", code)); + // @ExceptionHandler method is skipped + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsRequestBodyAndPathVariable() { + String code = """ + @RestController + public class ItemController { + @PostMapping("/items/{id}") + public Item update(@PathVariable Long id, @RequestBody Item item) { return item; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var params = (List) r.nodes().get(0).getProperties().get("parameters"); + assertNotNull(params); + assertFalse(params.isEmpty()); + } + + @Test + void detectsMultipleEndpoints() { + String code = """ + @RestController + @RequestMapping("/orders") + public class OrderController { + @GetMapping + public List list() { return null; } + @PostMapping + public String create() { return ""; } + @GetMapping("/{id}") + public String get(@PathVariable Long id) { return ""; } + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) {} + } + """; + var r = d.detect(ctx("java", code)); + assertEquals(4, r.nodes().stream().filter(n -> n.getKind() == NodeKind.ENDPOINT).count()); + } + + @Test + void detectsRestTemplateCallsEdge() { + String code = """ + @Service + public class ClientService { + private RestTemplate restTemplate = new RestTemplate(); + public String fetch() { + return restTemplate.getForObject("/api/data", String.class); + } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.CALLS)); + } + + @Test + void detectsWebClientCallsEdge() { + String code = """ + @Service + public class ReactiveClient { + private WebClient webClient = WebClient.create(); + public String fetch() { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.CALLS)); + } + + @Test + void detectsFeignClientCallsEdge() { + String code = """ + @FeignClient("payment-service") + public interface PaymentClient { + @GetMapping("/pay") + String pay(); + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.CALLS)); + } + + @Test + void isDeterministic() { + String code = """ + @RestController + @RequestMapping("/api") + public class TestController { + @GetMapping("/items") + public List list() { return null; } + @PostMapping("/items") + public String create(@RequestBody String item) { return item; } + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("spring_rest", d.getName()); + } + + @Test + void getSupportedLanguages() { + assertTrue(d.getSupportedLanguages().contains("java")); + } + } + + // ======================================================================== + // SpringSecurityDetector + // ======================================================================== + @Nested + class SpringSecurityDetectorBranches { + private final SpringSecurityDetector d = new SpringSecurityDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Sec.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyOnEmpty() { + assertTrue(d.detect(new DetectorContext("Sec.java", "java", "")).nodes().isEmpty()); + } + + @Test + void detectsEnableWebSecurity() { + String code = """ + @Configuration + @EnableWebSecurity + public class SecurityConfig {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("spring_security", r.nodes().get(0).getProperties().get("auth_type")); + } + + @Test + void detectsEnableMethodSecurity() { + String code = """ + @Configuration + @EnableMethodSecurity + public class MethodSecurityConfig {} + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsSecurityFilterChain() { + String code = """ + @Configuration + public class SecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http.build(); + } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.nodes().stream().anyMatch(n -> + "spring_security".equals(n.getProperties().get("auth_type")))); + } + + @Test + void detectsAuthorizeHttpRequests() { + String code = """ + @Configuration + public class SecurityConfig { + public SecurityFilterChain configure(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/public/**").permitAll() + .anyRequest().authenticated()); + return http.build(); + } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> + n.getLabel() != null && n.getLabel().contains("authorizeHttpRequests"))); + } + + @Test + void detectsPreAuthorizeWithHasRole() { + String code = """ + @Service + public class AdminService { + @PreAuthorize("hasRole('ADMIN')") + public void adminOnly() {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var roles = (List) r.nodes().get(0).getProperties().get("roles"); + assertNotNull(roles); + assertTrue(roles.contains("ADMIN")); + } + + @Test + void detectsPreAuthorizeWithHasAnyRole() { + String code = """ + @Service + public class ContentService { + @PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')") + public void editContent() {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var roles = (List) r.nodes().get(0).getProperties().get("roles"); + assertNotNull(roles); + assertFalse(roles.isEmpty()); + } + + @Test + void detectsSecuredSingleRole() { + String code = """ + @Service + public class ReportService { + @Secured("ROLE_ADMIN") + public void generateReport() {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var roles = (List) r.nodes().get(0).getProperties().get("roles"); + assertNotNull(roles); + } + + @Test + void detectsSecuredMultipleRoles() { + String code = """ + @Service + public class DataService { + @Secured({"ROLE_ADMIN", "ROLE_MANAGER"}) + public void sensitiveOp() {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsRolesAllowed() { + String code = """ + @Service + public class InventoryService { + @RolesAllowed("ROLE_WAREHOUSE") + public void manage() {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsMultipleAnnotationsInOneClass() { + String code = """ + @Service + public class SecuredService { + @PreAuthorize("hasRole('USER')") + public void userMethod() {} + @Secured("ROLE_ADMIN") + public void adminMethod() {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().size() >= 2); + } + + @Test + void isDeterministic() { + String code = """ + @Configuration + @EnableWebSecurity + public class SecurityConfig { + @Bean + public SecurityFilterChain chain(HttpSecurity http) { return http.build(); } + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("spring_security", d.getName()); + } + } + + // ======================================================================== + // JpaEntityDetector + // ======================================================================== + @Nested + class JpaEntityDetectorBranches { + private final JpaEntityDetector d = new JpaEntityDetector(); + + @Test + void detectsManyToManyRelationship() { + String code = """ + @Entity + public class Student { + @Id private Long id; + @ManyToMany + private List courses; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.MAPS_TO)); + } + + @Test + void detectsEnumFieldType() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + public class Order { + @Id private Long id; + @Column(name = "status") + private String status; + @Column + private String description; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsVersionField() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + public class Product { + @Id private Long id; + @Column(name = "version_num") + private int version; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var columns = (List) r.nodes().get(0).getProperties().get("columns"); + assertNotNull(columns); + } + + @Test + void detectsTargetEntityOnRelation() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + public class Order { + @Id private Long id; + @OneToMany(targetEntity = OrderItem.class) + private List items; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.MAPS_TO)); + } + + @Test + void detectsTableWithBareValueAnnotation() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + @Table("users_table") + public class User { + @Id private Long id; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsMultipleEntitiesInOneFile() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + public class Foo { + @Id private Long id; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsConnectsToDbEdgeWithoutRegistry() { + String code = """ + @Entity + public class Widget { + @Id private Long id; + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.CONNECTS_TO)); + } + + @Test + void isDeterministic() { + String code = """ + package com.example; + import javax.persistence.*; + @Entity + @Table(name = "items") + public class Item { + @Id private Long id; + @Column(name = "item_name") private String name; + @ManyToOne private Category category; + @OneToMany(mappedBy = "item") private List tags; + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + } + + // ======================================================================== + // PublicApiDetector + // ======================================================================== + @Nested + class PublicApiDetectorBranches { + private final PublicApiDetector d = new PublicApiDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Api.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyOnEmpty() { + assertTrue(d.detect(new DetectorContext("Api.java", "java", "")).nodes().isEmpty()); + } + + @Test + void detectsPublicMethod() { + String code = """ + public class UserService { + public User findById(Long id) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("public", r.nodes().get(0).getProperties().get("visibility")); + } + + @Test + void detectsProtectedMethod() { + String code = """ + public abstract class BaseService { + protected User loadUser(Long id) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals("protected", r.nodes().get(0).getProperties().get("visibility")); + } + + @Test + void skipsPrivateMethod() { + String code = """ + public class InternalService { + private void internalHelper() {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void skipsGetterAndSetter() { + String code = """ + public class User { + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public boolean isActive() { return active; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void skipsToStringHashCodeEquals() { + String code = """ + public class Entity { + public String toString() { return ""; } + public int hashCode() { return 0; } + public boolean equals(Object o) { return false; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void detectsStaticPublicMethod() { + String code = """ + public class Factory { + public static Factory create(String config) { return new Factory(); } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(true, r.nodes().get(0).getProperties().get("is_static")); + } + + @Test + void detectsAbstractPublicMethod() { + String code = """ + public abstract class Template { + public abstract void execute(String input); + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertEquals(true, r.nodes().get(0).getProperties().get("is_abstract")); + } + + @Test + void detectsInterfaceMethods() { + String code = """ + public interface Repository { + User findById(Long id); + List findAll(); + } + """; + var r = d.detect(ctx("java", code)); + // Interface methods are implicitly public + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsMultipleParams() { + String code = """ + public class SearchService { + public List search(String query, int page, int size) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + @SuppressWarnings("unchecked") + var params = (List) r.nodes().get(0).getProperties().get("parameters"); + assertEquals(3, params.size()); + } + + @Test + void detectsReturnType() { + String code = """ + public class ReportService { + public ResponseEntity generate(Long id) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertNotNull(r.nodes().get(0).getProperties().get("return_type")); + } + + @Test + void detectsDefinesEdge() { + String code = """ + public class OrderService { + public Order findOrder(Long id) { return null; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEFINES)); + } + + @Test + void returnsEmptyWhenNoClass() { + // Content without a class/interface declaration + String code = "// Just a comment\nimport java.util.*;"; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void isDeterministic() { + String code = """ + public class ApiService { + public String doA(String x) { return x; } + public int doB(int n) { return n; } + protected void doC() {} + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("java.public_api", d.getName()); + } + } + + // ======================================================================== + // ConfigDefDetector + // ======================================================================== + @Nested + class ConfigDefDetectorBranches { + private final ConfigDefDetector d = new ConfigDefDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Cfg.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyWhenNoRelevantAnnotations() { + String code = """ + public class PlainClass { + private String name; + } + """; + assertTrue(d.detect(ctx("java", code)).nodes().isEmpty()); + } + + @Test + void detectsSpringValueAnnotation() { + String code = """ + @Component + public class AppConfig { + @Value("${server.port}") + private int serverPort; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.nodes().stream().anyMatch(n -> "server.port".equals(n.getLabel()))); + } + + @Test + void detectsMultipleValueAnnotations() { + String code = """ + @Component + public class ServiceConfig { + @Value("${db.url}") + private String dbUrl; + @Value("${db.password}") + private String dbPass; + @Value("${app.timeout}") + private int timeout; + } + """; + var r = d.detect(ctx("java", code)); + assertEquals(3, r.nodes().size()); + } + + @Test + void detectsConfigurationProperties() { + String code = """ + @ConfigurationProperties("app.kafka") + public class KafkaProperties { + private String brokers; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.nodes().stream().anyMatch(n -> "app.kafka".equals(n.getLabel()))); + } + + @Test + void detectsConfigurationPropertiesWithPrefix() { + String code = """ + @ConfigurationProperties(prefix = "spring.datasource") + public class DataSourceConfig { + private String url; + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsKafkaConfigDefDefine() { + String code = """ + public class MyConfig { + static final ConfigDef CONFIG = new ConfigDef() + .define("my.key", Type.STRING, "default", "description") + .define("my.other.key", Type.INT, 42, "other desc"); + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void deduplicatesSameKey() { + String code = """ + @Component + public class ServiceA { + @Value("${common.key}") + private String key1; + @Value("${common.key}") + private String key2; + } + """; + var r = d.detect(ctx("java", code)); + // Same key should only appear once + long count = r.nodes().stream().filter(n -> "common.key".equals(n.getLabel())).count(); + assertEquals(1, count); + } + + @Test + void detectsValueOnMethodParameter() { + String code = """ + @Component + public class ServiceB { + public void init(@Value("${init.timeout}") int timeout) {} + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + assertTrue(r.nodes().stream().anyMatch(n -> "init.timeout".equals(n.getLabel()))); + } + + @Test + void detectsReadsConfigEdge() { + String code = """ + @Component + public class ConfigReader { + @Value("${app.name}") + private String appName; + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.READS_CONFIG)); + } + + @Test + void isDeterministic() { + String code = """ + @Component + public class AppProps { + @Value("${server.port}") private int port; + @Value("${server.host}") private String host; + @Value("${server.timeout}") private long timeout; + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("config_def", d.getName()); + } + } + + // ======================================================================== + // MicronautDetector + // ======================================================================== + @Nested + class MicronautDetectorBranches { + private final MicronautDetector d = new MicronautDetector(); + + @Test + void returnsEmptyOnNull() { + assertTrue(d.detect(new DetectorContext("Mn.java", "java", null)).nodes().isEmpty()); + } + + @Test + void returnsEmptyOnEmpty() { + assertTrue(d.detect(new DetectorContext("Mn.java", "java", "")).nodes().isEmpty()); + } + + @Test + void returnsEmptyWithoutMicronautIndicator() { + // Has @Controller but no io.micronaut import or @Client + String code = """ + @Controller("/api") + public class ApiController { + @Get("/items") + public String list() { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void detectsControllerWithMicronautImport() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/api") + public class ApiController { + @Get("/items") + public String list() { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertFalse(r.nodes().isEmpty()); + } + + @Test + void detectsGetEndpoint() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/users") + public class UserController { + @Get("/{id}") + public String getUser(Long id) { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.ENDPOINT)); + assertTrue(r.nodes().stream().anyMatch(n -> + "GET".equals(n.getProperties().get("http_method")))); + } + + @Test + void detectsPostEndpoint() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/users") + public class UserController { + @Post + public String create() { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.ENDPOINT)); + } + + @Test + void detectsPutEndpoint() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/items") + public class ItemController { + @Put("/{id}") + public String update(Long id) { return ""; } + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.ENDPOINT)); + } + + @Test + void detectsDeleteEndpoint() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/items") + public class ItemController { + @Delete("/{id}") + public void delete(Long id) {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.ENDPOINT)); + } + + @Test + void detectsSingleton() { + String code = """ + import io.micronaut.http.annotation.*; + @Singleton + @Client("payment-service") + public class PaymentClient { + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.MIDDLEWARE)); + } + + @Test + void detectsClientAnnotation() { + String code = """ + import io.micronaut.http.client.annotation.Client; + @Client("http://inventory-service") + public interface InventoryClient { + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.CLASS)); + assertTrue(r.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEPENDS_ON)); + } + + @Test + void detectsInjectAnnotation() { + String code = """ + import io.micronaut.context.annotation.*; + @Client("http://service") + public class ServiceClient { + @Inject + private SomeDependency dep; + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> + n.getAnnotations() != null && n.getAnnotations().contains("@Inject"))); + } + + @Test + void detectsScheduledAnnotation() { + String code = """ + import io.micronaut.scheduling.annotation.Scheduled; + @Client("http://monitor") + public class HealthMonitor { + @Scheduled(fixedRate = "10s") + public void check() {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.EVENT)); + } + + @Test + void detectsEventListener() { + String code = """ + import io.micronaut.context.event.*; + @Client("http://service") + public class StartupListener { + @EventListener + public void onStart(StartupEvent event) {} + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.EVENT)); + } + + @Test + void detectsEndpointWithoutControllerPath() { + // @Get without @Controller, but with @Client as indicator + String code = """ + import io.micronaut.http.annotation.*; + @Client("http://api") + public interface ApiClient { + @Get("/data") + String getData(); + } + """; + var r = d.detect(ctx("java", code)); + assertTrue(r.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.ENDPOINT)); + } + + @Test + void isDeterministic() { + String code = """ + import io.micronaut.http.annotation.*; + @Controller("/orders") + public class OrderController { + @Get + public String list() { return ""; } + @Post + public String create() { return ""; } + @Delete("/{id}") + public void delete(Long id) {} + } + """; + DetectorTestUtils.assertDeterministic(d, ctx("java", code)); + } + + @Test + void getName() { + assertEquals("micronaut", d.getName()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/CeleryTaskDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/CeleryTaskDetectorTest.java index 3932644c..e8e2e130 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/CeleryTaskDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/CeleryTaskDetectorTest.java @@ -85,4 +85,138 @@ def process_data(data): DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsSharedTask() { + String code = """ + @shared_task + def cleanup(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.nodes().size()); + var queueNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.QUEUE).findFirst().orElseThrow(); + assertEquals("celery", queueNode.getProperties().get("broker")); + } + + @Test + void taskQueueNodeHasTaskNameProperty() { + String code = """ + @app.task + def process(data): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var queueNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.QUEUE).findFirst().orElseThrow(); + assertEquals("process", queueNode.getProperties().get("task_name")); + assertEquals("process", queueNode.getProperties().get("function")); + } + + @Test + void taskMethodNodeHasFqn() { + String code = """ + @app.task + def my_task(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var methodNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.METHOD).findFirst().orElseThrow(); + assertNotNull(methodNode.getFqn()); + assertTrue(methodNode.getFqn().contains("my_task")); + } + + @Test + void consumesEdgeGoesFromMethodToQueue() { + String code = """ + @app.task + def my_task(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var consumesEdge = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.CONSUMES).findFirst().orElseThrow(); + assertNotNull(consumesEdge.getSourceId()); + assertTrue(consumesEdge.getSourceId().startsWith("method:")); + } + + @Test + void detectsApplyAsync() { + String code = """ + send_email.apply_async(args=["user@test.com"], countdown=60) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.edges().size()); + assertEquals(EdgeKind.PRODUCES, result.edges().get(0).getKind()); + } + + @Test + void detectsSignatureCall() { + String code = """ + task_sig = my_task.s(arg1) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.edges().size()); + assertEquals(EdgeKind.PRODUCES, result.edges().get(0).getKind()); + } + + @Test + void multipleTaskDefinitions() { + String code = """ + @app.task + def task_a(): + pass + + @shared_task + def task_b(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + long queueCount = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.QUEUE).count(); + long methodCount = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.METHOD).count(); + assertEquals(2, queueCount); + assertEquals(2, methodCount); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + assertEquals(0, result.edges().size()); + } + + @Test + void explicitTaskNameOverridesFunctionName() { + String code = """ + @app.task(name='myapp.tasks.send_notification') + def notify_user(user_id): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("tasks.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var queueNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.QUEUE).findFirst().orElseThrow(); + assertEquals("celery:myapp.tasks.send_notification", queueNode.getLabel()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoAuthDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoAuthDetectorTest.java index 7c2c47d6..071fca57 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoAuthDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoAuthDetectorTest.java @@ -99,4 +99,149 @@ def view2(request): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsPermissionRequiredMixin() { + String code = """ + class AdminView(PermissionRequiredMixin, View): + permission_required = 'app.can_admin' + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("PermissionRequiredMixin", result.nodes().get(0).getProperties().get("mixin")); + assertEquals("AdminView", result.nodes().get(0).getProperties().get("class_name")); + } + + @Test + void detectsUserPassesTestMixin() { + String code = """ + class StaffView(UserPassesTestMixin, View): + def test_func(self): + return self.request.user.is_staff + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("UserPassesTestMixin", result.nodes().get(0).getProperties().get("mixin")); + } + + @Test + void loginRequiredHasAuthType() { + String code = """ + @login_required + def secure_view(request): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("django", result.nodes().get(0).getProperties().get("auth_type")); + assertEquals(true, result.nodes().get(0).getProperties().get("auth_required")); + } + + @Test + void loginRequiredHasAnnotations() { + String code = """ + @login_required + def secured(request): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var node = result.nodes().get(0); + assertTrue(node.getAnnotations().contains("@login_required")); + } + + @Test + void permissionRequiredHasPermissionsProperty() { + String code = """ + @permission_required("myapp.view_report") + def report_view(request): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + @SuppressWarnings("unchecked") + List perms = (List) result.nodes().get(0).getProperties().get("permissions"); + assertNotNull(perms); + assertFalse(perms.isEmpty()); + assertEquals("myapp.view_report", perms.get(0)); + } + + @Test + void userPassesTestHasTestFunctionProperty() { + String code = """ + @user_passes_test(lambda u: u.is_active) + def restricted_view(request): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + // test_function should be set from arg + assertNotNull(result.nodes().get(0).getProperties().get("test_function")); + } + + @Test + void mixinAnnotationFormat() { + String code = """ + class SecureList(LoginRequiredMixin, ListView): + model = Item + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var node = result.nodes().get(0); + assertTrue(node.getAnnotations().stream().anyMatch(a -> a.contains("LoginRequiredMixin"))); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + } + + @Test + void multipleDecoratorsCapturedSeparately() { + String code = """ + @login_required + def view_a(request): + pass + + @login_required + def view_b(request): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.nodes().size()); + } + + @Test + void allAuthTypeIsDjango() { + String code = """ + @login_required + def v1(request): pass + + @permission_required("x.y") + def v2(request): pass + + @user_passes_test(lambda u: True) + def v3(request): pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(3, result.nodes().size()); + assertTrue(result.nodes().stream() + .allMatch(n -> "django".equals(n.getProperties().get("auth_type")))); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoModelDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoModelDetectorTest.java index 9592b404..12f2b942 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoModelDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoModelDetectorTest.java @@ -7,6 +7,8 @@ import io.github.randomcodespace.iq.model.NodeKind; import org.junit.jupiter.api.Test; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.*; class DjangoModelDetectorTest { @@ -91,4 +93,147 @@ class Order(models.Model): DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsMetaTableName() { + String code = """ + class Post(models.Model): + title = models.CharField(max_length=200) + + class Meta: + db_table = 'blog_posts' + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + assertEquals("blog_posts", entityNode.getProperties().get("table_name")); + } + + @Test + void detectsMetaOrdering() { + String code = """ + class Article(models.Model): + title = models.CharField(max_length=200) + created = models.DateTimeField() + + class Meta: + ordering = ['-created'] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + assertNotNull(entityNode.getProperties().get("ordering")); + } + + @Test + void detectsManyToManyRelationship() { + String code = """ + class Article(models.Model): + tags = models.ManyToManyField("Tag") + title = models.CharField(max_length=200) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEPENDS_ON)); + } + + @Test + void detectsModelFields() { + String code = """ + class Product(models.Model): + name = models.CharField(max_length=100) + price = models.DecimalField() + stock = models.IntegerField() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + Map fields = (Map) entityNode.getProperties().get("fields"); + assertNotNull(fields); + assertTrue(fields.containsKey("name")); + assertTrue(fields.containsKey("price")); + assertTrue(fields.containsKey("stock")); + } + + @Test + void detectsManagerWithType() { + String code = """ + class PublishedManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(published=True) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var managerNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.REPOSITORY).findFirst().orElseThrow(); + assertEquals("PublishedManager", managerNode.getLabel()); + assertEquals("manager", managerNode.getProperties().get("type")); + assertEquals("django", managerNode.getProperties().get("framework")); + } + + @Test + void detectsMultipleModels() { + String code = """ + class User(models.Model): + name = models.CharField(max_length=100) + + class Profile(models.Model): + user = models.OneToOneField("User", on_delete=models.CASCADE) + bio = models.TextField() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + long entityCount = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).count(); + assertEquals(2, entityCount); + assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEPENDS_ON)); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + assertEquals(0, result.edges().size()); + } + + @Test + void databaseNodeCreatedOnce() { + String code = """ + class User(models.Model): + name = models.CharField(max_length=100) + + class Order(models.Model): + total = models.DecimalField() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + long dbNodes = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.DATABASE_CONNECTION).count(); + assertEquals(1, dbNodes); + } + + @Test + void oneToOneFieldCreatesEdge() { + String code = """ + class Profile(models.Model): + user = models.OneToOneField("User", on_delete=models.CASCADE) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEPENDS_ON)); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoViewDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoViewDetectorTest.java index 255da82e..a79aff92 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoViewDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/DjangoViewDetectorTest.java @@ -51,7 +51,6 @@ void noMatchWithoutUrlpatterns() { DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorResult result = detector.detect(ctx); - // No urlpatterns keyword, so no endpoint detection assertEquals(0, result.nodes().size()); } @@ -80,4 +79,134 @@ class UserView(APIView): DetectorContext ctx = DetectorTestUtils.contextFor("urls.py", "python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsMultipleUrlPatterns() { + String code = """ + urlpatterns = [ + path('api/users/', UserView.as_view()), + path('api/orders/', OrderView.as_view()), + re_path('^api/products/$', ProductView.as_view()), + ] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("urls.py", "python", code); + DetectorResult result = detector.detect(ctx); + + long endpointCount = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENDPOINT).count(); + assertTrue(endpointCount >= 2); + } + + @Test + void endpointNodeHasProtocol() { + String code = """ + urlpatterns = [ + path('api/items/', ItemView.as_view()), + ] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("urls.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var node = result.nodes().get(0); + assertEquals("REST", node.getProperties().get("protocol")); + assertNotNull(node.getProperties().get("view_reference")); + } + + @Test + void detectsViewSetClass() { + String code = """ + class ProductViewSet(ModelViewSet): + queryset = Product.objects.all() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals(NodeKind.CLASS, result.nodes().get(0).getKind()); + assertEquals("ProductViewSet", result.nodes().get(0).getLabel()); + } + + @Test + void detectsMixinClass() { + String code = """ + class CacheMixin(CacheMixin, View): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals(NodeKind.CLASS, result.nodes().get(0).getKind()); + } + + @Test + void classBased_hasFrameworkDjango() { + String code = """ + class ArticleView(DetailView): + model = Article + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("django", result.nodes().get(0).getProperties().get("framework")); + assertEquals("view", result.nodes().get(0).getProperties().get("stereotype")); + } + + @Test + void urlPatternViewReferenceExtracted() { + String code = """ + urlpatterns = [ + path('users/', views.user_list), + ] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("urls.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("views.user_list", result.nodes().get(0).getFqn()); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + } + + @Test + void detectsBothUrlsAndViews() { + String code = """ + urlpatterns = [ + path('api/items/', ItemView.as_view()), + ] + + class ItemView(APIView): + def get(self, request): + return Response([]) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + long endpoints = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENDPOINT).count(); + long classes = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.CLASS).count(); + assertEquals(1, endpoints); + assertEquals(1, classes); + } + + @Test + void classAnnotationIncludesBases() { + String code = """ + class OrderView(LoginRequiredMixin, APIView): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("views.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertFalse(result.nodes().get(0).getAnnotations().isEmpty()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIAuthDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIAuthDetectorTest.java index c8f70cac..b2044759 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIAuthDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIAuthDetectorTest.java @@ -103,4 +103,111 @@ async def protected(user=Depends(get_current_user)): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsDependsRequireAuth() { + String code = """ + async def get_data(auth=Depends(require_auth)): + return {} + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("require_auth", result.nodes().get(0).getProperties().get("dependency")); + } + + @Test + void detectsDependsAuthPrefix() { + String code = """ + async def endpoint(current=Depends(auth_handler)): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("fastapi", result.nodes().get(0).getProperties().get("auth_type")); + assertEquals("oauth2", result.nodes().get(0).getProperties().get("auth_flow")); + assertEquals(true, result.nodes().get(0).getProperties().get("auth_required")); + } + + @Test + void allAuthTypesInOneFile() { + String code = """ + oauth2 = OAuth2PasswordBearer(tokenUrl="/token") + bearer_scheme = HTTPBearer() + basic_scheme = HTTPBasic() + + async def endpoint1(user=Depends(get_current_user)): + pass + + async def endpoint2(user=Security(oauth2)): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("api.py", "python", code); + DetectorResult result = detector.detect(ctx); + + // OAuth2PasswordBearer + HTTPBearer + HTTPBasic + Depends + Security = 5 guards + assertTrue(result.nodes().size() >= 5); + assertTrue(result.nodes().stream().allMatch(n -> n.getKind() == NodeKind.GUARD)); + } + + @Test + void authRequiredPropertyIsTrue() { + String code = """ + auth = HTTPBearer() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(true, result.nodes().get(0).getProperties().get("auth_required")); + } + + @Test + void annotationsSetCorrectly() { + String code = """ + bearer = HTTPBearer() + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var node = result.nodes().get(0); + assertNotNull(node.getAnnotations()); + assertFalse(node.getAnnotations().isEmpty()); + assertTrue(node.getAnnotations().contains("HTTPBearer")); + } + + @Test + void oauth2PasswordBearerAuthFlow() { + String code = """ + scheme = OAuth2PasswordBearer(tokenUrl="/login") + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("oauth2", result.nodes().get(0).getProperties().get("auth_flow")); + } + + @Test + void emptyFileReturnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + assertEquals(0, result.edges().size()); + } + + @Test + void fileLevelModuleAndPath() { + String code = """ + async def get_items(user=Depends(get_current_user)): + return [] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("api/routes.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("api/routes.py", result.nodes().get(0).getFilePath()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIRouteDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIRouteDetectorTest.java index e02cbf88..5140f013 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIRouteDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/FastAPIRouteDetectorTest.java @@ -84,4 +84,129 @@ async def create_item(): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsPutRoute() { + String code = """ + @router.put("/items/{id}") + async def update_item(id: int): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("PUT /items/{id}", result.nodes().get(0).getLabel()); + assertEquals("PUT", result.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void detectsDeleteRoute() { + String code = """ + @app.delete("/items/{id}") + async def delete_item(id: int): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("DELETE /items/{id}", result.nodes().get(0).getLabel()); + } + + @Test + void detectsPatchRoute() { + String code = """ + @app.patch("/items/{id}") + async def patch_item(id: int): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("PATCH /items/{id}", result.nodes().get(0).getLabel()); + } + + @Test + void routeHasProtocolRest() { + String code = """ + @app.get("/health") + def health(): + return {"status": "ok"} + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("REST", result.nodes().get(0).getProperties().get("protocol")); + } + + @Test + void routeHasRouterName() { + String code = """ + @myrouter.get("/api/data") + def get_data(): + return {} + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("myrouter", result.nodes().get(0).getProperties().get("router")); + } + + @Test + void routeWithPrefixCombinesFullPath() { + String code = """ + router = APIRouter(prefix="/v2") + + @router.post("/articles") + def create_article(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("POST /v2/articles", result.nodes().get(0).getLabel()); + assertEquals("/v2/articles", result.nodes().get(0).getProperties().get("path_pattern")); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + } + + @Test + void fqnIncludesFunctionName() { + String code = """ + @app.get("/ping") + def ping(): + return "pong" + """; + DetectorContext ctx = DetectorTestUtils.contextFor("routes.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertNotNull(result.nodes().get(0).getFqn()); + assertTrue(result.nodes().get(0).getFqn().contains("ping")); + } + + @Test + void multipleRoutes() { + String code = """ + @app.get("/a") + async def route_a(): pass + + @app.post("/b") + async def route_b(): pass + + @app.delete("/c") + async def route_c(): pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(3, result.nodes().size()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/FlaskRouteDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/FlaskRouteDetectorTest.java index 92aae408..ca4046e2 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/FlaskRouteDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/FlaskRouteDetectorTest.java @@ -72,4 +72,122 @@ def items(): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsBlueprintRoute() { + String code = """ + @bp.route('/users') + def list_users(): + return [] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("GET /users", result.nodes().get(0).getLabel()); + assertEquals("bp", result.nodes().get(0).getProperties().get("blueprint")); + } + + @Test + void routeHasProtocolRest() { + String code = """ + @app.route('/api/data') + def data(): + return {} + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("REST", result.nodes().get(0).getProperties().get("protocol")); + } + + @Test + void routeHasHttpMethodProperty() { + String code = """ + @app.route('/submit', methods=['POST']) + def submit(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("POST", result.nodes().get(0).getProperties().get("http_method")); + } + + @Test + void routeHasPathPattern() { + String code = """ + @app.route('/user/') + def user(id): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("/user/", result.nodes().get(0).getProperties().get("path_pattern")); + } + + @Test + void exposesEdgeSourceIsBlueprint() { + String code = """ + @app.route('/ping') + def ping(): + return 'pong' + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var edge = result.edges().get(0); + assertTrue(edge.getSourceId().contains("app")); + } + + @Test + void multipleMethodsGenerateMultipleNodes() { + String code = """ + @api.route('/resource', methods=['GET', 'PUT', 'DELETE']) + def resource(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(3, result.nodes().size()); + assertEquals(3, result.edges().size()); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + assertEquals(0, result.edges().size()); + } + + @Test + void fqnIncludesFunctionName() { + String code = """ + @app.route('/health') + def health_check(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("api.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertNotNull(result.nodes().get(0).getFqn()); + assertTrue(result.nodes().get(0).getFqn().contains("health_check")); + } + + @Test + void defaultMethodIsGet() { + String code = """ + @app.route('/list') + def list_items(): + return [] + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("GET", result.nodes().get(0).getProperties().get("http_method")); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/PydanticModelDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/PydanticModelDetectorTest.java index c0a2b08b..5b179295 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/PydanticModelDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/PydanticModelDetectorTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -59,9 +60,6 @@ class Base(BaseModel): class User(Base): name: str """; - // Note: the regex only matches classes extending BaseModel/BaseSettings directly, - // so User(Base) won't match unless Base contains BaseModel in name. - // This tests that only Base is detected. DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorResult result = detector.detect(ctx); @@ -95,4 +93,147 @@ class Config(BaseSettings): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsValidators() { + String code = """ + class User(BaseModel): + name: str + age: int + + @validator('age') + def age_must_be_positive(cls, v): + return v + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + List annotations = result.nodes().get(0).getAnnotations(); + assertNotNull(annotations); + assertTrue(annotations.contains("age")); + } + + @Test + void detectsFieldValidator() { + String code = """ + class Product(BaseModel): + price: float + + @field_validator('price') + def price_positive(cls, v): + return v + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + List annotations = result.nodes().get(0).getAnnotations(); + assertTrue(annotations.contains("price")); + } + + @Test + void detectsConfigClass() { + String code = """ + class MyModel(BaseModel): + name: str + + class Config: + orm_mode = True + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + @SuppressWarnings("unchecked") + Map config = (Map) result.nodes().get(0).getProperties().get("config"); + assertNotNull(config); + assertEquals("True", config.get("orm_mode")); + } + + @Test + void detectsFieldTypes() { + String code = """ + class Order(BaseModel): + id: int + items: List[str] + total: float + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + @SuppressWarnings("unchecked") + Map fieldTypes = (Map) result.nodes().get(0).getProperties().get("field_types"); + assertNotNull(fieldTypes); + assertTrue(fieldTypes.containsKey("id")); + assertTrue(fieldTypes.containsKey("total")); + } + + @Test + void detectsMultipleModels() { + String code = """ + class UserCreate(BaseModel): + name: str + email: str + + class UserResponse(BaseModel): + id: int + name: str + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.nodes().size()); + assertTrue(result.nodes().stream().allMatch(n -> n.getKind() == NodeKind.ENTITY)); + } + + @Test + void baseSettingsHasConfigDefinitionKind() { + String code = """ + class Settings(BaseSettings): + database_url: str + redis_url: str + debug: bool = False + """; + DetectorContext ctx = DetectorTestUtils.contextFor("config.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals(NodeKind.CONFIG_DEFINITION, result.nodes().get(0).getKind()); + assertEquals("BaseSettings", result.nodes().get(0).getProperties().get("base_class")); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + } + + @Test + void filePathSetOnNode() { + String code = """ + class Item(BaseModel): + name: str + """; + DetectorContext ctx = DetectorTestUtils.contextFor("models/item.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals("models/item.py", result.nodes().get(0).getFilePath()); + } + + @Test + void fqnSetCorrectly() { + String code = """ + class Response(BaseModel): + status: str + """; + DetectorContext ctx = DetectorTestUtils.contextFor("schemas.py", "python", code); + DetectorResult result = detector.detect(ctx); + + assertNotNull(result.nodes().get(0).getFqn()); + assertTrue(result.nodes().get(0).getFqn().contains("Response")); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/PythonStructuresDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/PythonStructuresDetectorTest.java index 04c31ce5..051dfcd0 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/PythonStructuresDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/PythonStructuresDetectorTest.java @@ -7,6 +7,8 @@ import io.github.randomcodespace.iq.model.NodeKind; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class PythonStructuresDetectorTest { @@ -27,7 +29,6 @@ def my_method(self): n -> n.getKind() == NodeKind.CLASS && n.getLabel().equals("MyClass"))); assertTrue(result.nodes().stream().anyMatch( n -> n.getKind() == NodeKind.METHOD && n.getLabel().equals("MyClass.my_method"))); - // EXTENDS edge + DEFINES edge assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.EXTENDS)); assertTrue(result.edges().stream().anyMatch(e -> e.getKind() == EdgeKind.DEFINES)); } @@ -55,7 +56,6 @@ void detectsImports() { DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorResult result = detector.detect(ctx); - // 3 import edges: os.path, sys, json assertEquals(3, result.edges().size()); assertTrue(result.edges().stream().allMatch(e -> e.getKind() == EdgeKind.IMPORTS)); } @@ -74,12 +74,15 @@ class Bar: DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorResult result = detector.detect(ctx); - // module node + foo function + Bar class assertTrue(result.nodes().stream().anyMatch(n -> n.getKind() == NodeKind.MODULE)); var fooNode = result.nodes().stream() .filter(n -> n.getLabel().equals("foo")) .findFirst().orElseThrow(); assertEquals(true, fooNode.getProperties().get("exported")); + var barNode = result.nodes().stream() + .filter(n -> n.getLabel().equals("Bar")) + .findFirst().orElseThrow(); + assertEquals(true, barNode.getProperties().get("exported")); } @Test @@ -121,4 +124,218 @@ def standalone(): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsClassWithMultipleBases() { + String code = """ + class MyView(LoginRequiredMixin, View): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var classNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.CLASS) + .findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + List bases = (List) classNode.getProperties().get("bases"); + assertNotNull(bases); + assertTrue(bases.size() >= 2); + // Two extends edges + long extendsCount = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.EXTENDS).count(); + assertEquals(2, extendsCount); + } + + @Test + void detectsAsyncMethod() { + String code = """ + class MyService: + async def fetch(self): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var methodNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.METHOD) + .findFirst().orElseThrow(); + assertEquals(true, methodNode.getProperties().get("async")); + assertEquals("MyService.fetch", methodNode.getLabel()); + } + + @Test + void detectsFromImportEdge() { + String code = """ + from django.db import models + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.edges().size()); + assertEquals(EdgeKind.IMPORTS, result.edges().get(0).getKind()); + } + + @Test + void detectsImportNameEdges() { + String code = """ + import os + import sys + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.edges().size()); + assertTrue(result.edges().stream().allMatch(e -> e.getKind() == EdgeKind.IMPORTS)); + } + + @Test + void detectsModuleNameProperty() { + String code = """ + __all__ = ['MyClass'] + + class MyClass: + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("mymod.py", "python", code); + DetectorResult result = detector.detect(ctx); + + var moduleNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.MODULE) + .findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + List exports = (List) moduleNode.getProperties().get("__all__"); + assertTrue(exports.contains("MyClass")); + } + + @Test + void detectsClassWithDecorators() { + String code = """ + @dataclass + class Point: + x: float + y: float + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var classNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.CLASS && "Point".equals(n.getLabel())) + .findFirst().orElseThrow(); + assertNotNull(classNode.getAnnotations()); + assertFalse(classNode.getAnnotations().isEmpty()); + } + + @Test + void detectsFunctionWithDecorators() { + String code = """ + @staticmethod + def helper(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var funcNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.METHOD) + .findFirst().orElseThrow(); + assertFalse(funcNode.getAnnotations().isEmpty()); + } + + @Test + void exportedFunctionHasExportedProperty() { + String code = """ + __all__ = ['process'] + + def process(): + pass + + def _private(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var processNode = result.nodes().stream() + .filter(n -> "process".equals(n.getLabel())) + .findFirst().orElseThrow(); + assertEquals(true, processNode.getProperties().get("exported")); + + var privateNode = result.nodes().stream() + .filter(n -> "_private".equals(n.getLabel())) + .findFirst().orElseThrow(); + assertNull(privateNode.getProperties().get("exported")); + } + + @Test + void classWithNoBasesHasNoExtendsEdge() { + String code = """ + class Standalone: + def run(self): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + long extendsEdges = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.EXTENDS).count(); + assertEquals(0, extendsEdges); + long definesEdges = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.DEFINES).count(); + assertEquals(1, definesEdges); + } + + @Test + void multipleTopLevelFunctions() { + String code = """ + def alpha(): + pass + + def beta(): + pass + + def gamma(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(3, result.nodes().size()); + assertTrue(result.nodes().stream().allMatch(n -> n.getKind() == NodeKind.METHOD)); + } + + @Test + void indentedMethodOutsideClassNotAdded() { + // A function that's indented but not inside a class should not appear as class method + // (the detector only creates class methods for indented fns when there's an enclosing class) + String code = """ + def outer(): + def inner(): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + // outer is top-level, inner is indented but not in a class + // inner would match the indented path but findEnclosingClass returns null + var outerNode = result.nodes().stream() + .filter(n -> "outer".equals(n.getLabel())).findFirst(); + assertTrue(outerNode.isPresent()); + } + + @Test + void methodPropertyHasClassField() { + String code = """ + class Service: + def execute(self): + pass + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var methodNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.METHOD) + .findFirst().orElseThrow(); + assertEquals("Service", methodNode.getProperties().get("class")); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/python/SQLAlchemyModelDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/python/SQLAlchemyModelDetectorTest.java index 1b81a4d9..89d9c2ec 100644 --- a/src/test/java/io/github/randomcodespace/iq/detector/python/SQLAlchemyModelDetectorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/detector/python/SQLAlchemyModelDetectorTest.java @@ -96,4 +96,116 @@ class Order(Base): DetectorContext ctx = DetectorTestUtils.contextFor("python", code); DetectorTestUtils.assertDeterministic(detector, ctx); } + + @Test + void detectsDeclarativeBaseModel() { + String code = """ + class Category(DeclarativeBase): + __tablename__ = 'categories' + id = Column(Integer, primary_key=True) + name = Column(String) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + assertEquals("categories", entityNode.getProperties().get("table_name")); + } + + @Test + void detectsMappedColumns() { + String code = """ + class Article(Base): + __tablename__ = 'articles' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + List columns = (List) entityNode.getProperties().get("columns"); + assertNotNull(columns); + assertTrue(columns.contains("id")); + assertTrue(columns.contains("title")); + } + + @Test + void multipleRelationshipsCreateMultipleEdges() { + String code = """ + class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + posts = relationship("Post") + comments = relationship("Comment") + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + long mapsToEdges = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.MAPS_TO).count(); + assertEquals(2, mapsToEdges); + } + + @Test + void databaseNodeKindIsDbConnection() { + String code = """ + class Customer(Base): + __tablename__ = 'customers' + id = Column(Integer, primary_key=True) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var dbNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.DATABASE_CONNECTION).findFirst(); + assertTrue(dbNode.isPresent()); + } + + @Test + void noMatchOnEmptyContent() { + DetectorContext ctx = DetectorTestUtils.contextFor("python", ""); + DetectorResult result = detector.detect(ctx); + + assertEquals(0, result.nodes().size()); + assertEquals(0, result.edges().size()); + } + + @Test + void detectsModelBase() { + String code = """ + class Tag(Model): + __tablename__ = 'tags' + id = Column(Integer, primary_key=True) + name = Column(String) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + var entityNode = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.ENTITY).findFirst().orElseThrow(); + assertEquals("Tag", entityNode.getLabel()); + } + + @Test + void databaseCreatedOnce() { + String code = """ + class A(Base): + __tablename__ = 'a' + id = Column(Integer) + + class B(Base): + __tablename__ = 'b' + id = Column(Integer) + """; + DetectorContext ctx = DetectorTestUtils.contextFor("python", code); + DetectorResult result = detector.detect(ctx); + + long dbCount = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.DATABASE_CONNECTION).count(); + assertEquals(1, dbCount); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/flow/FlowViewsTest.java b/src/test/java/io/github/randomcodespace/iq/flow/FlowViewsTest.java new file mode 100644 index 00000000..72ce2c97 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/flow/FlowViewsTest.java @@ -0,0 +1,568 @@ +package io.github.randomcodespace.iq.flow; + +import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram; +import io.github.randomcodespace.iq.model.CodeEdge; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.EdgeKind; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for FlowViews view builders. + * Uses a simple stub FlowDataSource that returns pre-configured nodes. + */ +class FlowViewsTest { + + // ============================================= + // Stub FlowDataSource + // ============================================= + + private static class StubDataSource implements FlowDataSource { + private final List nodes; + + StubDataSource(List nodes) { + this.nodes = nodes; + } + + @Override + public List findAll() { + return nodes; + } + + @Override + public List findByKind(NodeKind kind) { + return nodes.stream().filter(n -> n.getKind() == kind).toList(); + } + + @Override + public long count() { + return nodes.size(); + } + } + + private static CodeNode node(String id, NodeKind kind, String label) { + CodeNode n = new CodeNode(id, kind, label); + return n; + } + + // ============================================= + // buildOverview tests + // ============================================= + + @Test + void overviewEmptyGraph() { + FlowDataSource ds = new StubDataSource(List.of()); + FlowDiagram diagram = FlowViews.buildOverview(ds); + + assertEquals("overview", diagram.view()); + assertEquals("LR", diagram.direction()); + assertNotNull(diagram.stats()); + assertEquals(0, diagram.stats().get("total_nodes")); + } + + @Test + void overviewWithEndpointsAndEntities() { + List nodes = new ArrayList<>(); + nodes.add(node("ep:1", NodeKind.ENDPOINT, "GET /users")); + nodes.add(node("ep:2", NodeKind.ENDPOINT, "POST /users")); + nodes.add(node("entity:1", NodeKind.ENTITY, "User")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + assertEquals("overview", diagram.view()); + assertThat(diagram.stats().get("endpoints")).isEqualTo(2); + assertThat(diagram.stats().get("entities")).isEqualTo(1); + // App subgraph should contain both endpoints and entities flow nodes + var appSg = diagram.subgraphs().stream().filter(sg -> "app".equals(sg.id())).findFirst(); + assertTrue(appSg.isPresent()); + } + + @Test + void overviewWithCiNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("gha:workflow1", NodeKind.MODULE, "CI Workflow")); + nodes.add(node("gha:job1", NodeKind.METHOD, "Build Job")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + var ciSg = diagram.subgraphs().stream().filter(sg -> "ci".equals(sg.id())).findFirst(); + assertTrue(ciSg.isPresent()); + assertTrue(ciSg.get().nodes().stream().anyMatch(n -> n.id().equals("ci_pipelines"))); + assertTrue(ciSg.get().nodes().stream().anyMatch(n -> n.id().equals("ci_jobs"))); + } + + @Test + void overviewWithInfraNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("k8s:deploy1", NodeKind.INFRA_RESOURCE, "K8s Deployment")); + nodes.add(node("tf:resource1", NodeKind.INFRA_RESOURCE, "Terraform Resource")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + var infraSg = diagram.subgraphs().stream().filter(sg -> "infra".equals(sg.id())).findFirst(); + assertTrue(infraSg.isPresent()); + } + + @Test + void overviewWithGuards() { + List nodes = new ArrayList<>(); + nodes.add(node("ep:1", NodeKind.ENDPOINT, "GET /api")); + nodes.add(node("guard:1", NodeKind.GUARD, "Auth Guard")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + var secSg = diagram.subgraphs().stream().filter(sg -> "security".equals(sg.id())).findFirst(); + assertTrue(secSg.isPresent()); + assertEquals(1, diagram.stats().get("guards")); + } + + @Test + void overviewWithMiddleware() { + List nodes = new ArrayList<>(); + nodes.add(node("mw:1", NodeKind.MIDDLEWARE, "Auth Middleware")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + var secSg = diagram.subgraphs().stream().filter(sg -> "security".equals(sg.id())).findFirst(); + assertTrue(secSg.isPresent()); + } + + @Test + void overviewWithTopicsAndQueues() { + List nodes = new ArrayList<>(); + nodes.add(node("topic:1", NodeKind.TOPIC, "events.user")); + nodes.add(node("queue:1", NodeKind.QUEUE, "celery:send_email")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + // Topics and queues go into the app subgraph as messaging node + assertNotNull(diagram); + // If app subgraph exists, messaging node should be there + diagram.subgraphs().stream() + .filter(sg -> "app".equals(sg.id())).findFirst() + .ifPresent(appSg -> assertTrue(appSg.nodes().stream().anyMatch(n -> "app_messaging".equals(n.id())))); + } + + @Test + void overviewWithComponents() { + List nodes = new ArrayList<>(); + nodes.add(node("comp:1", NodeKind.COMPONENT, "UserComponent")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + assertNotNull(diagram); + assertEquals(1, diagram.stats().get("components")); + } + + @Test + void overviewWithDbConnections() { + List nodes = new ArrayList<>(); + nodes.add(node("db:1", NodeKind.DATABASE_CONNECTION, "PostgreSQL")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + assertNotNull(diagram); + // App subgraph should contain DB flow node + diagram.subgraphs().stream() + .filter(sg -> "app".equals(sg.id())).findFirst() + .ifPresent(appSg -> assertTrue(appSg.nodes().stream().anyMatch(n -> "app_database".equals(n.id())))); + } + + @Test + void overviewAppSubgraphWithOnlyClasses() { + List nodes = new ArrayList<>(); + nodes.add(node("class:1", NodeKind.CLASS, "UserService")); + nodes.add(node("method:1", NodeKind.METHOD, "process")); + + FlowDiagram diagram = FlowViews.buildOverview(new StubDataSource(nodes)); + + // When no endpoints/entities/etc, falls back to code node + var appSg = diagram.subgraphs().stream().filter(sg -> "app".equals(sg.id())).findFirst(); + if (appSg.isPresent()) { + assertTrue(appSg.get().nodes().stream().anyMatch(n -> "app_code".equals(n.id()))); + } + } + + // ============================================= + // buildCiView tests + // ============================================= + + @Test + void ciViewEmptyGraph() { + FlowDiagram diagram = FlowViews.buildCiView(new StubDataSource(List.of())); + + assertEquals("ci", diagram.view()); + assertEquals("TD", diagram.direction()); + assertEquals(0, diagram.stats().get("workflows")); + assertEquals(0, diagram.stats().get("jobs")); + } + + @Test + void ciViewWithWorkflowsAndJobs() { + List nodes = new ArrayList<>(); + CodeNode wf = node("gha:workflow:ci.yml", NodeKind.MODULE, "CI Pipeline"); + wf.setModule("gha:workflow:ci.yml"); + nodes.add(wf); + CodeNode job = node("gha:job:build", NodeKind.METHOD, "Build"); + job.setModule("gha:workflow:ci.yml"); + nodes.add(job); + + FlowDiagram diagram = FlowViews.buildCiView(new StubDataSource(nodes)); + + assertEquals("ci", diagram.view()); + assertEquals(1, diagram.stats().get("workflows")); + assertEquals(1, diagram.stats().get("jobs")); + } + + @Test + void ciViewWithTriggers() { + List nodes = new ArrayList<>(); + nodes.add(node("gha:trigger:push", NodeKind.CONFIG_KEY, "push trigger")); + + FlowDiagram diagram = FlowViews.buildCiView(new StubDataSource(nodes)); + + var triggerSg = diagram.subgraphs().stream() + .filter(sg -> "triggers".equals(sg.id())).findFirst(); + assertTrue(triggerSg.isPresent()); + assertEquals(1, diagram.stats().get("triggers")); + } + + @Test + void ciViewWithDependsOnEdges() { + List nodes = new ArrayList<>(); + CodeNode job1 = node("gha:job:build", NodeKind.METHOD, "Build"); + CodeNode job2 = node("gha:job:test", NodeKind.METHOD, "Test"); + CodeEdge dep = new CodeEdge(); + dep.setId("gha:job:build->depends_on->gha:job:test"); + dep.setKind(EdgeKind.DEPENDS_ON); + dep.setSourceId("gha:job:build"); + dep.setTarget(job2); + job1.getEdges().add(dep); + nodes.add(job1); + nodes.add(job2); + + FlowDiagram diagram = FlowViews.buildCiView(new StubDataSource(nodes)); + + assertNotNull(diagram); + // Edges should be sorted for determinism + assertNotNull(diagram.edges()); + } + + @Test + void ciViewTriggerWorkflowEdge() { + List nodes = new ArrayList<>(); + nodes.add(node("gha:trigger:push", NodeKind.CONFIG_KEY, "push")); + nodes.add(node("gha:workflow:main.yml", NodeKind.MODULE, "Main CI")); + + FlowDiagram diagram = FlowViews.buildCiView(new StubDataSource(nodes)); + + // Trigger -> workflow dotted edge should be present + assertFalse(diagram.edges().isEmpty()); + } + + // ============================================= + // buildDeployView tests + // ============================================= + + @Test + void deployViewEmptyGraph() { + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(List.of())); + + assertEquals("deploy", diagram.view()); + assertEquals(0, diagram.stats().get("k8s")); + assertEquals(0, diagram.stats().get("compose")); + assertEquals(0, diagram.stats().get("terraform")); + } + + @Test + void deployViewWithK8sNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("k8s:deployment:web", NodeKind.INFRA_RESOURCE, "Web Deployment")); + nodes.add(node("k8s:service:web-svc", NodeKind.INFRA_RESOURCE, "Web Service")); + + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(nodes)); + + var k8sSg = diagram.subgraphs().stream().filter(sg -> "k8s".equals(sg.id())).findFirst(); + assertTrue(k8sSg.isPresent()); + assertEquals(2, diagram.stats().get("k8s")); + } + + @Test + void deployViewWithComposeNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("compose:service:web", NodeKind.INFRA_RESOURCE, "Web Service")); + + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(nodes)); + + var composeSg = diagram.subgraphs().stream().filter(sg -> "compose".equals(sg.id())).findFirst(); + assertTrue(composeSg.isPresent()); + } + + @Test + void deployViewWithTerraformNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("tf:aws_instance:web", NodeKind.INFRA_RESOURCE, "EC2 Instance")); + + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(nodes)); + + var tfSg = diagram.subgraphs().stream().filter(sg -> "terraform".equals(sg.id())).findFirst(); + assertTrue(tfSg.isPresent()); + } + + @Test + void deployViewWithDockerNodes() { + List nodes = new ArrayList<>(); + nodes.add(node("dockerfile:web", NodeKind.INFRA_RESOURCE, "Web Image")); + + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(nodes)); + + var dockerSg = diagram.subgraphs().stream().filter(sg -> "docker".equals(sg.id())).findFirst(); + assertTrue(dockerSg.isPresent()); + } + + @Test + void deployViewWithAzureResource() { + List nodes = new ArrayList<>(); + nodes.add(node("azure:resource:1", NodeKind.AZURE_RESOURCE, "App Service")); + + FlowDiagram diagram = FlowViews.buildDeployView(new StubDataSource(nodes)); + + // Azure resources go into "other" category + var otherSg = diagram.subgraphs().stream().filter(sg -> "other_infra".equals(sg.id())).findFirst(); + assertTrue(otherSg.isPresent()); + } + + // ============================================= + // buildRuntimeView tests + // ============================================= + + @Test + void runtimeViewEmptyGraph() { + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(List.of())); + + assertEquals("runtime", diagram.view()); + assertEquals("LR", diagram.direction()); + assertEquals(0, diagram.stats().get("endpoints")); + assertEquals(0, diagram.stats().get("entities")); + } + + @Test + void runtimeViewWithBackendEndpoints() { + List nodes = new ArrayList<>(); + nodes.add(node("ep:1", NodeKind.ENDPOINT, "GET /api/users")); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + var backendSg = diagram.subgraphs().stream() + .filter(sg -> "backend".equals(sg.id())).findFirst(); + assertTrue(backendSg.isPresent()); + } + + @Test + void runtimeViewWithFrontendEndpoints() { + List nodes = new ArrayList<>(); + CodeNode ep = node("ep:1", NodeKind.ENDPOINT, "Route /home"); + ep.getProperties().put("layer", "frontend"); + nodes.add(ep); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + var frontendSg = diagram.subgraphs().stream() + .filter(sg -> "frontend".equals(sg.id())).findFirst(); + assertTrue(frontendSg.isPresent()); + } + + @Test + void runtimeViewWithComponents() { + List nodes = new ArrayList<>(); + nodes.add(node("comp:1", NodeKind.COMPONENT, "NavBar")); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + var frontendSg = diagram.subgraphs().stream() + .filter(sg -> "frontend".equals(sg.id())).findFirst(); + assertTrue(frontendSg.isPresent()); + assertTrue(frontendSg.get().nodes().stream().anyMatch(n -> "rt_components".equals(n.id()))); + } + + @Test + void runtimeViewWithDataLayer() { + List nodes = new ArrayList<>(); + nodes.add(node("entity:1", NodeKind.ENTITY, "User")); + nodes.add(node("db:1", NodeKind.DATABASE_CONNECTION, "PostgreSQL")); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + var dataSg = diagram.subgraphs().stream() + .filter(sg -> "data".equals(sg.id())).findFirst(); + assertTrue(dataSg.isPresent()); + } + + @Test + void runtimeViewEdgesConnectFrontendToBackend() { + List nodes = new ArrayList<>(); + CodeNode feEp = node("ep:1", NodeKind.ENDPOINT, "Route /home"); + feEp.getProperties().put("layer", "frontend"); + nodes.add(feEp); + nodes.add(node("ep:2", NodeKind.ENDPOINT, "GET /api")); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + // Edge from frontend to backend should exist + assertFalse(diagram.edges().isEmpty()); + } + + @Test + void runtimeViewWithMessaging() { + List nodes = new ArrayList<>(); + nodes.add(node("queue:1", NodeKind.QUEUE, "celery:task")); + nodes.add(node("topic:1", NodeKind.TOPIC, "events")); + + FlowDiagram diagram = FlowViews.buildRuntimeView(new StubDataSource(nodes)); + + assertNotNull(diagram); + assertEquals(2, (int)(Integer) diagram.stats().get("topics")); + } + + // ============================================= + // buildAuthView tests + // ============================================= + + @Test + void authViewEmptyGraph() { + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(List.of())); + + assertEquals("auth", diagram.view()); + assertEquals(0, diagram.stats().get("guards")); + assertEquals(0, diagram.stats().get("protected")); + assertEquals(0, diagram.stats().get("unprotected")); + } + + @Test + void authViewWithGuards() { + List nodes = new ArrayList<>(); + CodeNode guard = node("guard:1", NodeKind.GUARD, "JwtGuard"); + guard.getProperties().put("auth_type", "jwt"); + nodes.add(guard); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + var guardsSg = diagram.subgraphs().stream() + .filter(sg -> "guards".equals(sg.id())).findFirst(); + assertTrue(guardsSg.isPresent()); + assertEquals(1, diagram.stats().get("guards")); + } + + @Test + void authViewWithMiddleware() { + List nodes = new ArrayList<>(); + nodes.add(node("mw:1", NodeKind.MIDDLEWARE, "CorsMiddleware")); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + var guardsSg = diagram.subgraphs().stream() + .filter(sg -> "guards".equals(sg.id())).findFirst(); + assertTrue(guardsSg.isPresent()); + assertTrue(guardsSg.get().nodes().stream().anyMatch(n -> "auth_middleware".equals(n.id()))); + } + + @Test + void authViewWithProtectedEndpoints() { + List nodes = new ArrayList<>(); + CodeNode guard = node("guard:1", NodeKind.GUARD, "Guard"); + CodeNode ep = node("ep:1", NodeKind.ENDPOINT, "GET /admin"); + CodeEdge protects = new CodeEdge(); + protects.setId("guard:1->protects->ep:1"); + protects.setKind(EdgeKind.PROTECTS); + protects.setSourceId("guard:1"); + protects.setTarget(ep); + guard.getEdges().add(protects); + guard.getProperties().put("auth_type", "jwt"); + nodes.add(guard); + nodes.add(ep); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + assertEquals(1, diagram.stats().get("protected")); + assertEquals(0, diagram.stats().get("unprotected")); + } + + @Test + void authViewWithUnprotectedEndpoints() { + List nodes = new ArrayList<>(); + nodes.add(node("ep:1", NodeKind.ENDPOINT, "GET /public")); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + assertEquals(0, diagram.stats().get("protected")); + assertEquals(1, diagram.stats().get("unprotected")); + } + + @Test + void authViewCoverageCalculatedCorrectly() { + List nodes = new ArrayList<>(); + CodeNode guard = node("guard:1", NodeKind.GUARD, "Guard"); + guard.getProperties().put("auth_type", "jwt"); + CodeNode ep1 = node("ep:1", NodeKind.ENDPOINT, "Protected"); + CodeNode ep2 = node("ep:2", NodeKind.ENDPOINT, "Unprotected"); + CodeEdge protects = new CodeEdge(); + protects.setId("guard:1->protects->ep:1"); + protects.setKind(EdgeKind.PROTECTS); + protects.setSourceId("guard:1"); + protects.setTarget(ep1); + guard.getEdges().add(protects); + nodes.add(guard); + nodes.add(ep1); + nodes.add(ep2); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + double coverage = (double) diagram.stats().get("coverage_pct"); + assertEquals(50.0, coverage, 0.1); + } + + @Test + void authViewGuardsGroupedByAuthType() { + List nodes = new ArrayList<>(); + CodeNode g1 = node("guard:1", NodeKind.GUARD, "JwtGuard"); + g1.getProperties().put("auth_type", "jwt"); + CodeNode g2 = node("guard:2", NodeKind.GUARD, "OAuthGuard"); + g2.getProperties().put("auth_type", "oauth"); + nodes.add(g1); + nodes.add(g2); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + var guardsSg = diagram.subgraphs().stream() + .filter(sg -> "guards".equals(sg.id())).findFirst().orElseThrow(); + // Two distinct auth types => two guard nodes + assertEquals(2, guardsSg.nodes().size()); + } + + @Test + void authViewEdgesFromGuardsToProtectedEndpoints() { + List nodes = new ArrayList<>(); + CodeNode guard = node("guard:1", NodeKind.GUARD, "Guard"); + guard.getProperties().put("auth_type", "jwt"); + CodeNode ep = node("ep:1", NodeKind.ENDPOINT, "Protected"); + CodeEdge protects = new CodeEdge(); + protects.setId("guard:1->protects->ep:1"); + protects.setKind(EdgeKind.PROTECTS); + protects.setSourceId("guard:1"); + protects.setTarget(ep); + guard.getEdges().add(protects); + nodes.add(guard); + nodes.add(ep); + + FlowDiagram diagram = FlowViews.buildAuthView(new StubDataSource(nodes)); + + assertFalse(diagram.edges().isEmpty()); + assertTrue(diagram.edges().stream().anyMatch(e -> "ep_protected".equals(e.target()))); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsExpandedTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsExpandedTest.java new file mode 100644 index 00000000..ea0f19c5 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsExpandedTest.java @@ -0,0 +1,579 @@ +package io.github.randomcodespace.iq.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import io.github.randomcodespace.iq.query.QueryService; +import io.github.randomcodespace.iq.query.StatsService; +import io.github.randomcodespace.iq.query.TopologyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.graphdb.GraphDatabaseService; +import org.neo4j.graphdb.Result; +import org.neo4j.graphdb.Transaction; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Expanded McpTools tests targeting coverage gaps: + * - topology service-level tools (service_detail, service_dependencies, etc.) + * - getCachedData with node data + * - run_cypher mutation blocking + * - get_detailed_stats category handling + * - find_dead_code with/without limit + * - get_topology delegation + * - getCapabilities + * - error branches + */ +@ExtendWith(MockitoExtension.class) +class McpToolsExpandedTest { + + @Mock + private QueryService queryService; + + @Mock + private StatsService statsService; + + @Mock + private GraphDatabaseService graphDb; + + @Mock + private GraphStore graphStore; + + private CodeIqConfig config; + private ObjectMapper objectMapper; + private McpTools mcpTools; + + @BeforeEach + void setUp() { + config = new CodeIqConfig(); + config.setRootPath("."); + objectMapper = new ObjectMapper(); + mcpTools = new McpTools( + queryService, config, objectMapper, + Optional.empty(), graphDb, statsService, + new TopologyService(), graphStore, + Optional.empty(), Optional.empty() + ); + } + + private Map parseJson(String json) throws IOException { + return objectMapper.readValue(json, new TypeReference<>() {}); + } + + // ── get_detailed_stats ── + + @Test + void getDetailedStatsShouldUseAllByDefault() throws IOException { + when(queryService.getDetailedStats("all")).thenReturn(Map.of("graph", Map.of("nodes", 10))); + String result = mcpTools.getDetailedStats(null); + verify(queryService).getDetailedStats("all"); + } + + @Test + void getDetailedStatsShouldPassCategory() throws IOException { + when(queryService.getDetailedStats("frameworks")).thenReturn(Map.of("frameworks", Map.of())); + mcpTools.getDetailedStats("frameworks"); + verify(queryService).getDetailedStats("frameworks"); + } + + @Test + void getDetailedStatsShouldReturnErrorOnException() throws IOException { + when(queryService.getDetailedStats(anyString())).thenThrow(new RuntimeException("failed")); + String result = mcpTools.getDetailedStats("all"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_dead_code ── + + @Test + void findDeadCodeShouldUseDefaultLimit() throws IOException { + when(queryService.findDeadCode(null, 100)).thenReturn(Map.of("dead_code", List.of(), "count", 0)); + mcpTools.findDeadCode(null, null); + verify(queryService).findDeadCode(null, 100); + } + + @Test + void findDeadCodeShouldCapLimitAt1000() throws IOException { + when(queryService.findDeadCode(null, 1000)).thenReturn(Map.of("dead_code", List.of(), "count", 0)); + mcpTools.findDeadCode(null, 5000); + verify(queryService).findDeadCode(null, 1000); + } + + @Test + void findDeadCodeShouldUseKindAndCustomLimit() throws IOException { + when(queryService.findDeadCode("class", 50)).thenReturn(Map.of("dead_code", List.of(), "count", 0)); + mcpTools.findDeadCode("class", 50); + verify(queryService).findDeadCode("class", 50); + } + + // ── run_cypher mutation blocking ── + + @Test + void runCypherShouldBlockCreateKeyword() throws IOException { + String result = mcpTools.runCypher("CREATE (n:Node) RETURN n"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + assertTrue(parsed.get("error").toString().toLowerCase().contains("mutation") + || parsed.get("error").toString().toLowerCase().contains("read-only")); + } + + @Test + void runCypherShouldBlockDeleteKeyword() throws IOException { + String result = mcpTools.runCypher("MATCH (n) DELETE n"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockMergeKeyword() throws IOException { + String result = mcpTools.runCypher("MERGE (n:Node {id: '1'}) RETURN n"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockSetKeyword() throws IOException { + String result = mcpTools.runCypher("MATCH (n) SET n.foo = 'bar'"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockRemove() throws IOException { + String result = mcpTools.runCypher("MATCH (n) REMOVE n.property"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockDrop() throws IOException { + String result = mcpTools.runCypher("DROP CONSTRAINT ON (n:Node)"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockDetach() throws IOException { + String result = mcpTools.runCypher("MATCH (n) DETACH DELETE n"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockForeach() throws IOException { + String result = mcpTools.runCypher("FOREACH (n IN nodes(p) | SET n.visited = true)"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldBlockCall() throws IOException { + String result = mcpTools.runCypher("CALL db.indexes()"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void runCypherShouldAllowMatchReturn() throws IOException { + Transaction tx = mock(Transaction.class); + Result queryResult = mock(Result.class); + when(graphDb.beginTx()).thenReturn(tx); + when(tx.execute("MATCH (n) RETURN n.id LIMIT 10")).thenReturn(queryResult); + when(queryResult.columns()).thenReturn(List.of("n.id")); + when(queryResult.hasNext()).thenReturn(false); + + String result = mcpTools.runCypher("MATCH (n) RETURN n.id LIMIT 10"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("rows")); + assertEquals(0, parsed.get("count")); + } + + // ── get_topology (QueryService delegation) ── + + @Test + void getTopologyShouldDelegate() throws IOException { + LinkedHashMap topology = new LinkedHashMap<>(); + topology.put("services", List.of()); + topology.put("connections", List.of()); + when(queryService.getTopology()).thenReturn(topology); + String result = mcpTools.getTopology(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("services")); + } + + @Test + void getTopologyShouldReturnErrorOnException() throws IOException { + when(queryService.getTopology()).thenThrow(new RuntimeException("DB error")); + String result = mcpTools.getTopology(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── topology tools that use getCachedData (with nodes from graphStore) ── + + private CodeNode makeServiceNode(String id, String name) { + CodeNode n = new CodeNode(id, NodeKind.SERVICE, name); + n.setFilePath("/project/" + name); + return n; + } + + @Test + void serviceDetailShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.serviceDetail("my-service"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void serviceDependenciesShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.serviceDependencies("my-service"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void serviceDependentsShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.serviceDependents("my-service"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void blastRadiusShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.blastRadius("node-1"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void findPathShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.findPath("service-a", "service-b"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void findBottlenecksShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.findBottlenecks(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void findCircularDepsShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.findCircularDeps(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void findDeadServicesShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.findDeadServices(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + @Test + void findNodeShouldReturnErrorWhenNoData() throws IOException { + when(graphStore.findAll()).thenThrow(new RuntimeException("No analysis data available. Run 'code-iq analyze' first.")); + String result = mcpTools.findNode("UserService"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── topology tools with actual data ── + + @Test + void serviceDetailShouldWorkWithEmptyNodeList() throws IOException { + // Returns empty list (not exception) → getCachedData throws because nodes.isEmpty() + when(graphStore.findAll()).thenReturn(List.of()); + String result = mcpTools.serviceDetail("my-service"); + Map parsed = parseJson(result); + // With empty nodes, getCachedData throws RuntimeException + assertNotNull(parsed.get("error")); + } + + @Test + void serviceDetailShouldWorkWithData() throws IOException { + CodeNode svc = makeServiceNode("svc:api", "api"); + when(graphStore.findAll()).thenReturn(List.of(svc)); + + String result = mcpTools.serviceDetail("api"); + assertNotNull(result); + // Should not throw — result is valid JSON + Map parsed = parseJson(result); + assertNotNull(parsed); + } + + @Test + void findBottlenecksShouldWorkWithData() throws IOException { + CodeNode svc = makeServiceNode("svc:api", "api"); + when(graphStore.findAll()).thenReturn(List.of(svc)); + + String result = mcpTools.findBottlenecks(); + assertNotNull(result); + // Result could be array or map — just verify it's valid JSON + assertFalse(result.isEmpty()); + } + + @Test + void findCircularDepsShouldWorkWithData() throws IOException { + CodeNode svc = makeServiceNode("svc:api", "api"); + when(graphStore.findAll()).thenReturn(List.of(svc)); + + String result = mcpTools.findCircularDeps(); + assertNotNull(result); + assertFalse(result.isEmpty()); + } + + @Test + void findDeadServicesShouldWorkWithData() throws IOException { + CodeNode svc = makeServiceNode("svc:api", "api"); + when(graphStore.findAll()).thenReturn(List.of(svc)); + + String result = mcpTools.findDeadServices(); + assertNotNull(result); + assertFalse(result.isEmpty()); + } + + @Test + void findNodeShouldWorkWithData() throws IOException { + CodeNode svc = makeServiceNode("svc:api", "api"); + when(graphStore.findAll()).thenReturn(List.of(svc)); + + String result = mcpTools.findNode("api"); + assertNotNull(result); + assertFalse(result.isEmpty()); + } + + // ── getCapabilities ── + + @Test + void getCapabilitiesShouldReturnFullMatrix() throws IOException { + String result = mcpTools.getCapabilities(null); + Map parsed = parseJson(result); + assertNotNull(parsed.get("matrix")); + } + + @Test + void getCapabilitiesShouldFilterByLanguage() throws IOException { + String result = mcpTools.getCapabilities("java"); + Map parsed = parseJson(result); + assertEquals("java", parsed.get("language")); + assertNotNull(parsed.get("capabilities")); + } + + @Test + void getCapabilitiesShouldHandleBlankLanguage() throws IOException { + String result = mcpTools.getCapabilities(" "); + Map parsed = parseJson(result); + assertNotNull(parsed.get("matrix")); + } + + // ── get_evidence_pack when assembler is null ── + + @Test + void getEvidencePackShouldReturnErrorWhenAssemblerNull() throws IOException { + // McpTools was created with Optional.empty() for evidencePackAssembler + String result = mcpTools.getEvidencePack("UserService", null, null, null); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + assertTrue(parsed.get("error").toString().contains("unavailable") + || parsed.get("error").toString().contains("enrich")); + } + + // ── get_artifact_metadata when metadata is null ── + + @Test + void getArtifactMetadataShouldReturnErrorWhenNull() throws IOException { + // McpTools was created with Optional.empty() for artifactMetadata + String result = mcpTools.getArtifactMetadata(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── generate_flow with no flow engine and no cache data ── + + @Test + void generateFlowShouldReturnErrorWhenNoEngineAndNoData() throws IOException { + // graphStore.findAll() throws (empty) + when(graphStore.findAll()).thenReturn(List.of()); + String result = mcpTools.generateFlow("overview", "json"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── get_stats error handling ── + + @Test + void getStatsShouldReturnErrorOnException() throws IOException { + when(queryService.getStats()).thenThrow(new RuntimeException("Neo4j down")); + String result = mcpTools.getStats(); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── search_graph error handling ── + + @Test + void searchGraphShouldReturnErrorOnException() throws IOException { + when(queryService.searchGraph(anyString(), anyInt())).thenThrow(new RuntimeException("index error")); + String result = mcpTools.searchGraph("User", null); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_callers error handling ── + + @Test + void findCallersShouldReturnErrorOnException() throws IOException { + when(queryService.callersOf("fn1")).thenThrow(new RuntimeException("DB error")); + String result = mcpTools.findCallers("fn1"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── trace_impact error handling ── + + @Test + void traceImpactShouldReturnErrorOnException() throws IOException { + when(queryService.traceImpact("n1", 3)).thenThrow(new RuntimeException("timeout")); + String result = mcpTools.traceImpact("n1", null); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_consumers error handling ── + + @Test + void findConsumersShouldReturnErrorOnException() throws IOException { + when(queryService.consumersOf("t1")).thenThrow(new RuntimeException("error")); + String result = mcpTools.findConsumers("t1"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_dependencies error handling ── + + @Test + void findDependenciesShouldReturnErrorOnException() throws IOException { + when(queryService.dependenciesOf("mod1")).thenThrow(new RuntimeException("error")); + String result = mcpTools.findDependencies("mod1"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_dependents error handling ── + + @Test + void findDependentsShouldReturnErrorOnException() throws IOException { + when(queryService.dependentsOf("mod1")).thenThrow(new RuntimeException("error")); + String result = mcpTools.findDependents("mod1"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── get_node_neighbors error handling ── + + @Test + void getNodeNeighborsShouldReturnErrorOnException() throws IOException { + when(queryService.getNeighbors(anyString(), anyString())).thenThrow(new RuntimeException("error")); + String result = mcpTools.getNodeNeighbors("n1", "both"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── get_ego_graph error handling ── + + @Test + void getEgoGraphShouldReturnErrorOnException() throws IOException { + when(queryService.egoGraph(anyString(), anyInt())).thenThrow(new RuntimeException("error")); + String result = mcpTools.getEgoGraph("n1", 2); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_cycles error handling ── + + @Test + void findCyclesShouldReturnErrorOnException() throws IOException { + when(queryService.findCycles(anyInt())).thenThrow(new RuntimeException("cycle error")); + String result = mcpTools.findCycles(100); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_shortest_path error handling ── + + @Test + void findShortestPathShouldReturnErrorOnException() throws IOException { + when(queryService.shortestPath(anyString(), anyString())).thenThrow(new RuntimeException("path error")); + String result = mcpTools.findShortestPath("a", "b"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_component_by_file error handling ── + + @Test + void findComponentByFileShouldReturnErrorOnException() throws IOException { + when(queryService.findComponentByFile(anyString())).thenThrow(new RuntimeException("error")); + String result = mcpTools.findComponentByFile("src/app.py"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── find_related_endpoints error handling ── + + @Test + void findRelatedEndpointsShouldReturnErrorOnException() throws IOException { + when(queryService.findRelatedEndpoints(anyString())).thenThrow(new RuntimeException("error")); + String result = mcpTools.findRelatedEndpoints("UserService"); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── query_nodes error handling ── + + @Test + void queryNodesShouldReturnErrorOnException() throws IOException { + when(queryService.listNodes(anyString(), anyInt(), anyInt())).thenThrow(new RuntimeException("error")); + String result = mcpTools.queryNodes("class", 10); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } + + // ── query_edges error handling ── + + @Test + void queryEdgesShouldReturnErrorOnException() throws IOException { + when(queryService.listEdges(anyString(), anyInt(), anyInt())).thenThrow(new RuntimeException("error")); + String result = mcpTools.queryEdges("calls", 10); + Map parsed = parseJson(result); + assertNotNull(parsed.get("error")); + } +}