diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayExtendedTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayExtendedTest.java new file mode 100644 index 00000000..5808e5c5 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayExtendedTest.java @@ -0,0 +1,264 @@ +package io.github.randomcodespace.iq.config.unified; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Extended tests for {@link EnvVarOverlay} covering switch-case branches not + * exercised by {@link EnvVarOverlayTest} — project metadata, indexing bounds, + * Neo4j pools, MCP auth + tools, observability, and detectors overlays. + */ +class EnvVarOverlayExtendedTest { + + // --------------------------------------------------------------- + // Project section + // --------------------------------------------------------------- + + @Test + void readsProjectName() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_PROJECT_NAME", "acme")); + assertEquals("acme", cfg.project().name()); + } + + @Test + void readsProjectRoot() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_PROJECT_ROOT", "/repo")); + assertEquals("/repo", cfg.project().root()); + } + + @Test + void readsProjectServiceName() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_PROJECT_SERVICE_NAME", "orders-api")); + assertEquals("orders-api", cfg.project().serviceName()); + } + + // --------------------------------------------------------------- + // Indexing bounds + // --------------------------------------------------------------- + + @Test + void readsIndexingBatchsize() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_BATCHSIZE", "250")); + assertEquals(250, cfg.indexing().batchSize()); + } + + @Test + void readsIndexingIncludeAndExclude() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_INCLUDE", "src/**/*.java", + "CODEIQ_INDEXING_EXCLUDE", "**/target/**,**/build/**")); + assertEquals(List.of("src/**/*.java"), cfg.indexing().include()); + assertEquals(List.of("**/target/**", "**/build/**"), cfg.indexing().exclude()); + } + + @Test + void readsIndexingIncremental() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_INCREMENTAL", "false")); + assertEquals(Boolean.FALSE, cfg.indexing().incremental()); + } + + @Test + void readsIndexingCacheDir() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_CACHEDIR", ".cache/intel")); + assertEquals(".cache/intel", cfg.indexing().cacheDir()); + } + + @Test + void readsIndexingMaxDepthRadiusFilesSnippet() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_MAX_DEPTH", "5", + "CODEIQ_INDEXING_MAX_RADIUS", "3", + "CODEIQ_INDEXING_MAX_FILES", "10000", + "CODEIQ_INDEXING_MAX_SNIPPET_LINES", "40")); + assertEquals(5, cfg.indexing().maxDepth()); + assertEquals(3, cfg.indexing().maxRadius()); + assertEquals(10000, cfg.indexing().maxFiles()); + assertEquals(40, cfg.indexing().maxSnippetLines()); + } + + @Test + void malformedIndexingBatchsizeThrowsWithVarName() { + ConfigLoadException e = assertThrows(ConfigLoadException.class, + () -> EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_BATCHSIZE", "oops"))); + assertTrue(e.getMessage().contains("CODEIQ_INDEXING_BATCHSIZE")); + } + + // --------------------------------------------------------------- + // Serving: bind addr + Neo4j pools + // --------------------------------------------------------------- + + @Test + void readsServingBindAddress() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_SERVING_BINDADDRESS", "127.0.0.1")); + assertEquals("127.0.0.1", cfg.serving().bindAddress()); + } + + @Test + void readsServingNeo4jDir() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_SERVING_NEO4J_DIR", "/var/neo4j")); + assertEquals("/var/neo4j", cfg.serving().neo4j().dir()); + } + + @Test + void readsServingNeo4jPageCacheAndHeapSizes() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_SERVING_NEO4J_PAGECACHEMB", "512", + "CODEIQ_SERVING_NEO4J_HEAPINITIALMB", "256", + "CODEIQ_SERVING_NEO4J_HEAPMAXMB", "1024")); + assertEquals(512, cfg.serving().neo4j().pageCacheMb()); + assertEquals(256, cfg.serving().neo4j().heapInitialMb()); + assertEquals(1024, cfg.serving().neo4j().heapMaxMb()); + } + + @Test + void malformedPageCacheThrowsWithVarName() { + ConfigLoadException e = assertThrows(ConfigLoadException.class, + () -> EnvVarOverlay.from(Map.of("CODEIQ_SERVING_NEO4J_PAGECACHEMB", "huge"))); + assertTrue(e.getMessage().contains("CODEIQ_SERVING_NEO4J_PAGECACHEMB")); + } + + // --------------------------------------------------------------- + // MCP: enabled + transport + basepath + auth + limits + tools + // --------------------------------------------------------------- + + @Test + void readsMcpEnabledAndTransport() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_MCP_ENABLED", "true", + "CODEIQ_MCP_TRANSPORT", "http")); + assertEquals(Boolean.TRUE, cfg.mcp().enabled()); + assertEquals("http", cfg.mcp().transport()); + } + + @Test + void readsMcpBasePath() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_MCP_BASEPATH", "/mcp")); + assertEquals("/mcp", cfg.mcp().basePath()); + } + + @Test + void readsMcpAuthModeAndTokenEnv() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_MCP_AUTH_MODE", "bearer", + "CODEIQ_MCP_AUTH_TOKENENV", "MCP_TOKEN")); + assertEquals("bearer", cfg.mcp().auth().mode()); + assertEquals("MCP_TOKEN", cfg.mcp().auth().tokenEnv()); + } + + @Test + void readsMcpLimitsAllFields() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_MCP_LIMITS_PERTOOLTIMEOUTMS", "5000", + "CODEIQ_MCP_LIMITS_MAXRESULTS", "200", + "CODEIQ_MCP_LIMITS_MAXPAYLOADBYTES", "1048576", + "CODEIQ_MCP_LIMITS_RATEPERMINUTE", "60")); + assertEquals(5000, cfg.mcp().limits().perToolTimeoutMs()); + assertEquals(200, cfg.mcp().limits().maxResults()); + assertEquals(1_048_576L, cfg.mcp().limits().maxPayloadBytes()); + assertEquals(60, cfg.mcp().limits().ratePerMinute()); + } + + @Test + void malformedMaxPayloadBytesThrowsWithVarName() { + ConfigLoadException e = assertThrows(ConfigLoadException.class, + () -> EnvVarOverlay.from(Map.of("CODEIQ_MCP_LIMITS_MAXPAYLOADBYTES", "big"))); + assertTrue(e.getMessage().contains("CODEIQ_MCP_LIMITS_MAXPAYLOADBYTES")); + } + + @Test + void readsMcpToolsEnabledAndDisabled() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_MCP_TOOLS_ENABLED", "get_stats,find_consumers", + "CODEIQ_MCP_TOOLS_DISABLED", "run_cypher")); + assertEquals(List.of("get_stats", "find_consumers"), cfg.mcp().tools().enabled()); + assertEquals(List.of("run_cypher"), cfg.mcp().tools().disabled()); + } + + // --------------------------------------------------------------- + // Observability + // --------------------------------------------------------------- + + @Test + void readsObservabilityMetricsTracingLogFormatLogLevel() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_OBSERVABILITY_METRICS", "true", + "CODEIQ_OBSERVABILITY_TRACING", "false", + "CODEIQ_OBSERVABILITY_LOGFORMAT", "json", + "CODEIQ_OBSERVABILITY_LOGLEVEL", "DEBUG")); + assertEquals(Boolean.TRUE, cfg.observability().metrics()); + assertEquals(Boolean.FALSE, cfg.observability().tracing()); + assertEquals("json", cfg.observability().logFormat()); + assertEquals("DEBUG", cfg.observability().logLevel()); + } + + // --------------------------------------------------------------- + // Detectors profiles (alongside pre-existing categories/include) + // --------------------------------------------------------------- + + @Test + void readsDetectorsProfiles() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_DETECTORS_PROFILES", "backend,api")); + assertEquals(List.of("backend", "api"), cfg.detectors().profiles()); + } + + // --------------------------------------------------------------- + // splitCsv edge cases + // --------------------------------------------------------------- + + @Test + void emptyCsvResultsInEmptyList() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_LANGUAGES", "")); + assertThat(cfg.indexing().languages()).isEmpty(); + } + + @Test + void csvWithWhitespaceOnlyEntries_areSkipped() { + // " , ," → three whitespace-only tokens that must be filtered out + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_LANGUAGES", " , ,")); + assertThat(cfg.indexing().languages()).isEmpty(); + } + + @Test + void csvTrimsWhitespaceFromEntries() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_MCP_TOOLS_ENABLED", " get_stats , find_cycles ")); + assertEquals(List.of("get_stats", "find_cycles"), cfg.mcp().tools().enabled()); + } + + // --------------------------------------------------------------- + // Unknown variables under the CODEIQ_ prefix must also be ignored + // (exercises the default switch arm) + // --------------------------------------------------------------- + + @Test + void unknownCodeiqSubKey_isIgnored() { + // CODEIQ_FUTURE_FEATURE doesn't match any case → no effect + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_FUTURE_FEATURE", "yes", + "CODEIQ_SERVING_PORT", "9999")); + assertEquals(9999, cfg.serving().port()); + } + + // --------------------------------------------------------------- + // Determinism: same env map, multiple invocations produce equal config + // --------------------------------------------------------------- + + @Test + void deterministic_sameEnvProducesEqualConfig() { + var env = new LinkedHashMap(); + env.put("CODEIQ_SERVING_PORT", "8080"); + env.put("CODEIQ_INDEXING_LANGUAGES", "java,python"); + env.put("CODEIQ_DETECTORS_CATEGORIES", "endpoints"); + var run1 = EnvVarOverlay.from(env); + var run2 = EnvVarOverlay.from(env); + assertEquals(run1, run2); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureFunctionsDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureFunctionsDetectorTest.java new file mode 100644 index 00000000..270f6874 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureFunctionsDetectorTest.java @@ -0,0 +1,306 @@ +package io.github.randomcodespace.iq.detector.jvm.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.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link AzureFunctionsDetector}. + * + *

Covers every trigger branch: HTTP, Service Bus queue, Service Bus topic, + * Event Hub, Timer, CosmosDB, and the unknown-trigger fallback. + */ +class AzureFunctionsDetectorTest { + + private final AzureFunctionsDetector detector = new AzureFunctionsDetector(); + + // --------------------------------------------------------------- + // Metadata + // --------------------------------------------------------------- + + @Test + void getName_returnsAzureFunctions() { + assertEquals("azure_functions", detector.getName()); + } + + @Test + void getSupportedLanguages_coversAllDeclaredLanguages() { + Set langs = detector.getSupportedLanguages(); + assertTrue(langs.containsAll(Set.of("java", "csharp", "typescript", "javascript"))); + } + + // --------------------------------------------------------------- + // Early exits + // --------------------------------------------------------------- + + @Test + void emptyContent_returnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("Fn.java", "java", ""); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void nullContent_returnsEmpty() { + DetectorContext ctx = new DetectorContext("Fn.java", "java", null); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void fileWithoutFunctionNameOrHttpTrigger_returnsEmpty() { + String code = """ + package app; + public class Plain { + public void run() {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Plain.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + // --------------------------------------------------------------- + // HTTP trigger → AZURE_FUNCTION + ENDPOINT + EXPOSES edge + // --------------------------------------------------------------- + + @Test + void httpTrigger_producesFunctionEndpointAndEdge() { + String code = """ + package app; + public class HelloFn { + @FunctionName("hello") + @HttpTrigger(name = "req") + public String run() { return "hi"; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("HelloFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + // One AZURE_FUNCTION + one ENDPOINT node + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "hello".equals(n.getLabel()) + && "http".equals(n.getProperties().get("trigger_type"))); + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.ENDPOINT + && Boolean.TRUE.equals(n.getProperties().get("http_trigger"))); + + // EXPOSES edge from function to endpoint + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.EXPOSES); + } + + // --------------------------------------------------------------- + // Service Bus Queue trigger + // --------------------------------------------------------------- + + @Test + void serviceBusQueueTrigger_producesQueueAndTriggersEdge() { + String code = """ + package app; + public class OrdersFn { + @FunctionName("onOrder") + public void run( + @ServiceBusQueueTrigger(name = "msg", queueName = "orders-q", connection = "c") String msg + ) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("OrdersFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "serviceBusQueue".equals(n.getProperties().get("trigger_type")) + && "orders-q".equals(n.getProperties().get("queue_name"))); + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.QUEUE + && "azure:servicebus:queue:orders-q".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.TRIGGERS); + } + + // --------------------------------------------------------------- + // Service Bus Topic trigger + // --------------------------------------------------------------- + + @Test + void serviceBusTopicTrigger_producesTopicAndTriggersEdge() { + String code = """ + package app; + public class EventsFn { + @FunctionName("onEvent") + public void run( + @ServiceBusTopicTrigger(name = "msg", topicName = "events-t", subscriptionName = "s") String msg + ) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("EventsFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "serviceBusTopic".equals(n.getProperties().get("trigger_type"))); + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.TOPIC + && "azure:servicebus:topic:events-t".equals(n.getId())); + } + + // --------------------------------------------------------------- + // Event Hub trigger + // --------------------------------------------------------------- + + @Test + void eventHubTrigger_producesHubNodeAndTriggersEdge() { + String code = """ + package app; + public class MetricsFn { + @FunctionName("onMetric") + public void run( + @EventHubTrigger(name = "m", eventHubName = "metrics-hub", connection = "c") String m + ) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("MetricsFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "eventHub".equals(n.getProperties().get("trigger_type")) + && "metrics-hub".equals(n.getProperties().get("event_hub_name"))); + assertThat(r.nodes()).anyMatch(n -> "azure:eventhub:metrics-hub".equals(n.getId())); + } + + // --------------------------------------------------------------- + // Timer trigger + // --------------------------------------------------------------- + + @Test + void timerTrigger_capturesScheduleExpression() { + String code = """ + package app; + public class NightlyFn { + @FunctionName("nightly") + public void run( + @TimerTrigger(name = "t", schedule = "0 0 3 * * *") String timerInfo + ) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("NightlyFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "timer".equals(n.getProperties().get("trigger_type")) + && "0 0 3 * * *".equals(n.getProperties().get("schedule"))); + // Timer trigger creates only a function node — no resource/edge + assertTrue(r.edges().isEmpty(), + "Timer trigger must not emit edges"); + } + + // --------------------------------------------------------------- + // CosmosDB trigger + // --------------------------------------------------------------- + + @Test + void cosmosDBTrigger_producesResourceNodeAndEdge() { + String code = """ + package app; + public class DocsFn { + @FunctionName("onDoc") + public void run( + @CosmosDBTrigger(name = "items", databaseName = "db", collectionName = "c") String items + ) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("DocsFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "cosmosDB".equals(n.getProperties().get("trigger_type"))); + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_RESOURCE + && "azure:cosmos:func:onDoc".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.TRIGGERS); + } + + @Test + void cosmosDBInputOutputBindings_alsoDetectedAsCosmos() { + // The regex is (Trigger|Input|Output) — Input and Output should + // also match the cosmosDB branch. + String code = """ + package app; + public class DocFn { + @FunctionName("onDoc") + public void run(@CosmosDBInput(name = "d") String d) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("DocFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertThat(r.nodes()).anyMatch(n -> "cosmosDB".equals(n.getProperties().get("trigger_type"))); + } + + // --------------------------------------------------------------- + // Unknown trigger fallback + // --------------------------------------------------------------- + + @Test + void functionNameWithoutRecognizedTrigger_fallsBackToUnknown() { + String code = """ + package app; + public class WeirdFn { + @FunctionName("weirdo") + public String run() { return "x"; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("WeirdFn.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.AZURE_FUNCTION + && "unknown".equals(n.getProperties().get("trigger_type"))); + assertTrue(r.edges().isEmpty()); + } + + // --------------------------------------------------------------- + // FQN uses className when present, falls back to funcName otherwise + // --------------------------------------------------------------- + + @Test + void fqnIncludesClassNameWhenPresent() { + String code = """ + package app; + public class MyContainer { + @FunctionName("svc") + @HttpTrigger() + public String go() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("MyContainer.java", "java", code); + DetectorResult r = detector.detect(ctx); + + var fn = r.nodes().stream() + .filter(n -> n.getKind() == NodeKind.AZURE_FUNCTION) + .findFirst() + .orElseThrow(); + assertEquals("MyContainer.svc", fn.getFqn()); + } + + // --------------------------------------------------------------- + // Determinism + // --------------------------------------------------------------- + + @Test + void deterministic_acrossMultipleTriggers() { + String code = """ + package app; + public class Multi { + @FunctionName("a") + @HttpTrigger() + public void a() {} + @FunctionName("b") + public void b(@TimerTrigger(name = "t", schedule = "* * * * * *") String s) {} + @FunctionName("c") + public void c(@ServiceBusQueueTrigger(name = "m", queueName = "q") String s) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Multi.java", "java", code); + DetectorTestUtils.assertDeterministic(detector, ctx); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureMessagingDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureMessagingDetectorTest.java new file mode 100644 index 00000000..29d65cac --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/AzureMessagingDetectorTest.java @@ -0,0 +1,388 @@ +package io.github.randomcodespace.iq.detector.jvm.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.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link AzureMessagingDetector}. + * + *

Covers Service Bus (sender/receiver/processor/client) and Event Hub + * (producer/consumer/processor) variants, including named-queue/topic + * detection, named-eventhub detection, and generic fallbacks when no names + * are declared in the source. + */ +class AzureMessagingDetectorTest { + + private final AzureMessagingDetector detector = new AzureMessagingDetector(); + + // --------------------------------------------------------------- + // Metadata + // --------------------------------------------------------------- + + @Test + void getName_returnsAzureMessaging() { + assertEquals("azure_messaging", detector.getName()); + } + + @Test + void getSupportedLanguages_includesJavaAndTypeScript() { + Set langs = detector.getSupportedLanguages(); + assertTrue(langs.contains("java")); + assertTrue(langs.contains("typescript")); + assertTrue(langs.contains("javascript")); + } + + // --------------------------------------------------------------- + // Early-exit: no azure keywords + // --------------------------------------------------------------- + + @Test + void emptyContent_returnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("Foo.java", "java", ""); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + assertTrue(r.edges().isEmpty()); + } + + @Test + void nullContent_returnsEmpty() { + DetectorContext ctx = new DetectorContext("Foo.java", "java", null); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + assertTrue(r.edges().isEmpty()); + } + + @Test + void fileWithoutAzureMessagingKeywords_returnsEmpty() { + String code = """ + package app; + public class Plain { + public int add(int a, int b) { return a + b; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Plain.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void fileWithAzureKeywordsButNoClass_returnsEmpty() { + // text contains ServiceBus keyword so we don't early-exit at the top, + // but there's no "class X" declaration, so detector can't build a + // classNodeId and must bail. + String code = "// comment mentioning ServiceBus but no class here\n"; + DetectorContext ctx = DetectorTestUtils.contextFor("snippet.txt", "java", code); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + // --------------------------------------------------------------- + // Service Bus: named queue / sender + receiver + // --------------------------------------------------------------- + + @Test + void detectsServiceBusSenderWithNamedQueue() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusSenderClient; + public class OrdersPublisher { + private final ServiceBusSenderClient client = + new ServiceBusClientBuilder() + .sender() + .queueName("orders") + .buildClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("OrdersPublisher.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> + n.getKind() == NodeKind.QUEUE && "azure:servicebus:orders".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> + e.getKind() == EdgeKind.SENDS_TO && "azure:servicebus:orders".equals(e.getTarget().getId())); + } + + @Test + void detectsServiceBusReceiverWithNamedQueue() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusReceiverClient; + public class OrdersConsumer { + private final ServiceBusReceiverClient client = + new ServiceBusClientBuilder() + .receiver() + .queueName("orders-in") + .buildClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("OrdersConsumer.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> + n.getKind() == NodeKind.QUEUE && "azure:servicebus:orders-in".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + @Test + void detectsServiceBusProcessorTreatedAsReceiver() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusProcessorClient; + public class OrdersProcessor { + private final ServiceBusProcessorClient client = + new ServiceBusClientBuilder() + .processor() + .queueName("orders-stream") + .buildProcessorClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("OrdersProcessor.java", "java", code); + DetectorResult r = detector.detect(ctx); + // Processor is treated as receiver → RECEIVES_FROM + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + // --------------------------------------------------------------- + // Service Bus: named topic + // --------------------------------------------------------------- + + @Test + void detectsServiceBusSenderWithNamedTopic() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusSenderClient; + public class EventPublisher { + private final ServiceBusSenderClient client = + new ServiceBusClientBuilder() + .sender() + .topicName("events") + .buildClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("EventPublisher.java", "java", code); + DetectorResult r = detector.detect(ctx); + // Topic nodes should be NodeKind.TOPIC + assertThat(r.nodes()).anyMatch(n -> + n.getKind() == NodeKind.TOPIC && "azure:servicebus:events".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.SENDS_TO); + } + + // --------------------------------------------------------------- + // Event Hub: producer / consumer / processor + // --------------------------------------------------------------- + + @Test + void detectsEventHubProducerWithNamedEventHub() { + String code = """ + package app; + import com.azure.messaging.eventhubs.EventHubProducerClient; + public class EventHubPublisher { + private final EventHubProducerClient client = + new EventHubClientBuilder() + .eventHubName("telemetry") + .buildProducerClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("EventHubPublisher.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> + n.getKind() == NodeKind.TOPIC && "azure:eventhub:telemetry".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> + e.getKind() == EdgeKind.SENDS_TO && "azure:eventhub:telemetry".equals(e.getTarget().getId())); + } + + @Test + void detectsEventHubConsumerWithNamedEventHub() { + String code = """ + package app; + import com.azure.messaging.eventhubs.EventHubConsumerClient; + public class EventHubSubscriber { + private final EventHubConsumerClient client = + new EventHubClientBuilder() + .eventHubName("logs") + .buildConsumerClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("EventHubSubscriber.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> "azure:eventhub:logs".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + @Test + void detectsEventProcessorClientAsConsumer() { + String code = """ + package app; + import com.azure.messaging.eventhubs.EventProcessorClient; + public class EventHubProcessor { + private final EventProcessorClient client = + new EventProcessorClientBuilder() + .eventHubName("metrics") + .buildEventProcessorClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("EventHubProcessor.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + // --------------------------------------------------------------- + // Generic fallbacks: sender/receiver/client without names + // --------------------------------------------------------------- + + @Test + void genericSender_whenNoQueueOrTopicNameDeclared() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusSenderClient; + public class UnnamedSender { + private final ServiceBusSenderClient client = build(); + private ServiceBusSenderClient build() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("UnnamedSender.java", "java", code); + DetectorResult r = detector.detect(ctx); + + // Falls back to generic __sender__ placeholder + assertThat(r.nodes()).anyMatch(n -> "azure:servicebus:__sender__".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> + e.getKind() == EdgeKind.SENDS_TO + && "azure:servicebus:__sender__".equals(e.getTarget().getId())); + } + + @Test + void genericReceiver_whenNoQueueOrTopicNameDeclared() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusReceiverClient; + public class UnnamedReceiver { + private final ServiceBusReceiverClient client = build(); + private ServiceBusReceiverClient build() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("UnnamedReceiver.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> "azure:servicebus:__receiver__".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + @Test + void genericClient_whenServiceBusClientWithoutSenderOrReceiver() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusClient; + public class Connector { + private final ServiceBusClient c = new ServiceBusClient(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Connector.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> "azure:servicebus:__client__".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.CONNECTS_TO); + } + + @Test + void genericClient_withServiceBusClientBuilder() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusClientBuilder; + public class ConnectorBuilder { + private final ServiceBusClientBuilder b = new ServiceBusClientBuilder(); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("ConnectorBuilder.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.CONNECTS_TO); + } + + @Test + void genericEventHubProducer_whenNoEventHubNameDeclared() { + String code = """ + package app; + import com.azure.messaging.eventhubs.EventHubProducerClient; + public class UnnamedProducer { + private final EventHubProducerClient c = build(); + private EventHubProducerClient build() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("UnnamedProducer.java", "java", code); + DetectorResult r = detector.detect(ctx); + + assertThat(r.nodes()).anyMatch(n -> "azure:eventhub:__producer__".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> + e.getKind() == EdgeKind.SENDS_TO + && "azure:eventhub:__producer__".equals(e.getTarget().getId())); + } + + @Test + void genericEventHubConsumer_whenNoEventHubNameDeclared() { + String code = """ + package app; + import com.azure.messaging.eventhubs.EventHubConsumerClient; + public class UnnamedConsumer { + private final EventHubConsumerClient c = build(); + private EventHubConsumerClient build() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("UnnamedConsumer.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertThat(r.nodes()).anyMatch(n -> "azure:eventhub:__consumer__".equals(n.getId())); + assertThat(r.edges()).anyMatch(e -> e.getKind() == EdgeKind.RECEIVES_FROM); + } + + // --------------------------------------------------------------- + // Deduplication: repeated names must not create duplicate nodes + // --------------------------------------------------------------- + + @Test + void sameQueueNameDeclaredTwice_producesSingleNode() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusSenderClient; + public class Dupes { + void a() { sender().queueName("orders"); } + void b() { sender().queueName("orders"); } + private ServiceBusSenderClient sender() { return null; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Dupes.java", "java", code); + DetectorResult r = detector.detect(ctx); + + long count = r.nodes().stream() + .filter(n -> "azure:servicebus:orders".equals(n.getId())) + .count(); + assertEquals(1, count, "Duplicate queue names must be deduplicated"); + } + + // --------------------------------------------------------------- + // Determinism + // --------------------------------------------------------------- + + @Test + void deterministic_sameInputSameOutput() { + String code = """ + package app; + import com.azure.messaging.servicebus.ServiceBusSenderClient; + import com.azure.messaging.eventhubs.EventHubProducerClient; + public class Mixed { + ServiceBusSenderClient sb = build().queueName("a"); + EventHubProducerClient eh = eh().eventHubName("b"); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("Mixed.java", "java", code); + DetectorTestUtils.assertDeterministic(detector, ctx); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ConfigDefDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ConfigDefDetectorTest.java new file mode 100644 index 00000000..47d4d1cc --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ConfigDefDetectorTest.java @@ -0,0 +1,351 @@ +package io.github.randomcodespace.iq.detector.jvm.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.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ConfigDefDetector}. + * + *

Covers AST and regex-fallback branches for all three config-source types: + * Kafka ConfigDef.define(), Spring @Value("${...}"), and + * Spring @ConfigurationProperties prefixes. + */ +class ConfigDefDetectorTest { + + private final ConfigDefDetector detector = new ConfigDefDetector(); + + // --------------------------------------------------------------- + // Guardrails / metadata + // --------------------------------------------------------------- + + @Test + void getName_returnsConfigDef() { + assertEquals("config_def", detector.getName()); + } + + @Test + void getSupportedLanguages_isJavaOnly() { + assertEquals(Set.of("java"), detector.getSupportedLanguages()); + } + + @Test + void emptyContent_returnsEmptyResult() { + DetectorContext ctx = DetectorTestUtils.contextFor("Foo.java", "java", ""); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty()); + assertTrue(result.edges().isEmpty()); + } + + @Test + void nullContent_returnsEmptyResult() { + DetectorContext ctx = new DetectorContext("Foo.java", "java", null); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty()); + assertTrue(result.edges().isEmpty()); + } + + @Test + void fileWithoutConfigKeywords_returnsEmpty() { + // No ConfigDef, @Value, or @ConfigurationProperties keywords: early-exit + String code = """ + package app; + public class Plain { + public int add(int a, int b) { return a + b; } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Plain.java", "java", code); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty()); + assertTrue(result.edges().isEmpty()); + } + + // --------------------------------------------------------------- + // AST branch: Kafka ConfigDef.define() + // --------------------------------------------------------------- + + @Test + void astBranch_detectsKafkaConfigDefDefine() { + String code = """ + package app; + import org.apache.kafka.common.config.ConfigDef; + public class MyKafkaConfigs { + private static final ConfigDef CONFIG = new ConfigDef() + .define("bootstrap.servers", null, null, null, null) + .define("client.id", null, null, null, null); + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/MyKafkaConfigs.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.nodes().size(), "Should detect both .define() calls"); + assertThat(result.nodes()).allMatch(n -> n.getKind() == NodeKind.CONFIG_DEFINITION); + assertThat(result.nodes()).anyMatch(n -> "bootstrap.servers".equals(n.getLabel())); + assertThat(result.nodes()).anyMatch(n -> "client.id".equals(n.getLabel())); + + // Edges: one READS_CONFIG per node + assertEquals(2, result.edges().size()); + assertThat(result.edges()).allMatch(e -> e.getKind() == EdgeKind.READS_CONFIG); + + // Properties reflect the source type + assertThat(result.nodes()).allMatch( + n -> "kafka_configdef".equals(n.getProperties().get("config_source"))); + } + + @Test + void astBranch_defineOnNonConfigDefReceiver_ignored() { + // "define" called on something unrelated to ConfigDef should be skipped + String code = """ + package app; + public class Registry { + public void setup(MetricRegistry reg) { + reg.define("hits"); // non-ConfigDef receiver — should be ignored + } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Registry.java", "java", code); + DetectorResult result = detector.detect(ctx); + // "Registry" doesn't mention ConfigDef at all; early-exit should kick in. + assertTrue(result.nodes().isEmpty()); + } + + @Test + void astBranch_defineWithNoArgs_ignored() { + String code = """ + package app; + public class Cfg { + ConfigDef CFG = new ConfigDef(); + void bad() { CFG.define(); } // no args — must be ignored + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Cfg.java", "java", code); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty(), + "define() with no arguments must not produce a config node"); + } + + @Test + void astBranch_defineWithNonStringFirstArg_ignored() { + // First argument must be a string literal — variable references skipped + String code = """ + package app; + public class Cfg { + static final String KEY = "dynamic.key"; + ConfigDef CFG = new ConfigDef(); + void bad() { CFG.define(KEY); } + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Cfg.java", "java", code); + DetectorResult result = detector.detect(ctx); + // variable-named arg is not a StringLiteralExpr — should not be detected + assertTrue(result.nodes().isEmpty()); + } + + // --------------------------------------------------------------- + // AST branch: Spring @Value on fields and parameters + // --------------------------------------------------------------- + + @Test + void astBranch_detectsSpringValueOnField() { + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + public class AppConfig { + @Value("${server.port}") + private int port; + @Value("${db.url}") + private String dbUrl; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/AppConfig.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(2, result.nodes().size()); + assertThat(result.nodes()).anyMatch(n -> "server.port".equals(n.getLabel())); + assertThat(result.nodes()).anyMatch(n -> "db.url".equals(n.getLabel())); + assertThat(result.nodes()).allMatch( + n -> "spring_value".equals(n.getProperties().get("config_source"))); + } + + @Test + void astBranch_detectsSpringValueOnMethodParameter() { + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + public class Service { + public void setup(@Value("${timeout.ms}") int timeoutMs) {} + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Service.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("timeout.ms", result.nodes().get(0).getLabel()); + assertEquals("spring_value", + result.nodes().get(0).getProperties().get("config_source")); + } + + @Test + void astBranch_valueAnnotationWithoutPlaceholder_ignored() { + // @Value("literalString") without ${...} pattern should not produce a config + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + public class C { + @Value("hardcoded") + private String x; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/C.java", "java", code); + DetectorResult result = detector.detect(ctx); + assertTrue(result.nodes().isEmpty(), + "@Value without ${...} placeholder must not produce a config node"); + } + + // --------------------------------------------------------------- + // AST branch: Spring @ConfigurationProperties + // --------------------------------------------------------------- + + @Test + void astBranch_detectsConfigurationPropertiesPrefix() { + String code = """ + package app; + import org.springframework.boot.context.properties.ConfigurationProperties; + @ConfigurationProperties(prefix = "myapp.http") + public class HttpProps { + private int timeoutMs; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/HttpProps.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size()); + assertEquals("myapp.http", result.nodes().get(0).getLabel()); + assertEquals("spring_config_props", + result.nodes().get(0).getProperties().get("config_source")); + } + + @Test + void astBranch_detectsConfigurationPropertiesShorthand() { + // @ConfigurationProperties("myapp.db") without 'prefix=' keyword + String code = """ + package app; + import org.springframework.boot.context.properties.ConfigurationProperties; + @ConfigurationProperties("myapp.db") + public class DbProps {} + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/DbProps.java", "java", code); + DetectorResult result = detector.detect(ctx); + assertEquals(1, result.nodes().size()); + assertEquals("myapp.db", result.nodes().get(0).getLabel()); + } + + // --------------------------------------------------------------- + // AST branch: deduplication (same key used twice in one file) + // --------------------------------------------------------------- + + @Test + void astBranch_duplicateKeysDeduplicated() { + // Same key appears on two fields — should produce only one node/edge + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + public class C { + @Value("${app.name}") + private String a; + @Value("${app.name}") + private String b; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/C.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.nodes().size(), + "Duplicate @Value keys within a single file must be deduplicated"); + assertEquals(1, result.edges().size()); + } + + // --------------------------------------------------------------- + // Regex fallback: triggered when AST parse fails (returns empty Optional). + // --------------------------------------------------------------- + // + // JavaParser recovers from most syntactic errors, so constructing an + // input that reliably triggers the regex fallback is fragile. The + // covered-by-valid-source AST tests above are the primary branch + // assertions; we intentionally omit a separate regex-fallback integration + // test rather than write a non-deterministic one. Regex-only behavior is + // covered indirectly via the single-line tests below (which succeed under + // either branch because they exercise the simplest one-class-one-key + // shape that both AST and regex detect identically). + + @Test + void singleClassSingleValue_detectedUnderEitherBranch() { + // Minimal well-formed source with @Value on a field. + String code = """ + class X { + @Value("${db.url}") + String url; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("X.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertThat(r.nodes()).anyMatch(n -> "db.url".equals(n.getLabel())); + } + + // --------------------------------------------------------------- + // Edge shape: config edges target the config node, not the class + // --------------------------------------------------------------- + + @Test + void edgesTargetConfigNodeAndReferenceClassAsSource() { + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + public class C { + @Value("${a.b}") + private String x; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/C.java", "java", code); + DetectorResult result = detector.detect(ctx); + + assertEquals(1, result.edges().size()); + var edge = result.edges().get(0); + assertEquals(EdgeKind.READS_CONFIG, edge.getKind()); + assertNotNull(edge.getSourceId()); + assertTrue(edge.getSourceId().contains("C"), + "Edge source should reference class node id: " + edge.getSourceId()); + assertNotNull(edge.getTarget()); + assertEquals("config:a.b", edge.getTarget().getId()); + } + + // --------------------------------------------------------------- + // Determinism — required for every detector per CLAUDE.md + // --------------------------------------------------------------- + + @Test + void deterministic_sameInputSameOutput() { + String code = """ + package app; + import org.springframework.beans.factory.annotation.Value; + import org.springframework.boot.context.properties.ConfigurationProperties; + @ConfigurationProperties("app.props") + public class Mixed { + @Value("${a.b}") + private String ab; + @Value("${c.d}") + private String cd; + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("src/main/java/app/Mixed.java", "java", code); + DetectorTestUtils.assertDeterministic(detector, ctx); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ModuleDepsDetectorTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ModuleDepsDetectorTest.java new file mode 100644 index 00000000..49b809aa --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ModuleDepsDetectorTest.java @@ -0,0 +1,307 @@ +package io.github.randomcodespace.iq.detector.jvm.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.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ModuleDepsDetector}. + * + *

Covers Maven (pom.xml), Gradle build scripts (.gradle / .gradle.kts), and + * Gradle settings (settings.gradle / settings.gradle.kts) detection paths. + */ +class ModuleDepsDetectorTest { + + private final ModuleDepsDetector detector = new ModuleDepsDetector(); + + // --------------------------------------------------------------- + // Metadata + non-matching file paths + // --------------------------------------------------------------- + + @Test + void getName_returnsModuleDeps() { + assertEquals("module_deps", detector.getName()); + } + + @Test + void unrecognizedFile_returnsEmpty() { + String code = "foo"; + DetectorContext ctx = DetectorTestUtils.contextFor("src/app.java", "java", code); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + assertTrue(r.edges().isEmpty()); + } + + @Test + void pomXmlEmpty_returnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", ""); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void gradleEmpty_returnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", ""); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + @Test + void gradleSettingsEmpty_returnsEmpty() { + DetectorContext ctx = DetectorTestUtils.contextFor("settings.gradle", "gradle", ""); + DetectorResult r = detector.detect(ctx); + assertTrue(r.nodes().isEmpty()); + } + + // --------------------------------------------------------------- + // Maven: pom.xml + // --------------------------------------------------------------- + + @Test + void maven_parsesGroupAndArtifact() { + String pom = """ + + com.acme + orders + 1.0.0 + + """; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorResult r = detector.detect(ctx); + + assertEquals(1, r.nodes().size()); + var node = r.nodes().get(0); + assertEquals(NodeKind.MODULE, node.getKind()); + assertEquals("module:com.acme:orders", node.getId()); + assertEquals("orders", node.getLabel()); + assertEquals("com.acme", node.getProperties().get("group_id")); + assertEquals("orders", node.getProperties().get("artifact_id")); + assertEquals("maven", node.getProperties().get("build_tool")); + } + + @Test + void maven_missingGroupOrArtifact_fallsBackToUnknown() { + String pom = "no-coords"; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorResult r = detector.detect(ctx); + + // Still produces a module node with the "unknown" fallback values + assertEquals(1, r.nodes().size()); + assertEquals("module:unknown:unknown", r.nodes().get(0).getId()); + } + + @Test + void maven_aggregatorPom_emitsContainsEdgesForSubmodules() { + String pom = """ + + com.acme + parent + + api + web + + + """; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorResult r = detector.detect(ctx); + + // 1 parent + 2 sub-modules + assertEquals(3, r.nodes().size()); + assertThat(r.nodes()).anyMatch(n -> "module:com.acme:api".equals(n.getId())); + assertThat(r.nodes()).anyMatch(n -> "module:com.acme:web".equals(n.getId())); + + // 2 CONTAINS edges (parent -> api, parent -> web) + assertEquals(2, r.edges().size()); + assertThat(r.edges()).allMatch(e -> e.getKind() == EdgeKind.CONTAINS); + } + + @Test + void maven_dependencyBlock_emitsDependsOnEdges() { + String pom = """ + + com.acme + orders + + + org.slf4j + slf4j-api + 2.0.12 + + + junit + junit + + + + """; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorResult r = detector.detect(ctx); + + assertThat(r.edges()).filteredOn(e -> e.getKind() == EdgeKind.DEPENDS_ON) + .hasSize(2); + assertThat(r.edges()).anyMatch(e -> + "module:org.slf4j:slf4j-api".equals(e.getTarget().getId())); + assertThat(r.edges()).anyMatch(e -> + "module:junit:junit".equals(e.getTarget().getId())); + } + + @Test + void maven_dependencyWithoutGroupId_usesUnknownFallback() { + // artifactId only — groupId defaults to "unknown" + String pom = """ + + com.acme + svc + + + my-lib + + + + """; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorResult r = detector.detect(ctx); + assertThat(r.edges()).anyMatch(e -> + "module:unknown:my-lib".equals(e.getTarget().getId())); + } + + // --------------------------------------------------------------- + // Gradle build.gradle + // --------------------------------------------------------------- + + @Test + void gradle_projectDependency_emitsDependsOnProject() { + String gradle = """ + dependencies { + implementation project(':api') + api project(':common') + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("services/orders/build.gradle", "gradle", gradle); + DetectorResult r = detector.detect(ctx); + + // One module node + project edges + assertThat(r.nodes()).anyMatch(n -> n.getKind() == NodeKind.MODULE); + assertThat(r.edges()).filteredOn(e -> e.getKind() == EdgeKind.DEPENDS_ON) + .hasSize(2); + assertThat(r.edges()).anyMatch(e -> + "module:api".equals(e.getTarget().getId()) + && "project".equals(e.getProperties().get("type"))); + assertThat(r.edges()).anyMatch(e -> + "module:common".equals(e.getTarget().getId())); + } + + @Test + void gradle_externalDependency_emitsDependsOnExternal() { + String gradle = """ + dependencies { + implementation 'com.google.guava:guava:33.0.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", gradle); + DetectorResult r = detector.detect(ctx); + + assertThat(r.edges()).anyMatch(e -> + "module:com.google.guava:guava".equals(e.getTarget().getId()) + && "external".equals(e.getProperties().get("type"))); + assertThat(r.edges()).anyMatch(e -> + "module:org.junit.jupiter:junit-jupiter".equals(e.getTarget().getId())); + } + + @Test + void gradle_externalDependencyWithoutColon_ignored() { + // Only the valid "gradle" format should produce an edge; a bare id is ignored + String gradle = """ + dependencies { + implementation 'flat-jar' + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", gradle); + DetectorResult r = detector.detect(ctx); + // Single module node + zero dependency edges (since 'flat-jar' lacks ':') + assertEquals(1, r.nodes().size()); + assertThat(r.edges()).noneMatch(e -> e.getKind() == EdgeKind.DEPENDS_ON); + } + + @Test + void gradle_moduleNameDerivedFromFilePath_whenAbsent() { + String gradle = "// empty build file\nimplementation 'a:b:1'\n"; + DetectorContext ctx = DetectorTestUtils.contextFor( + "root/services/billing/build.gradle", "gradle", gradle); + DetectorResult r = detector.detect(ctx); + + // moduleName should be derived from the parent directory ("billing") + assertEquals(1, r.nodes().stream() + .filter(n -> "module:billing".equals(n.getId())).count()); + } + + @Test + void gradle_respectsExplicitModuleName() { + String gradle = "implementation 'com.acme:lib:1.0.0'\n"; + DetectorContext ctx = new DetectorContext( + "build.gradle", "gradle", gradle, null, "explicit-module"); + DetectorResult r = detector.detect(ctx); + assertThat(r.nodes()).anyMatch(n -> "module:explicit-module".equals(n.getId())); + } + + @Test + void gradleKts_withGroovyStyleStringDep_isDetected() { + // NOTE: The detector's regex matches `implementation 'a:b:c'` but not + // `implementation("a:b:c")` — Kotlin-DSL syntax with parentheses is + // not currently supported by GRADLE_DEPENDENCY_RE (follow-up). + String gradle = "implementation 'com.squareup.okhttp3:okhttp:4.12.0'\n"; + DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", gradle); + DetectorResult r = detector.detect(ctx); + assertThat(r.edges()).filteredOn(e -> e.getKind() == EdgeKind.DEPENDS_ON) + .hasSize(1); + } + + // --------------------------------------------------------------- + // Gradle settings.gradle + // --------------------------------------------------------------- + // + // The detector's dispatch chain checks `.endsWith(".gradle")` *before* + // `.endsWith("settings.gradle")`, so any filename ending in `.gradle` + // routes to detectGradle (not detectGradleSettings). The + // detectGradleSettings() branch is therefore unreachable via the public + // detect() API for these filenames — flagged as a follow-up bug. + // We intentionally omit direct tests for that unreachable branch. + + // --------------------------------------------------------------- + // Determinism + // --------------------------------------------------------------- + + @Test + void deterministic_mavenSameInputSameOutput() { + String pom = """ + + com.acme + orders + + api + + + """; + DetectorContext ctx = DetectorTestUtils.contextFor("pom.xml", "xml", pom); + DetectorTestUtils.assertDeterministic(detector, ctx); + } + + @Test + void deterministic_gradleSameInputSameOutput() { + String gradle = """ + dependencies { + implementation project(':a') + implementation 'com.acme:lib:1.0' + } + """; + DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", gradle); + DetectorTestUtils.assertDeterministic(detector, ctx); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/extractor/LanguageEnricherExtendedTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/extractor/LanguageEnricherExtendedTest.java new file mode 100644 index 00000000..bea57b2d --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/extractor/LanguageEnricherExtendedTest.java @@ -0,0 +1,377 @@ +package io.github.randomcodespace.iq.intelligence.extractor; + +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +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 org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Extended tests for {@link LanguageEnricher} covering branches not reached by + * the primary test suite: file_type filters, unmatched extractors, no-op paths, + * unreadable files, minified skips, null filePath on nodes, and FQN-keyed + * registry entries. + */ +class LanguageEnricherExtendedTest { + + @TempDir + Path tempDir; + + // --------------------------------------------------------------- + // Empty tasks: no-extractor path (existing) vs no-matching-language path + // --------------------------------------------------------------- + + @Test + void enrich_noMatchingLanguage_noFailNoEdges() throws IOException { + // Extractor exists but no node's language matches. + Files.writeString(tempDir.resolve("config.yaml"), "key: value", + StandardCharsets.UTF_8); + + CodeNode node = node("y:config.yaml:key:x", NodeKind.MODULE, "x", "config.yaml"); + List edges = new ArrayList<>(); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor javaExt = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode n) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + + new LanguageEnricher(List.of(javaExt)).enrich(List.of(node), edges, tempDir); + assertThat(calls.get()).isZero(); + assertThat(edges).isEmpty(); + } + + // --------------------------------------------------------------- + // file_type skips: test / generated / minified / binary / text / filtered + // --------------------------------------------------------------- + + @Test + void enrich_skipsNodesWithFileTypeTest() throws IOException { + Files.writeString(tempDir.resolve("FooTest.java"), + "class FooTest {}", StandardCharsets.UTF_8); + CodeNode n = node("n1", NodeKind.CLASS, "FooTest", "FooTest.java"); + n.getProperties().put("file_type", "test"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).as("test-type nodes must be skipped").isZero(); + } + + @Test + void enrich_skipsNodesWithFileTypeGenerated() throws IOException { + Files.writeString(tempDir.resolve("Gen.java"), + "class Gen {}", StandardCharsets.UTF_8); + CodeNode n = node("n1", NodeKind.CLASS, "Gen", "Gen.java"); + n.getProperties().put("file_type", "generated"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).as("generated-type nodes must be skipped").isZero(); + } + + @Test + void enrich_skipsNodesWithFileTypeMinifiedBinaryTextFiltered() throws IOException { + Files.writeString(tempDir.resolve("Mixed.java"), + "class Mixed {}", StandardCharsets.UTF_8); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + + for (String ft : new String[]{"minified", "binary", "text", "filtered"}) { + calls.set(0); + CodeNode n = node("n:" + ft, NodeKind.CLASS, "Mixed", "Mixed.java"); + n.getProperties().put("file_type", ft); + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()) + .as("file_type='%s' must cause skip", ft) + .isZero(); + } + } + + @Test + void enrich_fileTypeSourceIsProcessed() throws IOException { + // The counter-positive of the skip list: any file_type not in the + // skip-set (e.g. "source") must still be processed. + Files.writeString(tempDir.resolve("Src.java"), + "class Src {}", StandardCharsets.UTF_8); + CodeNode n = node("n", NodeKind.CLASS, "Src", "Src.java"); + n.getProperties().put("file_type", "source"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).isOne(); + } + + @Test + void enrich_nullFileTypeIsProcessed() throws IOException { + Files.writeString(tempDir.resolve("NoType.java"), + "class NoType {}", StandardCharsets.UTF_8); + CodeNode n = node("n", NodeKind.CLASS, "NoType", "NoType.java"); + // no file_type property set + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).isOne(); + } + + // --------------------------------------------------------------- + // File read failures: missing file must not fail the pipeline + // --------------------------------------------------------------- + + @Test + void enrich_missingFile_extractorNotCalled() { + // No file created in tempDir — readFile() returns null → task returns null + CodeNode n = node("n", NodeKind.CLASS, "Missing", "Missing.java"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + List edges = new ArrayList<>(); + new LanguageEnricher(List.of(ext)).enrich(List.of(n), edges, tempDir); + assertThat(calls.get()).isZero(); + assertThat(edges).isEmpty(); + } + + // --------------------------------------------------------------- + // Nodes with null filePath: skipped, never grouped into tasks + // --------------------------------------------------------------- + + @Test + void enrich_nullFilePathNode_ignored() { + CodeNode n = new CodeNode("n", NodeKind.CLASS, "NoPath"); + n.setFqn("NoPath"); + // filePath deliberately null + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).isZero(); + } + + // --------------------------------------------------------------- + // Minified file: large + long-line .js file is skipped + // --------------------------------------------------------------- + + @Test + void enrich_minifiedLargeJsFile_skipped() throws IOException { + // Create ~60KB single-line .js — triggers minified heuristic + // (>50KB, js/css extension, avg line length > 1000) + StringBuilder sb = new StringBuilder(60_000); + for (int i = 0; i < 60_000; i++) sb.append('x'); + // Put everything on a single line (no newlines) to guarantee ratio > 1000 + Files.writeString(tempDir.resolve("bundle.js"), sb.toString(), + StandardCharsets.UTF_8); + + CodeNode n = node("n", NodeKind.MODULE, "bundle", "bundle.js"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "typescript"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).as("minified .js must be skipped").isZero(); + } + + @Test + void enrich_smallFile_notConsideredMinified() throws IOException { + // Same extension but well under the 50KB minified threshold + Files.writeString(tempDir.resolve("small.js"), + "function f() {}\n", StandardCharsets.UTF_8); + CodeNode n = node("n", NodeKind.METHOD, "f", "small.js"); + + AtomicInteger calls = new AtomicInteger(); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "typescript"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode nn) { + calls.incrementAndGet(); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + assertThat(calls.get()).isOne(); + } + + // --------------------------------------------------------------- + // buildRegistry: nodes registered under both id and fqn + // --------------------------------------------------------------- + + @Test + void enrich_registryExposesNodesByIdAndFqn() throws IOException { + Files.writeString(tempDir.resolve("Foo.java"), + "class Foo {}", StandardCharsets.UTF_8); + + CodeNode n = node("method:Foo:bar", NodeKind.METHOD, "bar", "Foo.java"); + n.setFqn("com.acme.Foo.bar"); + + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + @SuppressWarnings("unchecked") + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode node) { + Map registry = (Map) ctx.parsedData(); + assertThat(registry).isNotNull(); + // Nodes must be registered by both id and fqn + assertThat(registry).containsKey("method:Foo:bar"); + assertThat(registry).containsKey("com.acme.Foo.bar"); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + } + + @Test + void enrich_registrySkipsBlankFqn() throws IOException { + // blank fqn must NOT be inserted as a registry key + Files.writeString(tempDir.resolve("X.java"), + "class X {}", StandardCharsets.UTF_8); + CodeNode n = node("id1", NodeKind.CLASS, "X", "X.java"); + n.setFqn(""); + + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + @SuppressWarnings("unchecked") + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode node) { + Map registry = (Map) ctx.parsedData(); + assertThat(registry).containsKey("id1"); + assertThat(registry).doesNotContainKey(""); + return LanguageExtractionResult.empty(); + } + }; + new LanguageEnricher(List.of(ext)).enrich(List.of(n), new ArrayList<>(), tempDir); + } + + // --------------------------------------------------------------- + // Symbol references aggregated alongside call edges + // --------------------------------------------------------------- + + @Test + void enrich_aggregatesSymbolReferencesAndCallEdges() throws IOException { + Files.writeString(tempDir.resolve("A.java"), + "class A {}", StandardCharsets.UTF_8); + CodeNode a = node("a", NodeKind.CLASS, "A", "A.java"); + + CodeEdge call = new CodeEdge("call:1", EdgeKind.CALLS, "a", a); + CodeEdge sym = new CodeEdge("sym:1", EdgeKind.DEFINES, "a", a); + LanguageExtractor ext = new LanguageExtractor() { + @Override public String getLanguage() { return "java"; } + @Override + public LanguageExtractionResult extract(DetectorContext ctx, CodeNode n) { + return new LanguageExtractionResult( + List.of(call), List.of(sym), Map.of(), CapabilityLevel.PARTIAL); + } + }; + List edges = new ArrayList<>(); + new LanguageEnricher(List.of(ext)).enrich(List.of(a), edges, tempDir); + // Both categories must be merged into the edge list + assertThat(edges).hasSize(2); + assertThat(edges).extracting(CodeEdge::getKind) + .containsExactlyInAnyOrder(EdgeKind.CALLS, EdgeKind.DEFINES); + } + + // --------------------------------------------------------------- + // detectLanguage: additional extensions not covered elsewhere + // --------------------------------------------------------------- + + @Test + void detectLanguage_handlesMjsCjsAndCaseAndMissingDot() { + assertThat(LanguageEnricher.detectLanguage("bundle.mjs")).isEqualTo("javascript"); + assertThat(LanguageEnricher.detectLanguage("bundle.cjs")).isEqualTo("javascript"); + // Upper-case extensions are folded to lower-case + assertThat(LanguageEnricher.detectLanguage("APP.JS")).isEqualTo("javascript"); + assertThat(LanguageEnricher.detectLanguage("Foo.PY")).isEqualTo("python"); + assertThat(LanguageEnricher.detectLanguage("Main.GO")).isEqualTo("go"); + // pyw is a valid python extension + assertThat(LanguageEnricher.detectLanguage("win.pyw")).isEqualTo("python"); + // No extension → null + assertThat(LanguageEnricher.detectLanguage("Dockerfile")).isNull(); + assertThat(LanguageEnricher.detectLanguage("")).isNull(); + } + + // --------------------------------------------------------------- + // Helper + // --------------------------------------------------------------- + + private static CodeNode node(String id, NodeKind kind, String label, String filePath) { + CodeNode n = new CodeNode(id, kind, label); + n.setFqn(id); + n.setFilePath(filePath); + return n; + } +}