REST operations using Delta Report 84/140584/33
authorArpit Singh <AS00745003@techmahindra.com>
Fri, 28 Mar 2025 13:24:30 +0000 (18:54 +0530)
committerArpit Singh <AS00745003@techmahindra.com>
Wed, 20 Aug 2025 12:45:54 +0000 (18:15 +0530)
- Feature that uses the delta report to perform REST operations.
- The code uses underlying CPS data java interface logic to perform the
  REST operations.
- The REST operations are executed based on the 'action' identified in the
  delta report.
- The supported operations are: create, delete and update.
- Fixed indentation error in CpsDataServiceImpl

Issue-ID: CPS-2614
Change-Id: Ic1c9f5d2320afd8e4fb49b638e6636291124ea75
Signed-off-by: Arpit Singh <AS00745003@techmahindra.com>
13 files changed:
cps-rest/docs/openapi/cpsDelta.yml
cps-rest/docs/openapi/openapi.yml
cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java
cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy
cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java
cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java
cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java
cps-service/src/main/java/org/onap/cps/utils/JsonObjectMapper.java
cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java [new file with mode: 0644]
cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy
cps-service/src/test/groovy/org/onap/cps/utils/JsonObjectMapperSpec.groovy
cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy [new file with mode: 0644]

index 14655ea..4d7781c 100644 (file)
@@ -94,3 +94,32 @@ delta:
         $ref: 'components.yml#/components/responses/Forbidden'
       '500':
         $ref: 'components.yml#/components/responses/InternalServerError'
+
+applyChangesInDeltaReport:
+  post:
+    description: Use the delta report to perform batch operations on an anchor in a dataspace.
+    tags:
+      - cps-delta
+    summary: Apply delta to an anchor
+    operationId: applyChangesInDeltaReport
+    parameters:
+      - $ref: 'components.yml#/components/parameters/dataspaceNameInPath'
+      - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: string
+          examples:
+            dataSample:
+              $ref: 'components.yml#/components/examples/deltaReportSample'
+    responses:
+      '201':
+        $ref: 'components.yml#/components/responses/Created'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+      '500':
+        $ref: 'components.yml#/components/responses/InternalServerError'
index a141032..615172b 100644 (file)
@@ -106,6 +106,9 @@ paths:
   /v2/dataspaces/{dataspace-name}/anchors/{source-anchor-name}/delta:
     $ref: 'cpsDelta.yml#/delta'
 
+  /v2/dataspaces/{dataspace-name}/anchors/{source-anchor-name}/applyChangesInDeltaReport:
+    $ref: 'cpsDelta.yml#/applyChangesInDeltaReport'
+
   /v1/dataspaces/{dataspace-name}/anchors/{anchor-name}/nodes/query:
     $ref: 'cpsQueryV1Deprecated.yml#/nodesByDataspaceAndAnchorAndCpsPath'
 
index c4631c7..824dad9 100644 (file)
@@ -48,7 +48,6 @@ public class DeltaRestController implements CpsDeltaApi {
     private final CpsDeltaService cpsDeltaService;
     private final JsonObjectMapper jsonObjectMapper;
 
-
     @Timed(value = "cps.delta.controller.get.delta",
         description = "Time taken to get delta between anchors")
     @Override
@@ -88,4 +87,12 @@ public class DeltaRestController implements CpsDeltaApi {
                 xpath, yangResourceMap, targetData, fetchDescendantsOption, groupDataNodes));
         return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaReports), HttpStatus.OK);
     }
+
+    public ResponseEntity<String> applyChangesInDeltaReport(final String dataspaceName,
+                                                            final String anchorName,
+                                                            final String deltaReport) {
+        cpsDeltaService.applyChangesInDeltaReport(dataspaceName, anchorName, deltaReport);
+        return ResponseEntity.status(HttpStatus.CREATED).build();
+    }
+
 }
index 28d74af..84cbad8 100755 (executable)
@@ -49,7 +49,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
 @Slf4j
-@RestControllerAdvice(assignableTypes = {AdminRestController.class, DataRestController.class,
+@RestControllerAdvice(assignableTypes = {AdminRestController.class, DataRestController.class, DeltaRestController.class,
     DeltaRestController.class, QueryRestController.class})
 @NoArgsConstructor(access = AccessLevel.PACKAGE)
 public class CpsRestExceptionHandler {
index fe9f230..a1eb32c 100644 (file)
@@ -35,6 +35,7 @@ import org.springframework.test.web.servlet.MockMvc
 import org.springframework.web.multipart.MultipartFile
 import spock.lang.Shared
 import spock.lang.Specification
+
 import java.nio.charset.StandardCharsets
 import java.nio.file.Files
 import java.nio.file.Path
@@ -44,6 +45,7 @@ import static org.onap.cps.api.parameters.FetchDescendantsOption.INCLUDE_ALL_DES
 import static org.onap.cps.api.parameters.FetchDescendantsOption.OMIT_DESCENDANTS
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
 
 @WebMvcTest(DeltaRestController)
 class DeltaRestControllerSpec extends Specification {
@@ -177,4 +179,18 @@ class DeltaRestControllerSpec extends Specification {
         then: 'the response contains expected error message'
             assert response.contentAsString.contains("Parsing error occurred while converting JSON content to Json Node")
     }
+
+    def 'Apply changes from a delta report, in JSON format, on an anchor'() {
+        given: 'sample delta report, xpath, and json payload'
+            def deltaReports = 'some delta report'
+            def applyDeltaEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName/anchors/$anchorName/applyChangesInDeltaReport"
+        when: 'request to apply delta report to an anchor is performed using REST API'
+            def response =
+                mvc.perform(post(applyDeltaEndpointV2)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content(deltaReports)
+                ).andReturn().response
+        then: 'expected response code is returned'
+            assert response.status == HttpStatus.CREATED.value()
+    }
 }
index a7f8fc3..ee2595b 100644 (file)
@@ -70,5 +70,13 @@ public interface CpsDeltaService {
                                                           FetchDescendantsOption fetchDescendantsOption,
                                                           boolean groupDataNodes);
 
-
+    /**
+     * Apply the changes in the given delta report to an anchor. The delta report contains the difference between two
+     * anchors or an anchor and a configuration.
+     *
+     * @param dataspaceName           dataspace name
+     * @param anchorName              anchor name where the delta report is to be applied
+     * @param deltaReportAsJsonString delta report in JSON string format
+     */
+    void applyChangesInDeltaReport(String dataspaceName, String anchorName, String deltaReportAsJsonString);
 }
index 045bba5..11cb2be 100644 (file)
@@ -158,10 +158,11 @@ public class CpsDataServiceImpl implements CpsDataService {
     @Override
     @Timed(value = "cps.data.service.datanode.leaves.descendants.leaves.update",
         description = "Time taken to update data node leaves and existing descendants leaves")
-    public void updateNodeLeavesAndExistingDescendantLeaves(final String dataspaceName, final String anchorName,
-        final String parentNodeXpath,
-        final String dataNodeUpdatesAsJson,
-        final OffsetDateTime observedTimestamp) {
+    public void updateNodeLeavesAndExistingDescendantLeaves(final String dataspaceName,
+                                                            final String anchorName,
+                                                            final String parentNodeXpath,
+                                                            final String dataNodeUpdatesAsJson,
+                                                            final OffsetDateTime observedTimestamp) {
         cpsValidator.validateNameCharacters(dataspaceName, anchorName);
         final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName);
         final Collection<DataNode> dataNodeUpdates = dataNodeFactory
index 9dcc9cd..9d3d38b 100644 (file)
@@ -40,6 +40,7 @@ import org.onap.cps.api.model.DeltaReport;
 import org.onap.cps.api.parameters.FetchDescendantsOption;
 import org.onap.cps.utils.DataMapper;
 import org.onap.cps.utils.JsonObjectMapper;
+import org.onap.cps.utils.deltareport.DeltaReportExecutor;
 import org.onap.cps.utils.deltareport.DeltaReportGenerator;
 import org.onap.cps.utils.deltareport.GroupedDeltaReportGenerator;
 import org.springframework.stereotype.Service;
@@ -49,6 +50,7 @@ import org.springframework.stereotype.Service;
 @RequiredArgsConstructor
 public class CpsDeltaServiceImpl implements CpsDeltaService {
 
+    private final DeltaReportExecutor deltaReportExecutor;
     private final CpsAnchorService cpsAnchorService;
     private final CpsDataService cpsDataService;
     private final DataNodeFactory dataNodeFactory;
@@ -95,6 +97,19 @@ public class CpsDeltaServiceImpl implements CpsDeltaService {
         return getDeltaReports(sourceDataNodesRebuilt, targetDataNodes, groupDataNodes);
     }
 
+    /**
+     * Apply the delta report to the data nodes.
+     *
+     * @param dataspaceName      name of the dataspace
+     * @param anchorName         name of the anchor
+     * @param deltaReportAsJsonString  JSON string representing the delta report
+     */
+    @Override
+    public void applyChangesInDeltaReport(final String dataspaceName, final String anchorName,
+                                          final String deltaReportAsJsonString) {
+        deltaReportExecutor.applyChangesInDeltaReport(dataspaceName, anchorName, deltaReportAsJsonString);
+    }
+
     private List<DeltaReport> getDeltaReports(final Collection<DataNode> sourceDataNodes,
                                               final Collection<DataNode> targetDataNodes,
                                               final boolean groupDataNodes) {
@@ -134,4 +149,5 @@ public class CpsDeltaServiceImpl implements CpsDeltaService {
                 .createDataNodesWithYangResourceXpathAndNodeData(yangResourceContentPerName, xpath, targetData, JSON);
         }
     }
+
 }
index 253c776..16c6828 100644 (file)
@@ -1,6 +1,7 @@
 /*
  *  ============LICENSE_START=======================================================
  *  Copyright (C) 2022 Nordix Foundation
+ *  Modifications Copyright (C) 2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -23,6 +24,8 @@ package org.onap.cps.utils;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.type.CollectionType;
+import java.util.List;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.onap.cps.api.exceptions.DataValidationException;
@@ -120,4 +123,24 @@ public class JsonObjectMapper {
                     + "JSON content to Json Node.", e.getMessage());
         }
     }
+
+    /**
+     * Deserialize JSON content from given JSON content String to List of specific class type.
+     *
+     * @param jsonContent            JSON content
+     * @param collectionEntryType    compatible Object class type
+     * @param <T>                    type parameter
+     * @return                       a list of specific class type 'T'
+     */
+    public <T> List<T> convertToJsonArray(final String jsonContent, final Class<T> collectionEntryType) {
+        try {
+            final CollectionType collectionType =
+                objectMapper.getTypeFactory().constructCollectionType(List.class, collectionEntryType);
+            return objectMapper.readValue(jsonContent, collectionType);
+        } catch (final JsonProcessingException e) {
+            log.error("Parsing error occurred while converting JSON content to specific class type.");
+            throw new DataValidationException("Parsing error occurred while converting "
+                + "JSON content to specific class type.", e.getMessage());
+        }
+    }
 }
diff --git a/cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java b/cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java
new file mode 100644 (file)
index 0000000..d066a82
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 TechMahindra Ltd.
+ *  ================================================================================
+ *  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.utils.deltareport;
+
+import static org.onap.cps.utils.ContentType.JSON;
+
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.onap.cps.api.CpsAnchorService;
+import org.onap.cps.api.CpsDataService;
+import org.onap.cps.api.DataNodeFactory;
+import org.onap.cps.api.model.Anchor;
+import org.onap.cps.api.model.DataNode;
+import org.onap.cps.api.model.DeltaReport;
+import org.onap.cps.cpspath.parser.CpsPathUtil;
+import org.onap.cps.utils.JsonObjectMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeltaReportExecutor {
+
+    private static final OffsetDateTime NO_TIMESTAMP = null;
+
+    private final CpsAnchorService cpsAnchorService;
+    private final CpsDataService cpsDataService;
+    private final DataNodeFactory dataNodeFactory;
+    private final JsonObjectMapper jsonObjectMapper;
+
+    /**
+     * Applies the delta report to the specified dataspace and anchor.
+     *
+     * @param dataspaceName     the name of the dataspace
+     * @param anchorName        the name of the anchor
+     * @param deltaReportAsJsonString the delta report as a JSON string
+     */
+    @Transactional
+    public void applyChangesInDeltaReport(final String dataspaceName, final String anchorName,
+                                   final String deltaReportAsJsonString) {
+        final List<DeltaReport> deltaReports =
+            jsonObjectMapper.convertToJsonArray(deltaReportAsJsonString, DeltaReport.class);
+        for (final DeltaReport deltaReport: deltaReports) {
+            final String action = deltaReport.getAction();
+            final String xpath = deltaReport.getXpath();
+            if (action.equals(DeltaReport.REPLACE_ACTION)) {
+                final String dataForUpdate = jsonObjectMapper.asJsonString(deltaReport.getTargetData());
+                updateDataNodes(dataspaceName, anchorName, xpath, dataForUpdate);
+            } else if (action.equals(DeltaReport.REMOVE_ACTION)) {
+                final String dataForDelete = jsonObjectMapper.asJsonString(deltaReport.getSourceData());
+                deleteDataNodesUsingDelta(dataspaceName, anchorName, xpath, dataForDelete);
+            } else {
+                final String dataForCreate = jsonObjectMapper.asJsonString(deltaReport.getTargetData());
+                addDataNodesUsingDelta(dataspaceName, anchorName, xpath, dataForCreate);
+            }
+        }
+    }
+
+    private void updateDataNodes(final String dataspaceName, final String anchorName, final String xpath,
+                                 final String updatedData) {
+        cpsDataService.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, anchorName,
+            CpsPathUtil.getNormalizedParentXpath(xpath), updatedData, NO_TIMESTAMP);
+    }
+
+    private void deleteDataNodesUsingDelta(final String dataspaceName, final String anchorName, final String xpath,
+                                           final String dataToDelete) {
+        final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName);
+        final Collection<DataNode> dataNodesToDelete =
+            dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor, xpath, dataToDelete, JSON);
+        final Collection<String> xpathsToDelete = dataNodesToDelete.stream().map(DataNode::getXpath).toList();
+        cpsDataService.deleteDataNodes(dataspaceName, anchorName, xpathsToDelete, NO_TIMESTAMP);
+    }
+
+    private void addDataNodesUsingDelta(final String dataspaceName, final String anchorName, final String xpath,
+                                        final String dataToAdd) {
+        final String xpathToAdd = isRootListNodeXpath(xpath) ? CpsPathUtil.ROOT_NODE_XPATH : xpath;
+        cpsDataService.saveListElements(dataspaceName, anchorName, xpathToAdd, dataToAdd, NO_TIMESTAMP, JSON);
+    }
+
+    private boolean isRootListNodeXpath(final String xpath) {
+        return CpsPathUtil.getNormalizedParentXpath(xpath).isEmpty() && CpsPathUtil.isPathToListElement(xpath);
+    }
+}
index 089c19e..7aaeedb 100644 (file)
@@ -36,6 +36,7 @@ import org.onap.cps.utils.JsonObjectMapper
 import org.onap.cps.utils.PrefixResolver
 import org.onap.cps.utils.YangParser
 import org.onap.cps.utils.YangParserHelper
+import org.onap.cps.utils.deltareport.DeltaReportExecutor
 import org.onap.cps.utils.deltareport.DeltaReportGenerator
 import org.onap.cps.utils.deltareport.DeltaReportHelper
 import org.onap.cps.utils.deltareport.GroupedDeltaReportGenerator
@@ -54,6 +55,7 @@ class CpsDeltaServiceImplSpec extends Specification {
 
     def mockCpsAnchorService = Mock(CpsAnchorService)
     def mockCpsDataService = Mock(CpsDataService)
+    def mockDeltaReportExecutor = Mock(DeltaReportExecutor)
     def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache)
     def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder)
     def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder)
@@ -64,7 +66,7 @@ class CpsDeltaServiceImplSpec extends Specification {
     def deltaReportHelper = new DeltaReportHelper()
     def deltaReportGenerator = new DeltaReportGenerator(deltaReportHelper)
     def groupedDeltaReportGenerator = new GroupedDeltaReportGenerator(deltaReportHelper)
-    def objectUnderTest = new CpsDeltaServiceImpl(mockCpsAnchorService, mockCpsDataService, dataNodeFactory, dataMapper, jsonObjectMapper, deltaReportGenerator, groupedDeltaReportGenerator)
+    def objectUnderTest = new CpsDeltaServiceImpl(mockDeltaReportExecutor, mockCpsAnchorService, mockCpsDataService, dataNodeFactory, dataMapper, jsonObjectMapper, deltaReportGenerator, groupedDeltaReportGenerator)
 
     static def bookstoreDataNodeWithParentXpath = [new DataNode(xpath: '/bookstore', leaves: ['bookstore-name': 'Easons'])]
     static def bookstoreDataNodeWithChildXpath = [new DataNode(xpath: '/bookstore/categories[@code=\'02\']', leaves: ['code': '02', 'name': 'Kids'])]
@@ -334,7 +336,16 @@ class CpsDeltaServiceImplSpec extends Specification {
             'removed' | bookstoreDataNodesWithChildXpathAndNoLeaves | bookstoreDataNodeWithChildXpath             || null                                             | ['categories': [['code': '02', 'name': 'Kids']]]
     }
 
-    def setupSchemaSetMocks(String... yangResources) {
+    def 'Apply changes from a delta report to an anchor'() {
+        given: 'delta report as JSON string'
+            def deltaReportJson = '[{"action":"replace","xpath":"/bookstore","sourceData":{"bookstore":{"bookstore-name":"Easons"}},"targetData":{"bookstore":{"bookstore-name":"My Store"}}}]'
+        when: 'an attempt to apply the delta report to the anchor'
+            objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+        then: 'utility class to apply the delta report is invoked with expected parameters'
+            1 * mockDeltaReportExecutor.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+    }
+
+    def setupSchemaSetMocks(yangResources) {
         def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
         mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
         def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources)
@@ -342,7 +353,7 @@ class CpsDeltaServiceImplSpec extends Specification {
         mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
     }
 
-    def setupSchemaSetMocksForDelta(Map<String, String> yangResourceContentPerName) {
+    def setupSchemaSetMocksForDelta(yangResourceContentPerName) {
         def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
         mockTimedYangTextSchemaSourceSetBuilder.getYangTextSchemaSourceSet(yangResourceContentPerName) >> mockYangTextSchemaSourceSet
         mockYangTextSchemaSourceSetCache.get(_, _) >> mockYangTextSchemaSourceSet
index ace786a..49a79fd 100644 (file)
@@ -1,6 +1,7 @@
 /*
  *  ============LICENSE_START=======================================================
  *  Copyright (C) 2022 Nordix Foundation
+ *  Modifications Copyright (C) 2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -136,4 +137,23 @@ class JsonObjectMapperSpec extends Specification {
         then: 'a data validation exception is thrown'
             thrown(DataValidationException)
     }
+
+    def 'Convert JSON content to a list of specific class type'() {
+        given: 'a JSON array string'
+            def jsonContent = '[{"key":"value1"}, {"key":"value2"}]'
+        when: 'The JSON content is converted to a list of the target class type'
+            def result = jsonObjectMapper.convertToJsonArray(jsonContent, Map)
+        then: 'The result is a list of the target class type and has expected content'
+            assert result == [[key: 'value1'], [key: 'value2']]
+    }
+
+    def 'Throw exception when JSON content is invalid for list conversion'() {
+        given: 'An invalid JSON array string'
+            def jsonContent = '[{"key":"value1", {"key":"value2"}'
+        when: 'an attempt to convert JSON content to a list of the target class type'
+            jsonObjectMapper.convertToJsonArray(jsonContent, Map)
+        then: 'a DataValidationException is thrown'
+            def thrown = thrown(DataValidationException)
+            thrown.message.contains('Parsing error occurred while converting JSON content to specific class type.')
+    }
 }
diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy
new file mode 100644 (file)
index 0000000..7c27efc
--- /dev/null
@@ -0,0 +1,157 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 TechMahindra Ltd
+ *  ================================================================================
+ *  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.utils.deltareport
+
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.onap.cps.TestUtils
+import org.onap.cps.api.CpsAnchorService
+import org.onap.cps.api.CpsDataService
+import org.onap.cps.api.exceptions.DataValidationException
+import org.onap.cps.api.model.Anchor
+import org.onap.cps.api.model.DataNode
+import org.onap.cps.api.model.DeltaReport
+import org.onap.cps.cpspath.parser.CpsPathUtil
+import org.onap.cps.impl.DataNodeFactoryImpl
+import org.onap.cps.impl.DeltaReportBuilder
+import org.onap.cps.impl.YangTextSchemaSourceSetCache
+import org.onap.cps.utils.ContentType
+import org.onap.cps.utils.JsonObjectMapper
+import org.onap.cps.utils.YangParser
+import org.onap.cps.utils.YangParserHelper
+import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder
+import org.onap.cps.yang.YangTextSchemaSourceSet
+import org.onap.cps.yang.YangTextSchemaSourceSetBuilder
+import spock.lang.Shared
+import spock.lang.Specification
+
+class DeltaReportExecutorSpec extends Specification {
+
+    def mockCpsAnchorService = Mock(CpsAnchorService)
+    def mockCpsDataService = Mock(CpsDataService)
+    def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache)
+    def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder)
+    def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder)
+    def dataNodeFactory = new DataNodeFactoryImpl(yangParser)
+    def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
+    def objectUnderTest = new DeltaReportExecutor(mockCpsAnchorService, mockCpsDataService, dataNodeFactory, jsonObjectMapper)
+
+    @Shared
+    static def ANCHOR_NAME_1 = 'some-anchor-1'
+    static def ANCHOR_NAME_2 = 'some-anchor-2'
+    static def NO_TIMESTAMP = null
+    def dataspaceName = 'some-dataspace'
+    def schemaSetName = 'some-schema-set'
+    def anchor1 = Anchor.builder().name(ANCHOR_NAME_1).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build()
+    def anchor2 = Anchor.builder().name(ANCHOR_NAME_2).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build()
+
+    def setup() {
+        mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_1) >> anchor1
+        mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_2) >> anchor2
+    }
+
+    def 'Perform delete operation on existing data under an anchor using delta report'() {
+        given: 'schema mocks and delta report in JSON format'
+            setupSchemaSetMocks('bookstore.yang')
+            def deltaReportJson = '[{"action":"remove","xpath":"/bookstore","sourceData":{"categories":[{"code":"1","name":"Children","books":[{"title":"Matilda"}]}]}}]'
+        and: 'delta report constructed from JSON'
+            def deltaReport = new DeltaReportBuilder().actionRemove().withXpath('/bookstore').withSourceData('categories': [['code': '1', 'name': 'Children', 'books': [['title': 'Matilda']]]]).build()
+        and: 'source data as JSON string from delta report'
+            def sourceData = jsonObjectMapper.asJsonString(deltaReport.getSourceData())
+        and: 'expected data nodes with child nodes to delete'
+            def dataNodes = [new DataNode(xpath: '/bookstore/categories[@code=\'1\']', childDataNodes: [new DataNode(xpath: '/bookstore/categories[@code=\'1\']/books[@title=\'Matilda\']')])]
+            def xpathsToDelete = dataNodes*.xpath
+        when: 'attempt to apply delta using the delta report'
+            objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+        then: 'the delta report in JSON format is converted to a list of DeltaReport objects'
+            jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport]
+        and: 'data nodes are built from the source data of delta report'
+            dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor1, deltaReport.getXpath(), sourceData, ContentType.JSON) >> [dataNodes]
+        and: 'appropriate cps data service method is invoked with expected parameters to delete data nodes'
+            1 * mockCpsDataService.deleteDataNodes(dataspaceName, ANCHOR_NAME_1, xpathsToDelete, NO_TIMESTAMP)
+    }
+
+    def 'Perform create operation on existing data under an anchor using delta report to add a node with #scenario'() {
+        given: 'schema mocks and delta report in JSON format'
+            setupSchemaSetMocks('bookstore.yang')
+        and: 'target data as JSON string from delta report'
+            def targetData = jsonObjectMapper.asJsonString(deltaReport.getTargetData())
+        when: 'attempt to apply delta using the delta report'
+            objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+        then: 'the delta report in JSON format is converted to a list of DeltaReport objects'
+            jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport]
+        and: 'appropriate cps data service method is invoked with expected parameters to create data nodes'
+            1 * mockCpsDataService.saveListElements(dataspaceName, ANCHOR_NAME_1, expectedXpath, targetData, NO_TIMESTAMP, ContentType.JSON)
+        where:
+            scenario                 | deltaReportJson                                                                                                                    | deltaReport                                                                                                                                                        || expectedXpath
+            'xpath'                  | '[{"action":"create","xpath":"/bookstore","targetData":{"categories":[{"code":"1","name":"Children"}]}}]'                          | new DeltaReportBuilder().actionCreate().withXpath('/bookstore').withTargetData(['categories': [['code': '1', 'name': 'Children']]]).build()                        || '/bookstore'
+            'list node xpath'        | '[{"action":"create","xpath":"/bookstore/categories[@code=\'1\']","targetData":{"books":[{"price":20,"title":"Matilda"}]}}]'       | new DeltaReportBuilder().actionCreate().withXpath('/bookstore/categories[@code=\'1\']').withTargetData(['books': [['price': 20, 'title': 'Matilda']]]).build()     || '/bookstore/categories[@code=\'1\']'
+            'parent list node xpath' | '[{"action":"create","xpath":"/bookstore-address[@bookstore-name=\'Easons\']","targetData":{"address":{"street":"Main Street"}}}]' | new DeltaReportBuilder().actionCreate().withXpath('/bookstore-address[@bookstore-name=\'Easons\']').withTargetData(['address': ['street': 'Main Street']]).build() || '/'
+    }
+
+    def 'Perform replace operation on existing data under an anchor using delta report'() {
+        given: 'schema mocks and delta report in JSON format'
+            setupSchemaSetMocks('bookstore.yang')
+            def deltaReportJson = '[{"action":"replace","xpath":"/bookstore/categories[@code=\'1\']","sourceData":{"books":[{"price":20,"title":"Matilda"}]},"targetData":{"books":[{"price":30,"title":"Matilda"}]}}]'
+        and: 'delta report constructed from JSON'
+            def deltaReport = new DeltaReportBuilder().actionReplace().withXpath('/bookstore/categories[@code=\'1\']').withSourceData(['books': [['price': 20, 'title': 'Matilda']]]).withTargetData(['books': [['price': 30, 'title': 'Matilda']]]).build()
+        and: 'the parent node xpath is fetched from delta report'
+            def parentNodeXpath = CpsPathUtil.getNormalizedParentXpath(deltaReport.getXpath())
+        and: 'target data as JSON is fetched from delta report'
+            def targetData = jsonObjectMapper.asJsonString(deltaReport.getTargetData())
+        when: 'attempt to apply delta using the delta report'
+            objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+        then: 'the delta report in JSON format is converted to a list of DeltaReport objects'
+            jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport]
+        and: 'cps data service is invoked with expected parameters to delete data nodes by using their xpaths'
+            1 * mockCpsDataService.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, ANCHOR_NAME_1, parentNodeXpath, targetData, NO_TIMESTAMP)
+    }
+
+    def 'Batch operation using delta report rolls back in case of a semantically invalid Delta Report'() {
+        given: 'schema mocks and a semantically invalid delta report in JSON format'
+            setupSchemaSetMocks('bookstore.yang')
+            def deltaReportJson = '[{"action":"create","xpath":"/bookstore/categories[@code=\'100\']","targetData":{"categories":[{"code":"100","name":"Funny"}]}},{"action":"remove","xpath":"/bookstore/categories[@code=\'4\']","sourceData":{"categorie":[{"code":"4","name":"Computing"}]}}]'
+        and: 'delta report object with invalid data for remove operation'
+            def deltaReport = [new DeltaReportBuilder().actionCreate().withXpath('/bookstore/categories[@code=\'100\']').withTargetData(['categories': [['code': '100', 'name': 'Funny']]]).build(), new DeltaReportBuilder().actionRemove().withXpath('/bookstore/categories[@code=\'4\'').withSourceData(['categorie': ['code': '4', 'name': 'Computing']]).build()]
+        and: 'appropriate data is fetched from delta report'
+            def xpathForCreateOperation = deltaReport[0].xpath
+            def targetDataForCreateOperation = jsonObjectMapper.asJsonString(deltaReport[0].targetData)
+            def xpathForDeleteOperation = deltaReport[1].xpath
+            def sourceDataForDeleteOperation = jsonObjectMapper.asJsonString(deltaReport[1].sourceData)
+        when: 'attempt to apply delta using the delta report'
+            objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson)
+        then: 'the delta report in JSON format is converted to DeltaReport objects'
+            jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport]
+        and: 'the create operation is attempted and succeeds'
+            1 * mockCpsDataService.saveListElements(dataspaceName, ANCHOR_NAME_1, xpathForCreateOperation, targetDataForCreateOperation, NO_TIMESTAMP, ContentType.JSON)
+        and: 'the remove operation fails due to invalid data, causing rollback'
+            dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor1, xpathForDeleteOperation, sourceDataForDeleteOperation, ContentType.JSON) >> {throw new DataValidationException('Data Validation Failed')}
+        then: 'a DataValidationException is thrown'
+            thrown(DataValidationException)
+    }
+
+    def setupSchemaSetMocks(yangResources) {
+        def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
+        mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
+        def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources)
+        def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
+        mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+    }
+}