Fix Replace a Node API behaviour handling List items 71/142271/20
authorArpit Singh <AS00745003@techmahindra.com>
Wed, 3 Dec 2025 11:47:59 +0000 (17:17 +0530)
committerArpit Singh <AS00745003@techmahindra.com>
Thu, 4 Dec 2025 10:24:44 +0000 (15:54 +0530)
Jira - [CPS-2708] Enhance Replace A Node with List Items
Documentation - https://lf-onap.atlassian.net/wiki/x/gIDMAQ

- Previously, Replace a node endpoint accepted list items and returned a
  200 OK response, but the data was not actually replaced when cross
  checked.
- This patch fixes the issue of phantom replace operations and ensures
  that the API correctly handles list items.

Issue-ID:CPS-2708
Change-Id: Ibc9bbb88ccbb07302355321c6d5c2eade0e7e5fa
Signed-off-by: Arpit Singh <AS00745003@techmahindra.com>
cps-rest/docs/openapi/cpsData.yml
cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java
cps-service/src/test/groovy/org/onap/cps/impl/CpsDataServiceImplSpec.groovy
integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DataServiceIntegrationSpec.groovy

index 178a68f..ad4fccb 100644 (file)
@@ -240,6 +240,8 @@ nodesByDataspaceAndAnchor:
     responses:
       '200':
         $ref: 'components.yml#/components/responses/Ok'
+      '204':
+        $ref: 'components.yml#/components/responses/Created'
       '400':
         $ref: 'components.yml#/components/responses/BadRequest'
       '403':
index a8aee62..e3da6c5 100644 (file)
@@ -26,6 +26,7 @@ package org.onap.cps.impl;
 
 import static org.onap.cps.cpspath.parser.CpsPathUtil.NO_PARENT_PATH;
 import static org.onap.cps.cpspath.parser.CpsPathUtil.ROOT_NODE_XPATH;
+import static org.onap.cps.cpspath.parser.CpsPathUtil.isPathToListElement;
 import static org.onap.cps.events.model.EventPayload.Action.CREATE;
 import static org.onap.cps.events.model.EventPayload.Action.REMOVE;
 import static org.onap.cps.events.model.EventPayload.Action.REPLACE;
@@ -209,7 +210,11 @@ public class CpsDataServiceImpl implements CpsDataService {
         final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName);
         final Collection<DataNode> dataNodes = dataNodeFactory
                 .createDataNodesWithAnchorParentXpathAndNodeData(anchor, parentNodeXpath, nodeData, contentType);
-        cpsDataPersistenceService.updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
+        if (ROOT_NODE_XPATH.equals(parentNodeXpath) || !isPathToListElement(parentNodeXpath)) {
+            cpsDataPersistenceService.updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
+        } else {
+            cpsDataPersistenceService.replaceListContent(dataspaceName, anchorName, parentNodeXpath, dataNodes);
+        }
         sendDataUpdatedEvent(anchor, parentNodeXpath, REPLACE_ACTION, observedTimestamp);
     }
 
index af2c50f..1aedb74 100644 (file)
@@ -310,6 +310,25 @@ class CpsDataServiceImplSpec extends Specification {
             'json list'      | '/test-tree'    | '{"branch": [{"name":"Name1"}, {"name":"Name2"}]}' || ["/test-tree/branch[@name='Name1']", "/test-tree/branch[@name='Name2']"]
     }
 
+    def 'Replace list data node using singular #scenario node'() {
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
+        when: 'replace data method is invoked with data and list node xpath'
+            objectUnderTest.updateDataNodeAndDescendants(dataspaceName, anchorName, '/test-tree/branch[@name=\'Name\']', data, observedTimestamp, contentType)
+        then: 'the persistence service method is invoked with correct parameters'
+            1 * mockCpsDataPersistenceService.replaceListContent(dataspaceName, anchorName, '/test-tree/branch[@name=\'Name\']',
+            { dataNodes ->{
+                assert dataNodes.size() == 1
+                assert dataNodes.collect { it.getXpath() == '/test-tree/branch[@name=\'Name\']/nest' }
+            }})
+        and: 'the CpsValidator is called on the dataspaceName and AnchorName'
+            1 * mockCpsValidator.validateNameCharacters(dataspaceName, anchorName)
+        where: 'the following data was used'
+            scenario    | contentType      | data
+            'JSON data' | ContentType.JSON | '{"nest":{"name":"nestName"}}'
+            'XML data'  | ContentType.XML  | '<nest><name>nestName</name></nest>'
+    }
+
     def 'Replace data node using singular XML data node: #scenario.'() {
         given: 'schema set for given anchor and dataspace references test-tree model'
             setupSchemaSetMocks('test-tree.yang')
index 802438f..0390b74 100644 (file)
@@ -431,7 +431,7 @@ class DataServiceIntegrationSpec extends FunctionalSpecBase {
         when: 'the webinfo (container) is updated'
             json = '{"webinfo": {"domain-name":"newdomain.com" ,"contact-email":"info@newdomain.com" }}'
             objectUnderTest.updateDataNodeAndDescendants(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/bookstore', json, now, ContentType.JSON)
-        then: 'webinfo has been updated with teh new details'
+        then: 'webinfo has been updated with the new details'
             def result = objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/bookstore/webinfo', DIRECT_CHILDREN_ONLY)
             result.leaves.'domain-name'[0] == 'newdomain.com'
             result.leaves.'contact-email'[0] == 'info@newdomain.com'
@@ -439,6 +439,27 @@ class DataServiceIntegrationSpec extends FunctionalSpecBase {
             restoreBookstoreDataAnchor(1)
     }
 
+    def 'Update list items.'() {
+        given: 'list of books'
+            def existingJsonData = '{"books": [ {"title":"Existing Book", "lang":"English"}, {"title":"Another existing book", "lang":"French"} ] }'
+            objectUnderTest.saveListElements(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2 , '/bookstore/categories[@code=\'1\']', existingJsonData, now, ContentType.JSON)
+        when: 'the books list is updated'
+            def updatedJsonData = '{"books": [ {"title":"Existing Book", "lang":"German"}, {"title":"A new book", "lang":"Hindi"} ] }'
+            objectUnderTest.updateDataNodeAndDescendants(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, '/bookstore/categories[@code=\'1\']', updatedJsonData, now, ContentType.JSON)
+        then: 'the expected number of updated books are retrieved'
+            def result = objectUnderTest.getDataNodesForMultipleXpaths(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, ['/bookstore/categories[@code=\'1\']/books[@title="Existing Book"]', '/bookstore/categories[@code=\'1\']/books[@title="A new book"]', '/bookstore/categories[@code=\'1\']/books[@title="Another existing book"]'], DIRECT_CHILDREN_ONLY)
+            assert result.size() == 2
+        and: 'the updated books have expected xpaths'
+            def xpaths = result*.xpath
+            assert xpaths.containsAll(["/bookstore/categories[@code='1']/books[@title='A new book']", "/bookstore/categories[@code='1']/books[@title='Existing Book']"])
+        and: 'the updated book has expected leaf value'
+            result[1].leaves['lang'] == 'German'
+        and: 'the book that was removed in the updated data is no longer present'
+            assert result.every { it.xpath != "/bookstore/categories[@code='1']/books[@title='Another existing book']" }
+        cleanup:
+            restoreBookstoreDataAnchor(2)
+    }
+
     def 'Update bookstore top-level container data node.'() {
         when: 'the bookstore top-level container is updated'
             def json = '{ "bookstore": { "bookstore-name": "new bookstore" }}'