diff --git a/src/main/java/org/gridsuite/modification/server/CompositeController.java b/src/main/java/org/gridsuite/modification/server/CompositeController.java index 1d3147e75..093493164 100644 --- a/src/main/java/org/gridsuite/modification/server/CompositeController.java +++ b/src/main/java/org/gridsuite/modification/server/CompositeController.java @@ -144,6 +144,19 @@ public ResponseEntity> getParentComposites( .body(networkModificationService.findModificationParentComposites(networkModificationUuids)); } + /** + * @return modification uuid -> uuid of the top-level group ultimately containing it, walking up through as + * many nested composite modifications as needed; modifications not reachable from any group have no entry + */ + @GetMapping(value = "/root-groups", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "For each given network modification, find the top-level group ultimately containing it") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The root groups were returned")}) + public ResponseEntity> getRootGroups( + @Parameter(description = "Network modification UUIDs") @RequestParam("uuids") List networkModificationUuids) { + return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON) + .body(networkModificationService.findModificationRootGroups(networkModificationUuids)); + } + @PutMapping(value = "/{uuid}/replace", consumes = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "Replaces all the network modifications inside a network composite modification") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The composite modification has been updated")}) diff --git a/src/main/java/org/gridsuite/modification/server/NetworkModificationController.java b/src/main/java/org/gridsuite/modification/server/NetworkModificationController.java index a057601a9..752b5e2d2 100644 --- a/src/main/java/org/gridsuite/modification/server/NetworkModificationController.java +++ b/src/main/java/org/gridsuite/modification/server/NetworkModificationController.java @@ -194,8 +194,9 @@ public CompletableFuture> createNetwo @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The network modification was updated")}) public ResponseEntity updateNetworkModification( @Parameter(description = "Network modification UUID") @PathVariable("uuid") UUID networkModificationUuid, - @RequestBody ModificationInfos modificationInfos) { - networkModificationService.updateNetworkModification(networkModificationUuid, modificationInfos); + @RequestBody ModificationInfos modificationInfos, + @RequestHeader("userId") String userId) { + networkModificationService.updateNetworkModification(networkModificationUuid, modificationInfos, userId); return ResponseEntity.ok().build(); } @@ -313,8 +314,9 @@ public ResponseEntity> getAllReferencesDataFromGroup( @ApiResponse(responseCode = "200", description = "The metadata of the network modifications has been successfully updated") public ResponseEntity updateNetworkModificationMetadata( @Parameter(description = "Network modifications UUIDs") @RequestParam("uuids") List networkModificationUuids, - @RequestBody ModificationInfos metadata) { - networkModificationService.updateNetworkModificationMetadata(networkModificationUuids, metadata); + @RequestBody ModificationInfos metadata, @RequestHeader("userId") String userId) { + + networkModificationService.updateNetworkModificationMetadata(networkModificationUuids, metadata, userId); return ResponseEntity.ok().build(); } diff --git a/src/main/java/org/gridsuite/modification/server/repositories/ModificationRepository.java b/src/main/java/org/gridsuite/modification/server/repositories/ModificationRepository.java index 198d9dc58..223c6744e 100644 --- a/src/main/java/org/gridsuite/modification/server/repositories/ModificationRepository.java +++ b/src/main/java/org/gridsuite/modification/server/repositories/ModificationRepository.java @@ -8,6 +8,7 @@ import org.gridsuite.modification.server.entities.CompositeModificationEntity; import org.gridsuite.modification.server.entities.ModificationEntity; +import org.gridsuite.modification.server.entities.ModificationReferenceEntity; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; @@ -98,6 +99,31 @@ SELECT CAST(m.id AS VARCHAR), CAST(m.container_id AS VARCHAR) """, nativeQuery = true) List findCompositeContainerIdsByModificationIds(@Param("uuids") Collection uuids); + /** + * For each given modification, walks up the {@code container_id} chain (a modification may live in a + * COMPOSITE that is itself nested in another container) until it reaches the enclosing GROUP. + * Modifications sitting directly in a group are resolved in one step. + * + * @return one [modification id, root group id] row per given modification uuid that is actually reachable + * from a group; ids not found (deleted, or orphaned from any group) have no row + */ + @Query(value = """ + WITH RECURSIVE container_chain(modification_id, current_container_id) AS ( + SELECT m.id AS modification_id, m.container_id AS current_container_id + FROM modification m + WHERE m.id IN :uuids + UNION ALL + SELECT cc.modification_id, m2.container_id + FROM container_chain cc + JOIN modification m2 ON m2.id = cc.current_container_id + ) + SELECT CAST(cc.modification_id AS VARCHAR), CAST(cc.current_container_id AS VARCHAR) + FROM container_chain cc + JOIN modification_container c ON c.id = cc.current_container_id + WHERE c.type = 'GROUP' + """, nativeQuery = true) + List findRootGroupIdsByModificationIds(@Param("uuids") Collection uuids); + @Query(""" SELECT COUNT(m) FROM ModificationEntity m WHERE m.container.id = :containerId AND m.stashed = :stashed @@ -109,6 +135,10 @@ SELECT COUNT(m) FROM ModificationEntity m "from ModificationEntity m WHERE m.id = (select r.referenceId from ModificationReferenceEntity r WHERE r.id = ?1)") ModificationEntity findReferencedModificationMetadataByReferenceId(UUID uuid); + // return all the modification-references pointing at a given element (e.g. a composite shared from directory-server) + @Query("SELECT r FROM ModificationReferenceEntity r WHERE r.referenceId = :elementUuid") + List findAllByReferenceId(@Param("elementUuid") UUID elementUuid); + @Query(value = "SELECT cast(operational_limits_groups_id AS VARCHAR) FROM line_modification_operational_limits_groups WHERE branch_id IN ?1", nativeQuery = true) List findLineModificationOpLimitsGroupsIdsByBranchIds(List uuids); diff --git a/src/main/java/org/gridsuite/modification/server/repositories/NetworkModificationRepository.java b/src/main/java/org/gridsuite/modification/server/repositories/NetworkModificationRepository.java index f0f941cb9..c0fa111ba 100644 --- a/src/main/java/org/gridsuite/modification/server/repositories/NetworkModificationRepository.java +++ b/src/main/java/org/gridsuite/modification/server/repositories/NetworkModificationRepository.java @@ -772,6 +772,32 @@ private List getUnstashedModificationsInfosNonTransactional(U return getModificationEntityStream(groupUuid).filter(m -> !m.getStashed()).map(this::toModificationsInfosOptimized).toList(); } + /** + * @return ancestor composite modification uuids, closest first; empty if the modification is a + * direct child of a group (not nested in any composite) + */ + @Transactional(readOnly = true) + public List findAncestorCompositeUuids(@NonNull UUID modificationUuid) { + List ancestors = new ArrayList<>(); + Set visited = new HashSet<>(); + UUID currentContainerId = modificationRepository.findById(modificationUuid) + .map(ModificationEntity::getContainerUuid) + .orElse(null); + + while (currentContainerId != null && visited.add(currentContainerId)) { + ModificationContainerType containerType = modificationContainerRepository.getTypeById(currentContainerId); + if (containerType != ModificationContainerType.COMPOSITE) { + break; // reached a GROUP (a study node's own modifications) : top of the tree + } + ancestors.add(currentContainerId); + // the composite container shares its id with the CompositeModificationEntity that owns it + currentContainerId = modificationRepository.findById(currentContainerId) + .map(ModificationEntity::getContainerUuid) + .orElse(null); + } + return ancestors; + } + /** * @return ReferenceData : modification and elementUuid of the shared modification -> Uuid of the composite containing the reference, null if the modification reference is at the root level */ @@ -789,6 +815,18 @@ public List getReferences(@NonNull List modificationUuids) return references; } + /** + * @return one ReferenceData per modification-reference pointing at {@code elementUuid} (e.g. a + * composite shared from directory-server), empty if nothing references it + */ + @Transactional(readOnly = true) + public List getReferencesByElementUuid(@NonNull UUID elementUuid) { + return modificationRepository.findAllByReferenceId(elementUuid).stream() + .map(reference -> new ReferenceData(reference.getId(), reference.getReferenceId(), + modificationRepository.findCompositeContainerIdByModificationId(reference.getId()))) + .toList(); + } + @Transactional public void stashNetworkModifications(@NonNull List modificationUuids, int stashedModificationCount) { int stashModificationOrder = -stashedModificationCount - 1; diff --git a/src/main/java/org/gridsuite/modification/server/service/NetworkModificationService.java b/src/main/java/org/gridsuite/modification/server/service/NetworkModificationService.java index 63268025f..d1b2c031e 100644 --- a/src/main/java/org/gridsuite/modification/server/service/NetworkModificationService.java +++ b/src/main/java/org/gridsuite/modification/server/service/NetworkModificationService.java @@ -275,13 +275,20 @@ public NetworkInfos getNetworkInfos(UUID networkUuid, String variantId, Preloadi } @Transactional - public void updateNetworkModification(@NonNull UUID modificationUuid, @NonNull ModificationInfos modificationInfos) { + public void updateNetworkModification(@NonNull UUID modificationUuid, @NonNull ModificationInfos modificationInfos, @NonNull String userId) { networkModificationRepository.updateModification(modificationUuid, modificationInfos); + // Notify directory-server once per ancestor composite (closest first) + List ancestorCompositeUuids = networkModificationRepository.findAncestorCompositeUuids(modificationUuid); + ancestorCompositeUuids.forEach(compositeUuid -> + notificationService.emitElementUpdated(compositeUuid, userId)); } @Transactional - public void updateNetworkModificationMetadata(@NonNull List modificationUuids, @NonNull ModificationInfos metadata) { + public void updateNetworkModificationMetadata(@NonNull List modificationUuids, @NonNull ModificationInfos metadata, @NonNull String userId) { networkModificationRepository.updateNetworkModificationMetadata(modificationUuids, metadata); + //List ancestorCompositeUuids = networkModificationRepository.findAncestorCompositeUuids(modificationUuids.getFirst()); + //ancestorCompositeUuids.forEach(compositeUuid -> notificationService.emitElementUpdated(compositeUuid, userId)); + } @Transactional @@ -297,6 +304,14 @@ public Map findModificationParentComposites(@NonNull List modi row -> UUID.fromString((String) row[1]))); } + @Transactional(readOnly = true) + public Map findModificationRootGroups(@NonNull List modificationUuids) { + return modificationRepository.findRootGroupIdsByModificationIds(modificationUuids).stream() + .collect(Collectors.toMap( + row -> UUID.fromString((String) row[0]), + row -> UUID.fromString((String) row[1]))); + } + @Transactional public void stashNetworkModifications(UUID groupUuid, @NonNull List modificationUuids) { for (UUID modificationUuid : modificationUuids) { diff --git a/src/main/java/org/gridsuite/modification/server/service/NotificationService.java b/src/main/java/org/gridsuite/modification/server/service/NotificationService.java index 2094d050f..e96a542e7 100644 --- a/src/main/java/org/gridsuite/modification/server/service/NotificationService.java +++ b/src/main/java/org/gridsuite/modification/server/service/NotificationService.java @@ -17,6 +17,9 @@ import org.springframework.messaging.support.MessageBuilder; import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.UUID; + /** * @author Seddik Yengui */ @@ -32,6 +35,9 @@ public class NotificationService { public static final String WORKFLOW_TYPE_HEADER = "workflowType"; public static final String WORKFLOW_INFOS_HEADER = "workflowInfos"; public static final String NETWORK_UUID_HEADER = "networkUuid"; + public static final String HEADER_ELEMENT_UUID = "elementUuid"; + public static final String HEADER_MODIFIED_BY = "modifiedBy"; + public static final String HEADER_MODIFICATION_DATE = "modificationDate"; @Autowired private StreamBridge publisher; @@ -66,4 +72,19 @@ public void emitCancelBuildMessage(@NonNull String receiver) { .build(); sendMessage(message, "publishCancelBuild-out-0"); } + + /** + * Tells directory-server that {@code elementUuid} (typically a composite modification's uuid, + * which is also its directory element uuid) was modified. directory-server is the sole holder + * of the "who references what" graph: it decides on its own whether {@code elementUuid} is a + * shared element and who needs to be notified further. + */ + public void emitElementUpdated(@NonNull UUID elementUuid, @NonNull String userId) { + Message message = MessageBuilder.withPayload("") + .setHeader(HEADER_ELEMENT_UUID, elementUuid) + .setHeader(HEADER_MODIFIED_BY, userId) + .setHeader(HEADER_MODIFICATION_DATE, Instant.now()) + .build(); + sendMessage(message, "publishElementUpdate-out-0"); + } } diff --git a/src/main/resources/config/application.yaml b/src/main/resources/config/application.yaml index f68627ccf..b83f668a4 100644 --- a/src/main/resources/config/application.yaml +++ b/src/main/resources/config/application.yaml @@ -38,7 +38,9 @@ spring: destination: ${powsybl-ws.rabbitmq.destination.prefix:}build.cancel publishStoppedBuild-out-0: destination: ${powsybl-ws.rabbitmq.destination.prefix:}build.stopped - output-bindings: publishBuild-out-0;publishResultBuild-out-0;publishCancelBuild-out-0;publishStoppedBuild-out-0 + publishElementUpdate-out-0: + destination: ${powsybl-ws.rabbitmq.destination.prefix:}element.update + output-bindings: publishBuild-out-0;publishResultBuild-out-0;publishCancelBuild-out-0;publishStoppedBuild-out-0;publishElementUpdate-out-0 rabbit: bindings: consumeBuild1-in-0: &consumeBuildRabbitConfig diff --git a/src/test/java/org/gridsuite/modification/server/CompositeControllerTest.java b/src/test/java/org/gridsuite/modification/server/CompositeControllerTest.java index fd19f15cf..602e29f6d 100644 --- a/src/test/java/org/gridsuite/modification/server/CompositeControllerTest.java +++ b/src/test/java/org/gridsuite/modification/server/CompositeControllerTest.java @@ -30,6 +30,7 @@ import org.gridsuite.modification.server.repositories.CompositeModificationRepository; import org.gridsuite.modification.server.repositories.ModificationRepository; import org.gridsuite.modification.server.repositories.NetworkModificationRepository; +import org.gridsuite.modification.server.service.NotificationService; import org.gridsuite.modification.server.service.ReportService; import org.gridsuite.modification.server.utils.NetworkCreation; import org.gridsuite.modification.server.utils.TestUtils; @@ -37,6 +38,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.mockito.stubbing.Answer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -44,6 +46,7 @@ import org.springframework.data.util.Pair; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -57,6 +60,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; +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.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -66,9 +72,11 @@ */ @AutoConfigureMockMvc @SpringBootTest +@ContextConfigurationWithTestChannel @DisableElasticsearch class CompositeControllerTest { private static final UUID TEST_NETWORK_ID = UUID.fromString("7928181c-7977-4592-ba19-88027e4254e4"); + private static final String TEST_USER_ID = "userId"; private static final UUID TEST_GROUP_ID = UUID.randomUUID(); private static final UUID TEST_GROUP2_ID = UUID.randomUUID(); private static final String URI_COMPOSITE_NETWORK_MODIF_BASE = "/v1/network-composite-modifications"; @@ -98,6 +106,9 @@ class CompositeControllerTest { @MockitoBean private NetworkStoreService networkStoreService; + @MockitoSpyBean + private NotificationService notificationService; + private Network network; @BeforeEach @@ -745,6 +756,75 @@ void testAssembleNetworkModificationsIntoNewComposite() throws Exception { assertContiguousOrder(modificationRepository.findAllByContainerId(TEST_GROUP_ID, false)); assertContiguousOrder(modificationRepository.findAllByContainer(firstCompositeUuid)); assertContiguousOrder(modificationRepository.findAllByContainer(twodepthCompositeUuid)); + + // ---- 3. editing the leaf nested 2 levels deep must notify directory-server (via the generic + // elementUpdated notification) for each ancestor composite, closest first - directory-server + // decides on its own whether either composite is actually shared and relays to study-server + UUID deeplyNestedLeafUuid = originalRootModUuids.get(0); + EquipmentAttributeModificationInfos leafUpdate = EquipmentAttributeModificationInfos.builder() + .equipmentType(IdentifiableType.SWITCH) + .equipmentAttributeName("open") + .equipmentId("v1b1") + .equipmentAttributeValue(true) + .build(); + mockMvc.perform(put(URI_NETWORK_MODIF_GET_PUT + deeplyNestedLeafUuid) + .content(mapper.writeValueAsString(leafUpdate)) + .contentType(MediaType.APPLICATION_JSON) + .header("userId", TEST_USER_ID)) + .andExpect(status().isOk()); + + InOrder inOrder = inOrder(notificationService); + inOrder.verify(notificationService).emitElementUpdated(twodepthCompositeUuid, TEST_USER_ID); + inOrder.verify(notificationService).emitElementUpdated(firstCompositeUuid, TEST_USER_ID); + } + + @Test + void testUpdateNetworkModificationInsideCompositeNotifiesDirectoryServer() throws Exception { + // Create a switch modification directly in the group, then assemble it into a composite + List modificationList = createSomeSwitchModifications(TEST_GROUP_ID, 1); + UUID leafUuid = modificationList.getFirst().getUuid(); + + MvcResult mvcResult = mockMvc.perform(post(URI_COMPOSITE_NETWORK_MODIF_BASE + "/") + .content(mapper.writeValueAsString(List.of(leafUuid))) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn(); + UUID compositeUuid = mapper.readValue(mvcResult.getResponse().getContentAsString(), new TypeReference<>() { }); + + // Editing the leaf while it is nested in the composite must notify directory-server + EquipmentAttributeModificationInfos leafUpdate = EquipmentAttributeModificationInfos.builder() + .equipmentType(IdentifiableType.SWITCH) + .equipmentAttributeName("open") + .equipmentId("v1b1") + .equipmentAttributeValue(true) + .build(); + mockMvc.perform(put(URI_NETWORK_MODIF_GET_PUT + leafUuid) + .content(mapper.writeValueAsString(leafUpdate)) + .contentType(MediaType.APPLICATION_JSON) + .header("userId", TEST_USER_ID)) + .andExpect(status().isOk()); + + verify(notificationService).emitElementUpdated(compositeUuid, TEST_USER_ID); + } + + @Test + void testUpdateRootLevelNetworkModificationDoesNotNotifyDirectoryServer() throws Exception { + // A modification sitting directly under a group is not nested in any composite : nothing to notify + List modificationList = createSomeSwitchModifications(TEST_GROUP_ID, 1); + UUID leafUuid = modificationList.getFirst().getUuid(); + + EquipmentAttributeModificationInfos leafUpdate = EquipmentAttributeModificationInfos.builder() + .equipmentType(IdentifiableType.SWITCH) + .equipmentAttributeName("open") + .equipmentId("v1b1") + .equipmentAttributeValue(true) + .build(); + mockMvc.perform(put(URI_NETWORK_MODIF_GET_PUT + leafUuid) + .content(mapper.writeValueAsString(leafUpdate)) + .contentType(MediaType.APPLICATION_JSON) + .header("userId", TEST_USER_ID)) + .andExpect(status().isOk()); + + verifyNoInteractions(notificationService); } @Test diff --git a/src/test/java/org/gridsuite/modification/server/ModificationControllerTest.java b/src/test/java/org/gridsuite/modification/server/ModificationControllerTest.java index b9a38861c..937c88011 100644 --- a/src/test/java/org/gridsuite/modification/server/ModificationControllerTest.java +++ b/src/test/java/org/gridsuite/modification/server/ModificationControllerTest.java @@ -255,9 +255,9 @@ void assertThrowsUpdateModificationNotFound() { UUID modificationUuid = UUID.randomUUID(); ModificationInfos modificationInfos = LoadCreationInfos.builder().equipmentId("id").build(); ModificationBusinessErrorCode businessErrorCode = assertThrows(NetworkModificationServerException.class, - () -> networkModificationService.updateNetworkModification(modificationUuid, modificationInfos)).getBusinessErrorCode(); + () -> networkModificationService.updateNetworkModification(modificationUuid, modificationInfos, "userId")).getBusinessErrorCode(); assertEquals(ModificationBusinessErrorCode.MODIFICATION_NOT_FOUND, businessErrorCode); - assertThrows(NullPointerException.class, () -> networkModificationService.updateNetworkModification(modificationUuid, null)); + assertThrows(NullPointerException.class, () -> networkModificationService.updateNetworkModification(modificationUuid, null, "userId")); } @Test @@ -478,6 +478,7 @@ void testDisableNetworkModifications() throws Exception { .queryParam("uuids", uuidString) .content(mapper.writeValueAsString(metadata)) .contentType(MediaType.APPLICATION_JSON) + .header("userId", "userId") ).andExpect(status().isOk()); assertEquals(false, modificationRepository.getModifications(TEST_GROUP_ID, true, true).getFirst().getActivated()); } @@ -512,6 +513,7 @@ void updateModificationDescription() throws Exception { .queryParam("uuids", uuidString) .content(mapper.writeValueAsString(metadata)) .contentType(MediaType.APPLICATION_JSON) + .header("userId", "userId") ).andExpect(status().isOk()); assertEquals("new description", modificationRepository.getModifications(TEST_GROUP_ID, true, true).getFirst().getDescription()); diff --git a/src/test/java/org/gridsuite/modification/server/modifications/AbstractNetworkModificationTest.java b/src/test/java/org/gridsuite/modification/server/modifications/AbstractNetworkModificationTest.java index 3b48912c4..cdb7dee41 100644 --- a/src/test/java/org/gridsuite/modification/server/modifications/AbstractNetworkModificationTest.java +++ b/src/test/java/org/gridsuite/modification/server/modifications/AbstractNetworkModificationTest.java @@ -209,7 +209,8 @@ public void testUpdate() throws Exception { String modificationToUpdateJson = mapper.writeValueAsString(modificationToUpdate); - mockMvc.perform(put(URI_NETWORK_MODIF_GET_PUT + modificationUuid).content(modificationToUpdateJson).contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(put(URI_NETWORK_MODIF_GET_PUT + modificationUuid).content(modificationToUpdateJson).contentType(MediaType.APPLICATION_JSON) + .header("userId", "userId")) .andExpect(status().isOk()); // TODO Need a test for substations impacted diff --git a/src/test/java/org/gridsuite/modification/server/service/NetworkModificationServiceTest.java b/src/test/java/org/gridsuite/modification/server/service/NetworkModificationServiceTest.java index fb563352d..b43fa08fe 100644 --- a/src/test/java/org/gridsuite/modification/server/service/NetworkModificationServiceTest.java +++ b/src/test/java/org/gridsuite/modification/server/service/NetworkModificationServiceTest.java @@ -210,6 +210,51 @@ void shouldFindParentCompositeOnlyForModificationsNestedInAComposite() { assertFalse(parentComposites.containsKey(directGroupChildUuid)); } + @Test + void shouldFindRootGroupForDirectAndNestedModifications() { + UUID groupUuid = UUID.randomUUID(); + CompositeModificationInfos compositeModificationInfos = compositeModification( + UUID.randomUUID(), + List.of(dummyModification(UUID.randomUUID())) + ); + + List saved = networkModificationRepository.saveModifications( + groupUuid, List.of( + ModificationEntity.fromDTO(compositeModificationInfos), + ModificationEntity.fromDTO(dummyModification(UUID.randomUUID())) + )); + CompositeModificationInfos savedComposite = (CompositeModificationInfos) saved.get(0); + UUID compositeUuid = savedComposite.getUuid(); + UUID nestedChildUuid = savedComposite.getModificationsInfos().get(0).getUuid(); + UUID directGroupChildUuid = saved.get(1).getUuid(); + + Map rootGroups = networkModificationService.findModificationRootGroups( + List.of(nestedChildUuid, directGroupChildUuid, compositeUuid)); + + assertEquals(3, rootGroups.size()); + assertEquals(groupUuid, rootGroups.get(nestedChildUuid)); + assertEquals(groupUuid, rootGroups.get(directGroupChildUuid)); + assertEquals(groupUuid, rootGroups.get(compositeUuid)); + } + + @Test + void shouldFindRootGroupThroughNestedComposites() { + UUID groupUuid = UUID.randomUUID(); + CompositeModificationInfos innerComposite = compositeModification(UUID.randomUUID(), List.of(dummyModification(UUID.randomUUID()))); + CompositeModificationInfos outerComposite = compositeModification(UUID.randomUUID(), List.of((ModificationInfos) innerComposite)); + + List saved = networkModificationRepository.saveModifications(groupUuid, List.of(ModificationEntity.fromDTO(outerComposite))); + // fromDTO always mints fresh ids, so read the actual persisted ids back rather than assume the DTO's survive + CompositeModificationInfos savedOuterComposite = (CompositeModificationInfos) saved.get(0); + CompositeModificationInfos savedInnerComposite = (CompositeModificationInfos) savedOuterComposite.getModificationsInfos().get(0); + UUID leafUuid = savedInnerComposite.getModificationsInfos().get(0).getUuid(); + + Map rootGroups = networkModificationService.findModificationRootGroups(List.of(leafUuid)); + + assertEquals(1, rootGroups.size()); + assertEquals(groupUuid, rootGroups.get(leafUuid)); + } + private static LoadModificationInfos dummyModification(UUID uuid) { return LoadModificationInfos.builder() .equipmentId("dummyEquipmentId")