diff --git a/src/main/java/org/gridsuite/study/server/controller/ComputationResultFiltersController.java b/src/main/java/org/gridsuite/study/server/controller/ComputationResultFiltersController.java new file mode 100644 index 000000000..abb37ec7a --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/controller/ComputationResultFiltersController.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.study.server.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.gridsuite.study.server.StudyApi; +import org.gridsuite.study.server.service.StudyService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; +import java.util.UUID; +/** + * @author Rehili Ghazwa + */ + +@RestController +@RequestMapping(value = "/" + StudyApi.API_VERSION + "/studies/{studyUuid}/computation-result-filters") +@Tag(name = "Study server - Computation result filters") +public class ComputationResultFiltersController { + private final StudyService studyService; + + public ComputationResultFiltersController(StudyService studyService) { + this.studyService = studyService; + } + + @GetMapping("/{computationType}") + @Operation(summary = "Get study's computation result global filters") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The computation result global filters")}) + public ResponseEntity getComputationResultGlobalFilters( + @PathVariable("studyUuid") UUID studyUuid, + @PathVariable("computationType") String computationType) { + return Optional.ofNullable(studyService.getComputationResultGlobalFilters(studyUuid, computationType)) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().build()); + } + + @GetMapping("/{computationType}/{computationSubType}") + @Operation(summary = "Get study's computation result column filters") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The computation result column filters")}) + public ResponseEntity getComputationResultColumnFilters( + @PathVariable("studyUuid") UUID studyUuid, + @PathVariable("computationType") String computationType, + @PathVariable("computationSubType") String computationSubType) { + return Optional.ofNullable(studyService.getComputationResultColumnFilters(studyUuid, computationType, computationSubType)) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().build()); + } + + @PostMapping("/{computationType}/global-filters") + @Operation(summary = "Set global filters", + description = "Replaces all existing global filters with the provided list for a computation result") + @ApiResponse(responseCode = "204", description = "Global filters set successfully") + @ApiResponse(responseCode = "404", description = "computation result global filters not found") + public ResponseEntity setGlobalFiltersForComputationResult( + @PathVariable("studyUuid") UUID studyUuid, + @PathVariable String computationType, + @RequestBody String globalFilters) { + studyService.setGlobalFiltersForComputationResult(studyUuid, computationType, globalFilters); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{computationType}/{computationSubType}/columns") + @Operation(summary = "Update a column", description = "Updates a column") + @ApiResponse(responseCode = "204", description = "Column updated") + public ResponseEntity updateColumns( + @PathVariable("studyUuid") UUID studyUuid, + @PathVariable String computationType, + @PathVariable String computationSubType, + @Valid @RequestBody String columnInfos) { + studyService.updateColumns(studyUuid, computationType, computationSubType, columnInfos); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java index 357326ba4..e824e0f3c 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java @@ -107,6 +107,9 @@ public class StudyEntity extends AbstractManuallyAssignedIdentifierEntity @Column(name = "workspacesConfigUuid") private UUID workspacesConfigUuid; + @Column(name = "computationResultFiltersUuid") + private UUID computationResultFiltersUuid; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "study_voltage_init_parameters_id", foreignKey = @ForeignKey( diff --git a/src/main/java/org/gridsuite/study/server/service/StudyConfigService.java b/src/main/java/org/gridsuite/study/server/service/StudyConfigService.java index cbe86e4c0..b307336a7 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyConfigService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyConfigService.java @@ -11,16 +11,15 @@ import org.gridsuite.study.server.repository.StudyEntity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; import static org.gridsuite.study.server.StudyConstants.DELIMITER; import static org.gridsuite.study.server.StudyConstants.STUDY_CONFIG_API_VERSION; @@ -49,6 +48,10 @@ public class StudyConfigService { private static final String NAME_URI = "/name"; private static final String WORKSPACE_PANELS_URI = "/panels"; private static final String DEFAULT_URI = "/default"; + private static final String COMPUTATION_RESULT_FILTERS_URI = "/computation-result-filters"; + private static final String COMPUTATION_TYPE = "computationType"; + private static final String COMPUTATION_SUB_TYPE = "computationSubType"; + private static final String COMPUTATION_RESULT_FILTERS_ID = "computationResultFiltersId"; private final RestTemplate restTemplate; @@ -423,4 +426,46 @@ public void deleteWorkspacePanelNadConfig(UUID configId, UUID workspaceId, UUID .buildAndExpand(configId, workspaceId, panelId).toUriString(); restTemplate.delete(studyConfigServerBaseUri + path); } + + public String getComputationResultGlobalFilters(UUID computationResultFiltersId, String computationType) { + Objects.requireNonNull(computationResultFiltersId); + Map uriVariables = Map.of(COMPUTATION_RESULT_FILTERS_ID, computationResultFiltersId, COMPUTATION_TYPE, computationType); + String path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_CONFIG_API_VERSION + COMPUTATION_RESULT_FILTERS_URI + + "/{computationResultFiltersId}/{computationType}").buildAndExpand(uriVariables).toUriString(); + return restTemplate.getForObject(studyConfigServerBaseUri + path, String.class); + } + + public String getComputationResultColumnFilters(UUID computationResultFiltersId, String computationType, String computationSubType) { + Objects.requireNonNull(computationResultFiltersId); + Map uriVariables = Map.of(COMPUTATION_RESULT_FILTERS_ID, computationResultFiltersId, COMPUTATION_TYPE, computationType, COMPUTATION_SUB_TYPE, computationSubType); + String path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_CONFIG_API_VERSION + COMPUTATION_RESULT_FILTERS_URI + + "/{computationResultFiltersId}/{computationType}/{computationSubType}").buildAndExpand(uriVariables).toUriString(); + return restTemplate.getForObject(studyConfigServerBaseUri + path, String.class); + } + + public void setGlobalFiltersForComputationResult(UUID computationResultFiltersUuid, String computationType, String globalFilters) { + Map uriVariables = Map.of(COMPUTATION_RESULT_FILTERS_ID, computationResultFiltersUuid, COMPUTATION_TYPE, computationType); + String path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_CONFIG_API_VERSION + COMPUTATION_RESULT_FILTERS_URI + + "/{computationResultFiltersId}/{computationType}/global-filters").buildAndExpand(uriVariables).toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(globalFilters, headers); + restTemplate.exchange(studyConfigServerBaseUri + path, HttpMethod.POST, httpEntity, Void.class); + } + + public void updateColumns(UUID computationResultFiltersId, String computationType, String computationSubType, String columnInfos) { + Map uriVariables = Map.of(COMPUTATION_RESULT_FILTERS_ID, computationResultFiltersId, COMPUTATION_TYPE, computationType, COMPUTATION_SUB_TYPE, computationSubType); + String path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_CONFIG_API_VERSION + COMPUTATION_RESULT_FILTERS_URI + + "/{computationResultFiltersId}/{computationType}/{computationSubType}/columns").buildAndExpand(uriVariables).toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(columnInfos, headers); + restTemplate.put(studyConfigServerBaseUri + path, httpEntity); + } + + public UUID createComputationResultsFiltersId() { + var path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_CONFIG_API_VERSION + COMPUTATION_RESULT_FILTERS_URI + DEFAULT_URI) + .buildAndExpand().toUriString(); + return restTemplate.exchange(studyConfigServerBaseUri + path, HttpMethod.POST, null, UUID.class).getBody(); + } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 6e254dfb6..23ab3ed24 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -41,7 +41,10 @@ import org.gridsuite.study.server.elasticsearch.StudyInfosService; import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.networkmodificationtree.dto.*; -import org.gridsuite.study.server.networkmodificationtree.entities.*; +import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeInfoEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NodeEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NodeType; +import org.gridsuite.study.server.networkmodificationtree.entities.RootNetworkNodeInfoEntity; import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.notification.dto.NetworkImpactsInfos; import org.gridsuite.study.server.repository.*; @@ -2788,6 +2791,26 @@ public String getSpreadsheetConfigCollection(UUID studyUuid) { return studyConfigService.getSpreadsheetConfigCollection(studyConfigService.getSpreadsheetConfigCollectionUuidOrElseCreateDefaults(studyEntity)); } + @Transactional + public String getComputationResultGlobalFilters(UUID studyUuid, String computationType) { + StudyEntity studyEntity = getStudy(studyUuid); + UUID computationResultFiltersId = studyEntity.getComputationResultFiltersUuid(); + if (Objects.isNull(computationResultFiltersId)) { + return null; + } + return studyConfigService.getComputationResultGlobalFilters(computationResultFiltersId, computationType); + } + + @Transactional + public String getComputationResultColumnFilters(UUID studyUuid, String computationType, String computationSubType) { + StudyEntity studyEntity = getStudy(studyUuid); + UUID computationResultFiltersId = studyEntity.getComputationResultFiltersUuid(); + if (Objects.isNull(computationResultFiltersId)) { + return null; + } + return studyConfigService.getComputationResultColumnFilters(computationResultFiltersId, computationType, computationSubType); + } + /** * Set spreadsheet config collection on study or reset to default one if empty body. * Default is the user profile one, or system default if no profile is available. @@ -3662,6 +3685,28 @@ public void setGlobalFilters(UUID studyUuid, UUID configUuid, String globalFilte notificationService.emitSpreadsheetConfigChanged(studyUuid, configUuid); } + @Transactional + public void setGlobalFiltersForComputationResult(UUID studyUuid, String computationType, String globalFilters) { + UUID computationResultFiltersId = getComputationResultFiltersId(studyUuid); + studyConfigService.setGlobalFiltersForComputationResult(computationResultFiltersId, computationType, globalFilters); + } + + @Transactional + public void updateColumns(UUID studyUuid, String computationType, String computationSubType, String columnInfos) { + UUID computationResultFiltersId = getComputationResultFiltersId(studyUuid); + studyConfigService.updateColumns(computationResultFiltersId, computationType, computationSubType, columnInfos); + } + + public UUID getComputationResultFiltersId(UUID studyUuid) { + StudyEntity studyEntity = getStudy(studyUuid); + UUID computationResultFiltersId = studyEntity.getComputationResultFiltersUuid(); + if (Objects.isNull(computationResultFiltersId)) { + computationResultFiltersId = studyConfigService.createComputationResultsFiltersId(); + studyEntity.setComputationResultFiltersUuid(computationResultFiltersId); + } + return computationResultFiltersId; + } + public void renameSpreadsheetConfig(UUID studyUuid, UUID configUuid, String newName) { studyConfigService.renameSpreadsheetConfig(configUuid, newName); notificationService.emitSpreadsheetConfigChanged(studyUuid, configUuid); diff --git a/src/main/resources/db/changelog/changesets/changelog_20251117T095627Z.xml b/src/main/resources/db/changelog/changesets/changelog_20251117T095627Z.xml new file mode 100644 index 000000000..388f7f03c --- /dev/null +++ b/src/main/resources/db/changelog/changesets/changelog_20251117T095627Z.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 99c05491a..b8d3ede1e 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -365,3 +365,6 @@ databaseChangeLog: - include: file: changesets/changelog_20260123T095727Z.xml relativeToChangelogFile: true + - include: + file: changesets/changelog_20251117T095627Z.xml + relativeToChangelogFile: true diff --git a/src/test/java/org/gridsuite/study/server/ComputationResultFiltersTest.java b/src/test/java/org/gridsuite/study/server/ComputationResultFiltersTest.java new file mode 100644 index 000000000..b17e27c83 --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/ComputationResultFiltersTest.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.study.server; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.StudyRepository; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; +import org.gridsuite.study.server.service.StudyConfigService; +import org.gridsuite.study.server.utils.elasticsearch.DisableElasticsearch; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.gridsuite.study.server.utils.assertions.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Rehili Ghazwa + */ +@AutoConfigureMockMvc +@SpringBootTest +@DisableElasticsearch +@ContextConfigurationWithTestChannel +class ComputationResultFiltersTest { + private static final String BASE_URI = "/v1/computation-result-filters/"; + public static final String DEFAULT = "default"; + private static final String COMPUTATION_FILTERS_JSON = "{\"computationResultFilters\":[]}"; + private static final UUID COMPUTATION_FILTERS_UUID = UUID.randomUUID(); + private static final String COMPUTATION_TYPE = "LoadFlow"; + private static final String COMPUTATION_SUB_TYPE = "LoadFlowResultsVoltageViolations"; + private WireMockServer wireMockServer; + @Autowired + private MockMvc mockMvc; + @Autowired + private StudyConfigService studyConfigService; + @Autowired + private StudyRepository studyRepository; + + private void stubGetGlobalFilters() { + stubGet(BASE_URI + COMPUTATION_FILTERS_UUID + "/" + ComputationResultFiltersTest.COMPUTATION_TYPE); + } + + private void stubGetColumnFilters() { + stubGet(BASE_URI + COMPUTATION_FILTERS_UUID + "/" + ComputationResultFiltersTest.COMPUTATION_TYPE + "/" + COMPUTATION_SUB_TYPE); + } + + private void stubGet(String url) { + wireMockServer.stubFor(WireMock.get(urlEqualTo(url)) + .willReturn(okJson(COMPUTATION_FILTERS_JSON))); + } + + private void stubCreateDefaultFilters() { + wireMockServer.stubFor(WireMock.post(urlEqualTo(BASE_URI + DEFAULT)).willReturn(okJson("\"" + COMPUTATION_FILTERS_UUID + "\""))); + } + + private void stubSetGlobalFilters() { + wireMockServer.stubFor(WireMock.post(urlEqualTo(BASE_URI + COMPUTATION_FILTERS_UUID + + "/" + COMPUTATION_TYPE + "/global-filters")).willReturn(noContent())); + } + + private void stubUpdateColumns() { + wireMockServer.stubFor(WireMock.put(urlEqualTo(BASE_URI + COMPUTATION_FILTERS_UUID + "/" + + COMPUTATION_TYPE + "/" + COMPUTATION_SUB_TYPE + "/columns")).willReturn(noContent())); + } + + private void verifyDefaultFiltersCalledOnce() { + wireMockServer.verify(1, postRequestedFor(urlEqualTo(BASE_URI + DEFAULT))); + } + + private void verifyDefaultFiltersNotCalled() { + wireMockServer.verify(0, postRequestedFor(urlEqualTo(BASE_URI + DEFAULT))); + } + + private void verifyGlobalFiltersCalledOnce() { + wireMockServer.verify(1, getRequestedFor(urlEqualTo(BASE_URI + COMPUTATION_FILTERS_UUID + "/" + COMPUTATION_TYPE))); + } + + @BeforeEach + void setup() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + studyConfigService.setStudyConfigServerBaseUri(wireMockServer.baseUrl()); + stubCreateDefaultFilters(); + stubGetGlobalFilters(); + stubGetColumnFilters(); + stubSetGlobalFilters(); + stubUpdateColumns(); + } + + @Test + void getComputationResultFilters() throws Exception { + StudyEntity study = insertDummyStudy(null); + MvcResult mvcResult = mockMvc.perform(get("/v1/studies/{studyUuid}/computation-result-filters/{computationType}/{computationSubType}", + study.getId(), COMPUTATION_TYPE, COMPUTATION_SUB_TYPE)).andExpectAll(status().isNoContent()).andReturn(); + assertThat(mvcResult.getResponse().getContentAsString()).isEmpty(); + + study = insertDummyStudy(COMPUTATION_FILTERS_UUID); + mvcResult = mockMvc.perform(get("/v1/studies/{studyUuid}/computation-result-filters/{computationType}/{computationSubType}", + study.getId(), COMPUTATION_TYPE, COMPUTATION_SUB_TYPE)).andExpectAll(status().isOk()).andReturn(); + JSONAssert.assertEquals(COMPUTATION_FILTERS_JSON, mvcResult.getResponse().getContentAsString(), JSONCompareMode.NON_EXTENSIBLE); + verifyDefaultFiltersNotCalled(); + wireMockServer.resetRequests(); + + study = insertDummyStudy(null); + mvcResult = mockMvc.perform(get("/v1/studies/{studyUuid}/computation-result-filters/{computationType}", + study.getId(), COMPUTATION_TYPE)).andExpectAll(status().isNoContent()).andReturn(); + assertThat(mvcResult.getResponse().getContentAsString()).isEmpty(); + + study = insertDummyStudy(COMPUTATION_FILTERS_UUID); + mvcResult = mockMvc.perform(get("/v1/studies/{studyUuid}/computation-result-filters/{computationType}", + study.getId(), COMPUTATION_TYPE)).andExpectAll(status().isOk()).andReturn(); + JSONAssert.assertEquals(COMPUTATION_FILTERS_JSON, mvcResult.getResponse().getContentAsString(), JSONCompareMode.NON_EXTENSIBLE); + verifyGlobalFiltersCalledOnce(); + wireMockServer.resetRequests(); + } + + @Test + void setGlobalFilters() throws Exception { + StudyEntity study = insertDummyStudy(null); + String json = "{\"globalFilters\":[]}"; + mockMvc.perform(post("/v1/studies/{studyUuid}/computation-result-filters/{computationType}/global-filters", study.getId(), COMPUTATION_TYPE) + .contentType(MediaType.APPLICATION_JSON).content(json)).andExpect(status().isNoContent()); + verifyDefaultFiltersCalledOnce(); + wireMockServer.resetRequests(); + + study = insertDummyStudy(COMPUTATION_FILTERS_UUID); + json = "{\"globalFilters\":[]}"; + mockMvc.perform(post("/v1/studies/{studyUuid}/computation-result-filters/{computationType}/global-filters", study.getId(), COMPUTATION_TYPE) + .contentType(MediaType.APPLICATION_JSON).content(json)).andExpect(status().isNoContent()); + verifyDefaultFiltersNotCalled(); + wireMockServer.resetRequests(); + } + + @Test + void updateColumn() throws Exception { + StudyEntity study = insertDummyStudy(COMPUTATION_FILTERS_UUID); + String json = "{\"columnsFilters\":[]}"; + mockMvc.perform(put("/v1/studies/{studyUuid}/computation-result-filters/{computationType}/{computationSubType}/columns", study.getId(), + COMPUTATION_TYPE, COMPUTATION_SUB_TYPE).contentType(MediaType.APPLICATION_JSON).content(json)).andExpect(status().isNoContent()); + verifyDefaultFiltersNotCalled(); + wireMockServer.resetRequests(); + } + + private StudyEntity insertDummyStudy(UUID computationResultFiltersUuid) { + StudyEntity studyEntity = StudyEntity.builder().id(UUID.randomUUID()).computationResultFiltersUuid(computationResultFiltersUuid).build(); + RootNetworkEntity rootNetworkEntity = RootNetworkEntity.builder().id(UUID.randomUUID()).name("rootNetworkName") + .tag("dum").caseFormat("").caseUuid(UUID.randomUUID()).caseName("").networkId(String.valueOf(UUID.randomUUID())).networkUuid(UUID.randomUUID()).build(); + studyEntity.addRootNetwork(rootNetworkEntity); + return studyRepository.save(studyEntity); + } + + @AfterEach + void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } +}