From dd29900a7b20dacb8bad5d345b8c49503e2960c0 Mon Sep 17 00:00:00 2001
From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com>
Date: Sat, 15 Aug 2026 10:57:26 +0200
Subject: [PATCH] test: Add tests for SpringDocDataRestConfiguration handling
of HateoasProperties
---
.../SpringDocDataRestConfigurationTest.java | 118 ++++++++++++++++++
.../api/v31/app39/SpringDocApp39Test.java | 114 -----------------
2 files changed, 118 insertions(+), 114 deletions(-)
create mode 100644 springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocDataRestConfigurationTest.java
delete mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app39/SpringDocApp39Test.java
diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocDataRestConfigurationTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocDataRestConfigurationTest.java
new file mode 100644
index 0000000000..3b252c12e1
--- /dev/null
+++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocDataRestConfigurationTest.java
@@ -0,0 +1,118 @@
+package org.springdoc.core.configuration;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springdoc.core.configuration.SpringDocDataRestConfiguration.DataRestHateoasPropertiesConfiguration;
+import org.springdoc.core.configuration.SpringDocDataRestConfiguration.DataRestNoHateoasPropertiesConfiguration;
+import org.springdoc.core.properties.SpringDocConfigProperties;
+import org.springdoc.core.providers.DataRestHalProvider;
+import org.springdoc.core.providers.ObjectMapperProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
+import org.springframework.boot.hateoas.autoconfigure.HateoasProperties;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link SpringDocDataRestConfiguration} and the way it handles the
+ * optional {@link HateoasProperties} class.
+ *
+ * There are two concerns being verified here:
+ *
+ * - Structure the {@code HateoasProperties}-dependent bean method is
+ * isolated in a nested configuration guarded by {@code @ConditionalOnClass}, with a
+ * {@code @ConditionalOnMissingClass} fallback, so that when {@code HateoasProperties}
+ * is absent from the classpath Spring never loads a class that references it (which
+ * would otherwise raise {@code TypeNotPresentException}).
+ * - Wiring depending on whether {@code HateoasProperties} is on the
+ * classpath, the matching nested configuration is selected and a working
+ * {@link DataRestHalProvider} is created.
+ *
+ * The absence of {@code HateoasProperties} is simulated with a
+ * {@link FilteredClassLoader}. A runtime property such as
+ * {@code springdoc.enable-hateoas=false} cannot be used for this, because it only
+ * toggles behavior at runtime and never removes the class from the classpath, so the
+ * {@code @ConditionalOnMissingClass} fallback would never be selected.
+ */
+class SpringDocDataRestConfigurationTest {
+ /**
+ * Structural guarantees that keep {@code HateoasProperties} references out of any
+ * class Spring might load when the type is absent.
+ */
+ @Nested
+ class Structure {
+ @Test
+ void outerConfigClassHasNoBeanMethodReferencingHateoasProperties() {
+ for (Method method : SpringDocDataRestConfiguration.class.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(Bean.class)) {
+ for (Type type : method.getGenericParameterTypes()) {
+ assertThat(type.getTypeName())
+ .as("@Bean method %s() should not reference HateoasProperties directly", method.getName())
+ .doesNotContain(HateoasProperties.class.getSimpleName());
+ }
+ }
+ }
+ }
+
+ @Test
+ void hateoasPropertiesConfigIsGuardedByConditionalOnClass() {
+ ConditionalOnClass onClass = DataRestHateoasPropertiesConfiguration.class.getAnnotation(ConditionalOnClass.class);
+ assertThat(onClass)
+ .as("%s should be guarded by @ConditionalOnClass", DataRestHateoasPropertiesConfiguration.class.getSimpleName())
+ .isNotNull();
+ assertThat(Arrays.asList(onClass.name())).contains(HateoasProperties.class.getName());
+ }
+
+ @Test
+ void noHateoasPropertiesConfigIsGuardedByConditionalOnMissingClass() {
+ ConditionalOnMissingClass onMissingClass = DataRestNoHateoasPropertiesConfiguration.class.getAnnotation(ConditionalOnMissingClass.class);
+ assertThat(onMissingClass)
+ .as("%s should be guarded by @ConditionalOnMissingClass", DataRestNoHateoasPropertiesConfiguration.class.getSimpleName())
+ .isNotNull();
+ assertThat(Arrays.asList(onMissingClass.value())).contains(HateoasProperties.class.getName());
+ }
+ }
+
+ /**
+ * Behavioral verification that the correct nested configuration is selected and a
+ * {@link DataRestHalProvider} is created in both scenarios.
+ */
+ @Nested
+ class Wiring {
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withBean(ObjectMapperProvider.class, () -> new ObjectMapperProvider(new SpringDocConfigProperties()))
+ .withUserConfiguration(
+ DataRestHateoasPropertiesConfiguration.class,
+ DataRestNoHateoasPropertiesConfiguration.class);
+
+ @Test
+ void halProviderIsCreatedFromHateoasPropertiesConfigWhenHateoasPropertiesIsPresent() {
+ contextRunner
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).hasSingleBean(DataRestHalProvider.class);
+ assertThat(context).hasSingleBean(DataRestHateoasPropertiesConfiguration.class);
+ assertThat(context).doesNotHaveBean(DataRestNoHateoasPropertiesConfiguration.class);
+ });
+ }
+
+ @Test
+ void halProviderIsCreatedFromFallbackConfigWhenHateoasPropertiesIsAbsent() {
+ contextRunner
+ .withClassLoader(new FilteredClassLoader(HateoasProperties.class))
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).hasSingleBean(DataRestHalProvider.class);
+ assertThat(context).hasSingleBean(DataRestNoHateoasPropertiesConfiguration.class);
+ assertThat(context).doesNotHaveBean(DataRestHateoasPropertiesConfiguration.class);
+ });
+ }
+ }
+}
diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app39/SpringDocApp39Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app39/SpringDocApp39Test.java
deleted file mode 100644
index 65d9ac5e3a..0000000000
--- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app39/SpringDocApp39Test.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- *
- * *
- * * *
- * * * *
- * * * * *
- * * * * * * Copyright 2019-2026 the original author or authors.
- * * * * * *
- * * * * * * Licensed under the Apache License, Version 2.0 (the "License");
- * * * * * * you may not use this file except in compliance with the License.
- * * * * * * You may obtain a copy of the License at
- * * * * * *
- * * * * * * https://www.apache.org/licenses/LICENSE-2.0
- * * * * * *
- * * * * * * Unless required by applicable law or agreed to in writing, software
- * * * * * * distributed under the License is distributed on an "AS IS" BASIS,
- * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * * * * * * See the License for the specific language governing permissions and
- * * * * * * limitations under the License.
- * * * * *
- * * * *
- * * *
- * *
- *
- */
-
-package test.org.springdoc.api.v31.app39;
-
-import java.lang.reflect.Method;
-import java.lang.reflect.Type;
-import java.util.Arrays;
-
-import org.junit.jupiter.api.Test;
-import org.springdoc.core.configuration.SpringDocDataRestConfiguration;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
-import org.springframework.context.annotation.Bean;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Verifies that HateoasProperties-dependent bean methods are isolated in nested
- * configuration classes guarded by {@code @ConditionalOnClass} /
- * {@code @ConditionalOnMissingClass}, so that when HateoasProperties is absent
- * from the classpath, Spring never loads the class that references it.
- *
- * @author bnasslahsen
- */
-class SpringDocApp39Test {
-
- private static final String HATEOAS_PROPERTIES_CLASS = "org.springframework.boot.hateoas.autoconfigure.HateoasProperties";
-
- @Test
- void dataRestConfigOuterClassDoesNotReferenceHateoasProperties() {
- assertOuterBeanMethodsDoNotReferenceHateoasProperties(SpringDocDataRestConfiguration.class);
- }
-
- @Test
- void dataRestConfigHasGuardedNestedClasses() {
- assertHasGuardedNestedClasses(SpringDocDataRestConfiguration.class);
- }
-
- /**
- * Asserts that no {@code @Bean} method declared directly on the outer
- * configuration class has {@code HateoasProperties} in its generic
- * parameter types. Before the fix, the outer class had methods like
- * {@code halProvider(Optional...)} which caused
- * {@code TypeNotPresentException} when HateoasProperties was absent.
- */
- private void assertOuterBeanMethodsDoNotReferenceHateoasProperties(Class> configClass) {
- for (Method method : configClass.getDeclaredMethods()) {
- if (method.isAnnotationPresent(Bean.class)) {
- for (Type type : method.getGenericParameterTypes()) {
- assertThat(type.getTypeName())
- .as("@Bean method %s.%s() should not reference HateoasProperties directly",
- configClass.getSimpleName(), method.getName())
- .doesNotContain("HateoasProperties");
- }
- }
- }
- }
-
- /**
- * Asserts that the configuration class has at least one nested class
- * guarded by {@code @ConditionalOnClass(name = "...HateoasProperties")}
- * and one by {@code @ConditionalOnMissingClass("...HateoasProperties")}.
- */
- private void assertHasGuardedNestedClasses(Class> configClass) {
- boolean hasConditionalOnClass = false;
- boolean hasConditionalOnMissingClass = false;
-
- for (Class> inner : configClass.getDeclaredClasses()) {
- ConditionalOnClass onClass = inner.getAnnotation(ConditionalOnClass.class);
- if (onClass != null && Arrays.asList(onClass.name()).contains(HATEOAS_PROPERTIES_CLASS)) {
- hasConditionalOnClass = true;
- }
- ConditionalOnMissingClass onMissingClass = inner.getAnnotation(ConditionalOnMissingClass.class);
- if (onMissingClass != null && Arrays.asList(onMissingClass.value()).contains(HATEOAS_PROPERTIES_CLASS)) {
- hasConditionalOnMissingClass = true;
- }
- }
-
- assertThat(hasConditionalOnClass)
- .as("%s should have a nested class with @ConditionalOnClass for HateoasProperties",
- configClass.getSimpleName())
- .isTrue();
- assertThat(hasConditionalOnMissingClass)
- .as("%s should have a nested class with @ConditionalOnMissingClass for HateoasProperties",
- configClass.getSimpleName())
- .isTrue();
- }
-
-}