diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java index 6b96c03d8bc..c0f7f4a3768 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java @@ -28,6 +28,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; @@ -38,7 +39,9 @@ import org.sonar.plugins.java.api.InputFileScannerContext; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.ModuleScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; import org.sonar.plugins.java.api.semantic.SymbolMetadata; +import org.sonar.plugins.java.api.semantic.Type; import org.sonar.plugins.java.api.tree.ClassTree; import org.sonar.plugins.java.api.tree.MethodTree; import org.sonar.plugins.java.api.tree.Tree; @@ -60,7 +63,11 @@ *
  • {@code @Primary} designation
  • *
  • Dependencies via {@code @Autowired} fields, constructors, and setters for class-level beans
  • *
  • Dependencies via method parameters for {@code @Bean} method beans
  • + *
  • Implicit single-constructor injection (no {@code @Autowired} required)
  • * + * + *

    Also populates {@link TypeToBeanNamesIndex} with the full type hierarchy of each bean, + * so that rules can look up all beans assignable to a given type. */ public class BeanDefinitionGatherer extends SpringContextModelGatherer { @@ -72,6 +79,7 @@ public class BeanDefinitionGatherer extends SpringContextModelGatherer { private static final String DEP_SEPARATOR = ","; private static final String DEP_KEY_VALUE_SEPARATOR = ":"; private static final String DEP_NAMES_SEPARATOR = ";"; + private static final String TYPE_HIERARCHY_SEPARATOR = ";"; private static final String PRIMARY_ANNOTATION = "org.springframework.context.annotation.Primary"; private static final String VALUE_ATTRIBUTE = "value"; @@ -88,7 +96,8 @@ private record BeanData( InputFile inputFile, AnalyzerMessage.TextSpan textSpan, boolean isPrimary, - Map> dependingBeans) { + Map> dependingBeans, + Set typeHierarchy) { } @Override @@ -117,13 +126,14 @@ public void visitNode(Tree tree) { String beanName = extractBeanName(meta) .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); Map> deps = collectAutowiredDependencies(classTree); - // Class-level bean (stereotype annotations) + Set typeHierarchy = collectTypeHierarchy(classTree.symbol()); var beanData = new BeanData( beanName, fqn, pkg, context.getInputFile(), AnalyzerMessage.textSpanFor(classTree.simpleName()), meta.isAnnotatedWith(PRIMARY_ANNOTATION), - deps); + deps, + typeHierarchy); collectedBeans.add(beanData); beansCollectedAtFileLevel.add(beanData); @@ -167,6 +177,7 @@ private static String serializeBean(BeanData bean) { .map(n -> Base64.getEncoder().encodeToString(n.getBytes(StandardCharsets.UTF_8))) .collect(Collectors.joining(DEP_NAMES_SEPARATOR))) .collect(Collectors.joining(DEP_SEPARATOR)); + var typeHierarchy = String.join(TYPE_HIERARCHY_SEPARATOR, bean.typeHierarchy()); var span = bean.textSpan(); var encodedName = Base64.getEncoder().encodeToString(bean.beanName().getBytes(StandardCharsets.UTF_8)); return String.join(FIELD_SEPARATOR, @@ -175,7 +186,8 @@ private static String serializeBean(BeanData bean) { bean.beanPackage(), span.startLine + ":" + span.startCharacter + ":" + span.endLine + ":" + span.endCharacter, Boolean.toString(bean.isPrimary()), - deps); + deps, + typeHierarchy); } @Override @@ -190,6 +202,9 @@ public void gatherSpringContextData(ModuleScannerContext context, SpringContextM } springContextModel.getBeanDefinitionRegistry() .addBeanDefinition(data.beanName(), holderBuilder.build()); + for (String typeFqn : data.typeHierarchy()) { + springContextModel.getTypeToBeanNamesIndex().addBeanForType(typeFqn, data.beanName()); + } } } @@ -247,7 +262,10 @@ private static BeanData deserializeBean(String line, InputFile inputFile) { deps.put(typeFqn, names); } } - return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps); + Set typeHierarchy = !fields[6].isEmpty() + ? new LinkedHashSet<>(List.of(fields[6].split(TYPE_HIERARCHY_SEPARATOR))) + : new LinkedHashSet<>(); + return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps, typeHierarchy); } private static Optional extractBeanName(SymbolMetadata meta) { @@ -274,34 +292,38 @@ private static String defaultBeanName(String simpleName) { private void collectBeanMethod(MethodTree method, String pkg) { SymbolMetadata beanMeta = method.symbol().metadata(); List attrs = beanMeta.valuesForAnnotation(SpringUtils.BEAN_ANNOTATION); - String beanName = Optional.ofNullable(attrs) - .flatMap(list -> list.stream() + List beanNames = Optional.ofNullable(attrs) + .map(list -> list.stream() .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) - .map(v -> { + .flatMap(v -> { Object val = v.value(); if (val instanceof Object[] arr && arr.length > 0) { - return (String) arr[0]; + return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); } - return val instanceof String s ? s : null; + return Stream.empty(); }) - .filter(s -> s != null && !s.isBlank()) - .findFirst()) - .orElseGet(() -> method.simpleName().name()); + .filter(s -> !s.isBlank()) + .toList()) + .filter(names -> !names.isEmpty()) + .orElse(List.of(method.simpleName().name())); String returnTypeFqn = method.returnType() != null ? method.returnType().symbolType().fullyQualifiedName() : ""; + Set typeHierarchy = method.returnType() != null + ? collectTypeHierarchy(method.returnType().symbolType().symbol()) + : Set.of(); Map> paramDeps = parameterDependencies(method); + boolean isPrimary = beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION); + var textSpan = AnalyzerMessage.textSpanFor(method.simpleName()); + var inputFile = context.getInputFile(); - var beanData = new BeanData( - beanName, returnTypeFqn, pkg, - context.getInputFile(), - AnalyzerMessage.textSpanFor(method.simpleName()), - beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION), - paramDeps); - collectedBeans.add(beanData); - beansCollectedAtFileLevel.add(beanData); + for (String beanName : beanNames) { + var beanData = new BeanData(beanName, returnTypeFqn, pkg, inputFile, textSpan, isPrimary, paramDeps, typeHierarchy); + collectedBeans.add(beanData); + beansCollectedAtFileLevel.add(beanData); + } } private static Map> collectAutowiredDependencies(ClassTree classTree) { @@ -358,4 +380,26 @@ private static String extractQualifier(SymbolMetadata metadata) { .orElse(null); } + private static Set collectTypeHierarchy(Symbol.TypeSymbol symbol) { + Set visited = new LinkedHashSet<>(); + walkTypeHierarchy(symbol, visited); + return visited; + } + + private static void walkTypeHierarchy(Symbol.TypeSymbol symbol, Set visited) { + String fqn = symbol.type().fullyQualifiedName(); + if ("java.lang.Object".equals(fqn) || symbol.type().isUnknown() || !visited.add(fqn)) { + return; + } + Type superClass = symbol.superClass(); + if (superClass != null && !superClass.isUnknown()) { + walkTypeHierarchy(superClass.symbol(), visited); + } + for (Type iface : symbol.interfaces()) { + if (!iface.isUnknown()) { + walkTypeHierarchy(iface.symbol(), visited); + } + } + } + } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java index b21e6e561af..ac054f4bfe6 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java @@ -53,4 +53,4 @@ public void addBeanForType(String beanType, String beanName) { public Set getNamesForType(String beanType) { return Collections.unmodifiableSet(beanNamesByType.getOrDefault(beanType, Set.of())); } -} \ No newline at end of file +} diff --git a/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java b/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java index e9512a02c61..2ba2e86ad9d 100644 --- a/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java +++ b/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java @@ -18,8 +18,8 @@ class AutowiredConstructorWithUnannotatedConstructor { } // Spring ignores this constructor — its parameters must not appear as dependencies - AutowiredConstructorWithUnannotatedConstructor(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; + AutowiredConstructorWithUnannotatedConstructor(ApplicationContext ignoredContext) { + this.applicationContext = ignoredContext; this.environment = null; } } diff --git a/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java new file mode 100644 index 00000000000..82670f3fb83 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java @@ -0,0 +1,14 @@ +package checks.spring.context; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +class ComponentImplementingInterface implements ApplicationContextAware { + + @Override + public void setApplicationContext(ApplicationContext ctx) { + // not needed for test + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java index a890b5bee19..4e02bbcf04c 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.sensor.cache.WriteCache; @@ -36,7 +37,6 @@ import org.sonar.plugins.java.api.caching.CacheContext; import org.sonar.plugins.java.api.caching.JavaReadCache; import org.sonar.plugins.java.api.caching.JavaWriteCache; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; @@ -151,6 +151,7 @@ void anonymous_class_is_skipped() { // Anonymous class (no simpleName) should be skipped — it would not be registered as a bean // SpringBootApplication itself is not a stereotype bean assertThat(model.getBeanDefinitionRegistry().getByName("")).isEmpty(); + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); } @Test @@ -202,13 +203,11 @@ static Stream dependencyCollectionArguments() { return Stream.of( Arguments.of("src/test/files/springcontext/AutowiredDependencies.java", "autowiredDependencies"), Arguments.of("src/test/files/springcontext/AutowiredConstructorDependencies.java", "autowiredConstructorDependencies"), - Arguments.of("src/test/files/springcontext/BeanMethodWithDependencies.java", "myBean"), - Arguments.of("src/test/files/springcontext/SingleConstructorDependencies.java", "singleConstructorDependencies") + Arguments.of("src/test/files/springcontext/SingleConstructorDependencies.java", "singleConstructorDependencies"), + Arguments.of("src/test/files/springcontext/BeanMethodWithDependencies.java", "myBean") ); } - // ---- Implicit single-constructor injection -------------------------------- - @Test void multiple_constructors_without_autowired_yields_no_dependencies() { scan("src/test/files/springcontext/MultipleConstructorsNoDependencies.java"); @@ -334,7 +333,8 @@ void leaveFile_writes_beans_to_cache() { .contains(encodedName) .contains("checks.spring.context.SimpleComponent") .contains("checks.spring.context") - .contains("false"); + .contains("false") + .endsWith("|checks.spring.context.SimpleComponent"); } @Test @@ -342,7 +342,7 @@ void scanWithoutParsing_returns_true_and_restores_beans_on_cache_hit() { InputFile inputFile = TestUtils.inputFile(new File("src/test/files/springcontext/SimpleComponent.java")); String cacheKey = "java:spring:bean-definitions:" + inputFile.key(); String encodedName = Base64.getEncoder().encodeToString("simpleComponent".getBytes(StandardCharsets.UTF_8)); - String serialized = encodedName + "|checks.spring.context.SimpleComponent|checks.spring.context|6:6:6:21|false|"; + String serialized = encodedName + "|checks.spring.context.SimpleComponent|checks.spring.context|6:6:6:21|false||checks.spring.context.SimpleComponent"; JavaReadCache readCache = mock(JavaReadCache.class); when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); @@ -362,6 +362,8 @@ void scanWithoutParsing_returns_true_and_restores_beans_on_cache_hit() { assertThat(beans).hasSize(1); assertThat(beans.get(0).getType()).isEqualTo("checks.spring.context.SimpleComponent"); assertThat(beans.get(0).isPrimary()).isFalse(); + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .containsOnly("simpleComponent"); } @Test @@ -462,7 +464,8 @@ void scanWithoutParsing_restores_dependencies_with_and_without_qualifier_from_ca String encodedEnvironment = Base64.getEncoder().encodeToString("environment".getBytes(StandardCharsets.UTF_8)); String serialized = encodedName + "|checks.spring.context.QualifiedFieldDependencies|checks.spring.context|10:6:10:30|false|" + encodedAppContext + ":" + encodedPrimaryContext - + "," + encodedEnvType + ":" + encodedEnvironment; + + "," + encodedEnvType + ":" + encodedEnvironment + + "|checks.spring.context.QualifiedFieldDependencies"; JavaReadCache readCache = mock(JavaReadCache.class); when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); @@ -496,6 +499,133 @@ void blank_qualifier_value_is_treated_as_no_qualifier() { assertThat(deps.get("org.springframework.context.ApplicationContext")).containsOnly("applicationContext"); } + // ---- TypeToBeanNamesIndex ------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java", + "src/test/files/springcontext/SimpleRepository.java", + "src/test/files/springcontext/SimpleController.java", + "src/test/files/springcontext/SimpleRestController.java", + "src/test/files/springcontext/SimpleConfiguration.java" + }) + void stereotype_bean_is_registered_under_its_own_type(String filePath) { + scan(filePath); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context." + beanClassNameFrom(filePath))) + .isNotEmpty(); + } + + @Test + void bean_is_registered_under_full_type_hierarchy() { + scan("src/test/files/springcontext/ComponentImplementingInterface.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.beans.factory.Aware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void scanWithoutParsing_restores_full_type_hierarchy_from_cache() { + InputFile inputFile = TestUtils.inputFile(new File("src/test/files/springcontext/ComponentImplementingInterface.java")); + String cacheKey = "java:spring:bean-definitions:" + inputFile.key(); + String encodedName = Base64.getEncoder().encodeToString("componentImplementingInterface".getBytes(StandardCharsets.UTF_8)); + String serialized = encodedName + "|checks.spring.context.ComponentImplementingInterface|checks.spring.context|8:6:8:36|false|" + + "|checks.spring.context.ComponentImplementingInterface" + + ";org.springframework.context.ApplicationContextAware" + + ";org.springframework.beans.factory.Aware"; + + JavaReadCache readCache = mock(JavaReadCache.class); + when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); + CacheContext cacheContext = mockCacheContext(readCache, mock(JavaWriteCache.class)); + + InputFileScannerContext context = mock(InputFileScannerContext.class); + when(context.getInputFile()).thenReturn(inputFile); + when(context.getCacheContext()).thenReturn(cacheContext); + + assertThat(gatherer.scanWithoutParsing(context)).isTrue(); + + ModuleScannerContext moduleScannerContext = mock(ModuleScannerContext.class); + when(moduleScannerContext.getModuleKey()).thenReturn(""); + gatherer.gatherSpringContextData(moduleScannerContext, model); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.beans.factory.Aware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void explicit_bean_name_is_used_in_index() { + scan("src/test/files/springcontext/ExplicitNameComponent.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ExplicitNameComponent")) + .containsOnly("myBean"); + } + + @Test + void bean_method_return_type_is_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); + } + + @Test + void bean_method_aliases_are_all_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + // @Bean(name = {"arrayNamedBean", "alias"}) — both names appear in the index + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("arrayNamedBean", "alias"); + } + + @Test + void multiple_beans_all_registered_in_index() { + scan( + "src/test/files/springcontext/PayPalProcessor.java", + "src/test/files/springcontext/CreditCardProcessor.java" + ); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.PayPalProcessor")) + .containsOnly("paypal"); + assertThat(index.getNamesForType("checks.spring.context.CreditCardProcessor")) + .containsOnly("creditCard"); + } + + @Test + void non_spring_class_registers_nothing_in_index() { + scan("src/test/files/springcontext/NoScanAnnotations.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.NoScanAnnotations")) + .isEmpty(); + } + + @Test + void index_gatherer_skipped_when_spring_not_in_classpath() { + scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .isEmpty(); + } + + private static String beanClassNameFrom(String filePath) { + return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); + } + private static CacheContext mockCacheContext(JavaReadCache readCache, JavaWriteCache writeCache) { CacheContext cacheContext = mock(CacheContext.class); when(cacheContext.isCacheEnabled()).thenReturn(true); diff --git a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java index e326a72c7c7..b084c691e9a 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java @@ -19,7 +19,9 @@ import org.junit.jupiter.api.Test; import org.sonar.java.model.JParserTestUtils; import org.sonar.java.model.declaration.ClassTreeImpl; +import org.sonar.java.model.declaration.MethodTreeImpl; import org.sonar.java.model.declaration.VariableTreeImpl; +import org.sonar.java.test.classpath.TestClasspathUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -48,4 +50,105 @@ class A { assertThat(SpringUtils.isAutowired(hoo.symbol())).isFalse(); } + // ---- isScopeSingleton ------------------------------------------------------- + + @Test + void is_scope_singleton_no_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Component + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_singleton_scope_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("singleton") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_prototype_scope_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + @Test + void is_scope_singleton_with_scope_name_attribute_and_prototype_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope(scopeName = "prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + // ---- isSpringBootTestClass -------------------------------------------------- + + @Test + void is_spring_boot_test_class_with_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.boot.test.context.SpringBootTest + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isTrue(); + } + + @Test + void is_spring_boot_test_class_without_annotation_returns_false() { + var cu = JParserTestUtils.parse("class A {}"); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isFalse(); + } + + // ---- isSpringBootUnitTest --------------------------------------------------- + + @Test + void is_spring_boot_unit_test_method_in_interface_returns_false() { + // getParentOfType(method, CLASS) returns null for methods inside interfaces (kind is INTERFACE, not CLASS) + var cu = JParserTestUtils.parse("interface A { default void m() {} }"); + var iface = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) iface.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + + @Test + void is_spring_boot_unit_test_in_spring_boot_test_class_returns_true() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + @org.springframework.boot.test.context.SpringBootTest + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isTrue(); + } + + @Test + void is_spring_boot_unit_test_in_non_spring_class_returns_false() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + }