diff --git a/CHANGELOG.md b/CHANGELOG.md index fa002fb24..48daa0441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Framework integration points are now instance-scoped (#198), and the Ktor integr - SQL failures raised by Storm translate to Spring's `DataAccessException` hierarchy in Spring applications: `SpringExceptionMapper` (storm-spring) translates on vendor error codes with `SQLException` subclass and SQL state fallback, auto-configured by both starters (`storm.exception-translation.enabled=false` to disable) and applied by the `SpringOrmTemplate.of` / `springOrmTemplate` compositions. Failures without a `SQLException` cause keep Storm's own exceptions. - The Spring Boot starters bind query observations automatically: with an `ObservationRegistry` bean present (Actuator provides one), every query executed by the auto-configured template reports as a `storm.query` Micrometer Observation via the shared `StormObservationAutoConfiguration`. The starters ship storm-micrometer; override the convention with an `ObservationConvention` bean, replace the binding with a `QueryObserver` bean, or disable via `management.observations.enable.storm.query=false`. - `@EnableStormRepositories(basePackages = ...)` (storm-spring, usable from both language stacks): switches on repository scanning in plain Spring applications, mirroring `@EnableJpaRepositories`; without packages, the annotated class's package is scanned. In Spring Boot the annotation doubles as the explicit override: the auto-configured scanning backs off. The `RepositoryBeanFactoryPostProcessor` adapters also gain configuring constructors (base packages, template bean name, prefix), so multi-template applications define one bean per repository set instead of one subclass. +- `@DataStormTest` test slice in the new storm-spring-boot-test-autoconfigure module, the counterpart of `@DataJpaTest`: starts only the `DataSource`, Storm's auto-configuration, and SQL initialization (plus Flyway/Liquibase when present), keeps regular components out, replaces the data source with an embedded database on Spring Boot 3 (opt out via `spring.test.database.replace=none` for Testcontainers `@ServiceConnection` setups), and rolls back a per-test transaction. One module serves both starters and both Spring Boot 3 and 4: the slice imports the starters' identically named auto-configuration classes, lists relocated platform classes as optional imports, and registers its type exclusion through a context customizer instead of the relocated `@TypeExcludeFilters`. ### Changed diff --git a/docs/spring-integration.md b/docs/spring-integration.md index 06f459b11..707fa601e 100644 --- a/docs/spring-integration.md +++ b/docs/spring-integration.md @@ -622,6 +622,57 @@ A generic DataSource proxy can only time statements. Storm knows the entity and Customization follows the usual back-off rules: contribute an `ObservationConvention` bean to override the naming and key values, define your own `QueryObserver` bean to replace the binding entirely, or disable the observation at the registry level with `management.observations.enable.storm.query=false`. +## Testing with @DataStormTest + +`@DataStormTest` (from `storm-spring-boot-test-autoconfigure`, test scope) is the Storm test slice, the counterpart of `@DataJpaTest`: it starts only the `DataSource`, Storm's auto-configuration (template, repository scanning, transaction integration, schema validation, exception translation), and SQL initialization, plus Flyway or Liquibase when present. It complements [`@StormTest`](testing.md), which tests data logic without a Spring context: use `@StormTest` for fast query-level tests, and the slice when the test should see what production Spring code sees — injected repository beans, translated exceptions, Spring-managed transactions, and your `storm.*` configuration. Regular `@Component`, `@Service`, and `@Controller` beans stay out of the context. Each test method runs in a transaction that is rolled back afterwards, so tests cannot see each other's writes. + +On Spring Boot 3 the application's `DataSource` is replaced with an embedded in-memory database by default; put a `schema.sql` (and optionally `data.sql`) on the test classpath to initialize it, or let Flyway or Liquibase (included in the slice) create the schema as in production. On Spring Boot 4 the replacement activates when the `spring-boot-jdbc-test-autoconfigure` artifact is present; without it, point the slice at a database with the `properties` attribute: + +```kotlin +@DataStormTest +class VisitRepositoryTest( + @Autowired private val visitRepository: VisitRepository, +) { + + @Test + fun `finds all visits`() { + visitRepository.count() shouldBe 14 + } + + @Test + fun `insert rolls back after the test`() { + visitRepository.insert(Visit(description = "temporary")) + } +} +``` + +To run against a real database instead, disable the replacement (`spring.test.database.replace=none` on Spring Boot 3; the default without the test-database artifact on Spring Boot 4) and hand the slice a Testcontainers-managed database through `@ServiceConnection`: + +```kotlin +@DataStormTest(properties = ["spring.test.database.replace=none"]) +@Testcontainers +class VisitRepositoryPostgresTest( + @Autowired private val visitRepository: VisitRepository, +) { + + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:17-alpine") + } + + @Test + fun `finds all visits`() { + visitRepository.count() shouldBe 14 + } +} +``` + +The slice works with both starters — it pulls in the starter's auto-configuration classes by name, which are identical for the Java and Kotlin stacks — and with both Spring Boot 3 and 4: where Spring Boot moved classes between the releases, the slice is exclusion-based rather than annotation-composed. The annotation supports `properties` for per-test configuration (for example `properties = ["storm.validation.schema-mode=none"]` when the test schema deliberately diverges from the entity model) and `includeFilters`/`excludeFilters` to pull additional components into the slice. + +Two composition notes. Test fixtures loaded per test method participate in the rollback transaction, so identity-generated keys drift across methods (sequences do not roll back); load reference fixtures once with `@Sql(executionPhase = ExecutionPhase.BEFORE_TEST_CLASS)` and let each test's own writes roll back. And an application that excludes `StormTransactionAutoConfiguration` (the coroutine-native setup) should re-enable it for the slice with `properties = ["spring.autoconfigure.exclude="]` — without the transaction bridge, repository writes cannot join the rollback transaction. + ## Multiple Data Sources Larger applications sometimes expose the same database through several `DataSource` beans: one connection pool per domain, each with its own pool size, timeout, and isolation settings, so heavy batch work cannot starve interactive traffic. Storm supports this topology with one `ORMTemplate` per `DataSource` and one repository post-processor per domain. diff --git a/docs/testing.md b/docs/testing.md index 1dfba85c3..27fa320fc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,8 @@ Writing tests for database code can involve repetitive setup: creating a `DataSo The module provides two categories of functionality: 1. **JUnit 5 integration** (`@StormTest`) for automatic database setup, script execution, and parameter injection. + +Spring Boot applications additionally have the [`@DataStormTest` slice](spring-integration.md#testing-with-datastormtest), which boots the Storm part of the Spring context instead of bypassing it: repositories arrive as Spring beans, exceptions arrive translated, and each test rolls back a Spring-managed transaction. `@StormTest` stays the fastest option for query-level tests; the slice covers the Spring wiring. 2. **Statement capture** (`SqlCapture`) for recording and inspecting SQL statements generated during test execution. This component is framework-agnostic and works independently of JUnit. --- diff --git a/pom.xml b/pom.xml index 0331bb3a0..e831f120c 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ storm-kotlin-spring storm-spring-boot-starter storm-kotlin-spring-boot-starter + storm-spring-boot-test-autoconfigure storm-ktor storm-ktor-test diff --git a/storm-bom/pom.xml b/storm-bom/pom.xml index 46bfe9411..0cb1c9e6c 100644 --- a/storm-bom/pom.xml +++ b/storm-bom/pom.xml @@ -191,6 +191,11 @@ storm-kotlin-spring-boot-starter ${project.version} + + st.orm + storm-spring-boot-test-autoconfigure + ${project.version} + st.orm storm-micrometer diff --git a/storm-kotlin-spring-boot-starter/pom.xml b/storm-kotlin-spring-boot-starter/pom.xml index 97967c82f..781530880 100644 --- a/storm-kotlin-spring-boot-starter/pom.xml +++ b/storm-kotlin-spring-boot-starter/pom.xml @@ -232,6 +232,12 @@ h2 test + + st.orm + storm-spring-boot-test-autoconfigure + ${project.version} + test + org.jetbrains.kotlin kotlin-stdlib-jdk8 diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/DataStormTestSliceTest.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/DataStormTestSliceTest.kt new file mode 100644 index 000000000..8442107a5 --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/DataStormTestSliceTest.kt @@ -0,0 +1,47 @@ +package st.orm.spring.boot.autoconfigure + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import st.orm.spring.boot.autoconfigure.slice.Visit +import st.orm.spring.boot.autoconfigure.slice.VisitRepository +import st.orm.spring.boot.test.DataStormTest + +/** + * Verifies that the shared @DataStormTest slice works against the Kotlin starter: the slice imports resolve + * to the Kotlin starter's auto-configuration classes, repositories bind through the Kotlin adapter, and each + * test rolls back. + */ +@TestMethodOrder(OrderAnnotation::class) +@DataStormTest +class DataStormTestSliceTest( + @Autowired private val visitRepository: VisitRepository, + @Autowired private val applicationContext: ApplicationContext, +) { + + @Test + @Order(1) + fun `repositories are scanned through the Kotlin adapter`() { + visitRepository.count() shouldBe 2 + applicationContext.getBean(st.orm.spring.AbstractRepositoryBeanFactoryPostProcessor::class.java) + .shouldBeInstanceOf() + } + + @Test + @Order(2) + fun `writes are rolled back per test`() { + visitRepository.insert(Visit(description = "written inside the test transaction")) + visitRepository.count() shouldBe 3 + } + + @Test + @Order(3) + fun `previous test's write is gone`() { + visitRepository.count() shouldBe 2 + } +} diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/SliceTestApplication.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/SliceTestApplication.kt new file mode 100644 index 000000000..491a530ae --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/SliceTestApplication.kt @@ -0,0 +1,6 @@ +package st.orm.spring.boot.autoconfigure + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +open class SliceTestApplication diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/Visit.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/Visit.kt new file mode 100644 index 000000000..84dc9d351 --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/Visit.kt @@ -0,0 +1,10 @@ +package st.orm.spring.boot.autoconfigure.slice + +import st.orm.Entity +import st.orm.PK + +@JvmRecord +data class Visit( + @PK val id: Int = 0, + val description: String? = null, +) : Entity diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/VisitRepository.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/VisitRepository.kt new file mode 100644 index 000000000..e02ab3d56 --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/slice/VisitRepository.kt @@ -0,0 +1,5 @@ +package st.orm.spring.boot.autoconfigure.slice + +import st.orm.repository.EntityRepository + +interface VisitRepository : EntityRepository diff --git a/storm-kotlin-spring-boot-starter/src/test/resources/data.sql b/storm-kotlin-spring-boot-starter/src/test/resources/data.sql new file mode 100644 index 000000000..999bade85 --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/resources/data.sql @@ -0,0 +1,2 @@ +INSERT INTO visit (description) VALUES ('rabies shot'); +INSERT INTO visit (description) VALUES ('checkup'); diff --git a/storm-kotlin-spring-boot-starter/src/test/resources/schema.sql b/storm-kotlin-spring-boot-starter/src/test/resources/schema.sql new file mode 100644 index 000000000..d86af8dfc --- /dev/null +++ b/storm-kotlin-spring-boot-starter/src/test/resources/schema.sql @@ -0,0 +1 @@ +CREATE TABLE visit (id INT AUTO_INCREMENT PRIMARY KEY, description VARCHAR(255)); diff --git a/storm-spring-boot-test-autoconfigure/pom.xml b/storm-spring-boot-test-autoconfigure/pom.xml new file mode 100644 index 000000000..4079b2002 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + + st.orm + storm-framework + ${revision} + ../pom.xml + + storm-spring-boot-test-autoconfigure + Storm Spring Boot Test AutoConfigure + Test slice support for Storm in Spring Boot applications, providing the @DataStormTest annotation. + https://github.com/storm-repo/storm-framework + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Leon van Zantvoort + storm@zantvoort.biz + + + + scm:git:git://github.com/storm-repo/storm-framework.git + scm:git:ssh://github.com/storm-repo/storm-framework.git + https://github.com/storm-repo/storm-framework/ + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + none + + + default-testCompile + none + + + compile + compile + + compile + + + + testCompile + test-compile + + testCompile + + + + + ${java.version} + ${java.version} + ${java.version} + --enable-preview + + + + org.apache.maven.plugins + maven-surefire-plugin + + + @{argLine} + --enable-preview + --add-opens java.base/java.lang=ALL-UNNAMED + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + st.orm.spring.boot.test.autoconfigure + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + true + + + + + + + + + org.springframework.boot + spring-boot-test-autoconfigure + + + org.springframework.boot + spring-boot-test + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-test + + + org.springframework + spring-tx + + + org.junit.jupiter + junit-jupiter-api + + + jakarta.annotation + jakarta.annotation-api + + + + st.orm + storm-spring-boot-starter + ${project.version} + test + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTest.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTest.java new file mode 100644 index 000000000..12a6480d4 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2024 - 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 st.orm.spring.boot.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.BootstrapWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * Annotation for a Storm test slice, the counterpart of annotations like {@code @DataJpaTest}. + * + *

Starts only the parts of the application relevant to Storm: the {@code DataSource}, Storm's + * auto-configuration (template, repository scanning, transaction integration, schema validation, exception + * translation), SQL initialization, and Flyway or Liquibase when present. Regular {@code @Component}, + * {@code @Service} and {@code @Controller} beans are not loaded.

+ * + *

On Spring Boot 3 an embedded in-memory database replaces the application's {@code DataSource} by + * default; set {@code spring.test.database.replace=none} to run against the configured database instead, + * such as a Testcontainers-managed one ({@code @ServiceConnection} works with the slice). On Spring Boot 4 + * the replacement activates when the {@code spring-boot-jdbc-test-autoconfigure} artifact is present; + * without it, point the slice at a database with the {@code properties} attribute. Each test method runs + * in a transaction that is rolled back afterwards.

+ + *

The slice is exclusion-based rather than annotation-composed where Spring Boot moved classes between + * releases, so one artifact serves both Spring Boot 3 and Spring Boot 4 applications.

+ * + * {@snippet lang = java: + * @DataStormTest + * class VisitRepositoryTest { + * + * @Autowired + * private VisitRepository visitRepository; + * + * @Test + * void findsVisits() { + * assertThat(visitRepository.count()).isEqualTo(14); + * } + * } + * } + * + *

Works with both Spring Boot starters: the slice pulls in the starter's auto-configuration classes by + * name, which are identical for the Java and Kotlin stacks.

+ * + * @since 1.13 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@BootstrapWith(DataStormTestContextBootstrapper.class) +@ExtendWith(SpringExtension.class) +@OverrideAutoConfiguration(enabled = false) +@Transactional +@ImportAutoConfiguration +public @interface DataStormTest { + + /** + * Properties in {@code key=value} form to add to the Spring environment before the test runs. + */ + String[] properties() default {}; + + /** + * A set of include filters applied to component scanning in combination with the slice's exclusion of + * regular components. + */ + ComponentScan.Filter[] includeFilters() default {}; + + /** + * A set of exclude filters applied to component scanning in addition to the slice's exclusion of + * regular components. + */ + ComponentScan.Filter[] excludeFilters() default {}; + + /** + * Whether the test should use the default filtering of the slice. Set to {@code false} to bring in + * regular {@code @Component} beans as well. + */ + boolean useDefaultFilters() default true; +} diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextBootstrapper.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextBootstrapper.java new file mode 100644 index 000000000..3b09cecf6 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextBootstrapper.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 - 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 st.orm.spring.boot.test; + +import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; +import org.springframework.test.context.TestContextAnnotationUtils; + +/** + * {@link org.springframework.test.context.TestContextBootstrapper} for + * {@link DataStormTest @DataStormTest}: applies the annotation's {@code properties}. + * + * @since 1.13 + */ +public class DataStormTestContextBootstrapper extends SpringBootTestContextBootstrapper { + + @Override + protected String[] getProperties(Class testClass) { + DataStormTest annotation = TestContextAnnotationUtils.findMergedAnnotation(testClass, DataStormTest.class); + return annotation != null ? annotation.properties() : null; + } +} diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java new file mode 100644 index 000000000..68e48b1b9 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024 - 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 st.orm.spring.boot.test; + +import jakarta.annotation.Nonnull; +import java.util.List; +import java.util.Objects; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContextAnnotationUtils; + +/** + * Registers the {@link DataStormTypeExcludeFilter} for {@link DataStormTest @DataStormTest} classes. + * + *

Registration goes through a context customizer rather than {@code @TypeExcludeFilters} so that one + * artifact serves both Spring Boot 3 and 4, which host that annotation in different locations. The filter + * is registered as a singleton named {@code dataStormTypeExcludeFilter} and picked up by the component + * scan's {@code TypeExcludeFilter} delegation.

+ * + * @since 1.13 + */ +public class DataStormTestContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + public ContextCustomizer createContextCustomizer( + @Nonnull Class testClass, + @Nonnull List configAttributes) { + DataStormTest annotation = TestContextAnnotationUtils.findMergedAnnotation(testClass, DataStormTest.class); + return annotation != null ? new DataStormTestContextCustomizer(annotation) : null; + } + + private record DataStormTestContextCustomizer(DataStormTest annotation) implements ContextCustomizer { + + @Override + public void customizeContext(@Nonnull ConfigurableApplicationContext context, + @Nonnull MergedContextConfiguration mergedConfig) { + context.getBeanFactory().registerSingleton( + "dataStormTypeExcludeFilter", new DataStormTypeExcludeFilter(annotation)); + } + + @Override + public boolean equals(Object other) { + return other instanceof DataStormTestContextCustomizer customizer + && Objects.equals(new DataStormTypeExcludeFilter(annotation), + new DataStormTypeExcludeFilter(customizer.annotation)); + } + + @Override + public int hashCode() { + return new DataStormTypeExcludeFilter(annotation).hashCode(); + } + } +} diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java new file mode 100644 index 000000000..6dad887f4 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024 - 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 st.orm.spring.boot.test; + +import jakarta.annotation.Nonnull; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.core.type.filter.AspectJTypeFilter; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.core.type.filter.RegexPatternTypeFilter; +import org.springframework.core.type.filter.TypeFilter; +import org.springframework.util.ObjectUtils; + +/** + * {@link TypeExcludeFilter} for {@link DataStormTest @DataStormTest}: keeps regular application components + * out of the slice, honoring the annotation's include and exclude filters. + * + *

Built directly on {@link TypeExcludeFilter}, whose location is stable across Spring Boot 3 and 4; the + * filter is registered by {@link DataStormTestContextCustomizerFactory} rather than through + * {@code @TypeExcludeFilters}, which moved between the releases.

+ * + * @since 1.13 + */ +public final class DataStormTypeExcludeFilter extends TypeExcludeFilter { + + private final DataStormTest annotation; + private final List includeFilters; + private final List excludeFilters; + + DataStormTypeExcludeFilter(@Nonnull DataStormTest annotation) { + this.annotation = annotation; + this.includeFilters = createFilters(annotation.includeFilters()); + this.excludeFilters = createFilters(annotation.excludeFilters()); + } + + @Override + public boolean match(@Nonnull MetadataReader metadataReader, + @Nonnull MetadataReaderFactory metadataReaderFactory) throws IOException { + for (TypeFilter exclude : excludeFilters) { + if (exclude.match(metadataReader, metadataReaderFactory)) { + return true; + } + } + for (TypeFilter include : includeFilters) { + if (include.match(metadataReader, metadataReaderFactory)) { + return false; + } + } + // With default filtering, every remaining scanned component stays out of the slice. Storm + // repositories are unaffected: they are registered by the repository post-processor, not by + // component scanning. + return annotation.useDefaultFilters(); + } + + private static List createFilters(ComponentScan.Filter[] filters) { + List typeFilters = new ArrayList<>(); + for (ComponentScan.Filter filter : filters) { + for (Class filterClass : filter.classes()) { + typeFilters.add(createFilter(filter, filterClass)); + } + for (String pattern : filter.pattern()) { + typeFilters.add(switch (filter.type()) { + case REGEX -> new RegexPatternTypeFilter(java.util.regex.Pattern.compile(pattern)); + case ASPECTJ -> new AspectJTypeFilter(pattern, DataStormTypeExcludeFilter.class.getClassLoader()); + default -> throw new IllegalStateException( + "Filter type " + filter.type() + " does not support patterns."); + }); + } + } + return typeFilters; + } + + @SuppressWarnings("unchecked") + private static TypeFilter createFilter(ComponentScan.Filter filter, Class filterClass) { + return switch (filter.type()) { + case ANNOTATION -> new AnnotationTypeFilter((Class) filterClass); + case ASSIGNABLE_TYPE -> new AssignableTypeFilter(filterClass); + case CUSTOM -> (TypeFilter) org.springframework.beans.BeanUtils.instantiateClass(filterClass); + default -> throw new IllegalStateException( + "Filter type " + filter.type() + " requires a pattern rather than classes."); + }; + } + + @Override + public boolean equals(Object other) { + return other instanceof DataStormTypeExcludeFilter filter + && Objects.equals(annotationState(annotation), annotationState(filter.annotation)); + } + + @Override + public int hashCode() { + return annotationState(annotation).hashCode(); + } + + private static List annotationState(DataStormTest annotation) { + return List.of( + annotation.useDefaultFilters(), + Arrays.stream(annotation.includeFilters()).map(AnnotationUtils::getAnnotationAttributes).toList(), + Arrays.stream(annotation.excludeFilters()).map(AnnotationUtils::getAnnotationAttributes).toList(), + ObjectUtils.nullSafeHashCode(annotation.properties())); + } +} diff --git a/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories b/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..a99a46efc --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.test.context.ContextCustomizerFactory=\ +st.orm.spring.boot.test.DataStormTestContextCustomizerFactory diff --git a/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/st.orm.spring.boot.test.DataStormTest.imports b/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/st.orm.spring.boot.test.DataStormTest.imports new file mode 100644 index 000000000..93408533f --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/st.orm.spring.boot.test.DataStormTest.imports @@ -0,0 +1,20 @@ +optional:org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration +optional:org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration +optional:org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +optional:org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration +optional:org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration +optional:org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration +optional:org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration +optional:org.springframework.boot.jdbc.autoconfigure.DataSourceInitializationAutoConfiguration +optional:org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration +optional:org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration +optional:org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration +optional:org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration +optional:org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration +optional:org.springframework.boot.jdbc.test.autoconfigure.TestDatabaseAutoConfiguration +st.orm.spring.boot.autoconfigure.StormAutoConfiguration +st.orm.spring.boot.autoconfigure.StormRepositoryAutoConfiguration +st.orm.spring.boot.StormTransactionAutoConfiguration +st.orm.spring.boot.StormValidationAutoConfiguration +st.orm.spring.boot.StormExceptionTranslationAutoConfiguration +st.orm.spring.boot.StormObservationAutoConfiguration diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestApplication.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestApplication.java new file mode 100644 index 000000000..12d59f8b1 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestApplication.java @@ -0,0 +1,7 @@ +package st.orm.spring.boot.test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DataStormTestApplication { +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestIntegrationTest.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestIntegrationTest.java new file mode 100644 index 000000000..82da356df --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestIntegrationTest.java @@ -0,0 +1,71 @@ +package st.orm.spring.boot.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.transaction.TestTransaction; +import st.orm.spring.boot.test.domain.UnrelatedService; +import st.orm.spring.boot.test.domain.Visit; +import st.orm.spring.boot.test.domain.VisitRepository; +import st.orm.template.ORMTemplate; + +/** + * Verifies the {@code @DataStormTest} slice end to end: repositories are scanned and injectable, regular + * components stay out, each test runs in a rollback transaction, and the Spring-integrated template + * behaviors (exception translation) are active. + */ +@TestMethodOrder(OrderAnnotation.class) +@DataStormTest +class DataStormTestIntegrationTest { + + @Autowired + private VisitRepository visitRepository; + + @Autowired + private ORMTemplate orm; + + @Autowired + private ApplicationContext applicationContext; + + @Test + @Order(1) + void repositoriesAreScannedAndInjectable() { + assertThat(visitRepository.count()).isEqualTo(3); + } + + @Test + @Order(2) + void regularComponentsStayOutOfTheSlice() { + assertThrows(NoSuchBeanDefinitionException.class, + () -> applicationContext.getBean(UnrelatedService.class)); + } + + @Test + @Order(3) + void testsRunInATransaction() { + assertThat(TestTransaction.isActive()).isTrue(); + visitRepository.insert(new Visit(null, "written inside the test transaction")); + assertThat(visitRepository.count()).isEqualTo(4); + } + + @Test + @Order(4) + void previousTestsWritesWereRolledBack() { + assertThat(visitRepository.count()).isEqualTo(3); + } + + @Test + @Order(5) + void exceptionTranslationIsActiveInTheSlice() { + assertThrows(DuplicateKeyException.class, + () -> orm.query("INSERT INTO visit (id, description) VALUES (1, 'duplicate')").executeUpdate()); + } +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestPropertiesTest.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestPropertiesTest.java new file mode 100644 index 000000000..26e2bdbc0 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/DataStormTestPropertiesTest.java @@ -0,0 +1,22 @@ +package st.orm.spring.boot.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; + +/** + * Verifies that the {@code properties} attribute reaches the environment. + */ +@DataStormTest(properties = "storm.validation.schema-mode=none") +class DataStormTestPropertiesTest { + + @Autowired + private Environment environment; + + @Test + void annotationPropertiesAreApplied() { + assertThat(environment.getProperty("storm.validation.schema-mode")).isEqualTo("none"); + } +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/UnrelatedService.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/UnrelatedService.java new file mode 100644 index 000000000..b6a15c9b0 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/UnrelatedService.java @@ -0,0 +1,10 @@ +package st.orm.spring.boot.test.domain; + +import org.springframework.stereotype.Service; + +/** + * Regular application component; must NOT be part of the slice. + */ +@Service +public class UnrelatedService { +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/Visit.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/Visit.java new file mode 100644 index 000000000..320127bcf --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/Visit.java @@ -0,0 +1,8 @@ +package st.orm.spring.boot.test.domain; + +import jakarta.annotation.Nullable; +import st.orm.Entity; +import st.orm.PK; + +public record Visit(@PK Integer id, @Nullable String description) implements Entity { +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/VisitRepository.java b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/VisitRepository.java new file mode 100644 index 000000000..e570a4f8c --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/java/st/orm/spring/boot/test/domain/VisitRepository.java @@ -0,0 +1,6 @@ +package st.orm.spring.boot.test.domain; + +import st.orm.repository.EntityRepository; + +public interface VisitRepository extends EntityRepository { +} diff --git a/storm-spring-boot-test-autoconfigure/src/test/resources/data.sql b/storm-spring-boot-test-autoconfigure/src/test/resources/data.sql new file mode 100644 index 000000000..a7f8770a1 --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/resources/data.sql @@ -0,0 +1,3 @@ +INSERT INTO visit (description) VALUES ('rabies shot'); +INSERT INTO visit (description) VALUES ('neutered'); +INSERT INTO visit (description) VALUES ('checkup'); diff --git a/storm-spring-boot-test-autoconfigure/src/test/resources/schema.sql b/storm-spring-boot-test-autoconfigure/src/test/resources/schema.sql new file mode 100644 index 000000000..d86af8dfc --- /dev/null +++ b/storm-spring-boot-test-autoconfigure/src/test/resources/schema.sql @@ -0,0 +1 @@ +CREATE TABLE visit (id INT AUTO_INCREMENT PRIMARY KEY, description VARCHAR(255));