diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java b/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java new file mode 100644 index 00000000..4b9d4924 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java @@ -0,0 +1,92 @@ +package io.github.randomcodespace.iq.analyzer.linker; + +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Links GUARD and MIDDLEWARE nodes to ENDPOINT nodes via PROTECTS edges. + *

+ * Uses file-path proximity as the matching heuristic: a guard or middleware + * in the same file as an endpoint is assumed to protect that endpoint. + * This correctly handles class-level Spring Security annotations + * (@PreAuthorize, @Secured on a class) which appear in the same file as + * the endpoint methods they protect. + */ +@Component +public class GuardLinker implements Linker { + + private static final Logger log = LoggerFactory.getLogger(GuardLinker.class); + + @Override + public LinkResult link(List nodes, List edges) { + // Group guards/middlewares and endpoints by filePath + Map> guardsByFile = new TreeMap<>(); + Map> endpointsByFile = new TreeMap<>(); + + for (CodeNode node : nodes) { + String fp = node.getFilePath(); + if (fp == null || fp.isBlank()) continue; + + if (node.getKind() == NodeKind.GUARD || node.getKind() == NodeKind.MIDDLEWARE) { + guardsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node); + } else if (node.getKind() == NodeKind.ENDPOINT) { + endpointsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node); + } + } + + if (guardsByFile.isEmpty() || endpointsByFile.isEmpty()) { + return LinkResult.empty(); + } + + // Collect existing PROTECTS edges to avoid duplicates + Set existingProtects = new HashSet<>(); + for (CodeEdge edge : edges) { + if (edge.getKind() == EdgeKind.PROTECTS && edge.getTarget() != null) { + existingProtects.add(edge.getSourceId() + "->" + edge.getTarget().getId()); + } + } + + List newEdges = new ArrayList<>(); + + // Same-file matching: each guard protects all endpoints in the same file + for (String filePath : new TreeSet<>(guardsByFile.keySet())) { + List fileEndpoints = endpointsByFile.get(filePath); + if (fileEndpoints == null || fileEndpoints.isEmpty()) continue; + + List fileGuards = guardsByFile.get(filePath); + for (CodeNode guard : fileGuards) { + for (CodeNode endpoint : fileEndpoints) { + String key = guard.getId() + "->" + endpoint.getId(); + if (!existingProtects.contains(key)) { + var edge = new CodeEdge(); + edge.setId("guard-link:" + guard.getId() + "->" + endpoint.getId()); + edge.setKind(EdgeKind.PROTECTS); + edge.setSourceId(guard.getId()); + edge.setTarget(endpoint); + edge.setProperties(Map.of("inferred", true)); + newEdges.add(edge); + existingProtects.add(key); + } + } + } + } + + if (!newEdges.isEmpty()) { + log.debug("GuardLinker created {} PROTECTS edges", newEdges.size()); + } + return LinkResult.ofEdges(newEdges); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index 44b4a83a..d2923579 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -307,6 +307,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.layer)"); tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.module)"); tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.filePath)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)"); tx.commit(); } CliOutput.info(" Created Neo4j indexes"); diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java index de843aa0..0528bdcf 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java @@ -23,7 +23,7 @@ public interface GraphRepository extends Neo4jRepository { @Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n") List findByFilePath(String filePath); - @Query("MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit") + @Query("MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit") List search(String text, int limit); @Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n") diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index 3e579d5c..564d4c8e 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -84,9 +84,11 @@ public void bulkSave(List nodes) { } } while (deleted > 0); - // 2. Create index on id property for fast MATCH during edge creation + // 2. Create indexes: id for MATCH, label_lower/fqn_lower for fast case-insensitive search try (Transaction tx = graphDb.beginTx()) { tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.id)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)"); tx.commit(); } @@ -182,6 +184,9 @@ private Map nodeToProps(CodeNode node) { if (node.getAnnotations() != null && !node.getAnnotations().isEmpty()) { props.put("annotations", String.join(",", node.getAnnotations())); } + // Pre-lowered properties for index-backed case-insensitive search + props.put("label_lower", node.getLabel() != null ? node.getLabel().toLowerCase() : ""); + if (node.getFqn() != null) props.put("fqn_lower", node.getFqn().toLowerCase()); if (node.getProperties() != null) { for (var entry : node.getProperties().entrySet()) { if (entry.getValue() != null) { @@ -249,10 +254,11 @@ public List search(String text) { } public List search(String text, int limit) { + String lowerText = text.toLowerCase(); return queryNodes( - "MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) " - + "OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit", - Map.of("text", text, "limit", limit)); + "MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text " + + "OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit", + Map.of("text", lowerText, "limit", limit)); } public List findNeighbors(String nodeId) { @@ -273,6 +279,31 @@ public List findIncomingNeighbors(String nodeId) { Map.of("nodeId", nodeId)); } + /** + * Batch-find all ENDPOINT/WEBSOCKET_ENDPOINT neighbors for a list of node IDs in one query. + * Returns a map of sourceNodeId -> list of endpoint neighbor nodes. + */ + public Map> findEndpointNeighborsBatch(List nodeIds) { + Map> result = new java.util.LinkedHashMap<>(); + if (nodeIds.isEmpty()) return result; + try (Transaction tx = graphDb.beginTx()) { + var queryResult = tx.execute( + "MATCH (n:CodeNode)-[]-(m:CodeNode) " + + "WHERE n.id IN $nodeIds AND m.kind IN ['ENDPOINT', 'WEBSOCKET_ENDPOINT'] " + + "RETURN n.id AS sourceId, m", + Map.of("nodeIds", nodeIds)); + while (queryResult.hasNext()) { + var row = queryResult.next(); + String sourceId = (String) row.get("sourceId"); + Object val = row.get("m"); + if (val instanceof org.neo4j.graphdb.Node neo4jNode) { + result.computeIfAbsent(sourceId, k -> new ArrayList<>()).add(nodeFromNeo4j(neo4jNode)); + } + } + } + return result; + } + public long count() { try (Transaction tx = graphDb.beginTx()) { var result = tx.execute("MATCH (n:CodeNode) RETURN count(n) AS cnt"); diff --git a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java index 26555192..954a21bd 100644 --- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java +++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java @@ -337,19 +337,24 @@ public Map findRelatedEndpoints(String identifier) { Set seenIds = new java.util.LinkedHashSet<>(); List> endpoints = new ArrayList<>(); + // First pass: collect matches that are themselves endpoints for (CodeNode match : matches) { if (match.getKind() == NodeKind.ENDPOINT || match.getKind() == NodeKind.WEBSOCKET_ENDPOINT) { if (seenIds.add(match.getId())) { endpoints.add(nodeToMap(match)); } } - // Check neighbors for connected endpoints - List neighbors = graphStore.findNeighbors(match.getId()); - for (CodeNode neighbor : neighbors) { - if ((neighbor.getKind() == NodeKind.ENDPOINT || neighbor.getKind() == NodeKind.WEBSOCKET_ENDPOINT) - && seenIds.add(neighbor.getId())) { + } + + // Single batched query for all endpoint neighbors (replaces N+1 loop) + List matchIds = matches.stream().map(CodeNode::getId).toList(); + Map> endpointNeighbors = graphStore.findEndpointNeighborsBatch(matchIds); + for (Map.Entry> entry : endpointNeighbors.entrySet()) { + String sourceId = entry.getKey(); + for (CodeNode neighbor : entry.getValue()) { + if (seenIds.add(neighbor.getId())) { Map epMap = nodeToMap(neighbor); - epMap.put("connected_via", match.getId()); + epMap.put("connected_via", sourceId); endpoints.add(epMap); } } @@ -379,9 +384,10 @@ public Map getTopology() { * they are always present from parent modules/config files. */ private static final List SEMANTIC_EDGE_KINDS = List.of( - "calls", "imports", "depends_on", "uses", "extends", "implements", + "calls", "imports", "depends_on", "extends", "implements", "injects", "queries", "maps_to", "consumes", "listens", - "invokes_rmi", "overrides", "connects_to", "triggers", "renders"); + "invokes_rmi", "overrides", "connects_to", "triggers", "renders", + "protects"); /** * Node kinds that are entry points — they are intended to have no callers @@ -393,7 +399,13 @@ public Map getTopology() { NodeKind.MIGRATION.getValue(), NodeKind.CONFIG_FILE.getValue(), NodeKind.CONFIG_KEY.getValue(), - NodeKind.CONFIG_DEFINITION.getValue()); + NodeKind.CONFIG_DEFINITION.getValue(), + NodeKind.GUARD.getValue(), + NodeKind.MIDDLEWARE.getValue(), + NodeKind.TOPIC.getValue(), + NodeKind.QUEUE.getValue(), + NodeKind.EVENT.getValue(), + NodeKind.MESSAGE_QUEUE.getValue()); @Cacheable(value = "dead-code", key = "#kind + ':' + #limit") public Map findDeadCode(String kind, int limit) { diff --git a/src/test/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinkerTest.java b/src/test/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinkerTest.java new file mode 100644 index 00000000..0919919e --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinkerTest.java @@ -0,0 +1,145 @@ +package io.github.randomcodespace.iq.analyzer.linker; + +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.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GuardLinkerTest { + + private final GuardLinker linker = new GuardLinker(); + + private CodeNode guardNode(String id, String filePath) { + var node = new CodeNode(id, NodeKind.GUARD, id); + node.setFilePath(filePath); + return node; + } + + private CodeNode middlewareNode(String id, String filePath) { + var node = new CodeNode(id, NodeKind.MIDDLEWARE, id); + node.setFilePath(filePath); + return node; + } + + private CodeNode endpointNode(String id, String filePath) { + var node = new CodeNode(id, NodeKind.ENDPOINT, id); + node.setFilePath(filePath); + return node; + } + + @Test + void linksGuardToEndpointInSameFile() { + var guard = guardNode("auth:UserController.java:Secured:10", "UserController.java"); + var endpoint = endpointNode("ep:UserController.java:GET:/users:20", "UserController.java"); + + LinkResult result = linker.link(List.of(guard, endpoint), List.of()); + + assertEquals(1, result.edges().size()); + CodeEdge edge = result.edges().getFirst(); + assertEquals(EdgeKind.PROTECTS, edge.getKind()); + assertEquals(guard.getId(), edge.getSourceId()); + assertEquals(endpoint.getId(), edge.getTarget().getId()); + assertEquals(true, edge.getProperties().get("inferred")); + } + + @Test + void linksMiddlewareToEndpointInSameFile() { + var middleware = middlewareNode("mw:routes.ts:authMiddleware:5", "routes.ts"); + var endpoint = endpointNode("ep:routes.ts:GET:/profile:15", "routes.ts"); + + LinkResult result = linker.link(List.of(middleware, endpoint), List.of()); + + assertEquals(1, result.edges().size()); + assertEquals(EdgeKind.PROTECTS, result.edges().getFirst().getKind()); + } + + @Test + void classLevelGuardProtectsAllEndpointsInSameFile() { + var guard = guardNode("auth:OrderController.java:PreAuthorize:3", "OrderController.java"); + var ep1 = endpointNode("ep:OrderController.java:GET:/orders:25", "OrderController.java"); + var ep2 = endpointNode("ep:OrderController.java:POST:/orders:35", "OrderController.java"); + var ep3 = endpointNode("ep:OrderController.java:DELETE:/orders/{id}:45", "OrderController.java"); + + LinkResult result = linker.link(List.of(guard, ep1, ep2, ep3), List.of()); + + assertEquals(3, result.edges().size()); + assertTrue(result.edges().stream().allMatch(e -> e.getKind() == EdgeKind.PROTECTS)); + assertTrue(result.edges().stream().allMatch(e -> e.getSourceId().equals(guard.getId()))); + } + + @Test + void guardInDifferentFileDoesNotProtectEndpoint() { + var guard = guardNode("auth:SecurityConfig.java:EnableWebSecurity:1", "SecurityConfig.java"); + var endpoint = endpointNode("ep:UserController.java:GET:/users:10", "UserController.java"); + + LinkResult result = linker.link(List.of(guard, endpoint), List.of()); + + assertTrue(result.edges().isEmpty()); + } + + @Test + void noGuardsReturnsEmpty() { + var endpoint = endpointNode("ep:UserController.java:GET:/users:10", "UserController.java"); + + LinkResult result = linker.link(List.of(endpoint), List.of()); + + assertTrue(result.edges().isEmpty()); + } + + @Test + void noEndpointsReturnsEmpty() { + var guard = guardNode("auth:UserController.java:Secured:5", "UserController.java"); + + LinkResult result = linker.link(List.of(guard), List.of()); + + assertTrue(result.edges().isEmpty()); + } + + @Test + void avoidsDuplicateEdges() { + var guard = guardNode("auth:UserController.java:Secured:5", "UserController.java"); + var endpoint = endpointNode("ep:UserController.java:GET:/users:15", "UserController.java"); + + var existing = new CodeEdge(); + existing.setId("existing"); + existing.setKind(EdgeKind.PROTECTS); + existing.setSourceId(guard.getId()); + existing.setTarget(endpoint); + + LinkResult result = linker.link(List.of(guard, endpoint), List.of(existing)); + + assertTrue(result.edges().isEmpty()); + } + + @Test + void nodesWithNullFilePathAreIgnored() { + var guard = new CodeNode("auth:guard:1", NodeKind.GUARD, "guard"); + // filePath is null by default + var endpoint = endpointNode("ep:file.java:GET:/users:10", "file.java"); + + LinkResult result = linker.link(List.of(guard, endpoint), List.of()); + + assertTrue(result.edges().isEmpty()); + } + + @Test + void determinismRunTwiceProducesSameResult() { + var guard1 = guardNode("auth:Ctrl.java:Secured:5", "Ctrl.java"); + var guard2 = guardNode("auth:Ctrl.java:PreAuthorize:8", "Ctrl.java"); + var ep1 = endpointNode("ep:Ctrl.java:GET:/a:20", "Ctrl.java"); + var ep2 = endpointNode("ep:Ctrl.java:POST:/b:30", "Ctrl.java"); + + LinkResult r1 = linker.link(List.of(guard1, guard2, ep1, ep2), List.of()); + LinkResult r2 = linker.link(List.of(guard1, guard2, ep1, ep2), List.of()); + + assertEquals(r1.edges().size(), r2.edges().size()); + for (int i = 0; i < r1.edges().size(); i++) { + assertEquals(r1.edges().get(i).getId(), r2.edges().get(i).getId()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java index da4750a6..51a2a91b 100644 --- a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java +++ b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java @@ -477,6 +477,48 @@ void findDeadCodeShouldExcludeEntryPointKinds() { assertTrue(excludeKinds.contains("websocket_endpoint"), "Should exclude websocket endpoints"); assertTrue(excludeKinds.contains("migration"), "Should exclude migrations"); assertTrue(excludeKinds.contains("config_file"), "Should exclude config files"); + assertTrue(excludeKinds.contains("guard"), "Should exclude guards"); + assertTrue(excludeKinds.contains("middleware"), "Should exclude middleware"); + assertTrue(excludeKinds.contains("topic"), "Should exclude topics"); + assertTrue(excludeKinds.contains("queue"), "Should exclude queues"); + assertTrue(excludeKinds.contains("event"), "Should exclude events"); + assertTrue(excludeKinds.contains("message_queue"), "Should exclude message queues"); + } + + @Test + void findDeadCodeShouldNotFlagMessageDrivenComponents() { + var guard = makeNode("guard:AuthGuard", NodeKind.GUARD, "AuthGuard"); + var middleware = makeNode("mid:LoggingMiddleware", NodeKind.MIDDLEWARE, "LoggingMiddleware"); + var topic = makeNode("topic:UserEvents", NodeKind.TOPIC, "UserEvents"); + var queue = makeNode("queue:EmailQueue", NodeKind.QUEUE, "EmailQueue"); + var event = makeNode("event:OrderPlaced", NodeKind.EVENT, "OrderPlaced"); + var messageQueue = makeNode("mq:NotificationQueue", NodeKind.MESSAGE_QUEUE, "NotificationQueue"); + + // These are excluded via ENTRY_POINT_KINDS so graphStore won't return them + when(graphStore.findNodesWithoutIncomingSemantic(anyList(), anyList(), anyList(), eq(0), eq(100))) + .thenReturn(List.of()); + + Map result = service.findDeadCode(null, 100); + + @SuppressWarnings("unchecked") + List> deadCode = (List>) result.get("dead_code"); + assertTrue(deadCode.isEmpty(), "Message-driven and security components should not be flagged as dead code"); + } + + @Test + void findDeadCodeShouldIncludeProtectsInSemanticEdgeKinds() { + when(graphStore.findNodesWithoutIncomingSemantic(anyList(), anyList(), anyList(), eq(0), eq(50))) + .thenReturn(List.of()); + + service.findDeadCode(null, 50); + + @SuppressWarnings("unchecked") + var captor = org.mockito.ArgumentCaptor.forClass(List.class); + verify(graphStore).findNodesWithoutIncomingSemantic(anyList(), captor.capture(), anyList(), eq(0), eq(50)); + @SuppressWarnings("unchecked") + List semanticKinds = captor.getValue(); + assertTrue(semanticKinds.contains("protects"), "Should include 'protects' as semantic edge"); + assertFalse(semanticKinds.contains("uses"), "Should NOT include 'uses' — not a valid EdgeKind"); } @Test @@ -502,6 +544,59 @@ void findDeadCodeShouldReturnEmptyWhenAllNodesHaveSemanticEdges() { assertTrue(deadCode.isEmpty()); } + // --- findRelatedEndpoints --- + + @Test + void findRelatedEndpointsShouldUsesBatchQueryInsteadOfNPlusOne() { + var classNode = makeNode("cls:UserService", NodeKind.CLASS, "UserService"); + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + when(graphStore.search("UserService", 50)).thenReturn(List.of(classNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("cls:UserService"))) + .thenReturn(Map.of("cls:UserService", List.of(endpointNode))); + + Map result = service.findRelatedEndpoints("UserService"); + + assertEquals("UserService", result.get("identifier")); + assertEquals(1, result.get("count")); + assertEquals(1, result.get("searched_nodes")); + @SuppressWarnings("unchecked") + List> endpoints = (List>) result.get("endpoints"); + assertEquals("ep:getUsers", endpoints.getFirst().get("id")); + assertEquals("cls:UserService", endpoints.getFirst().get("connected_via")); + // Verify no per-node findNeighbors calls were made + verify(graphStore, never()).findNeighbors(anyString()); + } + + @Test + void findRelatedEndpointsShouldIncludeDirectEndpointMatches() { + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + when(graphStore.search("getUsers", 50)).thenReturn(List.of(endpointNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers"))).thenReturn(Map.of()); + + Map result = service.findRelatedEndpoints("getUsers"); + + assertEquals(1, result.get("count")); + @SuppressWarnings("unchecked") + List> endpoints = (List>) result.get("endpoints"); + assertEquals("ep:getUsers", endpoints.getFirst().get("id")); + // Direct endpoint matches have no connected_via + assertNull(endpoints.getFirst().get("connected_via")); + } + + @Test + void findRelatedEndpointsShouldDeduplicateEndpoints() { + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + // Same endpoint appears as both a direct match and a neighbor + when(graphStore.search("ep", 50)).thenReturn(List.of(endpointNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers"))) + .thenReturn(Map.of("ep:getUsers", List.of(endpointNode))); + + Map result = service.findRelatedEndpoints("ep"); + + // Should only appear once + assertEquals(1, result.get("count")); + } + // --- nodeToMap --- @Test