diff --git a/src/main/java/org/gridsuite/study/server/notification/NotificationService.java b/src/main/java/org/gridsuite/study/server/notification/NotificationService.java index 5d6a14c2cd..bc8c27e275 100644 --- a/src/main/java/org/gridsuite/study/server/notification/NotificationService.java +++ b/src/main/java/org/gridsuite/study/server/notification/NotificationService.java @@ -40,6 +40,7 @@ public class NotificationService { public static final String HEADER_NODE = "node"; public static final String HEADER_ROOT_NETWORK_UUID = "rootNetworkUuid"; public static final String HEADER_NODES = "nodes"; + public static final String HEADER_NETWORK_MODIFICATION_UUIDS = "networkModificationUuids"; public static final String HEADER_ROOT_NETWORKS_UUIDS = "rootNetworksUuids"; public static final String HEADER_STUDY_UUID = "studyUuid"; public static final String HEADER_UPDATE_TYPE = "updateType"; @@ -124,6 +125,7 @@ public class NotificationService { public static final String MODIFICATIONS_UPDATING_FINISHED = "UPDATE_FINISHED"; public static final String MODIFICATIONS_DELETING_FINISHED = "DELETE_FINISHED"; + public static final String SHARED_ELEMENT_UPDATED = "sharedElementUpdate"; public static final String EVENTS_CRUD_FINISHED = "EVENT_CRUD_FINISHED"; @@ -471,6 +473,19 @@ public void emitModificationsDeleted(UUID studyUuid, UUID parentNodeUuid, Collec ); } + /** + * Notifies the front that a shared element referenced by some of {@code parentNodeUuid}'s network + * modifications ({@code networkModificationUuids}) has changed. The front should refresh the + * modifications list of that node so the affected modifications display the up-to-date reference. + */ + @PostCompletion + public void emitSharedElementUpdated(UUID studyUuid, UUID parentNodeUuid, Collection networkModificationUuids) { + sendStudyUpdateMessage(studyUuid, SHARED_ELEMENT_UPDATED, MessageBuilder.withPayload("") + .setHeader(HEADER_PARENT_NODE, parentNodeUuid) + .setHeader(HEADER_NETWORK_MODIFICATION_UUIDS, networkModificationUuids) + ); + } + @PostCompletion public void emitEventsUpdated(UUID studyUuid, UUID parentNodeUuid, Collection childrenUuids) { sendStudyUpdateMessage(studyUuid, EVENTS_CRUD_FINISHED, MessageBuilder.withPayload("") diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index 3f418048a0..ed18cecb55 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -70,6 +70,7 @@ public class ConsumerService { private final CaseService caseService; private final LoadFlowRestService loadFlowRestService; private final NetworkModificationTreeService networkModificationTreeService; + private final NetworkModificationService networkModificationService; private final StudyConfigService studyConfigService; private final RootNetworkNodeInfoService rootNetworkNodeInfoService; private final RootNetworkService rootNetworkService; @@ -86,6 +87,7 @@ public ConsumerService(ObjectMapper objectMapper, CaseService caseService, LoadFlowRestService loadFlowRestService, NetworkModificationTreeService networkModificationTreeService, + NetworkModificationService networkModificationService, StudyConfigService studyConfigService, RootNetworkNodeInfoService rootNetworkNodeInfoService, RootNetworkService rootNetworkService, @@ -101,6 +103,7 @@ public ConsumerService(ObjectMapper objectMapper, this.caseService = caseService; this.loadFlowRestService = loadFlowRestService; this.networkModificationTreeService = networkModificationTreeService; + this.networkModificationService = networkModificationService; this.studyConfigService = studyConfigService; this.rootNetworkNodeInfoService = rootNetworkNodeInfoService; this.rootNetworkService = rootNetworkService; @@ -856,4 +859,32 @@ public void createCase(String s3Key, NodeExportInfos nodeExport, String userId, public Consumer> consumeNetworkExportFinished() { return this::consumeNetworkExportFinished; } + + @Bean + public Consumer>>> consumeSharedElementUpdate() { + return message -> handleSharedElementUpdate(message.getPayload()); + } + + // TODO need to handle by type and not group modifications list + private void handleSharedElementUpdate(Map> referencesByType) { + List studyNodeUuids = extractReferenceIds(referencesByType, ReferenceAttributes.ReferenceType.STUDY_NODE); + List networkModificationUuids = extractReferenceIds(referencesByType, ReferenceAttributes.ReferenceType.NETWORK_MODIFICATION); + + Set nodeUuidsToInvalidate = new HashSet<>(studyNodeUuids); + + if (!networkModificationUuids.isEmpty()) { + Collection rootGroupUuids = networkModificationService.findRootGroupByModification(networkModificationUuids).values(); + if (!rootGroupUuids.isEmpty()) { + nodeUuidsToInvalidate.addAll(networkModificationTreeService.getNodeUuidsByModificationGroups(List.copyOf(rootGroupUuids)).values()); + } + } + + nodeUuidsToInvalidate.forEach(nodeUuid -> { + studyService.sharedElementUpdatedNotification(nodeUuid, networkModificationUuids); + }); + } + + private static List extractReferenceIds(Map> referencesByType, ReferenceAttributes.ReferenceType type) { + return referencesByType.getOrDefault(type, List.of()).stream().map(ReferenceAttributes::getReferenceId).toList(); + } } diff --git a/src/main/java/org/gridsuite/study/server/service/DirectoryService.java b/src/main/java/org/gridsuite/study/server/service/DirectoryService.java index 4f9bbbeb74..1124d89db9 100644 --- a/src/main/java/org/gridsuite/study/server/service/DirectoryService.java +++ b/src/main/java/org/gridsuite/study/server/service/DirectoryService.java @@ -115,7 +115,8 @@ public boolean elementExists(UUID directoryUuid, String elementName, String type * creates references and add them to shared composite modifications stored in directory server * @param elementsUuids element uuids of the shared composites in directory server * @param userId id of the user who creates the references - * @param targetReferenceUuid where the new references will point + * @param targetReferenceUuid where the new references will point to + * @param targetReferenceType type of the target the new references point to */ public void createsReferencesToSharedComposites(@NonNull List elementsUuids, String userId, UUID targetReferenceUuid, ReferenceAttributes.ReferenceType targetReferenceType) { // TODO : instead of multiple calls, an endpoint in directory server should be created to handle multiple references creation diff --git a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java index 287d023a8f..df96249cff 100644 --- a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java +++ b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java @@ -211,7 +211,7 @@ public NetworkModificationsResult createModification(UUID groupUuid, return restTemplate.exchange(path, HttpMethod.POST, httpEntity, NetworkModificationsResult.class).getBody(); } - public void updateModification(String createEquipmentAttributes, UUID modificationUuid) { + public void updateModification(String createEquipmentAttributes, UUID modificationUuid, String userId) { Objects.requireNonNull(createEquipmentAttributes); var path = UriComponentsBuilder @@ -221,6 +221,7 @@ public void updateModification(String createEquipmentAttributes, UUID modificati HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HEADER_USER_ID, userId); HttpEntity httpEntity = new HttpEntity<>(createEquipmentAttributes, headers); @@ -245,7 +246,7 @@ public void stashModifications(UUID groupUUid, List modificationsUuids) { restTemplate.exchange(path, HttpMethod.PUT, httpEntity, Void.class); } - public void updateModificationsMetadata(UUID groupUUid, List modificationsUuids, NetworkModificationMetadata metadata) { + public void updateModificationsMetadata(UUID groupUUid, List modificationsUuids, NetworkModificationMetadata metadata, String userId) { Objects.requireNonNull(groupUUid); Objects.requireNonNull(modificationsUuids); var path = UriComponentsBuilder @@ -257,6 +258,7 @@ public void updateModificationsMetadata(UUID groupUUid, List modifications HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HEADER_USER_ID, userId); HttpEntity httpEntity = new HttpEntity<>(metadata, headers); restTemplate.exchange(path, HttpMethod.PUT, httpEntity, Void.class); @@ -330,6 +332,35 @@ public Map findParentComposites(List modificationsUuids) { ).getBody(); } + /** + * Resolves, for each given modification, the modification group it ultimately belongs to. + * @param modificationsUuids the modifications to resolve; each may sit directly in a group or be nested + * inside one or more composite modifications + * @return a map from modification uuid to the uuid of its enclosing root group (the outermost group, + * found by walking up through every level of nested composite modifications). A modification that is + * not reachable from any group is absent from the map. + */ + public Map findRootGroupByModification(List modificationsUuids) { + Objects.requireNonNull(modificationsUuids); + var path = UriComponentsBuilder + .fromUriString(getNetworkModificationServerURI(false) + COMPOSITE_PATH + "root-groups") + .queryParam(UUIDS, modificationsUuids) + .buildAndExpand() + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity httpEntity = new HttpEntity<>(headers); + + return restTemplate.exchange( + path, + HttpMethod.GET, + httpEntity, + new ParameterizedTypeReference>() { } + ).getBody(); + } + /** * @return references data of the modifications in the group : * - element uuid in directory server diff --git a/src/main/java/org/gridsuite/study/server/service/NetworkModificationTreeService.java b/src/main/java/org/gridsuite/study/server/service/NetworkModificationTreeService.java index 4eb5369bff..970322f640 100644 --- a/src/main/java/org/gridsuite/study/server/service/NetworkModificationTreeService.java +++ b/src/main/java/org/gridsuite/study/server/service/NetworkModificationTreeService.java @@ -1460,4 +1460,10 @@ public UUID getNodeUuidByModificationGroup(UUID groupUuid) { var node = networkModificationNodeInfoRepository.findByModificationGroupUuidIn(List.of(groupUuid)); return node.isEmpty() ? null : node.getFirst().getIdNode(); } + + @Transactional(readOnly = true) + public Map getNodeUuidsByModificationGroups(List groupUuids) { + return networkModificationNodeInfoRepository.findByModificationGroupUuidIn(groupUuids).stream() + .collect(Collectors.toMap(NetworkModificationNodeInfoEntity::getModificationGroupUuid, NetworkModificationNodeInfoEntity::getIdNode)); + } } 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 dc700c6df9..c73557fd0e 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -1219,7 +1219,7 @@ public void createNetworkModification(UUID studyUuid, UUID nodeUuid, String crea public void updateNetworkModification(UUID studyUuid, String updateModificationAttributes, UUID nodeUuid, UUID modificationUuid, String userId) { List childrenUuids = networkModificationTreeService.getChildrenUuids(nodeUuid); try { - networkModificationService.updateModification(updateModificationAttributes, modificationUuid); + networkModificationService.updateModification(updateModificationAttributes, modificationUuid, userId); invalidateNodeTree(studyUuid, nodeUuid); } finally { notificationService.emitModificationsUpdated(studyUuid, nodeUuid, childrenUuids); @@ -1466,6 +1466,18 @@ public void invalidateNodeTreeWhenMoveModification(UUID studyUuid, UUID nodeUuid invalidateNodeTree(studyUuid, nodeUuid, InvalidateNodeTreeParameters.ALL); } + @Transactional + public void invalidateNodeTreeWhenSharedModificationChanged(UUID studyUuid, UUID nodeUuid) { + invalidateNodeTree(studyUuid, nodeUuid, InvalidateNodeTreeParameters.ALL); + } + + @Transactional + public void sharedElementUpdatedNotification(UUID nodeUuid, List networkModificationUuids) { + UUID studyUuid = networkModificationTreeService.getStudyUuidForNodeId(nodeUuid); + invalidateNodeTree(studyUuid, nodeUuid); + notificationService.emitSharedElementUpdated(studyUuid, nodeUuid, networkModificationUuids); + } + @Transactional public boolean invalidateNodeTreeWhenMoveModifications(UUID studyUuid, UUID targetNodeUuid, UUID originNodeUuid) { boolean isTargetInDifferentNodeTree = !targetNodeUuid.equals(originNodeUuid) @@ -1500,11 +1512,11 @@ private void invalidateNodeTreeWithLF(UUID studyUuid, UUID nodeUuid, UUID rootNe invalidateNodeTree(studyUuid, nodeUuid, rootNetworkUuid, invalidateNodeTreeParameters); } - public void invalidateNodeTree(UUID studyUuid, UUID nodeUuid, UUID rootNetworkUuid) { + private void invalidateNodeTree(UUID studyUuid, UUID nodeUuid, UUID rootNetworkUuid) { invalidateNodeTree(studyUuid, nodeUuid, rootNetworkUuid, InvalidateNodeTreeParameters.ALL); } - public void invalidateNodeTree(UUID studyUuid, UUID nodeUuid, UUID rootNetworkUuid, InvalidateNodeTreeParameters invalidateTreeParameters) { + private void invalidateNodeTree(UUID studyUuid, UUID nodeUuid, UUID rootNetworkUuid, InvalidateNodeTreeParameters invalidateTreeParameters) { networkModificationTreeService.invalidateNodeTree(studyUuid, nodeUuid, rootNetworkUuid, invalidateTreeParameters, false); } @@ -1562,7 +1574,7 @@ public void updateNetworkModificationsMetadata(UUID studyUuid, UUID nodeUuid, Li throw new StudyException(NOT_ALLOWED); } UUID groupId = networkModificationTreeService.getModificationGroupUuid(nodeUuid); - networkModificationService.updateModificationsMetadata(groupId, modificationsUuids, metadata); + networkModificationService.updateModificationsMetadata(groupId, modificationsUuids, metadata, userId); if (metadata.getActivated() != null || metadata.getName() != null) { invalidateNodeTree(studyUuid, nodeUuid); } diff --git a/src/main/resources/config/application.yaml b/src/main/resources/config/application.yaml index 0ea863bac6..c5e9fe4c4c 100644 --- a/src/main/resources/config/application.yaml +++ b/src/main/resources/config/application.yaml @@ -15,13 +15,17 @@ spring: consumeLoadFlowResult;consumeLoadFlowStopped;consumeLoadFlowFailed;consumeLoadFlowCancelFailed;\ consumeStateEstimationResult;consumeStateEstimationDebug;consumeStateEstimationStopped;consumeStateEstimationFailed;\ consumePccMinResult;consumePccMinStopped;consumePccMinFailed;\ - consumeNetworkExportFinished" + consumeNetworkExportFinished;\ + consumeSharedElementUpdate" stream: bindings: publishStudyUpdate-out-0: destination: ${powsybl-ws.rabbitmq.destination.prefix:}study.update publishElementUpdate-out-0: destination: ${powsybl-ws.rabbitmq.destination.prefix:}element.update + consumeSharedElementUpdate-in-0: + destination: ${powsybl-ws.rabbitmq.destination.prefix:}element.shared.update + group: studySharedElementUpdateGroup consumeSaResult-in-0: destination: ${powsybl-ws.rabbitmq.destination.prefix:}sa.result group: studySaResultGroup diff --git a/src/test/java/org/gridsuite/study/server/NetworkModificationTest.java b/src/test/java/org/gridsuite/study/server/NetworkModificationTest.java index 678559b37c..37e4fecc75 100644 --- a/src/test/java/org/gridsuite/study/server/NetworkModificationTest.java +++ b/src/test/java/org/gridsuite/study/server/NetworkModificationTest.java @@ -89,7 +89,6 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.gridsuite.study.server.StudyConstants.HEADER_ERROR_MESSAGE; import static org.gridsuite.study.server.StudyConstants.QUERY_PARAM_RECEIVER; -import static org.gridsuite.study.server.dto.ReferenceAttributes.ReferenceType.NETWORK_MODIFICATION; import static org.gridsuite.study.server.dto.ReferenceAttributes.ReferenceType.STUDY_NODE; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.MAX_NODE_BUILDS_EXCEEDED; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; @@ -2504,70 +2503,67 @@ void testDuplicateModificationReplicateChildExcludedUuids() throws Exception { } @Test - void testDuplicateModificationCreatesReferencesToSharedComposites() throws Exception { - // Verifies createReferencesToSharedComposites correctly splits references in two: - // - a duplicated modification that IS itself a reference -> new reference points to the target node (STUDY_NODE) - // - a reference nested inside a duplicated composite -> new reference points to the composite's copy (NETWORK_MODIFICATION) + void testDuplicateCompositeModificationWithNestedReference() throws Exception { + // copy-pasting a composite modification that itself contains a modification-reference (containerId != null): + // the new reference must be anchored on the NEW composite's uuid (type NETWORK_MODIFICATION), not on the target node String userId = "userId"; StudyEntity studyEntity = insertDummyStudy(UUID.fromString(NETWORK_UUID_STRING), CASE_UUID, "UCTE"); UUID studyUuid = studyEntity.getId(); UUID rootNodeUuid = getRootNode(studyUuid).getId(); NetworkModificationNode node1 = createNetworkModificationNode(studyUuid, rootNodeUuid, - UUID.randomUUID(), VARIANT_ID, "node for reference splitting", userId); + UUID.randomUUID(), VARIANT_ID, "node for nested reference copy", userId); UUID nodeUuid1 = node1.getId(); - UUID firstRootNetworkUuid = studyTestUtils.getOneRootNetworkUuid(studyUuid); - // modification1 is itself a modification-reference (root level); modification2 is a composite containing - // originalChild, itself a modification-reference nested inside modification2 - UUID modification1 = UUID.randomUUID(); - UUID modification2 = UUID.randomUUID(); - List modificationUuids = List.of(modification1, modification2); + // the composite modification being copied (X), containing 2 nested modification-reference children (R1, R2) + UUID compositeModification = UUID.randomUUID(); String modificationUuidListBody = mapper.writeValueAsString( - modificationUuids.stream().map(uuid -> new ModificationMoveOrCopyInfos(uuid, null)).toList()); - - UUID copy1 = UUID.randomUUID(); - UUID copy2 = UUID.randomUUID(); - List copyUuids = List.of(copy1, copy2); - UUID originalChild = UUID.randomUUID(); - UUID copyChild = UUID.randomUUID(); - UUID sharedComposite1 = UUID.randomUUID(); - UUID sharedComposite2 = UUID.randomUUID(); - + List.of(new ModificationMoveOrCopyInfos(compositeModification, null))); + UUID nestedReferenceModification1 = UUID.randomUUID(); + UUID nestedReferenceModification2 = UUID.randomUUID(); + + // the composite gets a brand-new uuid once duplicated (Z), with its own copies of R1 and R2 + UUID copiedCompositeModification = UUID.randomUUID(); + UUID copiedNestedReferenceModification1 = UUID.randomUUID(); + UUID copiedNestedReferenceModification2 = UUID.randomUUID(); wireMockServer.stubFor(WireMock.any(WireMock.urlPathMatching("/v1/containers/.*")) .withQueryParam("action", WireMock.equalTo("COPY")) .willReturn(WireMock.ok() - .withBody(mapper.writeValueAsString(new NetworkModificationsResult(copyUuids, List.of()))) + .withBody(mapper.writeValueAsString(new NetworkModificationsResult(List.of(copiedCompositeModification), List.of()))) .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); - // first call (originals) returns originalChild (nested under modification2); second call (copies) returns copyChild + // first call (before duplication) is for X's children, second call (after duplication) is for Z's children wireMockServer.stubFor(WireMock.get(WireMock.urlPathMatching("/v1/network-composite-modifications/children-uuids")) - .inScenario("referenceSplitMapping") - .whenScenarioStateIs(Scenario.STARTED) + .withQueryParam("uuids", WireMock.containing(compositeModification.toString())) .willReturn(WireMock.ok() - .withBody(mapper.writeValueAsString(List.of(originalChild))) - .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) - .willSetStateTo("secondCall")); + .withBody(mapper.writeValueAsString(List.of(nestedReferenceModification1, nestedReferenceModification2))) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); wireMockServer.stubFor(WireMock.get(WireMock.urlPathMatching("/v1/network-composite-modifications/children-uuids")) - .inScenario("referenceSplitMapping") - .whenScenarioStateIs("secondCall") + .withQueryParam("uuids", WireMock.containing(copiedCompositeModification.toString())) .willReturn(WireMock.ok() - .withBody(mapper.writeValueAsString(List.of(copyChild))) + .withBody(mapper.writeValueAsString(List.of(copiedNestedReferenceModification1, copiedNestedReferenceModification2))) .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); - // modification1 is a direct reference (containerId null); originalChild is a reference nested in modification2 + // R1 and R2 are themselves modification-references (containerId == compositeModification, their parent) - + // getReferences() only reports this once R1/R2's own uuids are included in the query (they are X's children, + // found above), NOT because X itself is queried - each one must get its own new reference, both anchored + // on the same new composite uuid + UUID sharedElementUuid1 = UUID.randomUUID(); + UUID sharedElementUuid2 = UUID.randomUUID(); wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/v1/references")) .willReturn(WireMock.ok() .withBody(mapper.writeValueAsString(List.of( - new ReferenceData(modification1, sharedComposite1, null), - new ReferenceData(originalChild, sharedComposite2, modification2)))) + new ReferenceData(nestedReferenceModification1, sharedElementUuid1, compositeModification), + new ReferenceData(nestedReferenceModification2, sharedElementUuid2, compositeModification)))) .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); - wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/elements/" + sharedComposite1 + "/references")) + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/elements/" + sharedElementUuid1 + "/references")) .withHeader(USER_ID_HEADER, WireMock.equalTo(userId)) - .willReturn(WireMock.ok().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); - wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/elements/" + sharedComposite2 + "/references")) + .willReturn(WireMock.ok() + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/elements/" + sharedElementUuid2 + "/references")) .withHeader(USER_ID_HEADER, WireMock.equalTo(userId)) - .willReturn(WireMock.ok().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + .willReturn(WireMock.ok() + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); mockMvc.perform(put("/v1/studies/{studyUuid}/nodes/{nodeUuid}?originStudyUuid={originStudyUuid}&originNodeUuid={originNodeUuid}&action=COPY", studyUuid, nodeUuid1, studyUuid, nodeUuid1) @@ -2575,33 +2571,94 @@ void testDuplicateModificationCreatesReferencesToSharedComposites() throws Excep .content(modificationUuidListBody) .header(USER_ID_HEADER, userId)) .andExpect(status().isOk()); + checkUpdateStatusMessagesReceived(studyUuid, nodeUuid1, output); checkEquipmentUpdatingFinishedMessagesReceived(studyUuid, nodeUuid1); checkElementUpdatedMessageSent(studyUuid, userId); - Pair, List> modificationBody = Pair.of(modificationUuids, - List.of(rootNetworkNodeInfoService.getNetworkModificationApplicationContext(firstRootNetworkUuid, node1.getId(), NETWORK_UUID))); - String expectedBody = mapper.writeValueAsString(modificationBody); - String url = "/v1/containers/" + node1.getModificationGroupUuid(); - WireMockUtilsCriteria.verifyPutRequest(wireMockServer, url, Map.of("action", WireMock.equalTo("COPY")), expectedBody); - - // Verify both findAllChildrenUuids calls were made (originals + copies) + Pair, List> modificationBody = Pair.of(List.of(compositeModification), + List.of(rootNetworkNodeInfoService.getNetworkModificationApplicationContext(studyTestUtils.getOneRootNetworkUuid(studyUuid), node1.getId(), NETWORK_UUID))); + WireMockUtilsCriteria.verifyPutRequest(wireMockServer, "/v1/containers/" + node1.getModificationGroupUuid(), Map.of("action", WireMock.equalTo("COPY")), + mapper.writeValueAsString(modificationBody)); WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/network-composite-modifications/children-uuids", Map.of("uuids", WireMock.matching(".*")), 2); + // the /references lookup must include X's children (R1, R2), not just X itself - otherwise the nested + // reference modifications are invisible to getReferences() WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/references", Map.of("uuids", WireMock.matching(".*")), 1); - // modification1 IS the requested reference -> new reference targets the node it was pasted into - WireMockUtilsCriteria.verifyPostRequest( - wireMockServer, - "/v1/elements/" + sharedComposite1 + "/references", - Map.of(), - mapper.writeValueAsString(new ReferenceAttributes(nodeUuid1, STUDY_NODE))); + // both new references must point to the NEW composite uuid, with type NETWORK_MODIFICATION - not to nodeUuid1/STUDY_NODE + String expectedReferenceBody = mapper.writeValueAsString(new ReferenceAttributes(copiedCompositeModification, ReferenceAttributes.ReferenceType.NETWORK_MODIFICATION)); + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/elements/" + sharedElementUuid1 + "/references", Map.of(), expectedReferenceBody); + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/elements/" + sharedElementUuid2 + "/references", Map.of(), expectedReferenceBody); + } - // originalChild's reference is nested inside modification2 -> new reference targets modification2's copy - WireMockUtilsCriteria.verifyPostRequest( - wireMockServer, - "/v1/elements/" + sharedComposite2 + "/references", - Map.of(), - mapper.writeValueAsString(new ReferenceAttributes(copy2, NETWORK_MODIFICATION))); + @Test + void testDuplicateStandaloneReferenceChildOfComposite() throws Exception { + // copy-pasting ONLY a modification-reference that currently lives inside a composite (its parent composite + // is NOT part of this copy request) : reference.containerId() still reports the composite's uuid (that's + // where it lives in network-modification-server), but since only the reference itself was requested, the + // new (standalone) copy must be anchored on the target NODE, type STUDY_NODE - NOT on the composite (which + // isn't even being duplicated here, so there is no "new composite uuid" to anchor on). + String userId = "userId"; + StudyEntity studyEntity = insertDummyStudy(UUID.fromString(NETWORK_UUID_STRING), CASE_UUID, "UCTE"); + UUID studyUuid = studyEntity.getId(); + UUID rootNodeUuid = getRootNode(studyUuid).getId(); + NetworkModificationNode node1 = createNetworkModificationNode(studyUuid, rootNodeUuid, + UUID.randomUUID(), VARIANT_ID, "node for standalone reference copy", userId); + UUID nodeUuid1 = node1.getId(); + + // R lives inside composite X in the source, but only R (not X) is requested for copy + UUID parentComposite = UUID.randomUUID(); + UUID referenceModification = UUID.randomUUID(); + String modificationUuidListBody = mapper.writeValueAsString( + List.of(new ModificationMoveOrCopyInfos(referenceModification, null))); + + UUID copiedReferenceModification = UUID.randomUUID(); + wireMockServer.stubFor(WireMock.any(WireMock.urlPathMatching("/v1/containers/.*")) + .withQueryParam("action", WireMock.equalTo("COPY")) + .willReturn(WireMock.ok() + .withBody(mapper.writeValueAsString(new NetworkModificationsResult(List.of(copiedReferenceModification), List.of()))) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + + // referenceModification is a leaf, not a composite: no children, before or after duplication + wireMockServer.stubFor(WireMock.get(WireMock.urlPathMatching("/v1/network-composite-modifications/children-uuids")) + .willReturn(WireMock.ok() + .withBody(mapper.writeValueAsString(List.of())) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + + // R IS a modification-reference; containerId reports its current parent composite X, even though X wasn't requested + UUID sharedElementUuid = UUID.randomUUID(); + wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/v1/references")) + .willReturn(WireMock.ok() + .withBody(mapper.writeValueAsString(List.of( + new ReferenceData(referenceModification, sharedElementUuid, parentComposite)))) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/elements/" + sharedElementUuid + "/references")) + .withHeader(USER_ID_HEADER, WireMock.equalTo(userId)) + .willReturn(WireMock.ok() + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))); + + mockMvc.perform(put("/v1/studies/{studyUuid}/nodes/{nodeUuid}?originStudyUuid={originStudyUuid}&originNodeUuid={originNodeUuid}&action=COPY", + studyUuid, nodeUuid1, studyUuid, nodeUuid1) + .contentType(MediaType.APPLICATION_JSON) + .content(modificationUuidListBody) + .header(USER_ID_HEADER, userId)) + .andExpect(status().isOk()); + + checkUpdateStatusMessagesReceived(studyUuid, nodeUuid1, output); + checkEquipmentUpdatingFinishedMessagesReceived(studyUuid, nodeUuid1); + checkElementUpdatedMessageSent(studyUuid, userId); + + Pair, List> modificationBody = Pair.of(List.of(referenceModification), + List.of(rootNetworkNodeInfoService.getNetworkModificationApplicationContext(studyTestUtils.getOneRootNetworkUuid(studyUuid), node1.getId(), NETWORK_UUID))); + WireMockUtilsCriteria.verifyPutRequest(wireMockServer, "/v1/containers/" + node1.getModificationGroupUuid(), Map.of("action", WireMock.equalTo("COPY")), + mapper.writeValueAsString(modificationBody)); + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/network-composite-modifications/children-uuids", Map.of("uuids", WireMock.matching(".*")), 2); + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/references", Map.of("uuids", WireMock.matching(".*")), 1); + + // the new reference must point to the target NODE, type STUDY_NODE - not to parentComposite/NETWORK_MODIFICATION + String expectedReferenceBody = mapper.writeValueAsString(new ReferenceAttributes(nodeUuid1, ReferenceAttributes.ReferenceType.STUDY_NODE)); + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/elements/" + sharedElementUuid + "/references", Map.of(), expectedReferenceBody); } @Test diff --git a/src/test/java/org/gridsuite/study/server/notification/NotificationServiceTest.java b/src/test/java/org/gridsuite/study/server/notification/NotificationServiceTest.java new file mode 100644 index 0000000000..f44008ce1f --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/notification/NotificationServiceTest.java @@ -0,0 +1,78 @@ +/** + * 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.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.messaging.Message; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +/** + * @author Souissi Maissa + */ +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + private static final String STUDY_UPDATE_DESTINATION = "publishStudyUpdate-out-0"; + + @Mock + private StreamBridge updatePublisher; + @Captor + private ArgumentCaptor> messageCaptor; + + private NotificationService notificationService; + + @BeforeEach + void setUp() { + notificationService = new NotificationService(updatePublisher, new ObjectMapper()); + } + + @Test + void emitSharedElementUpdatedSendsMessageWithParentNodeAndModificationUuids() { + UUID studyUuid = UUID.randomUUID(); + UUID parentNodeUuid = UUID.randomUUID(); + List networkModificationUuids = List.of(UUID.randomUUID(), UUID.randomUUID()); + + notificationService.emitSharedElementUpdated(studyUuid, parentNodeUuid, networkModificationUuids); + + verify(updatePublisher).send(org.mockito.ArgumentMatchers.eq(STUDY_UPDATE_DESTINATION), messageCaptor.capture()); + Message message = messageCaptor.getValue(); + assertThat(message.getPayload()).isEmpty(); + assertThat(message.getHeaders()) + .containsEntry(NotificationService.HEADER_STUDY_UUID, studyUuid) + .containsEntry(NotificationService.HEADER_UPDATE_TYPE, NotificationService.SHARED_ELEMENT_UPDATED) + .containsEntry(NotificationService.HEADER_PARENT_NODE, parentNodeUuid) + .containsEntry(NotificationService.HEADER_NETWORK_MODIFICATION_UUIDS, networkModificationUuids); + } + + @Test + void emitSharedElementUpdatedAcceptsEmptyModificationUuids() { + UUID studyUuid = UUID.randomUUID(); + UUID parentNodeUuid = UUID.randomUUID(); + + notificationService.emitSharedElementUpdated(studyUuid, parentNodeUuid, List.of()); + + verify(updatePublisher).send(org.mockito.ArgumentMatchers.eq(STUDY_UPDATE_DESTINATION), messageCaptor.capture()); + Message message = messageCaptor.getValue(); + assertThat(message.getHeaders()) + .containsEntry(NotificationService.HEADER_UPDATE_TYPE, NotificationService.SHARED_ELEMENT_UPDATED) + .containsEntry(NotificationService.HEADER_PARENT_NODE, parentNodeUuid) + .containsEntry(NotificationService.HEADER_NETWORK_MODIFICATION_UUIDS, List.of()); + } +} diff --git a/src/test/java/org/gridsuite/study/server/service/ConsumerServiceSharedElementUpdateTest.java b/src/test/java/org/gridsuite/study/server/service/ConsumerServiceSharedElementUpdateTest.java new file mode 100644 index 0000000000..87ee53c311 --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/service/ConsumerServiceSharedElementUpdateTest.java @@ -0,0 +1,153 @@ +/* + * 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.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.gridsuite.study.server.dto.ReferenceAttributes; +import org.gridsuite.study.server.nodeactivity.NodeActivityRunnerService; +import org.gridsuite.study.server.nodeactivity.NodeActivityService; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.service.common.ComputationParametersService; +import org.gridsuite.study.server.service.loadflow.LoadFlowRestService; +import org.gridsuite.study.server.service.loadflow.LoadFlowService; +import org.junit.jupiter.api.BeforeEach; +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.integration.support.MessageBuilder; +import org.springframework.messaging.Message; + +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.gridsuite.study.server.dto.ReferenceAttributes.ReferenceType.NETWORK_MODIFICATION; +import static org.gridsuite.study.server.dto.ReferenceAttributes.ReferenceType.STUDY_NODE; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Souissi Maissa + */ +@ExtendWith(MockitoExtension.class) +class ConsumerServiceSharedElementUpdateTest { + + @Mock + private NotificationService notificationService; + @Mock + private StudyService studyService; + @Mock + private CaseService caseService; + @Mock + private LoadFlowRestService loadFlowRestService; + @Mock + private NetworkModificationTreeService networkModificationTreeService; + @Mock + private NetworkModificationService networkModificationService; + @Mock + private StudyConfigService studyConfigService; + @Mock + private RootNetworkNodeInfoService rootNetworkNodeInfoService; + @Mock + private RootNetworkService rootNetworkService; + @Mock + private DirectoryService directoryService; + @Mock + private ComputationParametersService computationParametersService; + @Mock + private UserAdminService userAdminService; + @Mock + private LoadFlowService loadFlowService; + @Mock + private NodeActivityRunnerService nodeActivityRunnerService; + @Mock + private NodeActivityService nodeActivityService; + + private Consumer>>> consumeSharedElementUpdate; + + @BeforeEach + void setup() { + ConsumerService consumerService = new ConsumerService(new ObjectMapper(), notificationService, studyService, caseService, + loadFlowRestService, networkModificationTreeService, networkModificationService, studyConfigService, + rootNetworkNodeInfoService, rootNetworkService, directoryService, computationParametersService, userAdminService, loadFlowService, + nodeActivityRunnerService, nodeActivityService); + consumeSharedElementUpdate = consumerService.consumeSharedElementUpdate(); + } + + @Test + void directNodeReferenceInvalidatesThatNodeWithoutResolvingModifications() { + UUID nodeUuid = UUID.randomUUID(); + UUID studyUuid = UUID.randomUUID(); + when(networkModificationTreeService.getStudyUuidForNodeId(nodeUuid)).thenReturn(studyUuid); + + consumeSharedElementUpdate.accept(sharedElementUpdateMessage(List.of(nodeUuid), List.of())); + + verify(studyService).invalidateNodeTreeWhenSharedModificationChanged(studyUuid, nodeUuid); + verify(notificationService).emitSharedElementUpdated(studyUuid, nodeUuid, List.of()); + verify(networkModificationService, never()).findRootGroupByModification(anyList()); + } + + @Test + void compositeReferenceIsResolvedThroughItsRootGroupToItsNode() { + UUID compositeUuid = UUID.randomUUID(); + UUID groupUuid = UUID.randomUUID(); + UUID nodeUuid = UUID.randomUUID(); + UUID studyUuid = UUID.randomUUID(); + when(networkModificationService.findRootGroupByModification(List.of(compositeUuid))).thenReturn(Map.of(compositeUuid, groupUuid)); + when(networkModificationTreeService.getNodeUuidsByModificationGroups(List.of(groupUuid))).thenReturn(Map.of(groupUuid, nodeUuid)); + when(networkModificationTreeService.getStudyUuidForNodeId(nodeUuid)).thenReturn(studyUuid); + + consumeSharedElementUpdate.accept(sharedElementUpdateMessage(List.of(), List.of(compositeUuid))); + + verify(studyService).invalidateNodeTreeWhenSharedModificationChanged(studyUuid, nodeUuid); + verify(notificationService).emitSharedElementUpdated(studyUuid, nodeUuid, List.of(compositeUuid)); + } + + @Test + void sameNodeReachedDirectlyAndThroughACompositeIsInvalidatedOnce() { + UUID nodeUuid = UUID.randomUUID(); + UUID studyUuid = UUID.randomUUID(); + UUID compositeUuid = UUID.randomUUID(); + UUID groupUuid = UUID.randomUUID(); + when(networkModificationService.findRootGroupByModification(List.of(compositeUuid))).thenReturn(Map.of(compositeUuid, groupUuid)); + when(networkModificationTreeService.getNodeUuidsByModificationGroups(List.of(groupUuid))).thenReturn(Map.of(groupUuid, nodeUuid)); + when(networkModificationTreeService.getStudyUuidForNodeId(nodeUuid)).thenReturn(studyUuid); + + consumeSharedElementUpdate.accept(sharedElementUpdateMessage(List.of(nodeUuid), List.of(compositeUuid))); + + verify(studyService, times(1)).invalidateNodeTreeWhenSharedModificationChanged(studyUuid, nodeUuid); + verify(notificationService, times(1)).emitSharedElementUpdated(studyUuid, nodeUuid, List.of(compositeUuid)); + } + + @Test + void emptyMessageInvalidatesNothing() { + consumeSharedElementUpdate.accept(sharedElementUpdateMessage(List.of(), List.of())); + + verify(studyService, never()).invalidateNodeTreeWhenSharedModificationChanged(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(notificationService, never()).emitSharedElementUpdated(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), anyList()); + verify(networkModificationService, never()).findRootGroupByModification(anyList()); + } + + private static Message>> sharedElementUpdateMessage(List studyNodeUuids, List networkModificationUuids) { + Map> referencesByType = new EnumMap<>(ReferenceAttributes.ReferenceType.class); + if (!studyNodeUuids.isEmpty()) { + referencesByType.put(STUDY_NODE, toReferenceAttributes(studyNodeUuids, STUDY_NODE)); + } + if (!networkModificationUuids.isEmpty()) { + referencesByType.put(NETWORK_MODIFICATION, toReferenceAttributes(networkModificationUuids, NETWORK_MODIFICATION)); + } + return MessageBuilder.withPayload(referencesByType).build(); + } + + private static List toReferenceAttributes(List uuids, ReferenceAttributes.ReferenceType type) { + return uuids.stream().map(uuid -> new ReferenceAttributes(uuid, type)).collect(Collectors.toList()); + } +} diff --git a/src/test/java/org/gridsuite/study/server/service/NetworkModificationServiceTest.java b/src/test/java/org/gridsuite/study/server/service/NetworkModificationServiceTest.java index 5dd0f994f4..91afe00295 100644 --- a/src/test/java/org/gridsuite/study/server/service/NetworkModificationServiceTest.java +++ b/src/test/java/org/gridsuite/study/server/service/NetworkModificationServiceTest.java @@ -8,19 +8,25 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.gridsuite.study.server.RemoteServicesProperties; +import org.gridsuite.study.server.dto.ReferenceData; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -122,4 +128,69 @@ void testUpdateNetworkModificationsMetadata() { verify(restTemplate).exchange(eq(expectedUrl), eq(HttpMethod.PUT), org.mockito.ArgumentMatchers.>any(), eq(Void.class)); } + + @Test + void testGetReferences() { + UUID firstUuid = UUID.randomUUID(); + UUID secondUuid = UUID.randomUUID(); + String expectedUrl = NETWORK_MODIFICATION_SERVER_URI + "/v1/references?uuids=" + firstUuid + "&uuids=" + secondUuid; + List expected = List.of(new ReferenceData(firstUuid, UUID.randomUUID(), null)); + when(restTemplate.exchange( + eq(expectedUrl), + eq(HttpMethod.GET), + any(HttpEntity.class), + Mockito.>>any())) + .thenReturn(ResponseEntity.ok(expected)); + + assertThat(networkModificationService.getReferences(List.of(firstUuid, secondUuid))).isEqualTo(expected); + } + + @Test + void testFindParentComposites() { + UUID firstUuid = UUID.randomUUID(); + UUID secondUuid = UUID.randomUUID(); + UUID compositeUuid = UUID.randomUUID(); + String expectedUrl = NETWORK_MODIFICATION_SERVER_URI + "/v1/network-composite-modifications/parent-composites?uuids=" + firstUuid + "&uuids=" + secondUuid; + Map expected = Map.of(firstUuid, compositeUuid); + when(restTemplate.exchange( + eq(expectedUrl), + eq(HttpMethod.GET), + any(HttpEntity.class), + Mockito.>>any())) + .thenReturn(ResponseEntity.ok(expected)); + + assertThat(networkModificationService.findParentComposites(List.of(firstUuid, secondUuid))).isEqualTo(expected); + } + + @Test + void testFindRootGroupByModification() { + UUID firstUuid = UUID.randomUUID(); + UUID secondUuid = UUID.randomUUID(); + UUID groupUuid = UUID.randomUUID(); + String expectedUrl = NETWORK_MODIFICATION_SERVER_URI + "/v1/network-composite-modifications/root-groups?uuids=" + firstUuid + "&uuids=" + secondUuid; + Map expected = Map.of(firstUuid, groupUuid, secondUuid, groupUuid); + when(restTemplate.exchange( + eq(expectedUrl), + eq(HttpMethod.GET), + any(HttpEntity.class), + Mockito.>>any())) + .thenReturn(ResponseEntity.ok(expected)); + + assertThat(networkModificationService.findRootGroupByModification(List.of(firstUuid, secondUuid))).isEqualTo(expected); + } + + @Test + void testGetReferencesFromGroup() { + UUID groupUuid = UUID.randomUUID(); + String expectedUrl = NETWORK_MODIFICATION_SERVER_URI + "/v1/groups/" + groupUuid + "/references"; + List expected = List.of(new ReferenceData(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID())); + when(restTemplate.exchange( + eq(expectedUrl), + eq(HttpMethod.GET), + any(HttpEntity.class), + Mockito.>>any())) + .thenReturn(ResponseEntity.ok(expected)); + + assertThat(networkModificationService.getReferencesFromGroup(groupUuid)).isEqualTo(expected); + } }