Add test helper to manipulate the ApplicationContext
This commit adds ContextLoader, a test helper that configures an ApplicationContext that is meant to simulate a particular auto-configuration scenario. The auto-configuration, user configuration and environment can be customized. The loader invokes a ContextConsumer to assert the context and automatically close the context once it is done. Concretely, tests can create a shared field instance of that helper with the shared configuration to increase its visibility and tune the context further in each test. If the context is expected to fail, `loadAndFail` allows to optionally assert the root exception and consume it for further assertions. This commit also migrates some tests to illustrate the practical use of the helper Closes gh-9634pull/9637/head
parent
216c5c2179
commit
18ba414000
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2012-2017 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
|
||||
*
|
||||
* http://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.test.context;
|
||||
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/**
|
||||
* Callback interface used in tests to process a running
|
||||
* {@link ConfigurableApplicationContext} with the ability to throw a (checked)
|
||||
* exception.
|
||||
*
|
||||
* @author Stephane Nicoll
|
||||
* @author Andy Wilkinson
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ContextConsumer {
|
||||
|
||||
/**
|
||||
* Performs this operation on the supplied {@link ConfigurableApplicationContext
|
||||
* ApplicationContext}.
|
||||
* @param context the application context to consume
|
||||
* @throws Throwable any exception that might occur in assertions
|
||||
*/
|
||||
void accept(ConfigurableApplicationContext context) throws Throwable;
|
||||
|
||||
}
|
@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright 2012-2017 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
|
||||
*
|
||||
* http://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.test.context;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.boot.test.util.TestPropertyValues;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Manage the lifecycle of an {@link ApplicationContext}. Such helper is best used as
|
||||
* a field of a test class, describing the shared configuration required for the test:
|
||||
*
|
||||
* <pre class="code">
|
||||
* public class FooAutoConfigurationTests {
|
||||
*
|
||||
* private final ContextLoader contextLoader = new ContextLoader()
|
||||
* .autoConfig(FooAutoConfiguration.class).env("spring.foo=bar");
|
||||
*
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The initialization above makes sure to register {@code FooAutoConfiguration} for all
|
||||
* tests and set the {@code spring.foo} property to {@code bar} unless specified
|
||||
* otherwise.
|
||||
*
|
||||
* <p>Based on the configuration above, a specific test can simulate what would happen
|
||||
* if the user customizes a property and/or provides its own configuration:
|
||||
*
|
||||
* <pre class="code">
|
||||
* public class FooAutoConfigurationTests {
|
||||
*
|
||||
* @Test
|
||||
* public someTest() {
|
||||
* this.contextLoader.config(UserConfig.class).env("spring.foo=biz")
|
||||
* .load(context -> {
|
||||
* // assertions using the context
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The test above includes an extra {@code UserConfig} class that is guaranteed to
|
||||
* be processed <strong>before</strong> any auto-configuration. Also, {@code spring.foo}
|
||||
* has been overwritten to {@code biz}. The {@link #load(ContextConsumer) load} method
|
||||
* takes a consumer that can use the context to assert its state. Upon completion, the
|
||||
* context is automatically closed.
|
||||
*
|
||||
* <p>If a failure scenario has to be tested, {@link #loadAndFail(Consumer)} can be used
|
||||
* instead: it expects the startup of the context to fail and call the {@link Consumer}
|
||||
* with the exception for further assertions.
|
||||
*
|
||||
* @author Stephane Nicoll
|
||||
* @author Andy Wilkinson
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ContextLoader {
|
||||
|
||||
private final Map<String, String> systemProperties = new HashMap<>();
|
||||
|
||||
private final List<String> env = new ArrayList<>();
|
||||
|
||||
private final Set<Class<?>> userConfigurations = new LinkedHashSet<>();
|
||||
|
||||
private final LinkedList<Class<?>> autoConfigurations = new LinkedList<>();
|
||||
|
||||
private ClassLoader classLoader;
|
||||
|
||||
/**
|
||||
* Set the specified system property prior to loading the context and restore
|
||||
* its previous value once the consumer has been invoked and the context closed. If
|
||||
* the {@code value} is {@code null} this removes any prior customization for that
|
||||
* key.
|
||||
* @param key the system property
|
||||
* @param value the value (can be null to remove any existing customization)
|
||||
* @return this instance
|
||||
*/
|
||||
public ContextLoader systemProperty(String key, String value) {
|
||||
Assert.notNull(key, "Key must not be null");
|
||||
if (value != null) {
|
||||
this.systemProperties.put(key, value);
|
||||
}
|
||||
else {
|
||||
this.systemProperties.remove(key);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the specified property pairs. Name-value pairs can be specified with
|
||||
* colon (":") or equals ("=") separators. Override matching keys that might
|
||||
* have been specified previously.
|
||||
* @param pairs The key value pairs for properties that need to be added to the
|
||||
* environment
|
||||
* @return this instance
|
||||
*/
|
||||
public ContextLoader env(String... pairs) {
|
||||
if (!ObjectUtils.isEmpty(pairs)) {
|
||||
this.env.addAll(Arrays.asList(pairs));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the specified user configuration classes.
|
||||
* @param configs the user configuration classes to add
|
||||
* @return this instance
|
||||
*/
|
||||
public ContextLoader config(Class<?>... configs) {
|
||||
if (!ObjectUtils.isEmpty(configs)) {
|
||||
this.userConfigurations.addAll(Arrays.asList(configs));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the specified auto-configuration classes.
|
||||
* @param autoConfigurations the auto-configuration classes to add
|
||||
* @return this instance
|
||||
*/
|
||||
public ContextLoader autoConfig(Class<?>... autoConfigurations) {
|
||||
if (!ObjectUtils.isEmpty(autoConfigurations)) {
|
||||
this.autoConfigurations.addAll(Arrays.asList(autoConfigurations));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the specified auto-configuration at the beginning so that it is
|
||||
* applied before any other existing auto-configurations, but after any
|
||||
* user configuration.
|
||||
* @param autoConfiguration the auto-configuration to add
|
||||
* @return this instance
|
||||
*/
|
||||
public ContextLoader autoConfigFirst(Class<?> autoConfiguration) {
|
||||
this.autoConfigurations.addFirst(autoConfiguration);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customize the {@link ClassLoader} that the {@link ApplicationContext} should
|
||||
* use. Customizing the {@link ClassLoader} is an effective manner to hide resources
|
||||
* from the classpath.
|
||||
* @param classLoader the classloader to use (can be null to use the default)
|
||||
* @return this instance
|
||||
* @see HidePackagesClassLoader
|
||||
*/
|
||||
public ContextLoader classLoader(ClassLoader classLoader) {
|
||||
this.classLoader = classLoader;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and refresh a new {@link ApplicationContext} based on the current state
|
||||
* of this loader. The context is consumed by the specified {@link ContextConsumer}
|
||||
* and closed upon completion.
|
||||
* @param consumer the consumer of the created {@link ApplicationContext}
|
||||
*/
|
||||
public void load(ContextConsumer consumer) {
|
||||
try (ApplicationContextLifecycleHandler handler = new ApplicationContextLifecycleHandler()) {
|
||||
try {
|
||||
ConfigurableApplicationContext ctx = handler.load();
|
||||
consumer.accept(ctx);
|
||||
}
|
||||
catch (RuntimeException ex) {
|
||||
throw ex;
|
||||
}
|
||||
catch (Throwable ex) {
|
||||
throw new IllegalStateException("An unexpected error occurred: "
|
||||
+ ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and refresh a new {@link ApplicationContext} based on the current state
|
||||
* of this loader that this expected to fail. If the context does not fail, an
|
||||
* {@link AssertionError} is thrown. Otherwise the exception is consumed by the
|
||||
* specified {@link Consumer} with no expectation on the type of the exception.
|
||||
* @param consumer the consumer of the failure
|
||||
*/
|
||||
public void loadAndFail(Consumer<Throwable> consumer) {
|
||||
loadAndFail(Throwable.class, consumer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and refresh a new {@link ApplicationContext} based on the current state
|
||||
* of this loader that this expected to fail. If the context does not fail, an
|
||||
* {@link AssertionError} is thrown. If the exception does not match the specified
|
||||
* {@code exceptionType}, an {@link AssertionError} is thrown as well. If the
|
||||
* exception type matches, it is consumed by the specified {@link Consumer}.
|
||||
* @param exceptionType the expected type of the failure
|
||||
* @param consumer the consumer of the failure
|
||||
* @param <T> the expected type of the failure
|
||||
*/
|
||||
public <T extends Throwable> void loadAndFail(Class<T> exceptionType,
|
||||
Consumer<T> consumer) {
|
||||
try (ApplicationContextLifecycleHandler handler = new ApplicationContextLifecycleHandler()) {
|
||||
handler.load();
|
||||
throw new AssertionError("ApplicationContext should have failed");
|
||||
}
|
||||
catch (Throwable ex) {
|
||||
assertThat(ex).as("Wrong application context failure exception")
|
||||
.isInstanceOf(exceptionType);
|
||||
consumer.accept(exceptionType.cast(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private ConfigurableApplicationContext createApplicationContext() {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
|
||||
if (this.classLoader != null) {
|
||||
ctx.setClassLoader(this.classLoader);
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(this.env)) {
|
||||
TestPropertyValues.of(this.env.toArray(new String[this.env.size()]))
|
||||
.applyTo(ctx);
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(this.userConfigurations)) {
|
||||
ctx.register(this.userConfigurations.toArray(
|
||||
new Class<?>[this.userConfigurations.size()]));
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(this.autoConfigurations)) {
|
||||
LinkedHashSet<Class<?>> linkedHashSet =
|
||||
new LinkedHashSet(this.autoConfigurations);
|
||||
ctx.register(linkedHashSet.toArray(
|
||||
new Class<?>[this.autoConfigurations.size()]));
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the lifecycle of the {@link ApplicationContext}.
|
||||
*/
|
||||
private class ApplicationContextLifecycleHandler implements Closeable {
|
||||
|
||||
private final Map<String, String> customSystemProperties;
|
||||
|
||||
private final Map<String, String> previousSystemProperties = new HashMap<>();
|
||||
|
||||
private ConfigurableApplicationContext context;
|
||||
|
||||
ApplicationContextLifecycleHandler() {
|
||||
this.customSystemProperties = new HashMap<>(
|
||||
ContextLoader.this.systemProperties);
|
||||
}
|
||||
|
||||
public ConfigurableApplicationContext load() {
|
||||
setCustomSystemProperties();
|
||||
ConfigurableApplicationContext context = createApplicationContext();
|
||||
context.refresh();
|
||||
this.context = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (this.context != null) {
|
||||
this.context.close();
|
||||
}
|
||||
}
|
||||
finally {
|
||||
unsetCustomSystemProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void setCustomSystemProperties() {
|
||||
this.customSystemProperties.forEach((key, value) -> {
|
||||
String previous = System.setProperty(key, value);
|
||||
this.previousSystemProperties.put(key, previous);
|
||||
});
|
||||
}
|
||||
|
||||
private void unsetCustomSystemProperties() {
|
||||
this.previousSystemProperties.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
System.setProperty(key, value);
|
||||
}
|
||||
else {
|
||||
System.clearProperty(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright 2012-2017 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
|
||||
*
|
||||
* http://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.test.rule;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.ExpectedException;
|
||||
|
||||
import org.springframework.beans.factory.BeanCreationException;
|
||||
import org.springframework.boot.test.context.ContextLoader;
|
||||
import org.springframework.boot.test.context.HidePackagesClassLoader;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/**
|
||||
* Tests for {@link ContextLoader}.
|
||||
*
|
||||
* @author Stephane Nicoll
|
||||
*/
|
||||
public class ContextLoaderTests {
|
||||
|
||||
@Rule
|
||||
public final ExpectedException thrown = ExpectedException.none();
|
||||
|
||||
private final ContextLoader contextLoader = new ContextLoader();
|
||||
|
||||
@Test
|
||||
public void systemPropertyIsSetAndRemoved() {
|
||||
String key = "test." + UUID.randomUUID().toString();
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
this.contextLoader.systemProperty(key, "value").load(context -> {
|
||||
assertThat(System.getProperties().containsKey(key)).isTrue();
|
||||
assertThat(System.getProperties().getProperty(key)).isEqualTo("value");
|
||||
});
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void systemPropertyIsRemovedIfContextFailed() {
|
||||
String key = "test." + UUID.randomUUID().toString();
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
this.contextLoader.systemProperty(key, "value")
|
||||
.config(ConfigC.class).loadAndFail(e -> {
|
||||
});
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void systemPropertyIsRestoredToItsOriginalValue() {
|
||||
String key = "test." + UUID.randomUUID().toString();
|
||||
System.setProperty(key, "value");
|
||||
try {
|
||||
assertThat(System.getProperties().getProperty(key)).isEqualTo("value");
|
||||
this.contextLoader.systemProperty(key, "newValue").load(context -> {
|
||||
assertThat(System.getProperties().getProperty(key)).isEqualTo("newValue");
|
||||
});
|
||||
assertThat(System.getProperties().getProperty(key)).isEqualTo("value");
|
||||
}
|
||||
finally {
|
||||
System.clearProperty(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void systemPropertyCanBeSetToNullValue() {
|
||||
String key = "test." + UUID.randomUUID().toString();
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
this.contextLoader.systemProperty(key, "value")
|
||||
.systemProperty(key, null).load(context -> {
|
||||
assertThat(System.getProperties().containsKey(key)).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void systemPropertyNeedNonNullKey() {
|
||||
this.thrown.expect(IllegalArgumentException.class);
|
||||
this.contextLoader.systemProperty(null, "value");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void envIsAdditive() {
|
||||
this.contextLoader.env("test.foo=1").env("test.bar=2").load(context -> {
|
||||
ConfigurableEnvironment environment = context.getBean(
|
||||
ConfigurableEnvironment.class);
|
||||
assertThat(environment.getProperty("test.foo", Integer.class)).isEqualTo(1);
|
||||
assertThat(environment.getProperty("test.bar", Integer.class)).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void envOverridesExistingKey() {
|
||||
this.contextLoader.env("test.foo=1").env("test.foo=2").load(context ->
|
||||
assertThat(context.getBean(ConfigurableEnvironment.class)
|
||||
.getProperty("test.foo", Integer.class)).isEqualTo(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationIsProcessedInOrder() {
|
||||
this.contextLoader.config(ConfigA.class, AutoConfigA.class).load(context ->
|
||||
assertThat(context.getBean("a")).isEqualTo("autoconfig-a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationIsProcessedBeforeAutoConfiguration() {
|
||||
this.contextLoader.autoConfig(AutoConfigA.class)
|
||||
.config(ConfigA.class).load(context ->
|
||||
assertThat(context.getBean("a")).isEqualTo("autoconfig-a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationIsAdditive() {
|
||||
this.contextLoader.config(AutoConfigA.class)
|
||||
.config(AutoConfigB.class).load(context -> {
|
||||
assertThat(context.containsBean("a")).isTrue();
|
||||
assertThat(context.containsBean("b")).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void autoConfigureFirstIsAppliedProperly() {
|
||||
this.contextLoader.autoConfig(ConfigA.class)
|
||||
.autoConfigFirst(AutoConfigA.class).load(context ->
|
||||
assertThat(context.getBean("a")).isEqualTo("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void autoConfigurationIsAdditive() {
|
||||
this.contextLoader.autoConfig(AutoConfigA.class)
|
||||
.autoConfig(AutoConfigB.class).load(context -> {
|
||||
assertThat(context.containsBean("a")).isTrue();
|
||||
assertThat(context.containsBean("b")).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadAndFailWithExpectedException() {
|
||||
this.contextLoader.config(ConfigC.class)
|
||||
.loadAndFail(BeanCreationException.class, ex ->
|
||||
assertThat(ex.getMessage()).contains("Error creating bean with name 'c'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadAndFailWithWrongException() {
|
||||
this.thrown.expect(AssertionError.class);
|
||||
this.thrown.expectMessage("Wrong application context failure exception");
|
||||
this.contextLoader.config(ConfigC.class)
|
||||
.loadAndFail(IllegalArgumentException.class, ex -> {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void classLoaderIsUsed() {
|
||||
this.contextLoader.classLoader(new HidePackagesClassLoader(
|
||||
Gson.class.getPackage().getName())).load(context -> {
|
||||
try {
|
||||
ClassUtils.forName(Gson.class.getName(), context.getClassLoader());
|
||||
fail("Should have thrown a ClassNotFoundException");
|
||||
}
|
||||
catch (ClassNotFoundException e) {
|
||||
// expected
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class ConfigA {
|
||||
|
||||
@Bean
|
||||
public String a() {
|
||||
return "a";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class ConfigB {
|
||||
|
||||
@Bean
|
||||
public Integer b() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class AutoConfigA {
|
||||
|
||||
@Bean
|
||||
public String a() {
|
||||
return "autoconfig-a";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class AutoConfigB {
|
||||
|
||||
@Bean
|
||||
public Integer b() {
|
||||
return 42;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class ConfigC {
|
||||
|
||||
@Bean
|
||||
public String c(Integer value) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue