Migrate state data to new cm-handles.cm-handle-state 24/142424/13
authoregernug <gerard.nugent@est.tech>
Thu, 13 Nov 2025 11:31:12 +0000 (11:31 +0000)
committeregernug <gerard.nugent@est.tech>
Thu, 27 Nov 2025 14:08:02 +0000 (14:08 +0000)
- Added new DataMigration class to perform migration
- Amended InventoryModelLoader to use migration class
- Moved setAndUpdateCmHandleField to InventoryPersistence and made public
- Created bulk implementation of setAndUpdateCmHandleField

Issue-ID: CPS-3023

Change-Id: Ie6ee5af7d3edb1a4e27de358da1da3624af04f48
Signed-off-by: egernug <gerard.nugent@est.tech>
13 files changed:
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/inventory/CmHandleRegistrationServicePropertyHandler.java
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/inventory/InventoryPersistence.java
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/inventory/InventoryPersistenceImpl.java
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/models/CmHandleStateUpdate.java [new file with mode: 0644]
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/init/DataMigration.java [new file with mode: 0644]
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/init/InventoryModelLoader.java
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/inventory/CmHandleRegistrationServicePropertyHandlerSpec.groovy
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/inventory/InventoryPersistenceImplSpec.groovy
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/init/DataMigrationSpec.groovy [new file with mode: 0644]
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/init/InventoryModelLoaderSpec.groovy
cps-service/src/main/java/org/onap/cps/config/CpsServicesConfig.java [new file with mode: 0644]
cps-service/src/main/java/org/onap/cps/impl/CpsServicesBundle.java [new file with mode: 0644]
cps-service/src/test/groovy/org/onap/cps/config/CpsServicesConfigSpec.groovy [new file with mode: 0644]

index 50f5827..dcba275 100644 (file)
@@ -28,16 +28,11 @@ import static org.onap.cps.ncmp.api.NcmpResponseStatus.CM_HANDLE_ALREADY_EXIST;
 import static org.onap.cps.ncmp.api.NcmpResponseStatus.CM_HANDLE_INVALID_ID;
 import static org.onap.cps.ncmp.impl.inventory.CmHandleRegistrationServicePropertyHandler.PropertyType.ADDITIONAL_PROPERTY;
 import static org.onap.cps.ncmp.impl.inventory.CmHandleRegistrationServicePropertyHandler.PropertyType.PUBLIC_PROPERTY;
-import static org.onap.cps.ncmp.impl.inventory.NcmpPersistence.NCMP_DATASPACE_NAME;
-import static org.onap.cps.ncmp.impl.inventory.NcmpPersistence.NCMP_DMI_REGISTRY_ANCHOR;
-import static org.onap.cps.ncmp.impl.inventory.NcmpPersistence.NCMP_DMI_REGISTRY_PARENT;
 
 import com.google.common.collect.ImmutableMap;
 import com.hazelcast.map.IMap;
-import java.time.OffsetDateTime;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -58,7 +53,6 @@ import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle;
 import org.onap.cps.ncmp.impl.inventory.sync.lcm.CmHandleTransitionPair;
 import org.onap.cps.ncmp.impl.inventory.sync.lcm.LcmEventsHelper;
 import org.onap.cps.ncmp.impl.utils.YangDataConverter;
-import org.onap.cps.utils.ContentType;
 import org.onap.cps.utils.JsonObjectMapper;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.stereotype.Service;
@@ -136,7 +130,10 @@ public class CmHandleRegistrationServicePropertyHandler {
         final String cmHandleId = ncmpServiceCmHandle.getCmHandleId();
         final String newAlternateId = ncmpServiceCmHandle.getAlternateId();
         if (StringUtils.isNotBlank(newAlternateId)) {
-            setAndUpdateCmHandleField(ncmpServiceCmHandle.getCmHandleId(), "alternate-id", newAlternateId);
+            inventoryPersistence.updateCmHandleField(
+                    ncmpServiceCmHandle.getCmHandleId(),
+                    "alternate-id",
+                    newAlternateId);
             cmHandleIdPerAlternateId.delete(cmHandleId);
             cmHandleIdPerAlternateId.set(newAlternateId, cmHandleId);
         }
@@ -160,7 +157,11 @@ public class CmHandleRegistrationServicePropertyHandler {
                     targetDataProducerIdentifier);
             return;
         }
-        setAndUpdateCmHandleField(cmHandleId, "data-producer-identifier", targetDataProducerIdentifier);
+
+        inventoryPersistence.updateCmHandleField(
+                cmHandleId,
+                "data-producer-identifier",
+                targetDataProducerIdentifier);
         log.debug("dataProducerIdentifier for cmHandle {} updated from {} to {}", cmHandleId,
                 currentDataProducerIdentifier, targetDataProducerIdentifier);
         sendLcmEventForDataProducerIdentifier(cmHandleId, currentYangModelCmHandle);
@@ -243,18 +244,6 @@ public class CmHandleRegistrationServicePropertyHandler {
         return new DataNodeBuilder().withXpath(xpath).withLeaves(ImmutableMap.copyOf(updatedLeaves)).build();
     }
 
-    private void setAndUpdateCmHandleField(final String cmHandleIdToUpdate, final String fieldName,
-                                           final String newFieldValue) {
-        final Map<String, Map<String, String>> dmiRegistryData = new HashMap<>(1);
-        final Map<String, String> cmHandleData = new HashMap<>(2);
-        cmHandleData.put("id", cmHandleIdToUpdate);
-        cmHandleData.put(fieldName, newFieldValue);
-        dmiRegistryData.put("cm-handles", cmHandleData);
-        cpsDataService.updateNodeLeaves(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, NCMP_DMI_REGISTRY_PARENT,
-                jsonObjectMapper.asJsonString(dmiRegistryData), OffsetDateTime.now(), ContentType.JSON);
-        log.debug("Updating {} for cmHandle {} with value : {})", fieldName, cmHandleIdToUpdate, newFieldValue);
-    }
-
     enum PropertyType {
         ADDITIONAL_PROPERTY("additional-properties"), PUBLIC_PROPERTY("public-properties");
 
index 7bd7c8e..f10cc18 100644 (file)
@@ -30,6 +30,7 @@ import org.onap.cps.api.model.ModuleReference;
 import org.onap.cps.api.parameters.FetchDescendantsOption;
 import org.onap.cps.ncmp.api.inventory.models.CompositeState;
 import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle;
+import org.onap.cps.ncmp.impl.models.CmHandleStateUpdate;
 
 public interface InventoryPersistence extends NcmpPersistence {
 
@@ -151,4 +152,32 @@ public interface InventoryPersistence extends NcmpPersistence {
      * @return boolean
      */
     boolean isExistingCmHandleId(String cmHandleId);
+
+    /**
+     * Updates the specified field of a CM handle with a new value in the DMI registry.
+     *
+     * @param cmHandleIdToUpdate                         the unique identifier of the CM handle to be updated
+     * @param fieldName                                  the name of the field within the CM handle to be updated
+     * @param newFieldValue                              the new value to be set for the
+     *                                                   specified field of the CM handle
+     */
+    void updateCmHandleField(String cmHandleIdToUpdate, String fieldName, String newFieldValue);
+
+
+    /**
+     * Bulk updates the specified fields of a batch of CM handles with a new value in the DMI registry.
+     *
+     * @param fieldName         the name of the field within the CM handle to be updated
+     * @param cmHandleIdToValue the CM handle to be updated and new value
+     */
+    void updateCmHandleFields(final String fieldName, final Map<String, String> cmHandleIdToValue);
+
+    /**
+     * Method to update a batch of cm handles status to the value in CompositeState.
+     *
+     * @param cmHandleStateUpdates               the cmHandleId and state change being performed on it
+     */
+    void bulkUpdateCmHandleStates(List<CmHandleStateUpdate> cmHandleStateUpdates);
+
+
 }
index f069407..213094b 100644 (file)
@@ -49,6 +49,7 @@ import org.onap.cps.api.parameters.FetchDescendantsOption;
 import org.onap.cps.ncmp.api.inventory.models.CompositeState;
 import org.onap.cps.ncmp.api.inventory.models.CompositeStateBuilder;
 import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle;
+import org.onap.cps.ncmp.impl.models.CmHandleStateUpdate;
 import org.onap.cps.ncmp.impl.utils.YangDataConverter;
 import org.onap.cps.utils.ContentType;
 import org.onap.cps.utils.CpsValidator;
@@ -200,6 +201,54 @@ public class InventoryPersistenceImpl extends NcmpPersistenceImpl implements Inv
         }
     }
 
+    /**
+     * Updates the specified field of a CM handle with a new value in the DMI registry.
+     *
+     * @param cmHandleId                                the unique identifier of the CM handle to be updated
+     * @param fieldName                                 the name of the field within the CM handle to be updated
+     * @param fieldValue                                the new value to be set for
+     *                                                  the specified field of the CM handle
+     */
+    @Override
+    public void updateCmHandleField(final String cmHandleId, final String fieldName,
+                                    final String fieldValue) {
+        updateCmHandleFields(fieldName, Collections.singletonMap(cmHandleId, fieldValue));
+    }
+
+    @Override
+    public void updateCmHandleFields(final String fieldName, final Map<String, String> newValuePerCmHandleId) {
+        if (!newValuePerCmHandleId.isEmpty()) {
+            final Map<String, Object> targetCmHandleStatePerCmHandleId = new HashMap<>();
+            final List<Map<String, String>> targetCmHandleStatesPerCmHandleIds = new ArrayList<>();
+
+            for (final Map.Entry<String, String> entry : newValuePerCmHandleId.entrySet()) {
+                final Map<String, String> cmHandleData = new HashMap<>();
+                cmHandleData.put("id", entry.getKey());
+                cmHandleData.put(fieldName, entry.getValue());
+                targetCmHandleStatesPerCmHandleIds.add(cmHandleData);
+                log.debug("Updating {} for cmHandle {} to {}", fieldName, entry.getKey(), entry.getValue());
+            }
+            targetCmHandleStatePerCmHandleId.put("cm-handles", targetCmHandleStatesPerCmHandleIds);
+            cpsDataService.updateNodeLeaves(
+                    NCMP_DATASPACE_NAME,
+                    NCMP_DMI_REGISTRY_ANCHOR,
+                    NCMP_DMI_REGISTRY_PARENT,
+                    jsonObjectMapper.asJsonString(targetCmHandleStatePerCmHandleId),
+                    OffsetDateTime.now(),
+                    ContentType.JSON);
+        }
+    }
+
+    @Override
+    public void bulkUpdateCmHandleStates(final List<CmHandleStateUpdate> cmHandleStateUpdates) {
+        final Map<String, String> mappedCmHandleStateUpdates = cmHandleStateUpdates.stream()
+            .collect(Collectors.toMap(
+                    CmHandleStateUpdate::cmHandleId,
+                    CmHandleStateUpdate::state
+            ));
+        updateCmHandleFields("cm-handle-state", mappedCmHandleStateUpdates);
+    }
+
     private static String getXPathForCmHandleById(final String cmHandleId) {
         return NCMP_DMI_REGISTRY_PARENT + "/cm-handles[@id='" + cmHandleId + "']";
     }
diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/models/CmHandleStateUpdate.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/models/CmHandleStateUpdate.java
new file mode 100644 (file)
index 0000000..514947b
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.ncmp.impl.models;
+
+public record CmHandleStateUpdate (String cmHandleId, String state) {}
diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/init/DataMigration.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/init/DataMigration.java
new file mode 100644 (file)
index 0000000..82a1175
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.ncmp.init;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.onap.cps.ncmp.api.inventory.NetworkCmProxyInventoryFacade;
+import org.onap.cps.ncmp.api.inventory.models.NcmpServiceCmHandle;
+import org.onap.cps.ncmp.impl.inventory.CmHandleQueryService;
+import org.onap.cps.ncmp.impl.inventory.InventoryPersistence;
+import org.onap.cps.ncmp.impl.models.CmHandleStateUpdate;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DataMigration {
+
+    public final InventoryPersistence inventoryPersistence;
+    protected int batchSize = 300;
+    private final CmHandleQueryService cmHandleQueryService;
+    private final NetworkCmProxyInventoryFacade networkCmProxyInventoryFacade;
+
+
+    /**
+     * Migration of CompositeState CmHandleState into a new top level attribute.
+     * One off migration job.
+     */
+    public void migrateInventoryToModelRelease20250722() {
+        log.info("Inventory data migration started");
+        final List<String> cmHandleIds = new ArrayList<>(cmHandleQueryService.getAllCmHandleReferences(false));
+        log.info("Number of cm handles to process {}", cmHandleIds.size());
+        final int totalCmHandleIds = cmHandleIds.size();
+        for (int batchStart = 0; batchStart < totalCmHandleIds; batchStart += batchSize) {
+            final int batchEnd = Math.min(batchStart + batchSize, cmHandleIds.size());
+            final List<String> batchIds = cmHandleIds.subList(batchStart, batchEnd);
+            migrateBatch(batchIds);
+        }
+        log.info("Inventory Cm Handle data migration completed.");
+    }
+
+    private void migrateBatch(final List<String> cmHandleIds) {
+        log.debug("Processing batch of {} Cm Handles", cmHandleIds.size());
+        final List<CmHandleStateUpdate> cmHandleStateUpdates = new ArrayList<>();
+        for (final String cmHandleId : cmHandleIds) {
+            try {
+                final NcmpServiceCmHandle ncmpServiceCmHandle =
+                        networkCmProxyInventoryFacade.getNcmpServiceCmHandle(cmHandleId);
+                final String valueFromOldModel = ncmpServiceCmHandle.getCompositeState().getCmHandleState().name();
+                cmHandleStateUpdates.add(new CmHandleStateUpdate(
+                        ncmpServiceCmHandle.getCmHandleId(),
+                        valueFromOldModel
+                ));
+            } catch (final Exception e) {
+                log.error("Failed to process CM handle {} state", cmHandleId, e);
+            }
+        }
+        try {
+            inventoryPersistence.bulkUpdateCmHandleStates(cmHandleStateUpdates);
+            log.debug("Successfully updated Cm Handles");
+        } catch (final Exception e) {
+            log.error("Failed to perform bulk update for batch", e);
+        }
+    }
+}
+
index f20c1c5..139fbb7 100644 (file)
@@ -25,10 +25,7 @@ import static org.onap.cps.ncmp.impl.inventory.NcmpPersistence.NCMP_DMI_REGISTRY
 import static org.onap.cps.ncmp.impl.inventory.NcmpPersistence.NFP_OPERATIONAL_DATASTORE_DATASPACE_NAME;
 
 import lombok.extern.slf4j.Slf4j;
-import org.onap.cps.api.CpsAnchorService;
-import org.onap.cps.api.CpsDataService;
-import org.onap.cps.api.CpsDataspaceService;
-import org.onap.cps.api.CpsModuleService;
+import org.onap.cps.impl.CpsServicesBundle;
 import org.onap.cps.init.AbstractModelLoader;
 import org.onap.cps.init.ModelLoaderLock;
 import org.onap.cps.init.actuator.ReadinessManager;
@@ -43,6 +40,7 @@ import org.springframework.stereotype.Service;
 @Order(2)
 public class InventoryModelLoader extends AbstractModelLoader {
 
+    private final DataMigration dataMigration;
     private final ApplicationEventPublisher applicationEventPublisher;
 
     private static final String PREVIOUS_SCHEMA_SET_NAME = "dmi-registry-2024-02-23";
@@ -57,15 +55,18 @@ public class InventoryModelLoader extends AbstractModelLoader {
      * the NCMP inventory model schema sets and managing readiness state during migration.
      */
     public InventoryModelLoader(final ModelLoaderLock modelLoaderLock,
-                                final CpsDataspaceService cpsDataspaceService,
-                                final CpsModuleService cpsModuleService,
-                                final CpsAnchorService cpsAnchorService,
-                                final CpsDataService cpsDataService,
+                                final CpsServicesBundle cpsServicesBundle,
                                 final ApplicationEventPublisher applicationEventPublisher,
-                                final ReadinessManager readinessManager) {
-        super(modelLoaderLock, cpsDataspaceService, cpsModuleService, cpsAnchorService, cpsDataService,
+                                final ReadinessManager readinessManager,
+                                final DataMigration dataMigration) {
+        super(modelLoaderLock,
+                cpsServicesBundle.getDataspaceService(),
+                cpsServicesBundle.getModuleService(),
+                cpsServicesBundle.getAnchorService(),
+                cpsServicesBundle.getDataService(),
             readinessManager);
         this.applicationEventPublisher = applicationEventPublisher;
+        this.dataMigration = dataMigration;
     }
 
     @Override
@@ -118,13 +119,6 @@ public class InventoryModelLoader extends AbstractModelLoader {
         log.info("Model Loader #2: Inventory upgraded successfully to model {}", NEW_INVENTORY_SCHEMA_SET_NAME);
     }
 
-    private void performInventoryDataMigration() {
-
-        //1. Load all the cm handles (in batch)
-        //2. Copy the state and known properties
-        log.info("Model Loader #2: Inventory module data migration is completed successfully.");
-    }
-
     private static String toYangFileName(final String schemaSetName) {
         return INVENTORY_YANG_MODULE_NAME + "@" + getModuleRevision(schemaSetName) + ".yang";
     }
@@ -136,6 +130,6 @@ public class InventoryModelLoader extends AbstractModelLoader {
 
     private void upgradeAndMigrateInventoryModel() {
         upgradeInventoryModel();
-        performInventoryDataMigration();
+        dataMigration.migrateInventoryToModelRelease20250722();
     }
 }
index 0c91208..bf8d851 100644 (file)
@@ -209,17 +209,14 @@ class CmHandleRegistrationServicePropertyHandlerSpec extends Specification {
         given: 'cm handles request'
             def cmHandleUpdateRequest = [new NcmpServiceCmHandle(cmHandleId: cmHandleId, alternateId: 'alt-1')]
         and: 'the cm handle per alternate id cache returns a value'
-            mockCmHandleIdPerAlternateId.get(_) >> 'someId'
+            mockCmHandleIdPerAlternateId.get(_) >> cmHandleId
         and: 'a data node found'
             def dataNode = new DataNode(xpath: cmHandleXpath, leaves: ['id': cmHandleId, 'alternate-id': 'alt-1'])
             mockInventoryPersistence.getCmHandleDataNodeByCmHandleId(cmHandleId, INCLUDE_ALL_DESCENDANTS) >> [dataNode]
         when: 'cm handle properties is updated'
             def response = objectUnderTest.updateCmHandleProperties(cmHandleUpdateRequest)
-        then: 'the update is delegated to cps data service with correct parameters'
-            1 * mockCpsDataService.updateNodeLeaves('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', _, _, ContentType.JSON) >>
-                    { args ->
-                        assert args[3].contains('alt-1')
-                    }
+        then: 'the update is delegated to inventory persistence with correct parameters'
+            1 * mockInventoryPersistence.updateCmHandleField(cmHandleId, 'alternate-id', 'alt-1')
         and: 'one successful registration response'
             response.size() == 1
         and: 'the response shows success for the given cm handle id'
@@ -245,19 +242,17 @@ class CmHandleRegistrationServicePropertyHandlerSpec extends Specification {
     }
 
     def 'Update CM Handle data producer identifier from #scenario'() {
-        given: 'an existing cm handle with old data producer identifier'
+        given:  'an existing cm handle with old data producer identifier'
             DataNode existingCmHandleDataNode = new DataNode(xpath: cmHandleXpath, leaves: ['id': 'cmHandleId', 'data-producer-identifier': oldDataProducerIdentifier])
-        and: 'an update request with a new data producer identifier'
+        and:    'an update request with a new data producer identifier'
             def ncmpServiceCmHandle = new NcmpServiceCmHandle(cmHandleId: 'cmHandleId', dataProducerIdentifier: 'New Data Producer Identifier')
-        and: 'the inventory persistence returns updated yang model'
+        and:    'the inventory persistence returns updated yang model'
             1 * mockInventoryPersistence.getYangModelCmHandle('cmHandleId') >> createYangModelCmHandle('cmHandleId', 'New Data Producer Identifier')
-        when: 'data producer identifier is updated'
+        when:   'data producer identifier is updated'
             objectUnderTest.updateDataProducerIdentifier(existingCmHandleDataNode, ncmpServiceCmHandle)
-        then: 'the update node leaves method is invoked once with correct parameters'
-            1 * mockCpsDataService.updateNodeLeaves('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', _, _, ContentType.JSON) >> { args ->
-                assert args[3].contains('New Data Producer Identifier')
-            }
-        and: 'LCM event is sent'
+        then:   'the update node leaves method is invoked once with correct parameters'
+            1 * mockInventoryPersistence.updateCmHandleField('cmHandleId', 'data-producer-identifier', 'New Data Producer Identifier')
+        and:    'LCM event is sent'
             1 * mockLcmEventsHelper.sendLcmEventBatchAsynchronously({ cmHandleTransitionPairs ->
                 assert cmHandleTransitionPairs[0].targetYangModelCmHandle.dataProducerIdentifier == 'New Data Producer Identifier'
             })
@@ -294,18 +289,12 @@ class CmHandleRegistrationServicePropertyHandlerSpec extends Specification {
         when: 'update data producer identifier is called'
             objectUnderTest.updateDataProducerIdentifier(existingCmHandleDataNode, ncmpServiceCmHandle)
         then: 'the update node leaves method is invoked once with correct parameters'
-            1 * mockCpsDataService.updateNodeLeaves('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', _, _, ContentType.JSON) >> { args ->
-                assert args[3].contains('newDataProducerIdentifier')
-            }
+            1 * mockInventoryPersistence.updateCmHandleField('cmHandleId', 'data-producer-identifier', 'newDataProducerIdentifier')
         and: 'LCM event is sent'
             1 * mockLcmEventsHelper.sendLcmEventBatchAsynchronously( { cmHandleTransitionPairs ->
                 assert cmHandleTransitionPairs[0].targetYangModelCmHandle.dataProducerIdentifier == 'newDataProducerIdentifier'
                 assert cmHandleTransitionPairs[0].currentYangModelCmHandle.dataProducerIdentifier == 'oldDataProducerIdentifier'
             })
-        and: 'correct information is logged'
-            def loggingEvent = logger.list[1]
-            assert loggingEvent.level == Level.DEBUG
-            assert loggingEvent.formattedMessage.contains('updated from oldDataProducerIdentifier to newDataProducerIdentifier')
     }
 
     def 'Update CM Handle data producer identifier with null or blank target identifier'() {
index a1c10ae..866ff71 100644 (file)
@@ -35,6 +35,7 @@ import org.onap.cps.api.model.ModuleReference
 import org.onap.cps.ncmp.api.inventory.models.CmHandleState
 import org.onap.cps.ncmp.api.inventory.models.CompositeState
 import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle
+import org.onap.cps.ncmp.impl.models.CmHandleStateUpdate
 import org.onap.cps.utils.ContentType
 import org.onap.cps.utils.CpsValidator
 import org.onap.cps.utils.JsonObjectMapper
@@ -67,15 +68,19 @@ class InventoryPersistenceImplSpec extends Specification {
 
     def mockCmHandleIdPerAlternateId = Mock(IMap)
 
-    def objectUnderTest = new InventoryPersistenceImpl(mockCpsValidator, spiedJsonObjectMapper, mockCpsAnchorService, mockCpsModuleService, mockCpsDataService, mockCmHandleIdPerAlternateId)
+    def objectUnderTest = Spy(new InventoryPersistenceImpl(mockCpsValidator, spiedJsonObjectMapper, mockCpsAnchorService, mockCpsModuleService, mockCpsDataService, mockCmHandleIdPerAlternateId))
 
     def formattedDateAndTime = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
             .format(OffsetDateTime.of(2022, 12, 31, 20, 30, 40, 1, ZoneOffset.UTC))
 
-    def cmHandleId = 'some-cm-handle'
+    def cmHandleId = 'ch-1'
+    def updates = [
+            new CmHandleStateUpdate("ch-1", "READY"),
+            new CmHandleStateUpdate("ch-2", "DELETING")
+    ]
     def alternateId = 'some-alternate-id'
     def leaves = ["id":cmHandleId, "alternateId":alternateId,"dmi-service-name":"common service name","dmi-data-service-name":"data service name","dmi-model-service-name":"model service name"]
-    def xpath = "/dmi-registry/cm-handles[@id='some-cm-handle']"
+    def xpath = "/dmi-registry/cm-handles[@id='ch-1']"
 
     def cmHandleId2 = 'another-cm-handle'
     def xpath2 = "/dmi-registry/cm-handles[@id='another-cm-handle']"
@@ -87,13 +92,13 @@ class InventoryPersistenceImplSpec extends Specification {
                                                       new DataNode(xpath: "/dmi-registry/cm-handles[@id='some cm handle']/public-properties[@name='myPublicProperty']", leaves: ["name":"myPublicProperty","value":"myPublicValue"])]
 
     @Shared
-    def childDataNodesForCmHandleWithAdditionalProperties = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='some-cm-handle']/additional-properties[@name='myAdditionalProperty']", leaves: ["name":"myAdditionalProperty", "value":"myAdditionalValue"])]
+    def childDataNodesForCmHandleWithAdditionalProperties = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='ch-1']/additional-properties[@name='myAdditionalProperty']", leaves: ["name":"myAdditionalProperty", "value":"myAdditionalValue"])]
 
     @Shared
-    def childDataNodesForCmHandleWithPublicProperties = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='some-cm-handle']/public-properties[@name='myPublicProperty']", leaves: ["name":"myPublicProperty","value":"myPublicValue"])]
+    def childDataNodesForCmHandleWithPublicProperties = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='ch-1']/public-properties[@name='myPublicProperty']", leaves: ["name":"myPublicProperty","value":"myPublicValue"])]
 
     @Shared
-    def childDataNodesForCmHandleWithState = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='some-cm-handle']/state", leaves: ['cm-handle-state': 'ADVISED'])]
+    def childDataNodesForCmHandleWithState = [new DataNode(xpath: "/dmi-registry/cm-handles[@id='ch-1']/state", leaves: ['cm-handle-state': 'ADVISED'])]
 
     def 'Retrieve CmHandle using datanode with #scenario.'() {
         given: 'the cps data service returns a data node from the DMI registry'
@@ -161,11 +166,11 @@ class InventoryPersistenceImplSpec extends Specification {
 
     def 'Get a Cm Handle Composite State.'() {
         given: 'a valid cm handle id'
-            def cmHandleId = 'Some-Cm-Handle'
+            def cmHandleId = 'ch-1'
             def dataNode = new DataNode(leaves: ['cm-handle-state': 'ADVISED'])
         and: 'cps data service returns a valid data node'
             mockCpsDataService.getDataNodes(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR,
-                    '/dmi-registry/cm-handles[@id=\'Some-Cm-Handle\']/state', INCLUDE_ALL_DESCENDANTS) >> [dataNode]
+                    '/dmi-registry/cm-handles[@id=\'ch-1\']/state', INCLUDE_ALL_DESCENDANTS) >> [dataNode]
         when: 'get cm handle state is invoked'
             def result = objectUnderTest.getCmHandleState(cmHandleId)
         then: 'result has returned the correct cm handle state'
@@ -176,12 +181,12 @@ class InventoryPersistenceImplSpec extends Specification {
 
     def 'Update Cm Handle with #scenario State.'() {
         given: 'a cm handle and a composite state'
-            def cmHandleId = 'Some-Cm-Handle'
+            def cmHandleId = 'ch-1'
             def compositeState = new CompositeState(cmHandleState: cmHandleState, lastUpdateTime: formattedDateAndTime)
         when: 'update cm handle state is invoked with the #scenario state'
             objectUnderTest.saveCmHandleState(cmHandleId, compositeState)
         then: 'update node leaves is invoked with the correct params'
-            1 * mockCpsDataService.updateDataNodeAndDescendants(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, '/dmi-registry/cm-handles[@id=\'Some-Cm-Handle\']', expectedJsonData, _ as OffsetDateTime, ContentType.JSON)
+            1 * mockCpsDataService.updateDataNodeAndDescendants(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, '/dmi-registry/cm-handles[@id=\'ch-1\']', expectedJsonData, _ as OffsetDateTime, ContentType.JSON)
         where: 'the following states are used'
             scenario    | cmHandleState          || expectedJsonData
             'READY'     | CmHandleState.READY    || '{"state":{"cm-handle-state":"READY","last-update-time":"2022-12-31T20:30:40.000+0000"}}'
@@ -196,21 +201,21 @@ class InventoryPersistenceImplSpec extends Specification {
         and: 'alternate id cache contains the given cm handle reference'
             mockCmHandleIdPerAlternateId.containsKey(_) >> true
         when: 'update cm handle state is invoked with the #scenario state'
-            def cmHandleStateMap = ['Some-Cm-Handle1' : compositeState1, 'Some-Cm-Handle2' : compositeState2]
+            def cmHandleStateMap = ['ch-11' : compositeState1, 'ch-12' : compositeState2]
             objectUnderTest.saveCmHandleStateBatch(cmHandleStateMap)
         then: 'update node leaves is invoked with the correct params'
             1 * mockCpsDataService.updateDataNodesAndDescendants(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, cmHandlesJsonDataMap, _ as OffsetDateTime, ContentType.JSON)
         where: 'the following states are used'
             scenario    | cmHandleState          || cmHandlesJsonDataMap
-            'READY'     | CmHandleState.READY    || ['/dmi-registry/cm-handles[@id=\'Some-Cm-Handle1\']':'{"state":{"cm-handle-state":"READY","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'Some-Cm-Handle2\']':'{"state":{"cm-handle-state":"READY","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
-            'LOCKED'    | CmHandleState.LOCKED   || ['/dmi-registry/cm-handles[@id=\'Some-Cm-Handle1\']':'{"state":{"cm-handle-state":"LOCKED","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'Some-Cm-Handle2\']':'{"state":{"cm-handle-state":"LOCKED","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
-            'DELETING'  | CmHandleState.DELETING || ['/dmi-registry/cm-handles[@id=\'Some-Cm-Handle1\']':'{"state":{"cm-handle-state":"DELETING","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'Some-Cm-Handle2\']':'{"state":{"cm-handle-state":"DELETING","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
+            'READY'     | CmHandleState.READY    || ['/dmi-registry/cm-handles[@id=\'ch-11\']':'{"state":{"cm-handle-state":"READY","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'ch-12\']':'{"state":{"cm-handle-state":"READY","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
+            'LOCKED'    | CmHandleState.LOCKED   || ['/dmi-registry/cm-handles[@id=\'ch-11\']':'{"state":{"cm-handle-state":"LOCKED","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'ch-12\']':'{"state":{"cm-handle-state":"LOCKED","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
+            'DELETING'  | CmHandleState.DELETING || ['/dmi-registry/cm-handles[@id=\'ch-11\']':'{"state":{"cm-handle-state":"DELETING","last-update-time":"2022-12-31T20:30:40.000+0000"}}', '/dmi-registry/cm-handles[@id=\'ch-12\']':'{"state":{"cm-handle-state":"DELETING","last-update-time":"2022-12-31T20:30:40.000+0000"}}']
     }
 
     def 'Update cm handle states when #scenario in alternate id cache.'() {
         given: 'a map of cm handles composite states'
             def compositeState = new CompositeState(cmHandleState: CmHandleState.ADVISED, lastUpdateTime: formattedDateAndTime)
-            def cmHandleStateMap = ['some-cm-handle' : compositeState]
+            def cmHandleStateMap = ['ch-1' : compositeState]
         and: 'alternate id cache returns #scenario'
             mockCmHandleIdPerAlternateId.containsKey(_) >> keyExists
             mockCmHandleIdPerAlternateId.containsValue(_) >> valueExists
@@ -392,5 +397,20 @@ class InventoryPersistenceImplSpec extends Specification {
             assert result.size() == 2
             assert result.id.containsAll([cmHandleId, cmHandleId2])
     }
+
+    def 'Update Cm Handle Field.'(){
+        when: 'update is called.'
+            objectUnderTest.updateCmHandleField('ch-1', 'my field', 'my new value')
+        then: 'call is delegated to updateCmHandleFields'
+        1 * objectUnderTest.updateCmHandleFields('my field', ['ch-1':'my new value'])
+    }
+
+    def 'Bulk update cm handle state.'(){
+        when: 'bulk update is called'
+            objectUnderTest.bulkUpdateCmHandleStates(updates)
+        then: 'call is made to update the fileds of the cm handle'
+            1 * objectUnderTest.updateCmHandleFields('cm-handle-state', ['ch-1':'READY','ch-2':'DELETING'])
+    }
+
 }
 
diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/init/DataMigrationSpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/init/DataMigrationSpec.groovy
new file mode 100644 (file)
index 0000000..21e752d
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.ncmp.init
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import org.onap.cps.ncmp.api.inventory.NetworkCmProxyInventoryFacade
+import org.onap.cps.ncmp.api.inventory.models.CompositeState
+import org.onap.cps.ncmp.api.inventory.models.NcmpServiceCmHandle
+import org.onap.cps.ncmp.impl.inventory.CmHandleQueryService
+import org.onap.cps.ncmp.impl.inventory.InventoryPersistence
+import org.slf4j.LoggerFactory
+import spock.lang.Specification
+import spock.lang.Subject
+
+import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.ADVISED
+import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.READY
+
+class DataMigrationSpec extends Specification{
+
+    def mockCmHandleQueryService = Mock(CmHandleQueryService)
+    def mockNetworkCmProxyInventoryFacade = Mock(NetworkCmProxyInventoryFacade)
+    def mockInventoryPersistence = Mock(InventoryPersistence)
+    def cmHandle1 = new NcmpServiceCmHandle(cmHandleId: 'ch-1', dmiServiceName: 'dmi1', compositeState: new CompositeState(cmHandleState: READY))
+    def cmHandle2 = new NcmpServiceCmHandle(cmHandleId: 'ch-2', dmiServiceName: 'dmi1', compositeState: new CompositeState(cmHandleState: ADVISED))
+    def cmHandle3 = new NcmpServiceCmHandle(cmHandleId: 'ch-3', dmiServiceName: 'dmi2', compositeState: new CompositeState(cmHandleState: READY))
+
+    def logger = Spy(ListAppender<ILoggingEvent>)
+
+    def setup() {
+        mockNetworkCmProxyInventoryFacade.getNcmpServiceCmHandle('ch-1') >> cmHandle1
+        mockNetworkCmProxyInventoryFacade.getNcmpServiceCmHandle('ch-2') >> cmHandle2
+        mockNetworkCmProxyInventoryFacade.getNcmpServiceCmHandle('ch-3') >> cmHandle3
+        setupLogger(Level.ERROR)
+    }
+
+    def cleanup() {
+        ((Logger) LoggerFactory.getLogger(DataMigration.class)).detachAndStopAllAppenders()
+    }
+
+
+    @Subject
+    def objectUnderTest = Spy(new DataMigration(mockInventoryPersistence, mockCmHandleQueryService, mockNetworkCmProxyInventoryFacade))
+
+    def 'CM Handle migration.'() {
+        given:  'a list of CM handle IDs'
+            def cmHandleIds = ['ch-1', 'ch-2', 'ch-3']
+            mockCmHandleQueryService.getAllCmHandleReferences(false) >> cmHandleIds
+        when:   'migration is performed'
+            objectUnderTest.migrateInventoryToModelRelease20250722()
+        then:   'handles are processed in bulk'
+            1 * mockInventoryPersistence.bulkUpdateCmHandleStates({ cmHandleStateUpdates ->
+                def actualData = cmHandleStateUpdates.collect { [id: it.cmHandleId, state: it.state] }
+                assert actualData.size() == 3
+                assert actualData.containsAll([
+                    [id: 'ch-1', state: 'READY'],
+                    [id: 'ch-2', state: 'ADVISED'],
+                    [id: 'ch-3', state: 'READY']
+                ])
+            })
+    }
+
+    def 'CM Handle migration with exception for a cm handle in batch.'() {
+        given: 'a faulty CM handle ID'
+            def cmHandleIds = ['faultyCmHandle']
+            mockCmHandleQueryService.getAllCmHandleReferences(false) >> cmHandleIds
+        and: 'an exception is thrown when getting cm handle'
+            mockNetworkCmProxyInventoryFacade.getNcmpServiceCmHandle('faultyCmHandle') >> { throw new RuntimeException('Simulated failure') }
+        when: 'migration is performed'
+            objectUnderTest.migrateInventoryToModelRelease20250722()
+        then: 'migration processes no batches'
+            1 * mockInventoryPersistence.bulkUpdateCmHandleStates([])
+    }
+
+    def 'Migrate batch with error.'() {
+        given: 'a cm handle'
+            def cmHandleIds = ['ch-1']
+            mockCmHandleQueryService.getAllCmHandleReferences(false) >> cmHandleIds
+        and: 'an exception happens updating cm handle states'
+            mockInventoryPersistence.bulkUpdateCmHandleStates(*_) >> {
+                throw new RuntimeException('Simulated failure')
+            }
+        when: 'migration is performed'
+            objectUnderTest.migrateInventoryToModelRelease20250722()
+        then: 'exception is caught and logged'
+            def loggingEvent = logger.list[0]
+            assert loggingEvent.level == Level.ERROR
+            assert loggingEvent.formattedMessage.contains('Failed to perform bulk update for batch')
+    }
+
+    def setupLogger(level) {
+        def setupLogger = ((Logger) LoggerFactory.getLogger(DataMigration))
+        setupLogger.setLevel(level)
+        setupLogger.addAppender(logger)
+        logger.start()
+    }
+}
index 092ef2c..9f68934 100644 (file)
@@ -32,6 +32,7 @@ import org.onap.cps.api.model.Dataspace
 import org.onap.cps.api.model.ModuleDefinition
 import org.onap.cps.init.ModelLoaderLock
 import org.onap.cps.init.actuator.ReadinessManager
+import org.onap.cps.impl.CpsServicesBundle
 import org.slf4j.LoggerFactory
 import org.springframework.boot.context.event.ApplicationReadyEvent
 import org.springframework.context.ApplicationEventPublisher
@@ -48,9 +49,17 @@ class InventoryModelLoaderSpec extends Specification {
     def mockCpsModuleService = Mock(CpsModuleService)
     def mockCpsDataService = Mock(CpsDataService)
     def mockCpsAnchorService = Mock(CpsAnchorService)
+    def cpsServices = new CpsServicesBundle(
+            mockCpsAdminService,
+            mockCpsModuleService,
+            mockCpsAnchorService,
+            mockCpsDataService
+    )
+
     def mockApplicationEventPublisher = Mock(ApplicationEventPublisher)
     def mockReadinessManager = Mock(ReadinessManager)
-    def objectUnderTest = new InventoryModelLoader(mockModelLoaderLock, mockCpsAdminService, mockCpsModuleService, mockCpsAnchorService, mockCpsDataService, mockApplicationEventPublisher, mockReadinessManager)
+    def mockDataMigration = Mock(DataMigration)
+    def objectUnderTest = new InventoryModelLoader(mockModelLoaderLock, cpsServices, mockApplicationEventPublisher, mockReadinessManager, mockDataMigration)
 
     def applicationContext = new AnnotationConfigApplicationContext()
 
@@ -76,6 +85,13 @@ class InventoryModelLoaderSpec extends Specification {
         applicationContext.close()
     }
 
+    def callPrivatePerformInventoryDataMigration() {
+        def method = objectUnderTest.class.getDeclaredMethod('upgradeAndMigrateInventoryModel')
+        method.accessible = true
+        method.invoke(objectUnderTest)
+    }
+
+
     def 'Onboard subscription model via application ready event.'() {
         given: 'dataspace is ready for use with default newRevisionEnabled flag'
             objectUnderTest.newRevisionEnabled = false
@@ -138,5 +154,13 @@ class InventoryModelLoaderSpec extends Specification {
             assert loggingListAppender.list.any { it.message.contains("already installed") }
     }
 
+    def "Perform inventory data migration to Release20250722"() {
+        when: 'the migration is performed'
+            callPrivatePerformInventoryDataMigration()
+        then: 'the call is delegated to the Data Migration service'
+            1 * mockDataMigration.migrateInventoryToModelRelease20250722()
+    }
+
+
 
 }
diff --git a/cps-service/src/main/java/org/onap/cps/config/CpsServicesConfig.java b/cps-service/src/main/java/org/onap/cps/config/CpsServicesConfig.java
new file mode 100644 (file)
index 0000000..d6029c7
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.config;
+
+import org.onap.cps.api.CpsAnchorService;
+import org.onap.cps.api.CpsDataService;
+import org.onap.cps.api.CpsDataspaceService;
+import org.onap.cps.api.CpsModuleService;
+import org.onap.cps.impl.CpsServicesBundle;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class CpsServicesConfig {
+
+    @Bean
+    public CpsServicesBundle cpsServices(final CpsDataspaceService dataspaceService,
+                                         final CpsModuleService moduleService,
+                                         final CpsAnchorService anchorService,
+                                         final CpsDataService dataService) {
+        return new CpsServicesBundle(dataspaceService, moduleService, anchorService, dataService);
+    }
+
+}
diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsServicesBundle.java b/cps-service/src/main/java/org/onap/cps/impl/CpsServicesBundle.java
new file mode 100644 (file)
index 0000000..2e764ef
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.impl;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.onap.cps.api.CpsAnchorService;
+import org.onap.cps.api.CpsDataService;
+import org.onap.cps.api.CpsDataspaceService;
+import org.onap.cps.api.CpsModuleService;
+
+@Getter
+@RequiredArgsConstructor
+public class CpsServicesBundle {
+
+    private final CpsDataspaceService dataspaceService;
+    private final CpsModuleService moduleService;
+    private final CpsAnchorService anchorService;
+    private final CpsDataService dataService;
+
+}
\ No newline at end of file
diff --git a/cps-service/src/test/groovy/org/onap/cps/config/CpsServicesConfigSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/config/CpsServicesConfigSpec.groovy
new file mode 100644 (file)
index 0000000..9474e52
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.config
+
+import org.onap.cps.api.CpsAnchorService
+import org.onap.cps.api.CpsDataService
+import org.onap.cps.api.CpsDataspaceService
+import org.onap.cps.api.CpsModuleService
+import org.onap.cps.impl.CpsServicesBundle
+import spock.lang.Specification
+
+class CpsServicesConfigSpec extends Specification {
+
+    def dataspaceService = Mock(CpsDataspaceService)
+    def moduleService    = Mock(CpsModuleService)
+    def anchorService    = Mock(CpsAnchorService)
+    def dataService      = Mock(CpsDataService)
+
+    def 'cpsServices returns bundle wired with given services'() {
+        given: 'a cps service config'
+            def objectUnderTest = new CpsServicesConfig()
+        when: 'cpsServices bean method is invoked'
+            CpsServicesBundle bundle = objectUnderTest.cpsServices(
+                    dataspaceService,
+                    moduleService,
+                    anchorService,
+                    dataService
+            )
+        then: 'it is wired with the same instances that were passed in'
+            assert bundle.dataspaceService == dataspaceService
+            assert bundle.moduleService == moduleService
+            assert bundle.anchorService == anchorService
+            assert bundle.dataService == dataService
+    }
+}
\ No newline at end of file