diff --git a/simulator-docs/src/main/asciidoc/concepts-advanced.adoc b/simulator-docs/src/main/asciidoc/concepts-advanced.adoc index 8b1e4e17d..292becf86 100644 --- a/simulator-docs/src/main/asciidoc/concepts-advanced.adoc +++ b/simulator-docs/src/main/asciidoc/concepts-advanced.adoc @@ -1,14 +1,14 @@ -[[concepts-advanced]] +[[advanced-concepts]] = Advanced Concepts -[[concept-advanced-execution-modes]] +[[advanced-concepts-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]] +[[advanced-concepts-execution-sync-mode]] === Synchronous Execution Mode The synchronous execution mode ensures that scenarios are executed one after the other, in a single thread. @@ -31,7 +31,7 @@ citrus: mode: sync ---- -[[concept-advanced-execution-async-mode]] +[[advanced-concepts-execution-async-mode]] === Asynchronous Execution Mode In asynchronous execution mode, scenarios are executed concurrently in separate threads, allowing for parallel processing. @@ -57,7 +57,7 @@ citrus: threads: 10 ---- -[[concept-advanced-execution-custom-mode]] +[[advanced-concepts-execution-custom-mode]] === Custom Executors For advanced scenarios, you have the flexibility to provide custom executors by implementing the `ScenarioExecutorService` interface. @@ -78,14 +78,15 @@ public ScenarioExecutorService customScenarioExecutorService() { This custom executor will then be used by the simulator to execute scenarios according to the logic you've implemented. -== Best Practices +[[advanced-concepts-execution-mode-best-practices]] +=== 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]] +[[advanced-concepts-database-schema]] == Database Schema In some cases, it may be useful to keep the database schema in mind. @@ -94,8 +95,8 @@ 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-advanced-runtime-scenario-registration]] -== Registering Simulator Scenarios at Runtime +[[advanced-concepts-scenario-cache]] +== Scenario Cache Registering simulator scenarios at runtime is a perfectly valid approach. However, it's crucial to ensure that the scenario cache used by the simulator remains synchronized. @@ -107,6 +108,7 @@ Therefore, after making modifications, it's necessary to call `ScenarioLookupSer The following Java source code illustrates how to register a custom scenario and update the scenario cache: +.Custom Scenario Bean Registration [source,java] ---- import org.citrusframework.simulator.service.ScenarioLookupService; @@ -128,3 +130,26 @@ public class MyCustomBeanConfiguration { } } ---- + +[[advanced-concepts-housekeeping]] +== Housekeeping + +The Simulator has the ability to reset all recorded Test Results and Executions, either using the <> or <>. +This functionality is useful for smaller and/or ephemeral simulations, but can be problematic for long-lived or even central services. +In such cases, it is recommended to switch the endpoint off and set up your own housekeeping. + +To disable the functionality in both back- and frontend, configure the below property in the Spring Boot configuration files: + +.Example `application.properties` +[source, properties] +---- +citrus.simulator.simulation-results.reset-enabled=false +---- + +.Example `application.yml` +---- +citrus: + simulator: + test-results: + reset-enabeld: false +---- diff --git a/simulator-docs/src/main/asciidoc/rest-api.adoc b/simulator-docs/src/main/asciidoc/rest-api.adoc index 2df9f74df..53449ddad 100644 --- a/simulator-docs/src/main/asciidoc/rest-api.adoc +++ b/simulator-docs/src/main/asciidoc/rest-api.adoc @@ -21,6 +21,8 @@ For each listed resource, the following operations are supported: All REST resources adhere to this pattern, with exceptions noted in subsequent sections. +The endpoint `/api/test-results` additionally supports the `DELETE` request that removes all recorded Test Results and Executions. + [[receive-single-test-result]] === Receive SINGLE Test-Parameter diff --git a/simulator-docs/src/main/asciidoc/user-interface.adoc b/simulator-docs/src/main/asciidoc/user-interface.adoc index 885c5c1cc..c16e23382 100644 --- a/simulator-docs/src/main/asciidoc/user-interface.adoc +++ b/simulator-docs/src/main/asciidoc/user-interface.adoc @@ -1,17 +1,20 @@ +[[user-interface]] = User Interface The simulator application, initiated as a Spring Boot web application, offers an intuitive user interface to enhance user interaction and efficiency. Upon launching the simulator and navigating to http://localhost:8080 in your browser, you are greeted with the default welcome page, designed for straightforward interaction and focused on displaying JSON response data from the simulator REST API. -image:default-ui.png[The default Simulator UI] +image:default-ui.png[The default Simulator UI, title="Default UI of the Citrus Simulator"] For an enriched experience, the simulator supports a more advanced user interface built with Angular, providing comprehensive administrative capabilities. This enhanced UI allows users to effortlessly monitor the simulator's status and review detailed logs of executed scenarios and their outcomes. +[[user-interface-integration]] == Integrating the Angular-based UI To integrate the advanced Angular-based UI into your simulator project, add the following Maven dependency: +.Citrus Simulator UI Dependency [source,xml] ----- @@ -28,30 +31,32 @@ This information can typically be found in the project documentation or the repo Upon successful integration and starting the simulator, the Angular-based UI becomes accessible at http://localhost:8080, automatically enhancing the default user interface without any additional configuration. The simulator dashboard provides a comprehensive overview of your project, presenting key metrics and insights at a glance. -image:dashboard.png[The Dashboard provides a quick overview of the simulator's metrics and activity] +image:dashboard.png[Citrus Simulator Dashboard, title="Dashboard providing a quick overview of the simulator's metrics and activity"] -[[ui-scenarios]] +Using the "Reset"-button, it's also possible to delete all recorded Test Results and Executions. + +[[user-interface-scenarios]] == Scenarios The "Scenarios" tab within the user interface displays all scenarios available for automatic mapping upon handling incoming requests. This tab not only lists the scenarios but also offers functionalities such as initiating scenario executions directly from the UI. -image:scenario-list.png[List of available scenarios in the simulator] +image:scenario-list.png[Scenario Executions, title="List of available scenarios in the simulator"] Selecting any scenario from the list opens a detailed view of that specific scenario. This view includes comprehensive information about the scenario, such as the messages processed during executions and the results of each execution, providing valuable insights into the behavior and outcome of the scenario. -image:scenario-details.png[Detailed view of a scenario,including execution messages and outcomes] +image:scenario-details.png[Scenario Details, title="Detailed view of a scenario, including execution details"] Each scenario detail page is designed to offer a deep dive into the scenario's workings, including the input and output data, any validations or assertions applied, and a step-by-step breakdown of the scenario's execution path. This level of detail aids in understanding each scenario's role within the simulator and troubleshooting any issues that may arise. -[[ui-scenario-results]] +[[user-interface-scenario-results]] == Scenario Executions Every execution of a scenario within the simulator is meticulously recorded, with the results readily accessible through the user interface for review and analysis. -image:scenario-executions.png[Overview of scenario executions,showing a list with statuses and key details] +image:scenario-executions.png[Scenario Executions, title="Overview of scenario executions, showing a list with statuses and key details"] This view also provides rich filter possibilities, by name, status and so on. Even by headers of recorded messages. @@ -68,14 +73,14 @@ Combining multiple patterns: Separate multiple filter expressions with a semicol There is a helping dialog available to the right of the message-header input field. -[[ui-scenario-results]] +[[user-interface-execution-results]] === Viewing Execution Results The results of each scenario execution provide a comprehensive overview, including the outcome (such as Passed, Failed, or Errored), execution duration, and the parameters and data utilized during the execution. You can even see full stacktraces to debug your scenario failures. To access the detailed results of a specific execution, simply select the desired entry from the list. -image:scenario-execution-details.png[Detailed view of a scenario execution,including request/response data and any logged messages or errors] +image:scenario-execution-details.png[Scenario Execution Details, title="Detailed view of a scenario execution, including request/response data and any logged messages or errors"] *Detailed Execution Insights* @@ -99,13 +104,13 @@ Furthermore, the detailed logs and error messages serve as a direct insight into Leveraging the execution details effectively can significantly enhance the efficiency of testing cycles and contribute to a more robust and reliable testing process within the Citrus framework. -[[ui-entities]] +[[user-interface-entities]] == Exploring Database Entities For instances when you require more detailed information or are unable to locate specific data within the simulator's UI, the "Entities" drop-down menu offers a solution. Located at the top of the user interface, this menu provides direct access to all database entities used by the simulator, allowing for an in-depth review of the underlying data. -[[ui-entities-access]] +[[user-interface-entities-access]] === Accessing Database Entities To explore the database entities: diff --git a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java index 63807cf0e..5b9ef315e 100644 --- a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java +++ b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java @@ -36,6 +36,7 @@ import org.springframework.test.context.ContextConfiguration; import org.testng.annotations.Test; +import static java.lang.String.format; import static org.citrusframework.http.actions.HttpActionBuilder.http; /** @@ -57,26 +58,24 @@ public class SimulatorSwaggerIT extends TestNGCitrusSpringSupport { @CitrusTest public void testUiInfo() { - $(http().client(simulatorUiClient) - .send() - .get("/api/manage/info") - .message() - .accept(MediaType.APPLICATION_JSON_VALUE) - .contentType(MediaType.APPLICATION_JSON_VALUE)); - - $(http().client(simulatorUiClient) - .receive() - .response(HttpStatus.OK) - .message() - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body("{" + - "\"simulator\":" + - "{" + - "\"name\":\"REST Petstore Simulator\"," + - "\"version\":\"@ignore@\"" + - "}," + - "\"activeProfiles\": []" + - "}")); + $(http().client(simulatorUiClient).send().get("/api/manage/info").message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE)); + + $(http().client(simulatorUiClient).receive().response(HttpStatus.OK).message() + .contentType(MediaType.APPLICATION_JSON_VALUE).body( + "{" + + "\"simulator\":" + + "{" + + "\"name\":\"REST Petstore Simulator\"," + + "\"version\":\"@ignore@\"" + + "}," + + "\"config\":" + + "{\n" + + "\"reset-results-enabled\": \"true\"\n" + + "}," + + "\"activeProfiles\": []" + + "}")); } @CitrusTest @@ -238,14 +237,14 @@ public static class EndpointConfig { @Bean public HttpClient petstoreClient() { return CitrusEndpoints.http().client() - .requestUrl(String.format("http://localhost:%s/petstore/v2", 8080)) + .requestUrl(format("http://localhost:%s/petstore/v2", 8080)) .build(); } @Bean public HttpClient simulatorUiClient() { return CitrusEndpoints.http().client() - .requestUrl(String.format("http://localhost:%s", 8080)) + .requestUrl(format("http://localhost:%s", 8080)) .build(); } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java index 7a2ed449e..5214fa6c9 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java @@ -102,6 +102,8 @@ public class SimulatorConfigurationProperties implements EnvironmentAware, Initi */ private String outboundJsonDictionary = "outbound-json-dictionary.properties"; + private SimulationResults simulationResults = new SimulationResults(); + @Override public void setEnvironment(Environment environment) { inboundXmlDictionary = environment.getProperty(SIMULATOR_INBOUND_XML_DICTIONARY_PROPERTY, environment.getProperty(SIMULATOR_INBOUND_XML_DICTIONARY_ENV, inboundXmlDictionary)); @@ -114,4 +116,15 @@ public void setEnvironment(Environment environment) { public void afterPropertiesSet() { logger.info("Using the simulator configuration: {}", this); } + + @Getter + @Setter + @ToString + public static class SimulationResults { + + /** + * Specifies whether the test results shall be deletable or not. If you're working with a long-lived citrus-simulator and disable this, make sure to manually take care of housekeeping! + */ + private boolean resetEnabled = true; + } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/TestResultResource.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/TestResultResource.java index cd2b2dd63..b690f1ab2 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/TestResultResource.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/TestResultResource.java @@ -16,6 +16,8 @@ package org.citrusframework.simulator.web.rest; +import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.config.SimulatorConfigurationProperties.SimulationResults; import org.citrusframework.simulator.model.TestResult; import org.citrusframework.simulator.service.TestResultQueryService; import org.citrusframework.simulator.service.TestResultService; @@ -42,6 +44,8 @@ import java.util.List; import java.util.Optional; +import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; + /** * REST controller for managing {@link TestResult}. */ @@ -51,12 +55,14 @@ public class TestResultResource { private static final Logger logger = LoggerFactory.getLogger(TestResultResource.class); + private final SimulatorConfigurationProperties simulatorConfigurationProperties; private final TestResultService testResultService; private final TestResultQueryService testResultQueryService; private final TestResultMapper testResultMapper; - public TestResultResource(TestResultService testResultService, TestResultQueryService testResultQueryService, TestResultMapper testResultMapper) { + public TestResultResource(SimulatorConfigurationProperties simulatorConfigurationProperties, TestResultService testResultService, TestResultQueryService testResultQueryService, TestResultMapper testResultMapper) { + this.simulatorConfigurationProperties = simulatorConfigurationProperties; this.testResultService = testResultService; this.testResultQueryService = testResultQueryService; this.testResultMapper = testResultMapper; @@ -80,14 +86,27 @@ public ResponseEntity> getAllTestResults(TestResultCriteria /** * {@code DELETE /test-results} : delete all the testResults. + *

+ * Functionality can be disabled using the + * property {@link SimulationResults#isResetEnabled()}, in which case an HTTP 501 "Not + * Implemented" code will be returned. * * @return the {@link ResponseEntity} with status {@code 201 (NO CONTENT)}. */ @DeleteMapping("/test-results") public ResponseEntity deleteAllTestResults() { - logger.debug("REST request to delete all TestResults"); - testResultService.deleteAll(); - return ResponseEntity.noContent().build(); + if (simulatorConfigurationProperties.getSimulationResults().isResetEnabled()) { + logger.debug("REST request to delete all TestResults"); + testResultService.deleteAll(); + return ResponseEntity.noContent().build(); + } else { + logger.warn("REST request to delete all TestResults, but reset is disabled!"); + return ResponseEntity.status(NOT_IMPLEMENTED) + .header( + "message", + "Resetting TestResults is disabled on this simulator, see property 'citrus.simulator.simulation-results.reset-enabled' for more information!") + .build(); + } } /** diff --git a/simulator-spring-boot/src/main/resources/META-INF/citrus-simulator.properties b/simulator-spring-boot/src/main/resources/META-INF/citrus-simulator.properties index ab90c6a73..81456a21d 100644 --- a/simulator-spring-boot/src/main/resources/META-INF/citrus-simulator.properties +++ b/simulator-spring-boot/src/main/resources/META-INF/citrus-simulator.properties @@ -38,6 +38,7 @@ management.info.git.mode=full # Properties for the about page info.simulator.name=Citrus Simulator info.simulator.version=@project.version@ +info.config.reset-results-enabled=${citrus.simulator.simulation-results.reset-enabled:true} # Logging logging.level.org.citrusframework.simulator=INFO diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/SimulatorConfigurationPropertiesTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/SimulatorConfigurationPropertiesTest.java new file mode 100644 index 000000000..4401a12b7 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/SimulatorConfigurationPropertiesTest.java @@ -0,0 +1,33 @@ +package org.citrusframework.simulator.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SimulatorConfigurationPropertiesTest { + + private SimulatorConfigurationProperties fixture; + + @BeforeEach + void beforeEachSetup() { + fixture = new SimulatorConfigurationProperties(); + } + + @Nested + class SimulationResults { + + @Test + void isNotNull() { + assertThat(fixture.getSimulationResults()) + .isNotNull(); + } + + @Test + void resetIsEnabledByDefault() { + assertThat(fixture.getSimulationResults().isResetEnabled()) + .isTrue(); + } + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/AbstractInfoEndpointIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/AbstractInfoEndpointIT.java new file mode 100644 index 000000000..b05b6b9fd --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/AbstractInfoEndpointIT.java @@ -0,0 +1,29 @@ +package org.citrusframework.simulator.web.actuator; + +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; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.citrusframework.simulator.IntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +@DirtiesContext +@IntegrationTest +@AutoConfigureMockMvc +public abstract class AbstractInfoEndpointIT { + + @Autowired + private MockMvc mockMvc; + + protected void getSimulatorInfoAndAssertResetResultsEnabledHasValue(String resetResultsEnabled) + throws Exception { + mockMvc.perform(get("/api/manage/info")) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/vnd.spring-boot.actuator.v3+json")) + .andExpect(jsonPath("$.config['reset-results-enabled']").value(resetResultsEnabled)); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointIT.java new file mode 100644 index 000000000..bacdcef20 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointIT.java @@ -0,0 +1,29 @@ +package org.citrusframework.simulator.web.actuator; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource( + properties = {"management.server.port="}, + locations = {"classpath:META-INF/citrus-simulator.properties"} +) +class InfoEndpointIT extends AbstractInfoEndpointIT { + + @Test + void testResultsResetIsEnabledByDefault() throws Exception { + getSimulatorInfoAndAssertResetResultsEnabledHasValue("true"); + } + + @Nested + @TestPropertySource(properties = { + "citrus.simulator.simulation-results.reset-enabled=true" + }) + class WithResetBeingExplicitlyEnabled { + + @Test + void infoEndpointReturnsConfiguration() throws Exception { + getSimulatorInfoAndAssertResetResultsEnabledHasValue("true"); + } + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointResetDisabledIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointResetDisabledIT.java new file mode 100644 index 000000000..286abdb83 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/actuator/InfoEndpointResetDisabledIT.java @@ -0,0 +1,16 @@ +package org.citrusframework.simulator.web.actuator; + +import org.junit.jupiter.api.Test; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource( + properties = {"management.server.port=", "citrus.simulator.simulation-results.reset-enabled=false"}, + locations = {"classpath:META-INF/citrus-simulator.properties"} +) +class InfoEndpointResetDisabledIT extends AbstractInfoEndpointIT { + + @Test + void infoEndpointReturnsConfiguration() throws Exception { + getSimulatorInfoAndAssertResetResultsEnabledHasValue("false"); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/TestResultResourceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/TestResultResourceTest.java new file mode 100644 index 000000000..0d4183e85 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/TestResultResourceTest.java @@ -0,0 +1,82 @@ +package org.citrusframework.simulator.web.rest; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; +import static org.springframework.http.HttpStatus.NO_CONTENT; + +import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.service.TestResultQueryService; +import org.citrusframework.simulator.service.TestResultService; +import org.citrusframework.simulator.web.rest.dto.mapper.TestResultMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +@ExtendWith({MockitoExtension.class}) +class TestResultResourceTest { + + @Mock + private TestResultService testResultServiceMock; + + @Mock + private TestResultQueryService testResultQueryServiceMock; + + @Mock + private TestResultMapper testResultMapperMock; + + private SimulatorConfigurationProperties simulatorConfigurationProperties; + + private TestResultResource fixture; + + @BeforeEach + void beforeEachSetup() { + simulatorConfigurationProperties = new SimulatorConfigurationProperties(); + + fixture = new TestResultResource( + simulatorConfigurationProperties, + testResultServiceMock, + testResultQueryServiceMock, + testResultMapperMock); + } + + @Nested + class DeleteAllTestResults { + + @Test + void deletesTestResultsIfEnabled() { + simulatorConfigurationProperties.getSimulationResults().setResetEnabled(true); + + ResponseEntity response = fixture.deleteAllTestResults(); + + assertThat(response.getStatusCode()).isEqualTo(NO_CONTENT); + verify(testResultServiceMock).deleteAll(); + } + + @Test + void doesNotDeleteTestResultsIfDisabled() { + simulatorConfigurationProperties.getSimulationResults().setResetEnabled(false); + + ResponseEntity response = fixture.deleteAllTestResults(); + + assertAll( + () -> assertThat(response.getStatusCode()) + .isEqualTo(NOT_IMPLEMENTED), + () -> assertThat(response.getHeaders()). + containsEntry( + "message", + singletonList( + "Resetting TestResults is disabled on this simulator, see property 'citrus.simulator.simulation-results.reset-enabled' for more information!")) + ); + + verifyNoInteractions(testResultServiceMock); + } + } +} diff --git a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.html b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.html index f14e6131d..c3110af37 100644 --- a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.html +++ b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.html @@ -9,7 +9,13 @@

Refresh List - diff --git a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.spec.ts b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.spec.ts index 2afdda7ae..adbf17a26 100644 --- a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.spec.ts +++ b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.spec.ts @@ -7,24 +7,36 @@ import { of } from 'rxjs'; import { TestResultsByStatus, TestResultService } from 'app/entities/test-result/service/test-result.service'; +import { ProfileService } from 'app/layouts/profiles/profile.service'; + import TestResultSummaryComponent from './test-result-summary.component'; import Mocked = jest.Mocked; +import { SimulatorConfiguration } from '../layouts/profiles/profile-info.model'; describe('TestResultSummaryComponent', () => { + let testProfileService: Mocked; let testResultService: Mocked; let fixture: ComponentFixture; let component: TestResultSummaryComponent; beforeEach(async () => { + testProfileService = { + getSimulatorConfiguration: jest.fn().mockReturnValueOnce(of({ resetResultsEnabled: true } as SimulatorConfiguration)), + } as unknown as Mocked; + testResultService = { countByStatus: jest.fn(), } as unknown as Mocked; await TestBed.configureTestingModule({ imports: [TestResultSummaryComponent], - providers: [TranslateService, { provide: TestResultService, useValue: testResultService }], + providers: [ + { provide: ProfileService, useValue: testProfileService }, + { provide: TestResultService, useValue: testResultService }, + TranslateService, + ], }) .overrideTemplate(TestResultSummaryComponent, '') .compileComponents(); @@ -38,6 +50,11 @@ describe('TestResultSummaryComponent', () => { }); describe('ngOnInit', () => { + it('should subscribe to simulator configuration', () => { + expect(testProfileService.getSimulatorConfiguration).toHaveBeenCalled(); + expect(component.resetEnabled).toBeTruthy(); + }); + it('should correctly calculate fixed percentages', fakeAsync(() => { const mockData = new HttpResponse({ body: { diff --git a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.ts b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.ts index 09e856414..308a4f7f1 100644 --- a/simulator-ui/src/main/webapp/app/home/test-result-summary.component.ts +++ b/simulator-ui/src/main/webapp/app/home/test-result-summary.component.ts @@ -10,6 +10,9 @@ import { ITEM_DELETED_EVENT } from 'app/config/navigation.constants'; import { STATUS_FAILURE, STATUS_SUCCESS } from 'app/entities/test-result/test-result.model'; import { TestResultsByStatus, TestResultService } from 'app/entities/test-result/service/test-result.service'; +import { ProfileService } from 'app/layouts/profiles/profile.service'; +import { SimulatorConfiguration } from 'app/layouts/profiles/profile-info.model'; + import SharedModule from 'app/shared/shared.module'; import TestResultDeleteDialogComponent from './delete/test-result-delete-dialog.component'; @@ -31,8 +34,13 @@ export default class TestResultSummaryComponent implements OnInit { statusSuccess = STATUS_SUCCESS; statusFailed = STATUS_FAILURE; + resetEnabled = this.profileService + .getSimulatorConfiguration() + .pipe(map((simulatorConfiguration: SimulatorConfiguration) => simulatorConfiguration.resetResultsEnabled)); + constructor( private modalService: NgbModal, + private profileService: ProfileService, private testResultService: TestResultService, ) {} @@ -57,6 +65,7 @@ export default class TestResultSummaryComponent implements OnInit { complete: () => (this.isLoading = false), }); } + protected reset(): void { const modalRef = this.modalService.open(TestResultDeleteDialogComponent, { size: 'lg', backdrop: 'static' }); // unsubscribe not needed because closed completes on modal close diff --git a/simulator-ui/src/main/webapp/app/layouts/main/main.component.html b/simulator-ui/src/main/webapp/app/layouts/main/main.component.html index e338f210f..13d249c1c 100644 --- a/simulator-ui/src/main/webapp/app/layouts/main/main.component.html +++ b/simulator-ui/src/main/webapp/app/layouts/main/main.component.html @@ -1,5 +1,3 @@ - -
diff --git a/simulator-ui/src/main/webapp/app/layouts/main/main.module.ts b/simulator-ui/src/main/webapp/app/layouts/main/main.module.ts index f03354939..ca1b6767f 100644 --- a/simulator-ui/src/main/webapp/app/layouts/main/main.module.ts +++ b/simulator-ui/src/main/webapp/app/layouts/main/main.module.ts @@ -3,11 +3,10 @@ import { RouterModule } from '@angular/router'; import SharedModule from 'app/shared/shared.module'; import FooterComponent from '../footer/footer.component'; -import PageRibbonComponent from '../profiles/page-ribbon.component'; import MainComponent from './main.component'; @NgModule({ - imports: [SharedModule, RouterModule, FooterComponent, PageRibbonComponent], + imports: [SharedModule, RouterModule, FooterComponent], declarations: [MainComponent], }) export default class MainModule {} diff --git a/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts b/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts index 30e30a8fe..82a95a3e3 100644 --- a/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts +++ b/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts @@ -1,22 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; + import { TranslateModule } from '@ngx-translate/core'; -import { ProfileInfo } from 'app/layouts/profiles/profile-info.model'; -import { ProfileService } from 'app/layouts/profiles/profile.service'; +import { of } from 'rxjs'; import NavbarComponent from './navbar.component'; describe('Navbar Component', () => { - let comp: NavbarComponent; let fixture: ComponentFixture; - let profileService: ProfileService; + let component: NavbarComponent; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NavbarComponent, HttpClientTestingModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot()], + imports: [NavbarComponent, RouterTestingModule.withRoutes([]), TranslateModule.forRoot()], }) .overrideTemplate(NavbarComponent, '') .compileComponents(); @@ -24,18 +21,10 @@ describe('Navbar Component', () => { beforeEach(() => { fixture = TestBed.createComponent(NavbarComponent); - comp = fixture.componentInstance; - profileService = TestBed.inject(ProfileService); + component = fixture.componentInstance; }); - it('should call profileService.getProfileInfo on init', () => { - // GIVEN - jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(new ProfileInfo())); - - // WHEN - comp.ngOnInit(); - - // THEN - expect(profileService.getProfileInfo).toHaveBeenCalled(); + it('extracts version from environment', () => { + expect(component.version).toBeTruthy(); }); }); diff --git a/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.ts b/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.ts index ee2a6da6b..f9ba72f86 100644 --- a/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.ts +++ b/simulator-ui/src/main/webapp/app/layouts/navbar/navbar.component.ts @@ -1,12 +1,11 @@ import { Component, OnInit } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; +import { RouterModule } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { StateStorageService } from 'app/core/auth/state-storage.service'; import SharedModule from 'app/shared/shared.module'; import { VERSION } from 'app/app.constants'; import { LANGUAGES } from 'app/config/language.constants'; -import { ProfileService } from 'app/layouts/profiles/profile.service'; import { EntityNavbarItems } from 'app/entities/entity-navbar-items'; import ActiveMenuDirective from './active-menu.directive'; @@ -20,7 +19,6 @@ import NavbarItem from './navbar-item.model'; imports: [RouterModule, SharedModule, ActiveMenuDirective], }) export default class NavbarComponent implements OnInit { - inProduction?: boolean; isNavbarCollapsed = true; languages = LANGUAGES; version = ''; @@ -29,8 +27,6 @@ export default class NavbarComponent implements OnInit { constructor( private translateService: TranslateService, private stateStorageService: StateStorageService, - private profileService: ProfileService, - private router: Router, ) { if (VERSION) { this.version = VERSION.toLowerCase().startsWith('v') ? VERSION : `v${VERSION}`; @@ -39,9 +35,6 @@ export default class NavbarComponent implements OnInit { ngOnInit(): void { this.entitiesNavbarItems = EntityNavbarItems; - this.profileService.getProfileInfo().subscribe(profileInfo => { - this.inProduction = profileInfo.inProduction; - }); } changeLanguage(languageKey: string): void { diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss b/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss deleted file mode 100644 index 88b060226..000000000 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss +++ /dev/null @@ -1,25 +0,0 @@ -/* ========================================================================== -Developement Ribbon -========================================================================== */ -.ribbon { - background-color: rgba(170, 0, 0, 0.5); - overflow: hidden; - position: absolute; - top: 40px; - white-space: nowrap; - width: 15em; - z-index: 9999; - pointer-events: none; - opacity: 0.75; - a { - color: #fff; - display: block; - font-weight: 400; - margin: 1px 0; - padding: 10px 50px; - text-align: center; - text-decoration: none; - text-shadow: 0 0 5px #444; - pointer-events: none; - } -} diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts b/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts deleted file mode 100644 index 741018761..000000000 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { of } from 'rxjs'; - -import { ProfileInfo } from 'app/layouts/profiles/profile-info.model'; -import { ProfileService } from 'app/layouts/profiles/profile.service'; - -import PageRibbonComponent from './page-ribbon.component'; - -describe('Page Ribbon Component', () => { - let comp: PageRibbonComponent; - let fixture: ComponentFixture; - let profileService: ProfileService; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, PageRibbonComponent], - }) - .overrideTemplate(PageRibbonComponent, '') - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(PageRibbonComponent); - comp = fixture.componentInstance; - profileService = TestBed.inject(ProfileService); - }); - - it('should call profileService.getProfileInfo on init', () => { - // GIVEN - jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(new ProfileInfo())); - - // WHEN - comp.ngOnInit(); - - // THEN - expect(profileService.getProfileInfo).toHaveBeenCalled(); - }); -}); diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts b/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts deleted file mode 100644 index 843043a75..000000000 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import SharedModule from 'app/shared/shared.module'; -import { ProfileService } from './profile.service'; - -@Component({ - standalone: true, - selector: 'app-page-ribbon', - template: ` - - `, - styleUrls: ['./page-ribbon.component.scss'], - imports: [SharedModule], -}) -export default class PageRibbonComponent implements OnInit { - ribbonEnv$?: Observable; - - constructor(private profileService: ProfileService) {} - - ngOnInit(): void { - this.ribbonEnv$ = this.profileService.getProfileInfo().pipe(map(profileInfo => profileInfo.ribbonEnv)); - } -} diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/profile-info.model.ts b/simulator-ui/src/main/webapp/app/layouts/profiles/profile-info.model.ts index 5b51a6fc4..eb6f3d12d 100644 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/profile-info.model.ts +++ b/simulator-ui/src/main/webapp/app/layouts/profiles/profile-info.model.ts @@ -1,17 +1,11 @@ export interface InfoResponse { - 'display-ribbon-on-profiles'?: string; git?: any; build?: any; activeProfiles?: string[]; simulator?: SimulatorInfo; -} - -export class ProfileInfo { - constructor( - public activeProfiles?: string[], - public ribbonEnv?: string, - public inProduction?: boolean, - ) {} + config?: SimulatorConfiguration & { + 'reset-results-enabled': string; + }; } export class SimulatorInfo { @@ -20,3 +14,7 @@ export class SimulatorInfo { public version?: string, ) {} } + +export class SimulatorConfiguration { + constructor(public resetResultsEnabled: boolean = true) {} +} diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.spec.ts b/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.spec.ts index 74ab2f2f9..0ee6a70d3 100644 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.spec.ts +++ b/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.spec.ts @@ -1,63 +1,97 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ApplicationConfigService } from 'app/core/config/application-config.service'; import { ProfileService } from './profile.service'; +import { InfoResponse, SimulatorConfiguration, SimulatorInfo } from './profile-info.model'; + +import DoneCallback = jest.DoneCallback; describe('ProfileService', () => { - let service: ProfileService; - let httpMock: HttpTestingController; let applicationConfigServiceSpy: jest.Mocked; + let httpMock: HttpTestingController; + + let service: ProfileService; + beforeEach(() => { applicationConfigServiceSpy = { getEndpointFor: jest.fn().mockReturnValue('mock-url'), } as any; TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ProfileService, { provide: ApplicationConfigService, useValue: applicationConfigServiceSpy }], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ProfileService, + { provide: ApplicationConfigService, useValue: applicationConfigServiceSpy }, + ], }); - service = TestBed.inject(ProfileService); httpMock = TestBed.inject(HttpTestingController); + + service = TestBed.inject(ProfileService); }); afterEach(() => { httpMock.verify(); // Ensure that there are no outstanding HTTP requests. }); - it('should retrieve the profile info', () => { - const mockResponse: any = { - activeProfiles: ['prod', 'api-docs'], - 'display-ribbon-on-profiles': 'prod,api-docs', - }; - - service.getProfileInfo().subscribe(profile => { - expect(profile).toEqual({ - activeProfiles: ['prod', 'api-docs'], - inProduction: true, - openAPIEnabled: true, - ribbonEnv: 'prod', - }); - }); + describe('getSimulatorConfiguration', () => { + it.each([ + { resetResultsEnabled: 'true', expectedResult: true }, + { resetResultsEnabled: 'TRUE', expectedResult: true }, + { resetResultsEnabled: 'false', expectedResult: false }, + { resetResultsEnabled: 'FALSE', expectedResult: false }, + { resetResultsEnabled: 'any string', expectedResult: false }, + ])( + 'should retrieve the simulator configuration', + ({ resetResultsEnabled, expectedResult }: { resetResultsEnabled: string; expectedResult: boolean }, done: DoneCallback) => { + const mockResponse: InfoResponse = { + config: { + resetResultsEnabled: false, + 'reset-results-enabled': resetResultsEnabled, + }, + }; + + service.getSimulatorConfiguration().subscribe((simulatorConfiguration: SimulatorConfiguration) => { + expect(simulatorConfiguration.resetResultsEnabled).toEqual(expectedResult); + done(); + }); - const req = httpMock.expectOne('mock-url'); - expect(req.request.method).toBe('GET'); - req.flush(mockResponse); + const req = httpMock.expectOne('mock-url'); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }, + ); }); - it('should cache the profile info and not make another HTTP call on subsequent requests', () => { - const mockResponse: any = { - activeProfiles: ['prod', 'api-docs'], - 'display-ribbon-on-profiles': 'prod,api-docs', - }; + describe('getSimulatorInfo', () => { + it('should retrieve the simulator info', (done: DoneCallback) => { + const simulator: SimulatorInfo = { name: 'Test Simulator', version: '1.2.3' }; + const mockResponse: InfoResponse = { simulator }; - service.getProfileInfo().subscribe(); - httpMock.expectOne('mock-url').flush(mockResponse); + service.getSimulatorInfo().subscribe((simulatorInfo: SimulatorInfo) => { + expect(simulatorInfo).toEqual(simulator); + done(); + }); - service.getProfileInfo().subscribe(); - httpMock.expectNone('mock-url'); // No additional HTTP call should be made. + const req = httpMock.expectOne('mock-url'); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should cache the simulator info and not make another HTTP call on subsequent requests', () => { + const simulator: SimulatorInfo = { name: 'Test Simulator', version: '1.2.3' }; + const mockResponse: InfoResponse = { simulator }; + + service.getSimulatorInfo().subscribe(); + httpMock.expectOne('mock-url').flush(mockResponse); + + service.getSimulatorInfo().subscribe(); + httpMock.expectNone('mock-url'); // No additional HTTP call should be made. + }); }); }); diff --git a/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.ts b/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.ts index fdd811088..b0f2cdfc6 100644 --- a/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.ts +++ b/simulator-ui/src/main/webapp/app/layouts/profiles/profile.service.ts @@ -6,40 +6,41 @@ import { map, shareReplay } from 'rxjs/operators'; import { ApplicationConfigService } from 'app/core/config/application-config.service'; -import { ProfileInfo, InfoResponse } from './profile-info.model'; +import { InfoResponse, SimulatorInfo, SimulatorConfiguration } from './profile-info.model'; @Injectable({ providedIn: 'root' }) export class ProfileService { private infoUrl = this.applicationConfigService.getEndpointFor('api/manage/info'); - private profileInfo$?: Observable; + + private simulatorInfo$?: Observable; constructor( private http: HttpClient, private applicationConfigService: ApplicationConfigService, ) {} - getProfileInfo(): Observable { - if (this.profileInfo$) { - return this.profileInfo$; + getSimulatorConfiguration(): Observable { + return this.http.get(this.infoUrl).pipe( + map( + (response: InfoResponse) => + ({ + resetResultsEnabled: response.config?.['reset-results-enabled'].toLowerCase() === 'true', + }) as SimulatorConfiguration, + ), + shareReplay(), + ); + } + + getSimulatorInfo(): Observable { + if (this.simulatorInfo$) { + return this.simulatorInfo$; } - this.profileInfo$ = this.http.get(this.infoUrl).pipe( - map((response: InfoResponse) => { - const profileInfo: ProfileInfo = { - activeProfiles: response.activeProfiles, - inProduction: response.activeProfiles?.includes('prod'), - }; - if (response.activeProfiles && response['display-ribbon-on-profiles']) { - const displayRibbonOnProfiles = response['display-ribbon-on-profiles'].split(','); - const ribbonProfiles = displayRibbonOnProfiles.filter(profile => response.activeProfiles?.includes(profile)); - if (ribbonProfiles.length > 0) { - profileInfo.ribbonEnv = ribbonProfiles[0]; - } - } - return profileInfo; - }), + this.simulatorInfo$ = this.http.get(this.infoUrl).pipe( + map((response: InfoResponse) => ({ ...response.simulator })), shareReplay(), ); - return this.profileInfo$; + + return this.simulatorInfo$; } }