diff --git a/src/main/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationController.java b/src/main/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationController.java index b2001af251..689d89e8e5 100644 --- a/src/main/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationController.java +++ b/src/main/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationController.java @@ -11,19 +11,26 @@ 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.servlet.http.HttpServletRequest; import org.gridsuite.study.server.StudyApi; import org.gridsuite.study.server.nodeactivity.NodeActivityRunnerService; import org.gridsuite.study.server.service.RootNetworkNodeInfoService; import org.gridsuite.study.server.service.StudyService; import org.gridsuite.study.server.service.stateestimation.StateEstimationService; import org.gridsuite.study.server.utils.QuotaRunner; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.UUID; import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; +import static org.gridsuite.study.server.StudyConstants.QUERY_PARAM_FILTERS; +import static org.gridsuite.study.server.StudyConstants.QUERY_PARAM_GLOBAL_FILTERS; +import static org.gridsuite.study.server.StudyConstants.QUERY_PARAM_SORT; import static org.gridsuite.study.server.dto.ComputationType.STATE_ESTIMATION; import static org.gridsuite.study.server.nodeactivity.NodeActivityType.COMPUTE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -77,6 +84,27 @@ public ResponseEntity getStateEstimationResult(@Parameter(description = ResponseEntity.noContent().build(); } + @GetMapping(value = "/measurements", produces = APPLICATION_JSON_VALUE) + @Operation(summary = "Get the state estimation measurements of a study node, restricted by global filters only") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The state estimation measurements"), + @ApiResponse(responseCode = "204", description = "No state estimation has been done yet"), + @ApiResponse(responseCode = "400", description = "Invalid global filters, or column filters/sort supplied"), + @ApiResponse(responseCode = "404", description = "The study, root network, node or state estimation has not been found")}) + public ResponseEntity getStateEstimationMeasurements(@Parameter(description = "study UUID") @PathVariable("studyUuid") UUID studyUuid, + @PathVariable("rootNetworkUuid") UUID rootNetworkUuid, + @Parameter(description = "nodeUuid") @PathVariable("nodeUuid") UUID nodeUuid, + @Parameter(description = "Global filters, as one JSON object") + @RequestParam(name = QUERY_PARAM_GLOBAL_FILTERS, required = false) String globalFilters, + @Parameter(hidden = true) HttpServletRequest request) { + if (request.getParameterMap().containsKey(QUERY_PARAM_FILTERS) || request.getParameterMap().containsKey(QUERY_PARAM_SORT)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only global filters are supported on state estimation measurements"); + } + studyService.assertIsRootNetworkAndNodeInStudy(studyUuid, rootNetworkUuid, nodeUuid); + String measurements = stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, globalFilters); + return measurements != null ? ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(measurements) : + ResponseEntity.noContent().build(); + } + @GetMapping(value = "/status") @Operation(summary = "Get the state estimation status on study") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The state estimation status"), diff --git a/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestService.java b/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestService.java index 4afc7eddb8..b29a865004 100644 --- a/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestService.java +++ b/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestService.java @@ -21,12 +21,19 @@ import org.springframework.http.*; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.util.UriComponentsBuilder; +import java.io.InterruptedIOException; import java.io.UncheckedIOException; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import static org.gridsuite.study.server.StudyConstants.*; @@ -62,6 +69,33 @@ public String getStateEstimationResult(UUID resultUuid) { return restTemplate.getForObject(baseUri + path, String.class); } + public String getStateEstimationMeasurements(UUID resultUuid, UUID networkUuid, String variantId, String globalFilters) { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(baseUri) + .pathSegment(STATE_ESTIMATION_API_VERSION, "results", "{resultUuid}", "measurements") + .queryParam(QUERY_PARAM_NETWORK_UUID, "{networkUuid}"); + Map uriVariables = new HashMap<>(); + uriVariables.put(RESULT_UUID, resultUuid); + uriVariables.put(QUERY_PARAM_NETWORK_UUID, networkUuid); + if (!StringUtils.isBlank(globalFilters)) { + uriBuilder.queryParam(QUERY_PARAM_GLOBAL_FILTERS, "{globalFilters}"); + uriVariables.put(QUERY_PARAM_GLOBAL_FILTERS, globalFilters); + } + if (!StringUtils.isBlank(variantId)) { + uriBuilder.queryParam(QUERY_PARAM_VARIANT_ID, "{variantId}"); + uriVariables.put(QUERY_PARAM_VARIANT_ID, variantId); + } + // template variables are strictly encoded once, and the URI overload avoids encoding a second time + URI uri = uriBuilder.encode().buildAndExpand(uriVariables).toUri(); + + try { + return restTemplate.getForObject(uri, String.class); + } catch (ResourceAccessException e) { + boolean timeout = e.getCause() instanceof InterruptedIOException || e.getCause() instanceof HttpTimeoutException; + throw new ResponseStatusException(timeout ? HttpStatus.GATEWAY_TIMEOUT : HttpStatus.BAD_GATEWAY, + "State estimation server " + (timeout ? "timed out" : "is unreachable"), e); + } + } + public UUID runStateEstimation(UUID networkUuid, String variantId, UUID parametersUuid, ReportInfos reportInfos, String receiver, String userId, boolean debug) { var uriComponentsBuilder = UriComponentsBuilder .fromPath(DELIMITER + STATE_ESTIMATION_API_VERSION + "/networks/{networkUuid}/run-and-save") diff --git a/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationService.java b/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationService.java index f28fe03c9c..060438230d 100644 --- a/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationService.java +++ b/src/main/java/org/gridsuite/study/server/service/stateestimation/StateEstimationService.java @@ -117,6 +117,16 @@ public ResponseEntity downloadDebugFile(UUID resultUuid) { return stateEstimationRestService.downloadDebugFile(resultUuid); } + public String getStateEstimationMeasurements(@NonNull UUID nodeUuid, @NonNull UUID rootNetworkUuid, String globalFilters) { + UUID resultUuid = rootNetworkNodeInfoService.getComputationResultUuid(nodeUuid, rootNetworkUuid, STATE_ESTIMATION); + if (resultUuid == null) { + return null; + } + UUID networkUuid = rootNetworkService.getNetworkUuid(rootNetworkUuid); + String variantId = networkModificationTreeService.getVariantId(nodeUuid, rootNetworkUuid); + return stateEstimationRestService.getStateEstimationMeasurements(resultUuid, networkUuid, variantId, globalFilters); + } + public String computeLogicalControls(@NonNull UUID studyUuid, @NonNull UUID nodeUuid, @NonNull UUID rootNetworkUuid) { StudyEntity studyEntity = getStudy(studyUuid); UUID networkUuid = rootNetworkService.getNetworkUuid(rootNetworkUuid); diff --git a/src/test/java/org/gridsuite/study/server/StateEstimationMeasurementsEncodingTest.java b/src/test/java/org/gridsuite/study/server/StateEstimationMeasurementsEncodingTest.java new file mode 100644 index 0000000000..662949d493 --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/StateEstimationMeasurementsEncodingTest.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026, 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.fasterxml.jackson.databind.ObjectMapper; +import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import mockwebserver3.junit5.internal.MockWebServerExtension; +import okhttp3.Headers; +import org.gridsuite.study.server.dto.RootNetworkNodeInfo; +import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.StudyRepository; +import org.gridsuite.study.server.service.NetworkModificationTreeService; +import org.gridsuite.study.server.service.RootNetworkNodeInfoService; +import org.gridsuite.study.server.service.stateestimation.StateEstimationRestService; +import org.gridsuite.study.server.utils.TestUtils; +import org.gridsuite.study.server.utils.elasticsearch.DisableElasticsearch; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.ValueSource; +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.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Real HTTP on both hops (random study-server port, mocked state-estimation server). + * It proves forwarding and encoding of the opaque body, not the filtering done by the backend. + * + * @author Kamil MARUT {@literal } + */ +@ExtendWith(MockWebServerExtension.class) +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisableElasticsearch +@ContextConfigurationWithTestChannel +class StateEstimationMeasurementsEncodingTest { + + private static final String STUDY_UPDATE_DESTINATION = "study.update"; + private static final UUID NETWORK_UUID = UUID.fromString("38400000-8cf0-11bd-b23e-10b96e4ef00d"); + private static final UUID RESULT_UUID = UUID.fromString("cf203721-6150-4203-8960-d61d815a9d16"); + private static final String VARIANT_ID = "variant 1&x"; + private static final String GLOBAL_FILTERS = "{\"label\":\"a+b %41 100% & c=d \\\"q\\\" \\\\ é 中\",\"countryCode\":[\"FR\"]}"; + private static final String MEASUREMENTS = "[{\"id\":\"Café\",\"value\":1.5},{\"id\":\"Café\",\"value\":1.5},{\"id\":\"é 中\",\"value\":0}]"; + + @LocalServerPort + private int port; + @Autowired + private MockMvc mockMvc; + @Autowired + private OutputDestination output; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private StudyRepository studyRepository; + @Autowired + private NetworkModificationTreeService networkModificationTreeService; + @Autowired + private RootNetworkNodeInfoService rootNetworkNodeInfoService; + @Autowired + private StateEstimationRestService stateEstimationRestService; + @Autowired + private TestUtils studyTestUtils; + @Autowired + private RemoteServicesProperties remoteServicesProperties; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private UUID studyUuid; + private UUID rootNetworkUuid; + private UUID nodeUuid; + + @BeforeEach + void setUp(MockWebServer server) { + stateEstimationRestService.setBaseUri(server.url("").toString().replaceAll("/$", "")); + } + + @AfterEach + void tearDown() { + studyRepository.findAll().forEach(study -> networkModificationTreeService.doDeleteTree(study.getId())); + studyRepository.deleteAll(); + TestUtils.assertQueuesEmptyThenClear(List.of(STUDY_UPDATE_DESTINATION), output); + } + + private void createStudyAndNode(UUID resultUuid) throws Exception { + StudyEntity studyEntity = TestUtils.createDummyStudy(NETWORK_UUID, "netId", UUID.randomUUID(), "", "", null, + UUID.randomUUID(), null, null, null, null, null); + studyRepository.save(studyEntity); + networkModificationTreeService.createRoot(studyEntity); + studyUuid = studyEntity.getId(); + rootNetworkUuid = studyTestUtils.getOneRootNetworkUuid(studyUuid); + UUID rootNodeUuid = networkModificationTreeService.getStudyRootNodeUuid(studyUuid); + + NetworkModificationNode node = NetworkModificationNode.builder().name("node").description("description") + .modificationGroupUuid(UUID.randomUUID()).variantId(VARIANT_ID).children(List.of()).build(); + // variant and modification group are not serialized by default + JSONObject nodeJson = new JSONObject(objectMapper.writeValueAsString(node)); + nodeJson.put("variantId", VARIANT_ID); + nodeJson.put("modificationGroupUuid", node.getModificationGroupUuid()); + mockMvc.perform(post("/v1/studies/{studyUuid}/tree/nodes/{id}", studyUuid, rootNodeUuid) + .content(nodeJson.toString()).contentType(MediaType.APPLICATION_JSON).header("userId", "userId")) + .andExpect(status().isOk()); + nodeUuid = UUID.fromString(String.valueOf(TestUtils.receiveStudyUpdate(output, STUDY_UPDATE_DESTINATION).getHeaders().get(NotificationService.HEADER_NEW_NODE))); + rootNetworkNodeInfoService.updateRootNetworkNode(nodeUuid, rootNetworkUuid, + RootNetworkNodeInfo.builder().variantId(VARIANT_ID).stateEstimationResultUuid(resultUuid).build()); + } + + private URI measurementsUri(UUID study, UUID node, String query) { + return URI.create("http://localhost:" + port + "/v1/studies/" + study + "/root-networks/" + rootNetworkUuid + "/nodes/" + node + + "/state-estimation/measurements" + query); + } + + private static String browserQuery(String globalFilters) { + // what URLSearchParams does: a single percent-encoding, with '+' for spaces + return "?globalFilters=" + URLEncoder.encode(globalFilters, StandardCharsets.UTF_8); + } + + private HttpResponse get(URI uri) throws Exception { + return httpClient.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofByteArray()); + } + + private static MockResponse json(int code, String body) { + return new MockResponse(code, Headers.of("Content-Type", "application/json"), body); + } + + @Test + void globalFiltersAndResponseSurviveBothHops(MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + server.enqueue(json(200, MEASUREMENTS)); + + HttpResponse response = get(measurementsUri(studyUuid, nodeUuid, browserQuery(GLOBAL_FILTERS))); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")).hasValueSatisfying(type -> assertThat(type).startsWith("application/json")); + assertThat(response.body()).isEqualTo(MEASUREMENTS.getBytes(StandardCharsets.UTF_8)); + + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + assertThat(request.getRequestUrl().encodedPath()).isEqualTo("/v1/results/" + RESULT_UUID + "/measurements"); + assertThat(request.getRequestUrl().queryParameter("globalFilters")).isEqualTo(GLOBAL_FILTERS); + assertThat(request.getRequestUrl().queryParameter("networkUuid")).isEqualTo(NETWORK_UUID.toString()); + assertThat(request.getRequestUrl().queryParameter("variantId")).isEqualTo(VARIANT_ID); + assertThat(request.getRequestUrl().querySize()).isEqualTo(3); + assertThat(request.getRequestUrl().encodedQuery()).contains("%2B", "%2541", "100%25", "%26").doesNotContain("%252541", " ", "+"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + + @Test + void absentGlobalFiltersAreNotForwarded(MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + server.enqueue(json(200, "[]")); + + HttpResponse response = get(measurementsUri(studyUuid, nodeUuid, "")); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(new String(response.body(), StandardCharsets.UTF_8)).isEqualTo("[]"); + assertThat(server.takeRequest(5, TimeUnit.SECONDS).getRequestUrl().queryParameterNames()).containsExactlyInAnyOrder("networkUuid", "variantId"); + } + + @Test + void noStoredResultIsNoContentWithoutDownstreamCall(MockWebServer server) throws Exception { + createStudyAndNode(null); + HttpResponse response = get(measurementsUri(studyUuid, nodeUuid, browserQuery(GLOBAL_FILTERS))); + + assertThat(response.statusCode()).isEqualTo(204); + assertThat(response.body()).isEmpty(); + assertThat(server.getRequestCount()).isZero(); + } + + @ParameterizedTest + @ValueSource(ints = {400, 404, 500}) + void downstreamProblemDetailKeepsStatusAndDetail(int status, MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + PowsyblWsProblemDetail downstreamError = PowsyblWsProblemDetail.builder(HttpStatus.valueOf(status)) + .server("state-estimation-server").detail("downstream detail é").path("/v1/results/measurements").build(); + server.enqueue(new MockResponse(status, Headers.of("Content-Type", "application/problem+json"), objectMapper.writeValueAsString(downstreamError))); + + HttpResponse response = get(measurementsUri(studyUuid, nodeUuid, browserQuery(GLOBAL_FILTERS))); + + assertThat(response.statusCode()).isEqualTo(status); + assertThat(new String(response.body(), StandardCharsets.UTF_8)).contains("downstream detail é"); + } + + @Test + void downstreamErrorWithoutProblemDetailKeepsStatus(MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + server.enqueue(new MockResponse(503, Headers.of("Content-Type", "text/plain"), "maintenance")); + + assertThat(get(measurementsUri(studyUuid, nodeUuid, "")).statusCode()).isEqualTo(503); + } + + @ParameterizedTest + @ValueSource(strings = {"filters", "sort"}) + void columnFiltersAndSortAreRejectedBeforeAnyWork(String parameter, MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + + assertThat(get(measurementsUri(studyUuid, nodeUuid, "?" + parameter + "=%5B%5D")).statusCode()).isEqualTo(400); + assertThat(server.getRequestCount()).isZero(); + } + + @Test + void foreignOrUnknownNodeIsNotFoundBeforeAnyWork(MockWebServer server) throws Exception { + createStudyAndNode(RESULT_UUID); + + assertThat(get(measurementsUri(UUID.randomUUID(), nodeUuid, "")).statusCode()).isEqualTo(404); + assertThat(get(measurementsUri(studyUuid, UUID.randomUUID(), "")).statusCode()).isEqualTo(404); + assertThat(server.getRequestCount()).isZero(); + } + + @Test + void unreachableStateEstimationServerIsBadGateway() throws Exception { + createStudyAndNode(RESULT_UUID); + try (ServerSocket closedPort = new ServerSocket(0)) { + stateEstimationRestService.setBaseUri("http://localhost:" + closedPort.getLocalPort()); + } + + assertThat(get(measurementsUri(studyUuid, nodeUuid, "")).statusCode()).isEqualTo(502); + } + + @Test + void slowStateEstimationServerIsGatewayTimeoutWithTheApplicationHttpClient(MockWebServer server) { + StateEstimationRestService withTimeout = new StateEstimationRestService( + remoteServicesProperties, objectMapper, new RestTemplateBuilder().readTimeout(Duration.ofMillis(300)).build()); + withTimeout.setBaseUri(server.url("").toString().replaceAll("/$", "")); + server.enqueue(new MockResponse.Builder().code(200).body("[]").headersDelay(3, TimeUnit.SECONDS).build()); + + assertThatThrownBy(() -> withTimeout.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, VARIANT_ID, GLOBAL_FILTERS)) + .isInstanceOfSatisfying(ResponseStatusException.class, e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT)); + } +} diff --git a/src/test/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationControllerTest.java b/src/test/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationControllerTest.java new file mode 100644 index 0000000000..cdf2f59981 --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/controller/stateestimation/StateEstimationControllerTest.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, 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.stateestimation; + +import org.gridsuite.study.server.error.StudyException; +import org.gridsuite.study.server.nodeactivity.NodeActivityRunnerService; +import org.gridsuite.study.server.service.RootNetworkNodeInfoService; +import org.gridsuite.study.server.service.StudyService; +import org.gridsuite.study.server.service.stateestimation.StateEstimationService; +import org.junit.jupiter.api.BeforeEach; +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.ValueSource; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +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.status; + +/** + * @author Kamil MARUT {@literal } + */ +@ExtendWith(MockitoExtension.class) +class StateEstimationControllerTest { + + private static final String URL = "/v1/studies/{studyUuid}/root-networks/{rootNetworkUuid}/nodes/{nodeUuid}/state-estimation/measurements"; + private static final String GLOBAL_FILTERS = "{\"countryCode\":[\"FR\"],\"label\":\"a+b %41 & c=d é\"}"; + + private final UUID studyUuid = UUID.randomUUID(); + private final UUID rootNetworkUuid = UUID.randomUUID(); + private final UUID nodeUuid = UUID.randomUUID(); + + @Mock + private StudyService studyService; + @Mock + private RootNetworkNodeInfoService rootNetworkNodeInfoService; + @Mock + private StateEstimationService stateEstimationService; + @Mock + private NodeActivityRunnerService nodeActivityRunnerService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup( + new StateEstimationController(studyService, rootNetworkNodeInfoService, stateEstimationService, nodeActivityRunnerService)).build(); + } + + @Test + void returnsDownstreamArrayAfterOwnershipCheck() throws Exception { + String measurements = "[{\"id\":\"L1\"},{\"id\":\"L1\"}]"; + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, GLOBAL_FILTERS)).thenReturn(measurements); + + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(measurements)); + + InOrder inOrder = inOrder(studyService, stateEstimationService); + inOrder.verify(studyService).assertIsRootNetworkAndNodeInStudy(studyUuid, rootNetworkUuid, nodeUuid); + inOrder.verify(stateEstimationService).getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, GLOBAL_FILTERS); + } + + @Test + void globalFiltersAreOptional() throws Exception { + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, null)).thenReturn("[]"); + + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid)) + .andExpect(status().isOk()) + .andExpect(content().string("[]")); + } + + @Test + void noMatchIsEmptyArrayNotNoContent() throws Exception { + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, GLOBAL_FILTERS)).thenReturn("[]"); + + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS)) + .andExpect(status().isOk()) + .andExpect(content().string("[]")); + } + + @Test + void noStoredResultIsNoContent() throws Exception { + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, GLOBAL_FILTERS)).thenReturn(null); + + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS)) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + } + + @ParameterizedTest + @ValueSource(strings = {"filters", "sort"}) + void rejectsColumnFiltersAndSort(String parameter) throws Exception { + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS).queryParam(parameter, "[]")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam(parameter, "")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(studyService, stateEstimationService); + } + + @Test + void notOwnedNodeStopsBeforeResolution() { + doThrow(new StudyException(NOT_FOUND, "Node not found")).when(studyService).assertIsRootNetworkAndNodeInStudy(studyUuid, rootNetworkUuid, nodeUuid); + + assertThatThrownBy(() -> mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS))) + .hasCauseInstanceOf(StudyException.class); + verifyNoInteractions(stateEstimationService); + } + + @Test + void downstreamNotFoundIsNotTurnedIntoNoContent() throws Exception { + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, GLOBAL_FILTERS)) + .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND)); + + mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid).queryParam("globalFilters", GLOBAL_FILTERS)) + .andExpect(status().isNotFound()); + verify(studyService).assertIsRootNetworkAndNodeInStudy(studyUuid, rootNetworkUuid, nodeUuid); + } + + @Test + void responseKeepsUnicodeAsUtf8() throws Exception { + String measurements = "[{\"id\":\"Café é\"}]"; + when(stateEstimationService.getStateEstimationMeasurements(nodeUuid, rootNetworkUuid, null)).thenReturn(measurements); + + byte[] body = mockMvc.perform(get(URL, studyUuid, rootNetworkUuid, nodeUuid)).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsByteArray(); + + assertThat(body).isEqualTo(measurements.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestServiceTest.java b/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestServiceTest.java index fb0457e792..4850e87623 100644 --- a/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestServiceTest.java +++ b/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationRestServiceTest.java @@ -11,18 +11,42 @@ import org.junit.jupiter.api.BeforeEach; 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.ValueSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpConnectTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.gridsuite.study.server.StudyConstants.STATE_ESTIMATION_API_VERSION; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -52,4 +76,74 @@ void testDownloadDebugFile() { assertThat(stateEstimationRestService.downloadDebugFile(resultUuid)).isEqualTo(response); } + + private static final UUID RESULT_UUID = UUID.randomUUID(); + private static final UUID NETWORK_UUID = UUID.randomUUID(); + private static final String RESULTS_PATH = "/" + STATE_ESTIMATION_API_VERSION + "/results/" + RESULT_UUID + "/measurements"; + private static final String GLOBAL_FILTERS = "{\"label\":\"a+b %41 100% & c=d \\\"q\\\" \\\\ é 中\"}"; + + private URI requestedMeasurementsUri(String variantId, String globalFilters) { + when(restTemplate.getForObject(any(URI.class), eq(String.class))).thenReturn("[]"); + assertThat(stateEstimationRestService.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, variantId, globalFilters)).isEqualTo("[]"); + ArgumentCaptor uri = ArgumentCaptor.forClass(URI.class); + verify(restTemplate).getForObject(uri.capture(), eq(String.class)); + verifyNoMoreInteractions(restTemplate); + return uri.getValue(); + } + + private static Map decodedQuery(URI uri) { + return Arrays.stream(uri.getRawQuery().split("&")) + .map(parameter -> parameter.split("=", 2)) + .collect(Collectors.toMap(parameter -> parameter[0], parameter -> URLDecoder.decode(parameter[1], StandardCharsets.UTF_8))); + } + + @Test + void measurementsUriEncodesGlobalFiltersOnce() { + URI uri = requestedMeasurementsUri("variant 1&x", GLOBAL_FILTERS); + + assertThat(uri.getScheme() + "://" + uri.getAuthority() + uri.getRawPath()).isEqualTo(BASE_URI + RESULTS_PATH); + assertThat(uri.getRawQuery()).doesNotContain(" ", "+", "\"", "{") + .contains("%2B", "%2541", "100%25", "%26", "%3D", "%5C", "%C3%A9"); + assertThat(decodedQuery(uri)).containsOnly( + Map.entry("globalFilters", GLOBAL_FILTERS), + Map.entry("networkUuid", NETWORK_UUID.toString()), + Map.entry("variantId", "variant 1&x")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void measurementsUriOmitsBlankGlobalFiltersAndVariant(String blank) { + URI uri = requestedMeasurementsUri(blank, blank); + + assertThat(uri.getRawQuery()).isEqualTo("networkUuid=" + NETWORK_UUID); + } + + @Test + void measurementsDownstreamErrorsAreNotTranslated() { + HttpClientErrorException badRequest = HttpClientErrorException.create(HttpStatus.BAD_REQUEST, "bad", new HttpHeaders(), "{}".getBytes(), null); + HttpServerErrorException serverError = HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "ko", new HttpHeaders(), "{}".getBytes(), null); + when(restTemplate.getForObject(any(URI.class), eq(String.class))).thenThrow(badRequest).thenThrow(serverError); + + assertThatThrownBy(() -> stateEstimationRestService.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, "v", GLOBAL_FILTERS)).isSameAs(badRequest); + assertThatThrownBy(() -> stateEstimationRestService.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, "v", GLOBAL_FILTERS)).isSameAs(serverError); + } + + @Test + void measurementsConnectionFailureIs502AndTimeoutIs504() { + when(restTemplate.getForObject(any(URI.class), eq(String.class))) + .thenThrow(new ResourceAccessException("down", new ConnectException())) + .thenThrow(new ResourceAccessException("slow", new SocketTimeoutException())) + .thenThrow(new ResourceAccessException("slow", new HttpConnectTimeoutException("slow"))); + + assertThat(statusOfFailedMeasurementsRequest()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(statusOfFailedMeasurementsRequest()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + assertThat(statusOfFailedMeasurementsRequest()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + } + + private HttpStatus statusOfFailedMeasurementsRequest() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> stateEstimationRestService.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, "v", GLOBAL_FILTERS)); + assertThat(exception.getCause()).isInstanceOf(ResourceAccessException.class); + return HttpStatus.valueOf(exception.getStatusCode().value()); + } } diff --git a/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationServiceTest.java b/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationServiceTest.java index 8f0fff01c2..92e317a124 100644 --- a/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationServiceTest.java +++ b/src/test/java/org/gridsuite/study/server/service/stateestimation/StateEstimationServiceTest.java @@ -23,12 +23,20 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.gridsuite.study.server.dto.ComputationType.STATE_ESTIMATION; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class StateEstimationServiceTest { private static final UUID RESULT_UUID = UUID.randomUUID(); + private static final UUID NODE_UUID = UUID.randomUUID(); + private static final UUID ROOT_NETWORK_UUID = UUID.randomUUID(); + private static final UUID NETWORK_UUID = UUID.randomUUID(); + private static final String VARIANT_ID = "node_variant"; + private static final String GLOBAL_FILTERS = "{\"countryCode\":[\"FR\"]}"; @Mock private StudyRepository studyRepository; @@ -62,4 +70,26 @@ void testDownloadDebugFile() { assertThat(stateEstimationService.downloadDebugFile(RESULT_UUID)).isEqualTo(response); } + + @Test + void measurementsUseStoredResultNetworkAndNodeVariant() { + when(rootNetworkNodeInfoService.getComputationResultUuid(NODE_UUID, ROOT_NETWORK_UUID, STATE_ESTIMATION)).thenReturn(RESULT_UUID); + when(rootNetworkService.getNetworkUuid(ROOT_NETWORK_UUID)).thenReturn(NETWORK_UUID); + when(networkModificationTreeService.getVariantId(NODE_UUID, ROOT_NETWORK_UUID)).thenReturn(VARIANT_ID); + when(stateEstimationRestService.getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, VARIANT_ID, GLOBAL_FILTERS)).thenReturn("[]"); + + assertThat(stateEstimationService.getStateEstimationMeasurements(NODE_UUID, ROOT_NETWORK_UUID, GLOBAL_FILTERS)).isEqualTo("[]"); + + verify(stateEstimationRestService).getStateEstimationMeasurements(RESULT_UUID, NETWORK_UUID, VARIANT_ID, GLOBAL_FILTERS); + verifyNoInteractions(notificationService, studyRepository, computationParametersService); + } + + @Test + void measurementsWithoutStoredResultDoNothingElse() { + when(rootNetworkNodeInfoService.getComputationResultUuid(NODE_UUID, ROOT_NETWORK_UUID, STATE_ESTIMATION)).thenReturn(null); + + assertThat(stateEstimationService.getStateEstimationMeasurements(NODE_UUID, ROOT_NETWORK_UUID, GLOBAL_FILTERS)).isNull(); + + verifyNoInteractions(rootNetworkService, networkModificationTreeService, stateEstimationRestService, notificationService, studyRepository); + } }