diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java index 00f500b3818..54ac81550ec 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java @@ -31,6 +31,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException; import org.springframework.core.log.LogMessage; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; @@ -61,10 +62,21 @@ class ConnectionDetailsRegistrar { sources.forEach((source) -> registerBeanDefinitions(registry, source)); } - private void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource source) { - this.connectionDetailsFactories.getConnectionDetails(source, true) - .forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source, - connectionDetailsType, connectionDetails)); + void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource source) { + try { + this.connectionDetailsFactories.getConnectionDetails(source, true) + .forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source, + connectionDetailsType, connectionDetails)); + } + catch (ConnectionDetailsFactoryNotFoundException ex) { + if (!StringUtils.hasText(source.getConnectionName())) { + StringBuilder message = new StringBuilder(ex.getMessage()); + message.append((!message.toString().endsWith(".")) ? "." : ""); + message.append(" You may need to add a 'name' to your @ServiceConnection annotation"); + throw new ConnectionDetailsFactoryNotFoundException(message.toString(), ex.getCause()); + } + throw ex; + } } @SuppressWarnings("unchecked") diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java index 49bf7d9bfc5..c4f01a7ad34 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java @@ -20,6 +20,7 @@ import java.util.Arrays; import org.testcontainers.containers.Container; +import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; import org.springframework.boot.origin.Origin; @@ -108,16 +109,18 @@ public abstract class ContainerConnectionDetailsFactory, protected abstract D getContainerConnectionDetails(ContainerConnectionSource source); /** - * Convenient base class for {@link ConnectionDetails} results that are backed by a + * Base class for {@link ConnectionDetails} results that are backed by a * {@link ContainerConnectionSource}. * * @param the container type */ protected static class ContainerConnectionDetails> - implements ConnectionDetails, OriginProvider { + implements ConnectionDetails, OriginProvider, InitializingBean { private final ContainerConnectionSource source; + private volatile C container; + /** * Create a new {@link ContainerConnectionDetails} instance. * @param source the source {@link ContainerConnectionSource} @@ -127,8 +130,20 @@ public abstract class ContainerConnectionDetailsFactory, this.source = source; } + @Override + public void afterPropertiesSet() throws Exception { + this.container = this.source.getContainerSupplier().get(); + } + + /** + * Return the container that back this connection details instance. This method + * can only be called once the connection details bean has been initialized. + * @return the container instance + */ protected final C getContainer() { - return this.source.getContainer(); + Assert.state(this.container != null, + "Container cannot be obtained before the connection details bean has been initialized"); + return this.container; } @Override diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java index 22d5472e8d3..0bae2745bf1 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java @@ -17,6 +17,7 @@ package org.springframework.boot.testcontainers.service.connection; import java.util.Set; +import java.util.function.Supplier; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -49,63 +50,72 @@ public final class ContainerConnectionSource> implements private final Origin origin; - private final C container; + private final Class containerType; - private final String acceptedConnectionName; + private final String connectionName; - private final Set> acceptedConnectionDetailsTypes; + private final Set> connectionDetailsTypes; - ContainerConnectionSource(String beanNameSuffix, Origin origin, C container, - MergedAnnotation annotation) { + private Supplier containerSupplier; + + ContainerConnectionSource(String beanNameSuffix, Origin origin, Class containerType, String containerImageName, + MergedAnnotation annotation, Supplier containerSupplier) { this.beanNameSuffix = beanNameSuffix; this.origin = origin; - this.container = container; - this.acceptedConnectionName = getConnectionName(container, annotation.getString("name")); - this.acceptedConnectionDetailsTypes = Set.of(annotation.getClassArray("type")); + this.containerType = containerType; + this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName); + this.connectionDetailsTypes = Set.of(annotation.getClassArray("type")); + this.containerSupplier = containerSupplier; } - ContainerConnectionSource(String beanNameSuffix, Origin origin, C container, ServiceConnection annotation) { + ContainerConnectionSource(String beanNameSuffix, Origin origin, Class containerType, String containerImageName, + ServiceConnection annotation, Supplier containerSupplier) { this.beanNameSuffix = beanNameSuffix; this.origin = origin; - this.container = container; - this.acceptedConnectionName = getConnectionName(container, annotation.name()); - this.acceptedConnectionDetailsTypes = Set.of(annotation.type()); + this.containerType = containerType; + this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName); + this.connectionDetailsTypes = Set.of(annotation.type()); + this.containerSupplier = containerSupplier; } - private static String getConnectionName(Container container, String connectionName) { - if (StringUtils.hasLength(connectionName)) { + private static String getOrDeduceConnectionName(String connectionName, String containerImageName) { + if (StringUtils.hasText(connectionName)) { return connectionName; } - try { - DockerImageName imageName = DockerImageName.parse(container.getDockerImageName()); + if (StringUtils.hasText(containerImageName)) { + DockerImageName imageName = DockerImageName.parse(containerImageName); imageName.assertValid(); return imageName.getRepository(); } - catch (IllegalArgumentException ex) { - return container.getDockerImageName(); - } + return null; } - boolean accepts(String connectionName, Class connectionDetailsType, Class containerType) { - if (!containerType.isInstance(this.container)) { - logger.trace(LogMessage.of(() -> "%s not accepted as %s is not an instance of %s".formatted(this, - this.container.getClass().getName(), containerType.getName()))); + boolean accepts(String requiredConnectionName, Class requiredContainerType, + Class requiredConnectionDetailsType) { + if (StringUtils.hasText(requiredConnectionName) + && !requiredConnectionName.equalsIgnoreCase(this.connectionName)) { + logger.trace(LogMessage + .of(() -> "%s not accepted as source connection name '%s' does not match required connection name '%s'" + .formatted(this, this.connectionName, requiredConnectionName))); return false; } - if (StringUtils.hasLength(connectionName) && !connectionName.equalsIgnoreCase(this.acceptedConnectionName)) { - logger.trace(LogMessage.of(() -> "%s not accepted as connection names '%s' and '%s' do not match" - .formatted(this, connectionName, this.acceptedConnectionName))); + if (!requiredContainerType.isAssignableFrom(this.containerType)) { + logger.trace(LogMessage.of(() -> "%s not accepted as source container type %s is not assignable from %s" + .formatted(this, this.containerType.getName(), requiredContainerType.getName()))); return false; } - if (!this.acceptedConnectionDetailsTypes.isEmpty() && this.acceptedConnectionDetailsTypes.stream() - .noneMatch((candidate) -> candidate.isAssignableFrom(connectionDetailsType))) { - logger.trace(LogMessage.of(() -> "%s not accepted as connection details type %s not in %s".formatted(this, - connectionDetailsType, this.acceptedConnectionDetailsTypes))); + if (!this.connectionDetailsTypes.isEmpty() && this.connectionDetailsTypes.stream() + .noneMatch((candidate) -> candidate.isAssignableFrom(requiredConnectionDetailsType))) { + logger.trace(LogMessage + .of(() -> "%s not accepted as source connection details types %s has no element assignable from %s" + .formatted(this, this.connectionDetailsTypes.stream().map(Class::getName).toList(), + requiredConnectionDetailsType.getName()))); return false; } - logger.trace(LogMessage - .of(() -> "%s accepted for connection name '%s', connection details type %s, container type %s" - .formatted(this, connectionName, connectionDetailsType.getName(), containerType.getName()))); + logger.trace( + LogMessage.of(() -> "%s accepted for connection name '%s' container type %s, connection details type %s" + .formatted(this, requiredConnectionName, requiredContainerType.getName(), + requiredConnectionDetailsType.getName()))); return true; } @@ -118,8 +128,12 @@ public final class ContainerConnectionSource> implements return this.origin; } - C getContainer() { - return this.container; + String getConnectionName() { + return this.connectionName; + } + + Supplier getContainerSupplier() { + return this.containerSupplier; } @Override diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnection.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnection.java index 199d0c958f1..0640ce692af 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnection.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnection.java @@ -22,8 +22,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.testcontainers.containers.Container; +import org.testcontainers.utility.DockerImageName; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; +import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.AliasFor; /** @@ -40,9 +42,18 @@ import org.springframework.core.annotation.AliasFor; public @interface ServiceConnection { /** - * The name of the service being connected to. If not specified, the image name will - * be used. Container names are used to determine the connection details that should - * be created when a technology-specific {@link Container} subclass is not available. + * The name of the service being connected to. Container names are used to determine + * the connection details that should be created when a technology-specific + * {@link Container} subclass is not available. + *

+ * If not specified, and if the {@link Container} instance is available, the + * {@link DockerImageName#getRepository() repository} part of the + * {@link Container#getDockerImageName() docker image name} will be used. Note that + * {@link Container} instances are not available early enough when the + * container is defined as a {@link Bean @Bean} method. All + * {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need + * to match on the connection name must declare this attribute. + *

* This attribute is an alias for {@link #name()}. * @return the name of the service * @see #name() @@ -52,8 +63,19 @@ public @interface ServiceConnection { /** * The name of the service being connected to. If not specified, the image name will - * be used. Container names are used to determine the connection details that should - * be created when a technology-specific {@link Container} subclass is not available. + * The name of the service being connected to. Container names are used to determine + * the connection details that should be created when a technology-specific + * {@link Container} subclass is not available. + *

+ * If not specified, and if the {@link Container} instance is available, the + * {@link DockerImageName#getRepository() repository} part of the + * {@link Container#getDockerImageName() docker image name} will be used. Note that + * {@link Container} instances are not available early enough when the + * container is defined as a {@link Bean @Bean} method. All + * {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need + * to match on the connection name must declare this attribute. + *

+ * This attribute is an alias for {@link #value()}. * @return the name of the service * @see #value() */ diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java index ca5c7d0386e..4e82bc36f69 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java @@ -16,13 +16,12 @@ package org.springframework.boot.testcontainers.service.connection; -import java.util.ArrayList; -import java.util.List; import java.util.Set; import org.testcontainers.containers.Container; import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; @@ -48,33 +47,43 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { if (this.beanFactory instanceof ConfigurableListableBeanFactory listableBeanFactory) { - ConnectionDetailsFactories connectionDetailsFactories = new ConnectionDetailsFactories(); - List> sources = getSources(listableBeanFactory); - new ConnectionDetailsRegistrar(listableBeanFactory, connectionDetailsFactories) - .registerBeanDefinitions(registry, sources); + registerBeanDefinitions(listableBeanFactory, registry); } } - private List> getSources(ConfigurableListableBeanFactory beanFactory) { - List> sources = new ArrayList<>(); - for (String candidate : beanFactory.getBeanNamesForType(Container.class)) { - Set annotations = beanFactory.findAllAnnotationsOnBean(candidate, - ServiceConnection.class, false); - if (!annotations.isEmpty()) { - addSources(sources, beanFactory, candidate, annotations); + private void registerBeanDefinitions(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry) { + ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, + new ConnectionDetailsFactories()); + for (String beanName : beanFactory.getBeanNamesForType(Container.class)) { + BeanDefinition beanDefinition = getBeanDefinition(beanFactory, beanName); + for (ServiceConnection annotation : getAnnotations(beanFactory, beanName)) { + ContainerConnectionSource source = createSource(beanFactory, beanName, beanDefinition, annotation); + registrar.registerBeanDefinitions(registry, source); } } - return sources; } - private void addSources(List> sources, ConfigurableListableBeanFactory beanFactory, - String beanName, Set annotations) { - BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); - Origin origin = new BeanOrigin(beanName, beanDefinition); - Container container = beanFactory.getBean(beanName, Container.class); - for (ServiceConnection annotation : annotations) { - sources.add(new ContainerConnectionSource<>(beanName, origin, container, annotation)); + private Set getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName) { + return beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false); + } + + private BeanDefinition getBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) { + try { + return beanFactory.getBeanDefinition(beanName); + } + catch (NoSuchBeanDefinitionException ex) { + return null; } } + @SuppressWarnings("unchecked") + private > ContainerConnectionSource createSource( + ConfigurableListableBeanFactory beanFactory, String beanName, BeanDefinition beanDefinition, + ServiceConnection annotation) { + Origin origin = new BeanOrigin(beanName, beanDefinition); + Class containerType = (Class) beanFactory.getType(beanName, false); + return new ContainerConnectionSource<>(beanName, origin, containerType, null, annotation, + () -> beanFactory.getBean(beanName, containerType)); + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java index dcd1dbeb6f8..bba65dbab10 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java @@ -31,9 +31,7 @@ import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.ContextCustomizerFactory; import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; -import org.springframework.util.StringUtils; /** * Spring Test {@link ContextCustomizerFactory} to support @@ -65,17 +63,19 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact } } - private ContainerConnectionSource createSource(Field field, MergedAnnotation annotation) { + @SuppressWarnings("unchecked") + private > ContainerConnectionSource createSource(Field field, + MergedAnnotation annotation) { Assert.state(Modifier.isStatic(field.getModifiers()), () -> "@ServiceConnection field '%s' must be static".formatted(field.getName())); - String beanNameSuffix = StringUtils.capitalize(ClassUtils.getShortNameAsProperty(field.getDeclaringClass())) - + StringUtils.capitalize(field.getName()); Origin origin = new FieldOrigin(field); Object fieldValue = getFieldValue(field); Assert.state(fieldValue instanceof Container, () -> "Field '%s' in %s must be a %s".formatted(field.getName(), field.getDeclaringClass().getName(), Container.class.getName())); - Container container = (Container) fieldValue; - return new ContainerConnectionSource<>(beanNameSuffix, origin, container, annotation); + Class containerType = (Class) fieldValue.getClass(); + C container = (C) fieldValue; + return new ContainerConnectionSource<>("test", origin, containerType, container.getDockerImageName(), + annotation, () -> container); } private Object getFieldValue(Field field) { diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrarTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrarTests.java new file mode 100644 index 00000000000..321bb13cd94 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrarTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-2023 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 org.springframework.boot.testcontainers.service.connection; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException; +import org.springframework.boot.origin.Origin; +import org.springframework.core.annotation.MergedAnnotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ConnectionDetailsRegistrar}. + * + * @author Phillip Webb + */ +class ConnectionDetailsRegistrarTests { + + private Origin origin; + + private PostgreSQLContainer container; + + private MergedAnnotation annotation; + + private ContainerConnectionSource source; + + private ConnectionDetailsFactories factories; + + @BeforeEach + void setup() { + this.origin = mock(Origin.class); + this.container = mock(PostgreSQLContainer.class); + this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class[0])); + this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, null, + this.annotation, () -> this.container); + this.factories = mock(ConnectionDetailsFactories.class); + } + + @Test + void registerBeanDefinitionsWhenConnectionDetailsFactoryNotFoundAndNoConnectionNameThrowsExceptionWithBetterMessage() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories); + given(this.factories.getConnectionDetails(this.source, true)) + .willThrow(new ConnectionDetailsFactoryNotFoundException("fail")); + assertThatExceptionOfType(ConnectionDetailsFactoryNotFoundException.class) + .isThrownBy(() -> registrar.registerBeanDefinitions(beanFactory, this.source)) + .withMessage("fail. You may need to add a 'name' to your @ServiceConnection annotation"); + } + + @Test + void registerBeanDefinitionsWhenExistingBeanSkipsRegistration() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerBeanDefinition("testbean", new RootBeanDefinition(CustomTestConnectionDetails.class)); + ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories); + given(this.factories.getConnectionDetails(this.source, true)) + .willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails())); + registrar.registerBeanDefinitions(beanFactory, this.source); + assertThat(beanFactory.getBean(TestConnectionDetails.class)).isInstanceOf(CustomTestConnectionDetails.class); + } + + @Test + void registerBeanDefinitionsRegistersDefinition() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories); + given(this.factories.getConnectionDetails(this.source, true)) + .willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails())); + registrar.registerBeanDefinitions(beanFactory, this.source); + assertThat(beanFactory.getBean(TestConnectionDetails.class)).isNotNull(); + } + + static class TestConnectionDetails implements ConnectionDetails { + + } + + static class CustomTestConnectionDetails extends TestConnectionDetails { + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryTests.java index 404f9c7405e..bab9bf3488f 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryTests.java @@ -28,9 +28,11 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; import org.springframework.boot.origin.Origin; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryTests.TestContainerConnectionDetailsFactory.TestContainerConnectionDetails; import org.springframework.core.annotation.MergedAnnotation; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.mockito.Mockito.mock; /** @@ -59,8 +61,8 @@ class ContainerConnectionDetailsFactoryTests { this.container = mock(PostgreSQLContainer.class); this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "myname", "type", new Class[0])); - this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, - this.annotation); + this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class, + this.container.getDockerImageName(), this.annotation, () -> this.container); } @Test @@ -88,7 +90,7 @@ class ContainerConnectionDetailsFactoryTests { void getConnectionDetailsWhenContainerTypeDoesNotMatchReturnsNull() { ElasticsearchContainer container = mock(ElasticsearchContainer.class); ContainerConnectionSource source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, - container, this.annotation); + ElasticsearchContainer.class, container.getDockerImageName(), this.annotation, () -> container); TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory(); ConnectionDetails connectionDetails = getConnectionDetails(factory, source); assertThat(connectionDetails).isNull(); @@ -101,10 +103,26 @@ class ContainerConnectionDetailsFactoryTests { assertThat(Origin.from(connectionDetails)).isSameAs(this.origin); } + @Test + void getContainerWhenNotInitializedThrowsException() { + TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory(); + TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source); + assertThatIllegalStateException().isThrownBy(() -> connectionDetails.callGetContainer()) + .withMessage("Container cannot be obtained before the connection details bean has been initialized"); + } + + @Test + void getContainerWhenInitializedReturnsSuppliedContainer() throws Exception { + TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory(); + TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source); + connectionDetails.afterPropertiesSet(); + assertThat(connectionDetails.callGetContainer()).isSameAs(this.container); + } + @SuppressWarnings({ "rawtypes", "unchecked" }) - private ConnectionDetails getConnectionDetails(ConnectionDetailsFactory factory, + private TestContainerConnectionDetails getConnectionDetails(ConnectionDetailsFactory factory, ContainerConnectionSource source) { - return ((ConnectionDetailsFactory) factory).getConnectionDetails(source); + return (TestContainerConnectionDetails) ((ConnectionDetailsFactory) factory).getConnectionDetails(source); } /** @@ -127,8 +145,8 @@ class ContainerConnectionDetailsFactoryTests { return new TestContainerConnectionDetails(source); } - private static final class TestContainerConnectionDetails - extends ContainerConnectionDetails> implements JdbcConnectionDetails { + static final class TestContainerConnectionDetails extends ContainerConnectionDetails> + implements JdbcConnectionDetails { private TestContainerConnectionDetails(ContainerConnectionSource> source) { super(source); @@ -149,6 +167,10 @@ class ContainerConnectionDetailsFactoryTests { return "jdbc:example"; } + JdbcDatabaseContainer callGetContainer() { + return super.getContainer(); + } + } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSourceTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSourceTests.java index 278691f41f6..913b08037ed 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSourceTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSourceTests.java @@ -46,7 +46,7 @@ class ContainerConnectionSourceTests { private Origin origin; - private JdbcDatabaseContainer container; + private PostgreSQLContainer container; private MergedAnnotation annotation; @@ -59,92 +59,102 @@ class ContainerConnectionSourceTests { this.container = mock(PostgreSQLContainer.class); given(this.container.getDockerImageName()).willReturn("postgres"); this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class[0])); - this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, - this.annotation); + this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class, + this.container.getDockerImageName(), this.annotation, () -> this.container); } @Test - void acceptsWhenContainerIsNotInstanceOfContainerTypeReturnsFalse() { - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = ElasticsearchContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); + void acceptsWhenContainerIsNotInstanceOfRequiredContainerTypeReturnsFalse() { + String requiredConnectionName = null; + Class requiredContainerType = ElasticsearchContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isFalse(); } @Test - void acceptsWhenContainerIsInstanceOfContainerTypeReturnsTrue() { - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + void acceptsWhenContainerIsInstanceOfRequiredContainerTypeReturnsTrue() { + String requiredConnectionName = null; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test - void acceptsWhenConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() { + void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() { setupSourceAnnotatedWithName("myname"); - String connectionName = "othername"; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); + String requiredConnectionName = "othername"; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isFalse(); } @Test - void acceptsWhenConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() { - String connectionName = "othername"; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); + void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() { + String requiredConnectionName = "othername"; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isFalse(); } @Test - void acceptsWhenConnectionNameIsUnrestrictedReturnsTrue() { - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + void acceptsWhenRequiredConnectionNameIsUnrestrictedReturnsTrue() { + String requiredConnectionName = null; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test - void acceptsWhenConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() { + void acceptsWhenRequiredConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() { setupSourceAnnotatedWithName("myname"); - String connectionName = "myname"; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + String requiredConnectionName = "myname"; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test - void acceptsWhenConnectionNameMatchesNameTakenFromContainerReturnsTrue() { - String connectionName = "postgres"; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + void acceptsWhenRequiredConnectionNameMatchesNameTakenFromContainerReturnsTrue() { + String requiredConnectionName = "postgres"; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test - void acceptsWhenConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() { + void acceptsWhenRequiredConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() { setupSourceAnnotatedWithType(ElasticsearchConnectionDetails.class); - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); + String requiredConnectionName = null; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isFalse(); } @Test - void acceptsWhenConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() { + void acceptsWhenRequiredConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() { setupSourceAnnotatedWithType(JdbcConnectionDetails.class); - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + String requiredConnectionName = null; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test - void acceptsWhenConnectionDetailsTypeIsNotRestrictedReturnsTrue() { - String connectionName = null; - Class connectionDetailsType = JdbcConnectionDetails.class; - Class containerType = JdbcDatabaseContainer.class; - assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); + void acceptsWhenRequiredConnectionDetailsTypeIsNotRestrictedReturnsTrue() { + String requiredConnectionName = null; + Class requiredContainerType = JdbcDatabaseContainer.class; + Class requiredConnectionDetailsType = JdbcConnectionDetails.class; + assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType)) + .isTrue(); } @Test @@ -158,8 +168,8 @@ class ContainerConnectionSourceTests { } @Test - void getContainerReturnsContainer() { - assertThat(this.source.getContainer()).isSameAs(this.container); + void getContainerSupplierReturnsSupplierSupplyingContainer() { + assertThat(this.source.getContainerSupplier().get()).isSameAs(this.container); } @Test @@ -169,15 +179,15 @@ class ContainerConnectionSourceTests { private void setupSourceAnnotatedWithName(String name) { this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", name, "type", new Class[0])); - this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, - this.annotation); + this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class, + this.container.getDockerImageName(), this.annotation, () -> this.container); } private void setupSourceAnnotatedWithType(Class type) { this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class[] { type })); - this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, - this.annotation); + this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class, + this.container.getDockerImageName(), this.annotation, () -> this.container); } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java index 27ba5303d39..9afc86a2f25 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java @@ -94,7 +94,7 @@ class ServiceConnectionAutoConfigurationTests { static class ContainerConfiguration { @Bean - @ServiceConnection + @ServiceConnection("redis") RedisContainer redisContainer() { return new RedisContainer(); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java index 9bd90a7fe1d..2bff6cf2893 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java @@ -80,7 +80,7 @@ class ServiceConnectionContextCustomizerFactoryTests { ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory .createContextCustomizer(SingleServiceConnection.class, null); ContainerConnectionSource source = customizer.getSources().get(0); - assertThat(source.getBeanNameSuffix()).isEqualTo("SingleServiceConnectionService1"); + assertThat(source.getBeanNameSuffix()).isEqualTo("test"); } @Test diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerTests.java index 7f3d5e16ff7..910bd0a2b99 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerTests.java @@ -22,7 +22,6 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.containers.PostgreSQLContainer; import org.springframework.beans.factory.config.BeanDefinition; @@ -51,11 +50,9 @@ import static org.mockito.Mockito.spy; */ class ServiceConnectionContextCustomizerTests { - private String beanNameSuffix; - private Origin origin; - private JdbcDatabaseContainer container; + private PostgreSQLContainer container; private MergedAnnotation annotation; @@ -65,13 +62,12 @@ class ServiceConnectionContextCustomizerTests { @BeforeEach void setup() { - this.beanNameSuffix = "MyBean"; this.origin = mock(Origin.class); this.container = mock(PostgreSQLContainer.class); this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "myname", "type", new Class[0])); - this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, - this.annotation); + this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, + this.container.getDockerImageName(), this.annotation, () -> this.container); this.factories = mock(ConnectionDetailsFactories.class); } @@ -89,7 +85,7 @@ class ServiceConnectionContextCustomizerTests { customizer.customizeContext(context, mergedConfig); ArgumentCaptor beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class); then(beanFactory).should() - .registerBeanDefinition(eq("testJdbcConnectionDetailsForMyBean"), beanDefinitionCaptor.capture()); + .registerBeanDefinition(eq("testJdbcConnectionDetailsForTest"), beanDefinitionCaptor.capture()); RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue(); assertThat(beanDefinition.getInstanceSupplier().get()).isSameAs(connectionDetails); assertThat(beanDefinition.getBeanClass()).isEqualTo(TestJdbcConnectionDetails.class);