From 23a14d83913864dd378721e94597516906da9e5c Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 05:50:34 +0200 Subject: [PATCH] Expand entry-point and trigger detector coverage. Detect fire/trigger wrappers as state-machine triggers, resolve Kafka topicPattern and additional Rabbit listener attributes, and add regression tests for WebFlux RouterFunction routes and messaging listeners. Co-authored-by: Cursor --- .../service/GenericEventDetector.java | 9 ++- .../analysis/service/MessagingDetector.java | 9 +++ .../analysis/service/SpringMvcDetector.java | 10 +-- .../service/GenericEventDetectorFireTest.java | 46 +++++++++++ .../service/MessagingDetectorTest.java | 78 +++++++++++++++++++ .../service/SpringMvcDetectorTest.java | 74 ++++++++++++++++++ 6 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorFireTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetectorTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 932e759..b36ab6b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -31,6 +31,9 @@ public class GenericEventDetector { "eventString", "version"); + private static final Set TRIGGER_METHOD_NAMES = Set.of( + "sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger"); + public List detect(CompilationUnit cu) { List triggers = new ArrayList<>(); String fileName = "unknown"; @@ -45,15 +48,13 @@ public class GenericEventDetector { public boolean visit(MethodInvocation node) { String methodName = node.getName().getIdentifier(); - if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) || - "sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) { + if (TRIGGER_METHOD_NAMES.contains(methodName)) { processSendEvent(node, cu, triggers); } else { for (Object argObj : node.arguments()) { if (argObj instanceof ExpressionMethodReference emr) { String refMethod = emr.getName().getIdentifier(); - if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) || - "sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) { + if (TRIGGER_METHOD_NAMES.contains(refMethod)) { processSendEventMethodReference(node, emr, cu, triggers); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java index efd1964..c571cc7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java @@ -40,6 +40,12 @@ public class MessagingDetector { } else if (typeName.endsWith("RabbitListener")) { type = EntryPoint.Type.RABBIT; destination = resolveValue(annotation, "queues"); + if ("unknown".equals(destination)) { + destination = resolveValue(annotation, "queue"); + } + if ("unknown".equals(destination)) { + destination = resolveValue(annotation, "bindings"); + } } else if (typeName.endsWith("SqsListener")) { type = EntryPoint.Type.SQS; destination = resolveValue(annotation, "value"); @@ -55,6 +61,9 @@ public class MessagingDetector { } else if (typeName.endsWith("KafkaListener")) { type = EntryPoint.Type.KAFKA; destination = resolveValue(annotation, "topics"); + if ("unknown".equals(destination)) { + destination = resolveValue(annotation, "topicPattern"); + } } if (type != null) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java index 41597ff..cfa842f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java @@ -182,11 +182,11 @@ public class SpringMvcDetector { } private String getHttpVerb(String annotationName) { - if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST")) return "POST"; - if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET")) return "GET"; - if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT")) return "PUT"; - if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE")) return "DELETE"; - if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH")) return "PATCH"; + if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST") || "POST".equals(annotationName)) return "POST"; + if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET") || "GET".equals(annotationName)) return "GET"; + if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT") || "PUT".equals(annotationName)) return "PUT"; + if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE") || "DELETE".equals(annotationName)) return "DELETE"; + if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH") || "PATCH".equals(annotationName)) return "PATCH"; if (annotationName.endsWith("RequestMapping")) return "REQUEST"; return null; } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorFireTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorFireTest.java new file mode 100644 index 0000000..a35dc0a --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorFireTest.java @@ -0,0 +1,46 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenericEventDetectorFireTest { + + @Test + void shouldDetectFireMethodAsTrigger(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + enum OrderEvent { PAY, SHIP } + class OrderService { + private final StateMachineFacade facade; + OrderService(StateMachineFacade facade) { this.facade = facade; } + public void pay() { + facade.fire(OrderEvent.PAY); + } + } + class StateMachineFacade { + public void fire(OrderEvent event) {} + } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of()); + org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService"); + List triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot()); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getMethodName()).isEqualTo("pay"); + assertThat(triggers.get(0).getEvent()).contains("PAY"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetectorTest.java new file mode 100644 index 0000000..02842a8 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetectorTest.java @@ -0,0 +1,78 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingDetectorTest { + + @Test + void shouldDetectRabbitListenerQueue(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.amqp.rabbit.annotation.RabbitListener; + import org.springframework.stereotype.Component; + @Component + public class ReturnsListener { + @RabbitListener(queues = "returns.queue") + public void onReturn(String orderId) {} + } + """; + Files.writeString(tempDir.resolve("ReturnsListener.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + MessagingDetector detector = new MessagingDetector(context); + org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.ReturnsListener"); + List entryPoints = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot()); + + assertThat(entryPoints).hasSize(1); + assertThat(entryPoints.get(0).getType()).isEqualTo(EntryPoint.Type.RABBIT); + assertThat(entryPoints.get(0).getName()).isEqualTo("RABBIT: returns.queue"); + assertThat(entryPoints.get(0).getMetadata()).containsEntry("destination", "returns.queue"); + } + + @Test + void shouldDetectKafkaListenerTopicsAndTopicPattern(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.kafka.annotation.KafkaListener; + import org.springframework.stereotype.Component; + @Component + public class OrderKafkaListener { + @KafkaListener(topics = {"orders.created", "orders.updated"}) + public void onOrderEvent(String payload) {} + + @KafkaListener(topicPattern = "payments.*") + public void onPaymentEvent(String payload) {} + } + """; + Files.writeString(tempDir.resolve("OrderKafkaListener.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + MessagingDetector detector = new MessagingDetector(context); + org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderKafkaListener"); + List entryPoints = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot()); + + assertThat(entryPoints).hasSize(2); + assertThat(entryPoints).anyMatch(ep -> + ep.getType() == EntryPoint.Type.KAFKA + && ep.getName().contains("orders.created") + && ep.getName().contains("orders.updated")); + assertThat(entryPoints).anyMatch(ep -> + ep.getType() == EntryPoint.Type.KAFKA + && "payments.*".equals(ep.getMetadata().get("destination"))); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java new file mode 100644 index 0000000..bd02e79 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java @@ -0,0 +1,74 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpringMvcDetectorTest { + + @Test + void shouldDetectWebFluxRouterFunctionBean(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + import org.springframework.web.reactive.function.server.RouterFunction; + import org.springframework.web.reactive.function.server.ServerResponse; + import static org.springframework.web.reactive.function.server.RouterFunctions.route; + @Configuration + public class ReactiveOrderApi { + @Bean + public RouterFunction orderRoutes() { + return route().POST("/reactive/order", request -> ServerResponse.ok().build()).build(); + } + } + """; + Files.writeString(tempDir.resolve("ReactiveOrderApi.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + SpringMvcDetector detector = new SpringMvcDetector(context, context.getConstantResolver()); + org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.ReactiveOrderApi"); + List entryPoints = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot()); + + assertThat(entryPoints).anyMatch(ep -> + "POST /reactive/order".equals(ep.getName()) + && "orderRoutes".equals(ep.getMethodName()) + && "WebFlux Functional".equals(ep.getMetadata().get("style"))); + } + + @Test + void shouldDetectRestControllerPostMapping(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.web.bind.annotation.*; + @RestController + @RequestMapping("/api/orders") + public class OrderController { + @PostMapping("/pay") + public void pay() {} + } + """; + Files.writeString(tempDir.resolve("OrderController.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + SpringMvcDetector detector = new SpringMvcDetector(context, context.getConstantResolver()); + org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderController"); + List entryPoints = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot()); + + assertThat(entryPoints).anyMatch(ep -> + "POST /api/orders/pay".equals(ep.getName()) && "pay".equals(ep.getMethodName())); + } +}