From 7e5a8f8df08026e8e284f90be6bdd49f38de04c4 Mon Sep 17 00:00:00 2001 From: Timon Borter Date: Fri, 2 Feb 2024 17:38:23 +0100 Subject: [PATCH 1/5] fix(simulator): scenario filter with special chars Signed-off-by: Timon Borter --- .../simulator/web/rest/ScenarioResource.java | 12 ++++--- .../web/rest/ScenarioResourceTest.java | 31 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java index 2de63a4ef..0b6035195 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java @@ -44,11 +44,14 @@ import java.util.Optional; import java.util.Set; +import static java.net.URLDecoder.decode; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Comparator.comparing; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.MESSAGE_TRIGGERED; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.STARTER; import static org.citrusframework.simulator.web.util.PaginationUtil.createPage; import static org.citrusframework.simulator.web.util.PaginationUtil.generatePaginationHttpHeaders; +import static org.springframework.http.ResponseEntity.ok; import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; @RestController @@ -94,19 +97,20 @@ private synchronized void evictAndReloadScenarioCache(Set scenarioNames, * @return the {@link ResponseEntity} with status {@code 200 (OK)} and the list of scenarios in body. */ @GetMapping("/scenarios") - public ResponseEntity> getScenarios(@RequestParam(name = "nameContains", required = false) Optional< String> nameContains, @ParameterObject Pageable pageable) { - logger.debug("REST request get registered Scenarios, where name contains: {}", nameContains.orElse("*")); + public ResponseEntity> getScenarios(@RequestParam(name = "nameContains", required = false) Optional nameContains, @ParameterObject Pageable pageable) { + var nameFilter = nameContains.map(contains -> decode(contains, UTF_8)).orElse("*"); + logger.debug("REST request get registered Scenarios, where name contains: {}", nameFilter); Page page = createPage( scenarioCache.stream() - .filter(scenario -> nameContains.isEmpty() || scenario.name().contains(nameContains.get())) + .filter(scenario -> nameContains.isEmpty() || scenario.name().contains(nameFilter)) .toList(), pageable, ScenarioComparator::fromProperty ); HttpHeaders headers = generatePaginationHttpHeaders(fromCurrentRequest(), page); - return ResponseEntity.ok().headers(headers).body(page.getContent()); + return ok().headers(headers).body(page.getContent()); } /** diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java index 9eb0706dd..0adb8dcad 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java @@ -25,9 +25,11 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.web.context.request.ServletRequestAttributes; @@ -35,12 +37,15 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.STARTER; +import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.doReturn; +import static org.springframework.data.domain.Pageable.unpaged; import static org.springframework.test.util.ReflectionTestUtils.getField; import static org.springframework.test.util.ReflectionTestUtils.setField; import static org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes; @@ -91,17 +96,25 @@ class GetScenarios { private static final List SCENARIO_CACHE = asList( new Scenario("abc", STARTER), new Scenario("cde", STARTER), - new Scenario("efg", STARTER) + new Scenario("efg", STARTER), + new Scenario("$#&", STARTER) ); - @Test - void doesFilterCacheWithNameContains() { + static Stream doesFilterCacheWithNameContains() { + return Stream.of( + arguments("b", "abc"), + arguments("#", "$#&") + ); + } + + @MethodSource + @ParameterizedTest + void doesFilterCacheWithNameContains(String filterLetter, String expectedScenario) { setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); setField(fixture, "scenarioCache", SCENARIO_CACHE); - String filterLetter = "b"; - var result = fixture.getScenarios(Optional.of(filterLetter), Pageable.unpaged()); + var result = fixture.getScenarios(Optional.of(filterLetter), unpaged()); assertThat(result) .extracting(ResponseEntity::getBody) @@ -110,7 +123,7 @@ void doesFilterCacheWithNameContains() { .first() .asInstanceOf(type(Scenario.class)) .extracting(Scenario::name) - .isEqualTo("abc"); + .isEqualTo(expectedScenario); } @Test @@ -120,7 +133,7 @@ void doesFilterCacheWithNameStartsOrEndsWith() { setField(fixture, "scenarioCache", SCENARIO_CACHE); String filterLetter = "e"; - var result = fixture.getScenarios(Optional.of(filterLetter), Pageable.unpaged()); + var result = fixture.getScenarios(Optional.of(filterLetter), unpaged()); assertThat(result) .extracting(ResponseEntity::getBody) @@ -140,7 +153,7 @@ void doesNotFilterCacheWithoutNameContains() { setField(fixture, "scenarioCache", SCENARIO_CACHE); - var result = fixture.getScenarios(Optional.empty(), Pageable.unpaged()); + var result = fixture.getScenarios(Optional.empty(), unpaged()); assertThat(result) .extracting(ResponseEntity::getBody) From 2b378cb87741644a3e2edcf75729ce3b463ff939 Mon Sep 17 00:00:00 2001 From: Timon Borter Date: Thu, 15 Feb 2024 22:25:18 +0100 Subject: [PATCH 2/5] feat(simulator): different running modes the property `citrus.simulator.mode=[async/sync]` steers the mode the simulator will use to execute simulations. note that while the synchronous modus is simpler, it disallows the simulation of any intermediate messaging with synchronous protocols. Signed-off-by: Timon Borter --- .../src/main/resources/application.properties | 3 + .../src/main/resources/application.properties | 3 + .../src/main/resources/application.properties | 3 + .../src/main/resources/application.properties | 3 + .../src/main/resources/application.properties | 3 + .../listener/SimulatorStatusListener.java | 40 +++-- .../repository/MessageHeaderRepository.java | 16 ++ .../repository/MessageRepository.java | 16 ++ .../repository/ScenarioActionRepository.java | 16 ++ .../ScenarioExecutionRepository.java | 16 ++ .../ScenarioParameterRepository.java | 16 ++ .../repository/TestParameterRepository.java | 16 ++ .../repository/TestResultRepository.java | 16 ++ .../service/ScenarioExecutorService.java | 25 ++- .../DefaultScenarioExecutorServiceImpl.java | 147 --------------- .../runner/AsyncScenarioExecutorService.java | 136 ++++++++++++++ .../DefaultScenarioExecutorService.java | 151 ++++++++++++++++ .../service/runner/SimulatorMode.java | 23 +++ ...itional-spring-configuration-metadata.json | 6 + .../impl/ScenarioLookupServiceImplTest.java | 35 +++- .../AsyncScenarioExecutorServiceIT.java | 28 +++ .../AsyncScenarioExecutorServiceTest.java} | 169 ++++++++---------- .../DefaultScenarioExecutorServiceIT.java | 75 ++++++++ .../DefaultScenarioExecutorServiceTest.java | 122 +++++++++++++ .../runner/ScenarioExecutorServiceTest.java | 95 ++++++++++ .../runner/SyncScenarioExecutorServiceIT.java | 25 +++ .../web/rest/ScenarioResourceIT.java | 100 ++++++++--- 27 files changed, 994 insertions(+), 310 deletions(-) delete mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImpl.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorService.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorService.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/SimulatorMode.java create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceIT.java rename simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/{impl/DefaultScenarioExecutorServiceImplTest.java => runner/AsyncScenarioExecutorServiceTest.java} (52%) create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceIT.java create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceTest.java create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/ScenarioExecutorServiceTest.java create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/SyncScenarioExecutorServiceIT.java diff --git a/simulator-samples/sample-combined/src/main/resources/application.properties b/simulator-samples/sample-combined/src/main/resources/application.properties index 03e7e37eb..fd37c2bac 100644 --- a/simulator-samples/sample-combined/src/main/resources/application.properties +++ b/simulator-samples/sample-combined/src/main/resources/application.properties @@ -18,3 +18,6 @@ citrus.simulator.default-scenario=Default # Should Citrus validate incoming messages on syntax and semantics citrus.simulator.template-validation=true + +# Use async mode for intermediate messaging +citrus.simulator.mode=async diff --git a/simulator-samples/sample-jms-fax/src/main/resources/application.properties b/simulator-samples/sample-jms-fax/src/main/resources/application.properties index 9a7397281..ae0c18064 100644 --- a/simulator-samples/sample-jms-fax/src/main/resources/application.properties +++ b/simulator-samples/sample-jms-fax/src/main/resources/application.properties @@ -19,3 +19,6 @@ citrus.simulator.template-validation=true # JMS destinations citrus.simulator.jms.inbound-destination=Fax.Inbound citrus.simulator.jms.status-destination=Fax.Status + +# Use async mode for intermediate messaging +citrus.simulator.mode=async diff --git a/simulator-samples/sample-jms/src/main/resources/application.properties b/simulator-samples/sample-jms/src/main/resources/application.properties index 3544bee6d..568f4ac5e 100644 --- a/simulator-samples/sample-jms/src/main/resources/application.properties +++ b/simulator-samples/sample-jms/src/main/resources/application.properties @@ -15,3 +15,6 @@ citrus.simulator.default-scenario=Default # Should Citrus validate incoming messages on syntax and semantics citrus.simulator.template-validation=true + +# Use async mode for intermediate messaging +citrus.simulator.mode=async diff --git a/simulator-samples/sample-rest/src/main/resources/application.properties b/simulator-samples/sample-rest/src/main/resources/application.properties index 5553bae6c..35c1cd8e9 100644 --- a/simulator-samples/sample-rest/src/main/resources/application.properties +++ b/simulator-samples/sample-rest/src/main/resources/application.properties @@ -15,3 +15,6 @@ citrus.simulator.default-scenario=Default # Should Citrus validate incoming messages on syntax and semantics citrus.simulator.template-validation=true + +# Use async mode for intermediate messaging +citrus.simulator.mode=async diff --git a/simulator-samples/sample-ws/src/main/resources/application.properties b/simulator-samples/sample-ws/src/main/resources/application.properties index e5d234613..bf4c667c5 100644 --- a/simulator-samples/sample-ws/src/main/resources/application.properties +++ b/simulator-samples/sample-ws/src/main/resources/application.properties @@ -16,3 +16,6 @@ citrus.simulator.default-scenario=Default # Should Citrus validate incoming messages on syntax and semantics citrus.simulator.template-validation=true + +# Use async mode for intermediate messaging +citrus.simulator.mode=async diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/listener/SimulatorStatusListener.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/listener/SimulatorStatusListener.java index 10ec6c28e..252cb5283 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/listener/SimulatorStatusListener.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/listener/SimulatorStatusListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2017 the original author or authors. + * Copyright 2006-2024 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. @@ -30,13 +30,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import static org.citrusframework.TestResult.failed; +import static org.citrusframework.TestResult.success; +import static org.citrusframework.util.StringUtils.hasText; +import static org.springframework.util.StringUtils.arrayToCommaDelimitedString; + /** * @author Christoph Deppisch */ @@ -50,7 +54,7 @@ public class SimulatorStatusListener extends AbstractTestListener implements Tes /** * Currently running test. - * + *

* TODO: Replace with metric. */ private Map runningTests = new ConcurrentHashMap<>(); @@ -68,25 +72,25 @@ public SimulatorStatusListener(ScenarioActionService scenarioActionService, Scen @Override public void onTestStart(TestCase test) { - if (test instanceof DefaultTestCase) { - runningTests.put(StringUtils.arrayToCommaDelimitedString(getParameters(test)), TestResult.success(test.getName(), test.getTestClass().getSimpleName(), ((DefaultTestCase)test).getParameters())); + if (test instanceof DefaultTestCase defaultTestCase) { + runningTests.put(arrayToCommaDelimitedString(getParameters(test)), success(test.getName(), test.getTestClass().getSimpleName(), defaultTestCase.getParameters())); } else { - runningTests.put(StringUtils.arrayToCommaDelimitedString(getParameters(test)), TestResult.success(test.getName(), test.getTestClass().getSimpleName())); + runningTests.put(arrayToCommaDelimitedString(getParameters(test)), success(test.getName(), test.getTestClass().getSimpleName())); } } @Override public void onTestFinish(TestCase test) { - runningTests.remove(StringUtils.arrayToCommaDelimitedString(getParameters(test))); + runningTests.remove(arrayToCommaDelimitedString(getParameters(test))); } @Override public void onTestSuccess(TestCase test) { TestResult result; - if (test instanceof DefaultTestCase) { - result = TestResult.success(test.getName(), test.getTestClass().getSimpleName(), ((DefaultTestCase)test).getParameters()); + if (test instanceof DefaultTestCase defaultTestCase) { + result = success(test.getName(), test.getTestClass().getSimpleName(), defaultTestCase.getParameters()); } else { - result = TestResult.success(test.getName(), test.getTestClass().getSimpleName()); + result = success(test.getName(), test.getTestClass().getSimpleName()); } testResultService.transformAndSave(result); @@ -98,10 +102,10 @@ public void onTestSuccess(TestCase test) { @Override public void onTestFailure(TestCase test, Throwable cause) { TestResult result; - if (test instanceof DefaultTestCase) { - result = TestResult.failed(test.getName(), test.getTestClass().getSimpleName(), cause, ((DefaultTestCase)test).getParameters()); + if (test instanceof DefaultTestCase defaultTestCase) { + result = failed(test.getName(), test.getTestClass().getSimpleName(), cause, defaultTestCase.getParameters()); } else { - result = TestResult.failed(test.getName(), test.getTestClass().getSimpleName(), cause); + result = failed(test.getName(), test.getTestClass().getSimpleName(), cause); } testResultService.transformAndSave(result); @@ -116,9 +120,9 @@ public void onTestActionStart(TestCase testCase, TestAction testAction) { if (!ignoreTestAction(testAction)) { if (logger.isDebugEnabled()) { logger.debug(testCase.getName() + "(" + - StringUtils.arrayToCommaDelimitedString(getParameters(testCase)) + ") - " + - testAction.getName() + ": " + - (testAction instanceof Described && StringUtils.hasText(((Described) testAction).getDescription()) ? ((Described) testAction).getDescription() : "")); + arrayToCommaDelimitedString(getParameters(testCase)) + ") - " + + testAction.getName() + + (testAction instanceof Described described && hasText(described.getDescription()) ? ": " + described.getDescription() : "")); } scenarioActionService.createForScenarioExecutionAndSave(testCase, testAction); @@ -140,8 +144,8 @@ public void onTestActionSkipped(TestCase testCase, TestAction testAction) { private String[] getParameters(TestCase test) { List parameterStrings = new ArrayList<>(); - if (test instanceof DefaultTestCase) { - for (Map.Entry param : ((DefaultTestCase) test).getParameters().entrySet()) { + if (test instanceof DefaultTestCase defaultTestCase) { + for (Map.Entry param : defaultTestCase.getParameters().entrySet()) { parameterStrings.add(param.getKey() + "=" + param.getValue()); } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageHeaderRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageHeaderRepository.java index 1ff58bc07..59de4e4c6 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageHeaderRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageHeaderRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.MessageHeader; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageRepository.java index 2c254ef5a..59d71307e 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/MessageRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.Message; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioActionRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioActionRepository.java index 76cf1b3f3..ef155fa9d 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioActionRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioActionRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.ScenarioAction; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioExecutionRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioExecutionRepository.java index fa749540e..f5a3dca8e 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioExecutionRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioExecutionRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.ScenarioExecution; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioParameterRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioParameterRepository.java index 2b358bad7..83eff76f0 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioParameterRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/ScenarioParameterRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.ScenarioParameter; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestParameterRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestParameterRepository.java index c6c80dd85..ba9ad43a5 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestParameterRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestParameterRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.TestParameter; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestResultRepository.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestResultRepository.java index cab4b56ab..ecef57a3e 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestResultRepository.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/TestResultRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.repository; import org.citrusframework.simulator.model.TestResult; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioExecutorService.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioExecutorService.java index a5efa5c78..cbc0c1f0a 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioExecutorService.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioExecutorService.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-2024 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. @@ -19,31 +19,28 @@ import jakarta.annotation.Nullable; import org.citrusframework.simulator.model.ScenarioParameter; import org.citrusframework.simulator.scenario.SimulatorScenario; -import org.springframework.beans.factory.DisposableBean; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.ContextClosedEvent; import java.util.List; /** - * Service capable of executing test executables. The service takes care on setting up the - * executable before execution. Service gets a list of normalized parameters which has to be - * translated to setters on the test executable instance before execution. + * Service capable of executing test executables. It takes care on setting up the executable before execution. The given + * list of normalized parameters has to be translated to setters on the test executable instance before execution. *

- * Careful, this service is not to be confused with the {@link ScenarioExecutionService}. That is - * the "CRUD Service" for {@link org.citrusframework.simulator.model.ScenarioExecution} and has - * nothing to do with actual {@link SimulatorScenario} execution. + * Careful, this service is not to be confused with the {@link ScenarioExecutionService}. That is the "CRUD Service" + * for {@link org.citrusframework.simulator.model.ScenarioExecution} and has nothing to do with the + * actual {@link SimulatorScenario} execution. */ -public interface ScenarioExecutorService extends DisposableBean, ApplicationListener { +public interface ScenarioExecutorService { /** - * Starts a new scenario instance using the collection of supplied parameters. + * Starts a new scenario instance using the collection of supplied parameters. The {@link SimulatorScenario} will + * be constructed based on the given {@code name}. * * @param name the name of the scenario to start * @param scenarioParameters the list of parameters to pass to the scenario when starting * @return the scenario execution id */ - public Long run(String name, @Nullable List scenarioParameters); + Long run(String name, @Nullable List scenarioParameters); /** * Starts a new scenario instance using the collection of supplied parameters. @@ -53,5 +50,5 @@ public interface ScenarioExecutorService extends DisposableBean, ApplicationList * @param scenarioParameters the list of parameters to pass to the scenario when starting * @return the scenario execution id */ - public Long run(SimulatorScenario scenario, String name, @Nullable List scenarioParameters); + Long run(SimulatorScenario scenario, String name, @Nullable List scenarioParameters); } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImpl.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImpl.java deleted file mode 100644 index db7ea27c9..000000000 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImpl.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 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 - * - * 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.citrusframework.simulator.service.impl; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import jakarta.annotation.Nullable; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import org.citrusframework.Citrus; -import org.citrusframework.annotations.CitrusAnnotations; -import org.citrusframework.context.TestContext; -import org.citrusframework.simulator.config.SimulatorConfigurationProperties; -import org.citrusframework.simulator.model.ScenarioExecution; -import org.citrusframework.simulator.model.ScenarioParameter; -import org.citrusframework.simulator.scenario.ScenarioRunner; -import org.citrusframework.simulator.scenario.SimulatorScenario; -import org.citrusframework.simulator.service.ScenarioExecutionService; -import org.citrusframework.simulator.service.ScenarioExecutorService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.ContextClosedEvent; -import org.springframework.stereotype.Service; - -/** - * {@inheritDoc} - */ -@Service -public class DefaultScenarioExecutorServiceImpl implements ScenarioExecutorService { - - private static final Logger logger = LoggerFactory.getLogger( DefaultScenarioExecutorServiceImpl.class); - - private final ApplicationContext applicationContext; - private final Citrus citrus; - private final ScenarioExecutionService scenarioExecutionService; - - private final ExecutorService executorService; - - public DefaultScenarioExecutorServiceImpl(ApplicationContext applicationContext, Citrus citrus, ScenarioExecutionService scenarioExecutionService, SimulatorConfigurationProperties properties) { - this.applicationContext = applicationContext; - this.citrus = citrus; - this.scenarioExecutionService = scenarioExecutionService; - - this.executorService = Executors.newFixedThreadPool( - properties.getExecutorThreads(), - new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("execution-svc-thread-%d") - .build() - ); - } - - @Override - public void destroy() throws Exception { - shutdownExecutor(); - } - - @Override - public void onApplicationEvent(ContextClosedEvent event) { - shutdownExecutor(); - } - - @Override - public final Long run(String name, @Nullable List scenarioParameters) { - return run(applicationContext.getBean(name, SimulatorScenario.class), name, scenarioParameters); - } - - @Override - public final Long run(SimulatorScenario scenario, String name, @Nullable List scenarioParameters) { - logger.info("Starting scenario : {}", name); - - ScenarioExecution scenarioExecution = scenarioExecutionService.createAndSaveExecutionScenario(name, scenarioParameters); - - prepareBeforeExecution(scenario); - - startScenarioAsync(scenarioExecution.getExecutionId(), name, scenario, scenarioParameters); - - return scenarioExecution.getExecutionId(); - } - - private Future startScenarioAsync(Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { - return executorService.submit(() -> startScenarioSync(executionId, name, scenario, scenarioParameters)); - } - - private void startScenarioSync(Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { - try { - TestContext context = citrus.getCitrusContext().createTestContext(); - createAndRunScenarioRunner(context, executionId, name, scenario, scenarioParameters); - logger.debug("Scenario completed: {}", name); - } catch (Exception e) { - logger.error("Scenario completed with error: {}!", name, e); - } - } - - private void createAndRunScenarioRunner(TestContext context, Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { - ScenarioRunner runner = new ScenarioRunner(scenario.getScenarioEndpoint(), applicationContext, context); - if (scenarioParameters != null) { - scenarioParameters.forEach(p -> runner.variable(p.getName(), p.getValue())); - } - - runner.variable(ScenarioExecution.EXECUTION_ID, executionId); - runner.name(String.format("Scenario(%s)", name)); - - CitrusAnnotations.injectAll(scenario, citrus, context); - - try { - runner.start(); - scenario.run(runner); - } finally { - runner.stop(); - } - } - - /** - * Prepare scenario instance before execution. Subclasses can add custom preparation steps in - * here. - * - * @param scenario - */ - protected void prepareBeforeExecution(SimulatorScenario scenario) { - } - - private void shutdownExecutor() { - logger.debug("Request to shutdown executor"); - - if (!executorService.isShutdown()) { - logger.trace("Shutting down executor"); - executorService.shutdownNow(); - } - } -} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorService.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorService.java new file mode 100644 index 000000000..402cdc60d --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorService.java @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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.citrusframework.simulator.service.runner; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.citrusframework.Citrus; +import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.model.ScenarioParameter; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.citrusframework.simulator.service.ScenarioExecutionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.ExecutorService; + +import static java.util.concurrent.Executors.newFixedThreadPool; + +/** + * Provides an asynchronous implementation of the {@link org.citrusframework.simulator.service.ScenarioExecutorService} + * for executing simulation scenarios. Unlike its superclass {@link DefaultScenarioExecutorService} that runs scenarios + * synchronously, this service executes each scenario in a separate thread, allowing for scenario executions with + * intermediate synchronous messaging. + *

+ * This service is conditionally enabled when the {@code citrus.simulator.mode} property is set to {@code async}, + * providing a more flexible and non-blocking way to handle scenario executions. It leverages a fixed thread pool, the + * size of which is determined by the {@code executorThreads} property from {@link SimulatorConfigurationProperties}, to + * manage and execute scenario tasks. + *

+ * This class also implements {@link ApplicationListener} for {@link ContextClosedEvent} and {@link DisposableBean} to + * ensure proper shutdown of the executor service during application shutdown, preventing potential memory leaks or + * hanging threads. + *

+ * Use this service when scenario execution needs to be scalable and non-blocking, particularly useful when scenarios + * are long-running and do not need to be executed in sequence. + * + * @see org.citrusframework.simulator.service.ScenarioExecutorService + * @see ApplicationListener + * @see DefaultScenarioExecutorService + * @see DisposableBean + */ +@Service +@ConditionalOnProperty(name = "citrus.simulator.mode", havingValue = "async") +public class AsyncScenarioExecutorService extends DefaultScenarioExecutorService implements ApplicationListener, DisposableBean { + + private static final Logger logger = LoggerFactory.getLogger(AsyncScenarioExecutorService.class); + + private final ExecutorService executorService; + + public AsyncScenarioExecutorService(ApplicationContext applicationContext, Citrus citrus, ScenarioExecutionService scenarioExecutionService, SimulatorConfigurationProperties properties) { + super(applicationContext, citrus, scenarioExecutionService); + + this.executorService = newFixedThreadPool( + properties.getExecutorThreads(), + new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("execution-svc-thread-%d") + .build() + ); + } + + /** + * Initiates the shutdown of the executor service to release resources and stop all running threads gracefully upon + * bean destruction. + */ + @Override + public void destroy() throws Exception { + shutdownExecutor(); + } + + /** + * Handles the {@link ContextClosedEvent} to initiate the shutdown of the executor service ensuring no tasks are left + * running when the application context is closed. + * + * @param event the context closed event + */ + @Override + public void onApplicationEvent(ContextClosedEvent event) { + shutdownExecutor(); + } + + /** + * Overrides the {@link DefaultScenarioExecutorService#startScenario(Long, String, SimulatorScenario, List)} method + * to execute the scenario asynchronously using the executor service. + * + * @param executionId the unique identifier for the scenario execution + * @param name the name of the scenario to start + * @param scenario the scenario instance to execute + * @param scenarioParameters the list of parameters to pass to the scenario when starting + */ + @Override + public void startScenario(Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { + startScenarioAsync(executionId, name, scenario, scenarioParameters); + } + + /** + * Submits the scenario execution task to the executor service for asynchronous execution. + * + * @param executionId the unique identifier for the scenario execution + * @param name the name of the scenario to start + * @param scenario the scenario instance to execute + * @param scenarioParameters the list of parameters to pass to the scenario when starting + */ + private void startScenarioAsync(Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { + executorService.submit(() -> super.startScenario(executionId, name, scenario, scenarioParameters)); + } + + private void shutdownExecutor() { + logger.debug("Request to shutdown executor"); + + if (!executorService.isShutdown()) { + logger.trace("Shutting down executor"); + executorService.shutdownNow(); + } + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorService.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorService.java new file mode 100644 index 000000000..2e161aec4 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorService.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 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.citrusframework.simulator.service.runner; + +import jakarta.annotation.Nullable; +import org.citrusframework.Citrus; +import org.citrusframework.context.TestContext; +import org.citrusframework.simulator.model.ScenarioExecution; +import org.citrusframework.simulator.model.ScenarioParameter; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.citrusframework.simulator.service.ScenarioExecutionService; +import org.citrusframework.simulator.service.ScenarioExecutorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static org.citrusframework.annotations.CitrusAnnotations.injectAll; +import static org.citrusframework.simulator.model.ScenarioExecution.EXECUTION_ID; + +/** + * Provides a default, synchronous implementation of the {@link ScenarioExecutorService} interface for executing + * simulation scenarios within the Citrus framework. This implementation ensures that all scenarios are executed + * in a single thread, one at a time, allowing for straightforward debugging and ensuring that data consistency + * is maintained throughout the execution of each scenario. + *

+ * The service is activated by default but can be overridden by setting the {code citrus.simulator.mode} property to a + * different execution mode (e.g., {@code async} for {@link AsyncScenarioExecutorService}). When in synchronous mode, + * this service ensures that all messages are processed and all data persistence operations are completed before + * moving on to the next scenario, providing a predictable and linear execution flow. + *

+ * Scenarios are started by looking up {@link SimulatorScenario} beans by their names in the Spring application context, + * passing them a collection of {@link ScenarioParameter}s, and then executing them within the Citrus test context. + * Custom preparation steps for scenarios can be implemented by overriding the {@code prepareBeforeExecution} + * method. + * + * @see ScenarioExecutorService + * @see AsyncScenarioExecutorService + * @see SimulatorScenario + */ +@Service +@ConditionalOnProperty(name = "citrus.simulator.mode", havingValue = "sync", matchIfMissing = true) +public class DefaultScenarioExecutorService implements ScenarioExecutorService { + + private static final Logger logger = LoggerFactory.getLogger(DefaultScenarioExecutorService.class); + + private final ApplicationContext applicationContext; + private final Citrus citrus; + private final ScenarioExecutionService scenarioExecutionService; + + public DefaultScenarioExecutorService(ApplicationContext applicationContext, Citrus citrus, ScenarioExecutionService scenarioExecutionService) { + this.applicationContext = applicationContext; + this.citrus = citrus; + this.scenarioExecutionService = scenarioExecutionService; + } + + /** + * Starts a new scenario instance by looking up a {@link SimulatorScenario} bean by name and executing it with + * the provided parameters. This method serves as an entry point for scenario execution, handling the entire + * lifecycle from scenario lookup to execution completion. + * + * @param name the name of the scenario to execute, used to look up the corresponding {@link SimulatorScenario} bean + * @param scenarioParameters a list of {@link ScenarioParameter}s to pass to the scenario, may be {@code null} + * @return the unique identifier of the scenario execution, used for tracking and management purposes + */ + @Override + public final Long run(String name, @Nullable List scenarioParameters) { + return run(applicationContext.getBean(name, SimulatorScenario.class), name, scenarioParameters); + } + + /** + * Executes the given {@link SimulatorScenario} with the provided name and parameters. This method orchestrates + * the scenario execution process, including pre-execution preparation, scenario execution, and post-execution + * cleanup, ensuring a consistent execution environment for each scenario. + * + * @param scenario the {@link SimulatorScenario} to execute + * @param name the name of the scenario, used for logging and tracking purposes + * @param scenarioParameters a list of {@link ScenarioParameter}s to pass to the scenario, may be {@code null} + * @return the unique identifier of the scenario execution + */ + @Override + public final Long run(SimulatorScenario scenario, String name, @Nullable List scenarioParameters) { + ScenarioExecution scenarioExecution = scenarioExecutionService.createAndSaveExecutionScenario(name, scenarioParameters); + + prepareBeforeExecution(scenario); + + startScenario(scenarioExecution.getExecutionId(), name, scenario, scenarioParameters); + + return scenarioExecution.getExecutionId(); + } + + protected void startScenario(Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { + logger.info("Starting scenario : {}", name); + try { + var context = createTestContext(); + createAndRunScenarioRunner(context, executionId, name, scenario, scenarioParameters); + logger.debug("Scenario completed: {}", name); + } catch (Exception e) { + logger.error("Scenario completed with error: {}!", name, e); + } + } + + /** + * Prepare {@link SimulatorScenario} before execution. Subclasses can add custom preparation steps in here. + * + * @param scenario the scenario soon to be executed. + */ + protected void prepareBeforeExecution(SimulatorScenario scenario) { + } + + private TestContext createTestContext() { + return citrus.getCitrusContext().createTestContext(); + } + + private void createAndRunScenarioRunner(TestContext context, Long executionId, String name, SimulatorScenario scenario, List scenarioParameters) { + var runner = new ScenarioRunner(scenario.getScenarioEndpoint(), applicationContext, context); + if (scenarioParameters != null) { + scenarioParameters.forEach(p -> runner.variable(p.getName(), p.getValue())); + } + + runner.variable(EXECUTION_ID, executionId); + runner.name(String.format("Scenario(%s)", name)); + + injectAll(scenario, citrus, context); + + try { + runner.start(); + scenario.run(runner); + } finally { + runner.stop(); + } + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/SimulatorMode.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/SimulatorMode.java new file mode 100644 index 000000000..0ea4aa583 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/runner/SimulatorMode.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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.citrusframework.simulator.service.runner; + +public enum SimulatorMode { + + ASYNC, + SYNC +} diff --git a/simulator-spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/simulator-spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 1fa3a7f3e..c3c64aa02 100644 --- a/simulator-spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/simulator-spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -12,6 +12,12 @@ "description": "Full qualified class name of additional Spring Java configuration to automatically load beans from.", "defaultValue": "org.citrusframework.simulator.SimulatorConfig" }, + { + "name": "citrus.simulator.mode", + "type": "org.citrusframework.simulator.service.runner.SimulatorMode", + "description": "Execution mode of simulator.", + "defaultValue": "sync" + }, { "name": "citrus.simulator.inbound.xml.dictionary.enabled", "type": "java.lang.Boolean", diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImplTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImplTest.java index cc94e727e..cb10beb34 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImplTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImplTest.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 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.citrusframework.simulator.service.impl; import org.apache.commons.lang3.NotImplementedException; @@ -32,7 +48,10 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -class ScenarioLookupServiceImplTest { +public class ScenarioLookupServiceImplTest { + + public static final String SCENARIO_NAME = "ScenarioLookupServiceImplTest#testSimulatorScenario"; + public static final String STARTER_NAME = "ScenarioLookupServiceImplTest#testScenarioStarter"; private static final ScenarioParameter SCENARIO_PARAMETER = ScenarioParameter.builder() .name("parameter-name") @@ -61,11 +80,11 @@ static Stream evictAndReloadScenarioCacheIsIdempotent() { @MethodSource @ParameterizedTest void evictAndReloadScenarioCacheIsIdempotent(Consumer invocation) { - final String testSimulatorScenario = "testSimulatorScenario"; + final String testSimulatorScenario = SCENARIO_NAME; Map contextSimulatorScenarios = Map.of(testSimulatorScenario, new TestSimulatorScenario(), "invalidTestSimulatorScenario", new InvalidTestSimulatorScenario()); doReturn(contextSimulatorScenarios).when(applicationContextMock).getBeansOfType(SimulatorScenario.class); - final String testScenarioStarter = "testScenarioStarter"; + final String testScenarioStarter = STARTER_NAME; Map contextScenarioStarters = Map.of(testScenarioStarter, new TetsScenarioStarter()); doReturn(contextScenarioStarters).when(applicationContextMock).getBeansOfType(ScenarioStarter.class); @@ -96,7 +115,7 @@ private void verifyScenariosHaveBeenReloadedFromApplicationContext(String testSi @Test void getScenarioNames() { - final String testSimulatorScenario = "testSimulatorScenario"; + final String testSimulatorScenario = SCENARIO_NAME; ReflectionTestUtils.setField(fixture, "scenarios", Map.of(testSimulatorScenario, new TestSimulatorScenario()), Map.class); assertThat(fixture.getScenarioNames()) @@ -106,7 +125,7 @@ void getScenarioNames() { @Test void getStarterNames() { - final String testScenarioStarter = "testScenarioStarter"; + final String testScenarioStarter = STARTER_NAME; ReflectionTestUtils.setField(fixture, "scenarioStarters", Map.of(testScenarioStarter, new TetsScenarioStarter()), Map.class); assertThat(fixture.getStarterNames()) @@ -139,7 +158,7 @@ void lookupScenarioParametersReturnsEmptyListForInvalidScenarioNames() { .isEmpty(); } - @Scenario("testSimulatorScenario") + @Scenario(SCENARIO_NAME) private static class TestSimulatorScenario implements SimulatorScenario { @Override @@ -148,7 +167,7 @@ public ScenarioEndpoint getScenarioEndpoint() { } } - @Starter("testScenarioStarter") + @Starter(STARTER_NAME) private static class TetsScenarioStarter implements ScenarioStarter { @Override @@ -162,7 +181,7 @@ public ScenarioEndpoint getScenarioEndpoint() { } } - @Starter("invalidTestScenarioStarter") + @Starter("ScenarioLookupServiceImplTest#invalidTestScenarioStarter") private static class InvalidTestSimulatorScenario implements SimulatorScenario { @Override diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceIT.java new file mode 100644 index 000000000..8f2db5b15 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceIT.java @@ -0,0 +1,28 @@ +package org.citrusframework.simulator.service.runner; + +import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.service.ScenarioExecutorService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@Isolated +@DirtiesContext +@IntegrationTest +@TestPropertySource(properties={"citrus.simulator.mode=async"}) +class AsyncScenarioExecutorServiceIT { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void isDefaultScenarioExecutorService() { + assertThat(applicationContext.getBean(ScenarioExecutorService.class)) + .isInstanceOf(AsyncScenarioExecutorService.class); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImplTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceTest.java similarity index 52% rename from simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImplTest.java rename to simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceTest.java index 897023571..5153016a4 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/impl/DefaultScenarioExecutorServiceImplTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/AsyncScenarioExecutorServiceTest.java @@ -1,18 +1,12 @@ -package org.citrusframework.simulator.service.impl; +package org.citrusframework.simulator.service.runner; -import org.citrusframework.Citrus; -import org.citrusframework.CitrusContext; import org.citrusframework.TestCase; -import org.citrusframework.context.TestContext; import org.citrusframework.report.TestListeners; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; import org.citrusframework.simulator.model.ScenarioExecution; -import org.citrusframework.simulator.model.ScenarioParameter; -import org.citrusframework.simulator.scenario.ScenarioEndpoint; import org.citrusframework.simulator.scenario.ScenarioRunner; import org.citrusframework.simulator.scenario.SimulatorScenario; -import org.citrusframework.simulator.service.ScenarioExecutionService; -import org.junit.jupiter.api.Assertions; +import org.citrusframework.simulator.service.ScenarioExecutorService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,82 +15,80 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import org.springframework.context.event.ContextClosedEvent; -import org.springframework.test.util.ReflectionTestUtils; -import java.util.List; import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.ThreadPoolExecutor; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentCaptor.captor; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.springframework.test.util.ReflectionTestUtils.setField; @ExtendWith(MockitoExtension.class) -class DefaultScenarioExecutorServiceImplTest { +class AsyncScenarioExecutorServiceTest extends ScenarioExecutorServiceTest { - private static ScenarioEndpoint scenarioEndpointMock; - - private static AtomicBoolean customScenarioExecuted; + private static final int THREAD_POOL_SIZE = 1234; @Mock private ApplicationContext applicationContextMock; @Mock - private Citrus citrusMock; - - @Mock - private ScenarioExecutionService scenarioExecutionServiceMock; + private ExecutorService executorServiceMock; @Mock private SimulatorConfigurationProperties propertiesMock; - @Mock - private ExecutorService executorServiceMock; - - private DefaultScenarioExecutorServiceImpl fixture; - - private final String scenarioName = "testScenario"; - private final List parameters = List.of( - ScenarioParameter.builder() - .name("param1") - .value("value1") - .build(), - ScenarioParameter.builder() - .name("param2") - .value("value2") - .build() - ); + private AsyncScenarioExecutorService fixture; @BeforeEach public void beforeEachSetup() { - doReturn(1).when(propertiesMock).getExecutorThreads(); + super.beforeEachSetup(); + + doReturn(THREAD_POOL_SIZE).when(propertiesMock).getExecutorThreads(); + + fixture = new AsyncScenarioExecutorService(applicationContextMock, citrusMock, scenarioExecutionServiceMock, propertiesMock); + setField(fixture, "executorService", executorServiceMock, ExecutorService.class); + } - fixture = new DefaultScenarioExecutorServiceImpl(applicationContextMock, citrusMock, scenarioExecutionServiceMock, propertiesMock); - ReflectionTestUtils.setField(fixture, "executorService", executorServiceMock, ExecutorService.class); + @Test + void isScenarioExecutorService() { + assertThat(fixture) + .isInstanceOf(ScenarioExecutorService.class) + .isInstanceOf(DefaultScenarioExecutorService.class); + } - scenarioEndpointMock = mock(ScenarioEndpoint.class); - customScenarioExecuted = new AtomicBoolean(false); + @Test + void constructorCreatesThreadPool() { + assertThat(new AsyncScenarioExecutorService(applicationContextMock, citrusMock, scenarioExecutionServiceMock, propertiesMock)) + .hasNoNullFieldsOrProperties() + .extracting("executorService") + .isInstanceOf(ThreadPoolExecutor.class) + .hasFieldOrPropertyWithValue("corePoolSize", THREAD_POOL_SIZE) + .hasFieldOrPropertyWithValue("maximumPoolSize", THREAD_POOL_SIZE); } @Test void runSimulatorScenarioByName() { - SimulatorScenario simulatorScenarioMock = mock(SimulatorScenario.class); + var simulatorScenarioMock = mock(SimulatorScenario.class); doReturn(simulatorScenarioMock).when(applicationContextMock).getBean(scenarioName, SimulatorScenario.class); Long executionId = mockScenarioExecutionCreation(); - // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. Long result = fixture.run(scenarioName, parameters); assertEquals(executionId, result); - ArgumentCaptor scenarioRunnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor scenarioRunnableArgumentCaptor = captor(); verify(executorServiceMock).submit(scenarioRunnableArgumentCaptor.capture()); // Now, we need more mocks! @@ -114,36 +106,30 @@ void runSimulatorScenarioByName() { void runScenarioDirectly() { Long executionId = mockScenarioExecutionCreation(); - SimulatorScenario simulatorScenario = spy(new CustomSimulatorScenario()); + var simulatorScenario = spy(new CustomSimulatorScenario()); - // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. Long result = fixture.run(simulatorScenario, scenarioName, parameters); - assertEquals(executionId, result); + verifyScenarioExecution(executionId, result, simulatorScenario); - ArgumentCaptor scenarioRunnableArgumentCaptor = ArgumentCaptor.forClass(Runnable.class); - verify(executorServiceMock).submit(scenarioRunnableArgumentCaptor.capture()); + assertTrue(isCustomScenarioExecuted()); + } - // Now, we need more mocks! - TestContext testContextMock = mockCitrusTestContext(); - TestListeners testListenersMock = mock(TestListeners.class); - doReturn(testListenersMock).when(testContextMock).getTestListeners(); + @Test + void exceptionDuringExecutionWillBeCatched() { + Long executionId = mockScenarioExecutionCreation(); - // This invokes the scenario execution with the captured runnable - scenarioRunnableArgumentCaptor.getValue().run(); + var simulatorScenario = spy(new CustomSimulatorScenario()); - ArgumentCaptor scenarioRunnerArgumentCaptor = ArgumentCaptor.forClass(ScenarioRunner.class); - verify(simulatorScenario, times(1)).run(scenarioRunnerArgumentCaptor.capture()); + // Invoke exception + doThrow(new IllegalArgumentException()).when(simulatorScenario).run(any(ScenarioRunner.class)); - ScenarioRunner scenarioRunner = scenarioRunnerArgumentCaptor.getValue(); - assertEquals(scenarioEndpointMock, scenarioRunner.scenarioEndpoint()); - assertEquals(executionId, scenarioRunner.getTestCaseRunner().getTestCase().getVariableDefinitions().get(ScenarioExecution.EXECUTION_ID)); - - verify(testListenersMock).onTestStart(any(TestCase.class)); - verify(testListenersMock).onTestSuccess(any(TestCase.class)); - verify(testListenersMock).onTestFinish(any(TestCase.class)); - verifyNoMoreInteractions(testListenersMock); + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. + // This method-invocation may not throw, despite the above exception! + Long result = fixture.run(simulatorScenario, scenarioName, parameters); + verifyScenarioExecution(executionId, result, simulatorScenario); - assertTrue(customScenarioExecuted.get()); + assertFalse(isCustomScenarioExecuted()); } @Test @@ -158,43 +144,30 @@ void shutdownExecutorOnApplicationContextEvent() { verify(executorServiceMock).shutdownNow(); } - private Long mockScenarioExecutionCreation() { - Long executionId = 1L; - ScenarioExecution scenarioExecutionMock = mock(ScenarioExecution.class); - doReturn(executionId).when(scenarioExecutionMock).getExecutionId(); - doReturn(scenarioExecutionMock).when(scenarioExecutionServiceMock).createAndSaveExecutionScenario(scenarioName, parameters); - return executionId; - } - - private TestContext mockCitrusTestContext() { - CitrusContext citrusContextMock = mock(CitrusContext.class); - doReturn(citrusContextMock).when(citrusMock).getCitrusContext(); - TestContext testContextMock = mock(TestContext.class); - doReturn(testContextMock).when(citrusContextMock).createTestContext(); + private void verifyScenarioExecution(Long executionId, Long result, AsyncScenarioExecutorServiceTest.CustomSimulatorScenario simulatorScenario) { + assertEquals(executionId, result); - TestListeners testListenersMock = mock(TestListeners.class); - lenient().doReturn(testListenersMock).when(testContextMock).getTestListeners(); - return testContextMock; - } + ArgumentCaptor scenarioRunnableArgumentCaptor = captor(); + verify(executorServiceMock).submit(scenarioRunnableArgumentCaptor.capture()); - public static class BaseCustomSimulatorScenario implements SimulatorScenario { + // Now, we need more mocks! + var testContextMock = mockCitrusTestContext(); + var testListenersMock = mock(TestListeners.class); + doReturn(testListenersMock).when(testContextMock).getTestListeners(); - @Override - public ScenarioEndpoint getScenarioEndpoint() { - return scenarioEndpointMock; - } + // This invokes the scenario execution with the captured runnable + scenarioRunnableArgumentCaptor.getValue().run(); - @Override - public void run(ScenarioRunner runner) { - Assertions.fail("This method should never be called"); - } - } + ArgumentCaptor scenarioRunnerArgumentCaptor = ArgumentCaptor.forClass(ScenarioRunner.class); + verify(simulatorScenario, times(1)).run(scenarioRunnerArgumentCaptor.capture()); - public static class CustomSimulatorScenario extends BaseCustomSimulatorScenario { + var scenarioRunner = scenarioRunnerArgumentCaptor.getValue(); + assertEquals(scenarioEndpointMock, scenarioRunner.scenarioEndpoint()); + assertEquals(executionId, scenarioRunner.getTestCaseRunner().getTestCase().getVariableDefinitions().get(ScenarioExecution.EXECUTION_ID)); - @Override - public void run(ScenarioRunner runner) { - customScenarioExecuted.set(true); - } + verify(testListenersMock).onTestStart(any(TestCase.class)); + verify(testListenersMock).onTestSuccess(any(TestCase.class)); + verify(testListenersMock).onTestFinish(any(TestCase.class)); + verifyNoMoreInteractions(testListenersMock); } } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceIT.java new file mode 100644 index 000000000..370e4ed52 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceIT.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 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.citrusframework.simulator.service.runner; + +import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.model.ScenarioExecution; +import org.citrusframework.simulator.repository.ScenarioExecutionRepository; +import org.citrusframework.simulator.scenario.AbstractSimulatorScenario; +import org.citrusframework.simulator.scenario.Scenario; +import org.citrusframework.simulator.service.ScenarioExecutorService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.citrusframework.simulator.model.ScenarioExecution.Status.SUCCESS; + +@IntegrationTest +class DefaultScenarioExecutorServiceIT { + + private static final String SCENARIO_NAME = "DefaultScenarioExecutorServiceIT"; + + @Autowired + private ScenarioExecutorService scenarioExecutorService; + + @Autowired + private ScenarioExecutionRepository scenarioExecutionRepository; + + @Test + void isDefaultScenarioExecutorService() { + assertThat(scenarioExecutorService) + .isInstanceOf(DefaultScenarioExecutorService.class) + .isNotInstanceOf(AsyncScenarioExecutorService.class); + } + + @Test + void throwsExceptionGivenInexistendScenarioName() { + var beanName = "sherlock"; + + assertThatThrownBy(() -> scenarioExecutorService.run(beanName, emptyList())) + .isInstanceOf(NoSuchBeanDefinitionException.class) + .hasMessage("No bean named '%s' available".formatted(beanName)); + } + + @Test + void resultsBeingPersistedSynchronously() { + var executionId = scenarioExecutorService.run(SCENARIO_NAME, emptyList()); + + assertThat(scenarioExecutionRepository.findOneByExecutionId(executionId)) + .hasValueSatisfying(scenarioExecution -> assertThat(scenarioExecution) + .hasNoNullFieldsOrPropertiesExcept("errorMessage") + .extracting(ScenarioExecution::getStatus) + .isEqualTo(SUCCESS)); + } + + @Scenario(SCENARIO_NAME) + private static class TestSimulatorScenario extends AbstractSimulatorScenario { + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceTest.java new file mode 100644 index 000000000..5875fec41 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/DefaultScenarioExecutorServiceTest.java @@ -0,0 +1,122 @@ +package org.citrusframework.simulator.service.runner; + +import org.citrusframework.TestCase; +import org.citrusframework.report.TestListeners; +import org.citrusframework.simulator.model.ScenarioExecution; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.citrusframework.simulator.service.ScenarioExecutorService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +class DefaultScenarioExecutorServiceTest extends ScenarioExecutorServiceTest { + + @Mock + private ApplicationContext applicationContextMock; + + private DefaultScenarioExecutorService fixture; + + @BeforeEach + public void beforeEachSetup() { + super.beforeEachSetup(); + + fixture = new DefaultScenarioExecutorService(applicationContextMock, citrusMock, scenarioExecutionServiceMock); + } + + @Test + void isScenarioExecutorService() { + assertThat(fixture) + .isInstanceOf(ScenarioExecutorService.class); + } + + @Test + void runSimulatorScenarioByName() { + var simulatorScenarioMock = mock(SimulatorScenario.class); + doReturn(simulatorScenarioMock).when(applicationContextMock).getBean(scenarioName, SimulatorScenario.class); + + Long executionId = mockScenarioExecutionCreation(); + + mockCitrusTestContext(); + + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. + Long result = fixture.run(scenarioName, parameters); + assertEquals(executionId, result); + + verify(simulatorScenarioMock).getScenarioEndpoint(); + verify(simulatorScenarioMock).run(any(ScenarioRunner.class)); + verifyNoMoreInteractions(simulatorScenarioMock); + } + + @Test + void runScenarioDirectly() { + Long executionId = mockScenarioExecutionCreation(); + + var simulatorScenario = spy(new CustomSimulatorScenario()); + + var testContextMock = mockCitrusTestContext(); + var testListenersMock = mock(TestListeners.class); + doReturn(testListenersMock).when(testContextMock).getTestListeners(); + + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. + Long result = fixture.run(simulatorScenario, scenarioName, parameters); + verifyScenarioExecution(executionId, result, simulatorScenario, testListenersMock); + + assertTrue(isCustomScenarioExecuted()); + } + + @Test + void exceptionDuringExecutionWillBeCatched() { + Long executionId = mockScenarioExecutionCreation(); + + var simulatorScenario = spy(new CustomSimulatorScenario()); + + var testContextMock = mockCitrusTestContext(); + var testListenersMock = mock(TestListeners.class); + doReturn(testListenersMock).when(testContextMock).getTestListeners(); + + // Invoke exception + doThrow(new IllegalArgumentException()).when(simulatorScenario).run(any(ScenarioRunner.class)); + + // Note that this does not actually "run" the scenario (because of the mocked executor service), it just creates it. + // This method-invocation may not throw, despite the above exception! + Long result = fixture.run(simulatorScenario, scenarioName, parameters); + verifyScenarioExecution(executionId, result, simulatorScenario, testListenersMock); + + assertFalse(isCustomScenarioExecuted()); + } + + private void verifyScenarioExecution(Long executionId, Long result, CustomSimulatorScenario simulatorScenario, TestListeners testListenersMock) { + assertEquals(executionId, result); + + ArgumentCaptor scenarioRunnerArgumentCaptor = ArgumentCaptor.forClass(ScenarioRunner.class); + verify(simulatorScenario, times(1)).run(scenarioRunnerArgumentCaptor.capture()); + + var scenarioRunner = scenarioRunnerArgumentCaptor.getValue(); + assertEquals(scenarioEndpointMock, scenarioRunner.scenarioEndpoint()); + assertEquals(executionId, scenarioRunner.getTestCaseRunner().getTestCase().getVariableDefinitions().get(ScenarioExecution.EXECUTION_ID)); + + verify(testListenersMock).onTestStart(any(TestCase.class)); + verify(testListenersMock).onTestSuccess(any(TestCase.class)); + verify(testListenersMock).onTestFinish(any(TestCase.class)); + verifyNoMoreInteractions(testListenersMock); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/ScenarioExecutorServiceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/ScenarioExecutorServiceTest.java new file mode 100644 index 000000000..cb40c3be0 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/ScenarioExecutorServiceTest.java @@ -0,0 +1,95 @@ +package org.citrusframework.simulator.service.runner; + +import org.citrusframework.Citrus; +import org.citrusframework.CitrusContext; +import org.citrusframework.context.TestContext; +import org.citrusframework.report.TestListeners; +import org.citrusframework.simulator.model.ScenarioExecution; +import org.citrusframework.simulator.model.ScenarioParameter; +import org.citrusframework.simulator.scenario.ScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.citrusframework.simulator.service.ScenarioExecutionService; +import org.junit.jupiter.api.Assertions; +import org.mockito.Mock; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +abstract class ScenarioExecutorServiceTest { + + protected static ScenarioEndpoint scenarioEndpointMock; + + private static AtomicBoolean customScenarioExecuted; + + @Mock + protected Citrus citrusMock; + + @Mock + protected ScenarioExecutionService scenarioExecutionServiceMock; + + protected final String scenarioName = "testScenario"; + protected final List parameters = List.of( + ScenarioParameter.builder() + .name("param1") + .value("value1") + .build(), + ScenarioParameter.builder() + .name("param2") + .value("value2") + .build() + ); + + protected void beforeEachSetup() { + scenarioEndpointMock = mock(ScenarioEndpoint.class); + customScenarioExecuted = new AtomicBoolean(false); + } + + protected boolean isCustomScenarioExecuted() { + return customScenarioExecuted.get(); + } + + protected Long mockScenarioExecutionCreation() { + Long executionId = 1L; + var scenarioExecutionMock = mock(ScenarioExecution.class); + doReturn(executionId).when(scenarioExecutionMock).getExecutionId(); + doReturn(scenarioExecutionMock).when(scenarioExecutionServiceMock).createAndSaveExecutionScenario(scenarioName, parameters); + return executionId; + } + + protected TestContext mockCitrusTestContext() { + var citrusContextMock = mock(CitrusContext.class); + doReturn(citrusContextMock).when(citrusMock).getCitrusContext(); + var testContextMock = mock(TestContext.class); + doReturn(testContextMock).when(citrusContextMock).createTestContext(); + + var testListenersMock = mock(TestListeners.class); + lenient().doReturn(testListenersMock).when(testContextMock).getTestListeners(); + return testContextMock; + } + + protected static class BaseCustomSimulatorScenario implements SimulatorScenario { + + @Override + public ScenarioEndpoint getScenarioEndpoint() { + return scenarioEndpointMock; + } + + @Override + public void run(ScenarioRunner runner) { + Assertions.fail("This method should never be called"); + } + } + + protected static class CustomSimulatorScenario extends BaseCustomSimulatorScenario { + + @Override + public void run(ScenarioRunner runner) { + customScenarioExecuted.set(true); + } + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/SyncScenarioExecutorServiceIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/SyncScenarioExecutorServiceIT.java new file mode 100644 index 000000000..1f9c93e7f --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/runner/SyncScenarioExecutorServiceIT.java @@ -0,0 +1,25 @@ +package org.citrusframework.simulator.service.runner; + +import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.service.ScenarioExecutorService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@IntegrationTest +@TestPropertySource(properties={"citrus.simulator.mode=sync"}) +class SyncScenarioExecutorServiceIT { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void isDefaultScenarioExecutorService() { + assertThat(applicationContext.getBean(ScenarioExecutorService.class)) + .isInstanceOf(DefaultScenarioExecutorService.class) + .isNotInstanceOf(AsyncScenarioExecutorService.class); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java index b4b03adc2..a73ca2ed4 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java @@ -17,13 +17,25 @@ package org.citrusframework.simulator.web.rest; import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import static java.net.URLEncoder.encode; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest.SCENARIO_NAME; +import static org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest.STARTER_NAME; +import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.MESSAGE_TRIGGERED; +import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.STARTER; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -52,11 +64,17 @@ void getAllScenarioNames() throws Exception { .perform(get(ENTITY_API_URL)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("$.length()").value(equalTo(2))) - .andExpect(jsonPath("$.[0].name").value(equalTo("testScenarioStarter"))) - .andExpect(jsonPath("$.[0].type").value(equalTo("STARTER"))) - .andExpect(jsonPath("$.[1].name").value(equalTo("testSimulatorScenario"))) - .andExpect(jsonPath("$.[1].type").value(equalTo("MESSAGE_TRIGGERED"))); + .andExpect(jsonPath("$.length()").value(greaterThan(2))) + .andExpect(jsonPath("$.[*]", hasItems( + allOf( + hasEntry("name", STARTER_NAME), + hasEntry("type", STARTER.toString()) + ), + allOf( + hasEntry("name", SCENARIO_NAME), + hasEntry("type", MESSAGE_TRIGGERED.toString()) + ) + ))); } @Test @@ -66,11 +84,17 @@ void getAllScenarioNamesDesc() throws Exception { .perform(get(ENTITY_API_URL + "?sort=name,desc")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("$.length()").value(equalTo(2))) - .andExpect(jsonPath("$.[0].name").value(equalTo("testSimulatorScenario"))) - .andExpect(jsonPath("$.[0].type").value(equalTo("MESSAGE_TRIGGERED"))) - .andExpect(jsonPath("$.[1].name").value(equalTo("testScenarioStarter"))) - .andExpect(jsonPath("$.[1].type").value(equalTo("STARTER"))); + .andExpect(jsonPath("$.length()").value(greaterThan(2))) + .andExpect(jsonPath("$.[*]", hasItems( + allOf( + hasEntry("name", SCENARIO_NAME), + hasEntry("type", MESSAGE_TRIGGERED.toString()) + ), + allOf( + hasEntry("name", STARTER_NAME), + hasEntry("type", STARTER.toString()) + ) + ))); } @Test @@ -80,11 +104,17 @@ void getAllScenarioNamesByTypeDesc() throws Exception { .perform(get(ENTITY_API_URL + "?sort=type,desc")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("$.length()").value(equalTo(2))) - .andExpect(jsonPath("$.[0].name").value(equalTo("testSimulatorScenario"))) - .andExpect(jsonPath("$.[0].type").value(equalTo("MESSAGE_TRIGGERED"))) - .andExpect(jsonPath("$.[1].name").value(equalTo("testScenarioStarter"))) - .andExpect(jsonPath("$.[1].type").value(equalTo("STARTER"))); + .andExpect(jsonPath("$.length()").value(greaterThan(2))) + .andExpect(jsonPath("$.[*]", hasItems( + allOf( + hasEntry("name", SCENARIO_NAME), + hasEntry("type", MESSAGE_TRIGGERED.toString()) + ), + allOf( + hasEntry("name", STARTER_NAME), + hasEntry("type", STARTER.toString()) + ) + ))); } @Test @@ -94,28 +124,48 @@ void getTestSimulatorScenario() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("$.length()").value(equalTo(1))) - .andExpect(jsonPath("$.[0].name").value(equalTo("testSimulatorScenario"))) - .andExpect(jsonPath("$.[0].type").value(equalTo("MESSAGE_TRIGGERED"))); + .andExpect(jsonPath("$.[*]", hasItem( + allOf( + hasEntry("name", SCENARIO_NAME), + hasEntry("type", MESSAGE_TRIGGERED.toString()) + ) + ))); } @Test - void getScenariosWithNameContains() throws Exception { + void getSingleScenarioWithNameContains() throws Exception { restScenarioParameterMockMvc - .perform(get(ENTITY_API_URL + "?nameContains=Scenario")) + .perform(get(ENTITY_API_URL + "?nameContains=" + encode(SCENARIO_NAME, UTF_8))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.length()").value(equalTo(1))) + .andExpect(jsonPath("$.[0].name", equalTo(SCENARIO_NAME))) + .andExpect(jsonPath("$.[0].type", equalTo(MESSAGE_TRIGGERED.toString()))); + } + + @Test + void getMultipleScenariosWithNameContains() throws Exception { + restScenarioParameterMockMvc + .perform(get(ENTITY_API_URL + "?nameContains=" + ScenarioLookupServiceImplTest.class.getSimpleName())) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("$.length()").value(equalTo(2))) - .andExpect(jsonPath("$.[0].name").value(equalTo("testScenarioStarter"))) - .andExpect(jsonPath("$.[0].type").value(equalTo("STARTER"))) - .andExpect(jsonPath("$.[1].name").value(equalTo("testSimulatorScenario"))) - .andExpect(jsonPath("$.[1].type").value(equalTo("MESSAGE_TRIGGERED"))); + .andExpect(jsonPath("$.[*]", hasItems( + allOf( + hasEntry("name", STARTER_NAME), + hasEntry("type", STARTER.toString()) + ), + allOf( + hasEntry("name", SCENARIO_NAME), + hasEntry("type", MESSAGE_TRIGGERED.toString()) + ) + ))); } @Test void getAllScenarioStarterParameters() throws Exception { - // Get all the scenarioParameterList restScenarioParameterMockMvc - .perform(get(ENTITY_API_URL_SCENARIO_NAME + "/parameters", "testScenarioStarter")) + .perform(get(ENTITY_API_URL_SCENARIO_NAME + "/parameters", STARTER_NAME)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("$.length()").value(equalTo(1))) From bb64b8c026f02fbe224e05478857b9734bc93d8c Mon Sep 17 00:00:00 2001 From: Timon Borter Date: Thu, 15 Feb 2024 22:12:34 +0100 Subject: [PATCH 3/5] docs(simulator): different run modes Signed-off-by: Timon Borter --- .../src/main/asciidoc/concepts-advanced.adoc | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/simulator-docs/src/main/asciidoc/concepts-advanced.adoc b/simulator-docs/src/main/asciidoc/concepts-advanced.adoc index 5b4884c5a..8b1e4e17d 100644 --- a/simulator-docs/src/main/asciidoc/concepts-advanced.adoc +++ b/simulator-docs/src/main/asciidoc/concepts-advanced.adoc @@ -1,6 +1,90 @@ [[concepts-advanced]] = Advanced Concepts +[[concept-advanced-execution-modes]] +== Execution Modes in Citrus Simulator + +The Citrus Simulator offers different modes of operation to accommodate various testing scenarios and requirements. +These modes dictate how the simulator executes the test scenarios. +It comes with two modes, a synchronous and an asynchronous one, providing flexibility in how interactions are simulated and tested. + +[[concept-advanced-execution-sync-mode]] +=== Synchronous Execution Mode + +The synchronous execution mode ensures that scenarios are executed one after the other, in a single thread. +This mode is beneficial for scenarios where operations need to be performed in a strict sequence, and data consistency is crucial. + +==== Configuration + +To configure the simulator in synchronous mode, set the `citrus.simulator.mode` property in your application's configuration file (`application.properties` or `application.yml`) to `sync`. +If this property is not set at all, the simulator defaults to synchronous mode. + +.Example `application.properties` +---- +citrus.simulator.mode=sync +---- + +.Example `application.yml` +---- +citrus: + simulator: + mode: sync +---- + +[[concept-advanced-execution-async-mode]] +=== Asynchronous Execution Mode + +In asynchronous execution mode, scenarios are executed concurrently in separate threads, allowing for parallel processing. +This mode is suitable for more complex simulations where scenarios do not depend on the execution order or when simulating high concurrency. + +==== Configuration + +To enable asynchronous mode, set the `citrus.simulator.mode` property to `async`. +Additionally, you can configure the number of executor threads that handle the parallel execution of scenarios through the `citrus.simulator.executor.threads` property. + +.Example `application.properties` +---- +citrus.simulator.mode=async +citrus.simulator.executor.threads=10 +---- + +.Example `application.yml` +---- +citrus: + simulator: + mode: async + executor: + threads: 10 +---- + +[[concept-advanced-execution-custom-mode]] +=== Custom Executors + +For advanced scenarios, you have the flexibility to provide custom executors by implementing the `ScenarioExecutorService` interface. +This allows for tailored execution strategies, such as custom thread management, prioritization of scenarios, or integration with external systems for scenario execution. + +To use a custom executor, define your implementation of the `ScenarioExecutorService` and register it as a bean in your Spring application context. +Ensure that your custom executor is appropriately configured to be recognized by the simulator in place of the default synchronous or asynchronous executors. +To disable the default synchronous executor, set the following property: `citrus.simulator.mode=custom`. + +.Example Custom Executor Bean Definition +[source,java] +---- +@Bean +public ScenarioExecutorService customScenarioExecutorService() { + return new MyCustomScenarioExecutorService(); +} +---- + +This custom executor will then be used by the simulator to execute scenarios according to the logic you've implemented. + +== Best Practices + +- Use the _synchronous mode_ as the standard, for linear simulations where data consistency matters or when debugging to ensure straightforward tracing of actions and outcomes. +- Opt for the _asynchronous mode_ only when explicitly needed, when simulating more complex scenarios that involve intermediate synchronous messages. + +By understanding and appropriately configuring the execution modes of the Citrus Simulator, you can tailor the simulation environment to best suit your testing needs, whether you require precise control over scenario execution or need to simulate high-volume, concurrent interactions. + [[concept-advanced-database-schema]] == Database Schema @@ -10,7 +94,7 @@ This visual representation can help understand the relationships and structure o image::database-schema.png[Database Schema, title="Database Schema of the Citrus Simulator"] -[[concept-runtime-scenario-registration]] +[[concept-advanced-runtime-scenario-registration]] == Registering Simulator Scenarios at Runtime Registering simulator scenarios at runtime is a perfectly valid approach. From cf9fe0f615a700a23880f77245f3a4cb05cf338e Mon Sep 17 00:00:00 2001 From: bbortt Date: Wed, 28 Feb 2024 13:43:22 +0100 Subject: [PATCH 4/5] chore(deps): update org.springdoc to v2.3.0 Signed-off-by: Timon Borter --- simulator-spring-boot/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simulator-spring-boot/pom.xml b/simulator-spring-boot/pom.xml index 5f8387526..57e72be10 100644 --- a/simulator-spring-boot/pom.xml +++ b/simulator-spring-boot/pom.xml @@ -75,7 +75,7 @@ org.springdoc springdoc-openapi-starter-webmvc-api - 2.2.0 + 2.3.0 From 0c23ed5b179a4f170a9470aa841f1f788c1c4cf2 Mon Sep 17 00:00:00 2001 From: bbortt Date: Wed, 28 Feb 2024 13:46:26 +0100 Subject: [PATCH 5/5] fix(simulator): exclude recursive to-string properties Signed-off-by: Timon Borter --- .../main/java/org/citrusframework/simulator/model/Message.java | 1 + .../java/org/citrusframework/simulator/model/MessageHeader.java | 1 + .../java/org/citrusframework/simulator/model/ScenarioAction.java | 1 + .../org/citrusframework/simulator/model/ScenarioParameter.java | 1 + .../java/org/citrusframework/simulator/model/TestParameter.java | 1 + 5 files changed, 5 insertions(+) diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/Message.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/Message.java index 7881ff655..8f1b6c5ca 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/Message.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/Message.java @@ -85,6 +85,7 @@ public class Message extends AbstractAuditingEntity implements Se @OneToMany(fetch = FetchType.LAZY, mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true) private final Set headers = new HashSet<>(); + @ToString.Exclude @ManyToOne(fetch = FetchType.LAZY) @JsonIgnoreProperties(value = {"scenarioParameters", "scenarioActions", "scenarioMessages"}, allowSetters = true) private ScenarioExecution scenarioExecution; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/MessageHeader.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/MessageHeader.java index d6d309b3c..4834a77cc 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/MessageHeader.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/MessageHeader.java @@ -64,6 +64,7 @@ public class MessageHeader extends AbstractAuditingEntity i private String value; @NotNull + @ToString.Exclude @ManyToOne(optional = false) @JoinColumn(nullable = false) @JsonIgnoreProperties(value = {"headers", "scenarioExecution"}, allowSetters = true) diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioAction.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioAction.java index 012b55006..aaacd06eb 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioAction.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioAction.java @@ -62,6 +62,7 @@ public class ScenarioAction implements Serializable { private Instant endDate; @ManyToOne + @ToString.Exclude @JsonIgnoreProperties(value = { "scenarioParameters", "scenarioActions", "scenarioMessages" }, allowSetters = true) private ScenarioExecution scenarioExecution; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioParameter.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioParameter.java index 84f59a565..ea564a59e 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioParameter.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/model/ScenarioParameter.java @@ -76,6 +76,7 @@ public class ScenarioParameter extends AbstractAuditingEntity