Create list-node elements (part1): CPS service and persistence layers 97/121097/3
authorRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Wed, 5 May 2021 09:06:00 +0000 (12:06 +0300)
committerRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Tue, 11 May 2021 06:39:59 +0000 (09:39 +0300)
+ fix integrity violation exception exposed out of persistence layer
+ refactor CpsDataServiceImplSpec to eliminate repeated code

Issue-ID: CPS-360
Change-Id: Id70341fe54bf3c31af661f6aae04a7a80f4a1e9d
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
cps-ri/src/main/java/org/onap/cps/spi/impl/CpsDataPersistenceServiceImpl.java
cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsDataPersistenceServiceSpec.groovy
cps-ri/src/test/resources/data/fragment.sql
cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
cps-service/src/main/java/org/onap/cps/spi/CpsDataPersistenceService.java
cps-service/src/main/java/org/onap/cps/spi/exceptions/AlreadyDefinedException.java
cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy

index 343a088..ae399a1 100644 (file)
@@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSet.Builder;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -73,7 +74,30 @@ public class CpsDataPersistenceServiceImpl implements CpsDataPersistenceService
         final var fragmentEntity =
             toFragmentEntity(parentFragment.getDataspace(), parentFragment.getAnchor(), dataNode);
         parentFragment.getChildFragments().add(fragmentEntity);
-        fragmentRepository.save(parentFragment);
+        try {
+            fragmentRepository.save(parentFragment);
+        } catch (final DataIntegrityViolationException exception) {
+            throw AlreadyDefinedException.forDataNode(dataNode.getXpath(), anchorName, exception);
+        }
+    }
+
+    @Override
+    public void addListDataNodes(final String dataspaceName, final String anchorName, final String parentNodeXpath,
+        final Collection<DataNode> dataNodes) {
+        final FragmentEntity parentFragment = getFragmentByXpath(dataspaceName, anchorName, parentNodeXpath);
+        final List<FragmentEntity> newFragmentEntities =
+            dataNodes.stream().map(
+                dataNode -> toFragmentEntity(parentFragment.getDataspace(), parentFragment.getAnchor(), dataNode)
+            ).collect(Collectors.toUnmodifiableList());
+        parentFragment.getChildFragments().addAll(newFragmentEntities);
+        try {
+            fragmentRepository.save(parentFragment);
+        } catch (final DataIntegrityViolationException exception) {
+            final List<String> conflictXpaths = dataNodes.stream()
+                .map(DataNode::getXpath)
+                .collect(Collectors.toList());
+            throw AlreadyDefinedException.forDataNodes(conflictXpaths, anchorName, exception);
+        }
     }
 
     @Override
index f632e02..0f0b1b4 100755 (executable)
@@ -20,6 +20,9 @@
  */
 package org.onap.cps.spi.impl
 
+import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
+import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
+
 import com.google.common.collect.ImmutableSet
 import com.google.gson.Gson
 import com.google.gson.GsonBuilder
@@ -32,14 +35,10 @@ import org.onap.cps.spi.exceptions.DataspaceNotFoundException
 import org.onap.cps.spi.model.DataNode
 import org.onap.cps.spi.model.DataNodeBuilder
 import org.springframework.beans.factory.annotation.Autowired
-import org.springframework.dao.DataIntegrityViolationException
 import org.springframework.test.context.jdbc.Sql
 
 import javax.validation.ConstraintViolationException
 
-import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
-import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
-
 class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
 
     @Autowired
@@ -53,6 +52,7 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
     static final String XPATH_DATA_NODE_WITH_LEAVES = '/parent-100'
     static final long UPDATE_DATA_NODE_FRAGMENT_ID = 4202L
     static final long UPDATE_DATA_NODE_SUB_FRAGMENT_ID = 4203L
+    static final long LIST_DATA_NODE_PARENT_FRAGMENT_ID = 4206L
 
     static final DataNode newDataNode = new DataNodeBuilder().build()
     static DataNode existingDataNode
@@ -145,7 +145,36 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
         where: 'the following data is used'
             scenario                 | parentXpath                      | dataNode              || expectedException
             'parent does not exist'  | 'unknown'                        | newDataNode           || DataNodeNotFoundException
-            'already existing child' | XPATH_DATA_NODE_WITH_DESCENDANTS | existingChildDataNode || DataIntegrityViolationException
+            'already existing child' | XPATH_DATA_NODE_WITH_DESCENDANTS | existingChildDataNode || AlreadyDefinedException
+    }
+
+    @Sql([CLEAR_DATA, SET_DATA])
+    def 'Add list-node fragment with multiple elements.'() {
+        given: 'list node data fragment as a collection of data nodes'
+            def listNodeXpaths = ['/parent-201/child-204[@key="B"]', '/parent-201/child-204[@key="C"]']
+            def listNodeCollection = buildDataNodeCollection(listNodeXpaths)
+        when: 'list-node elements added to existing parent node'
+            objectUnderTest.addListDataNodes(DATASPACE_NAME, ANCHOR_NAME3, '/parent-201', listNodeCollection)
+        then: 'new entries successfully persisted, parent node now contains 5 children (2 new + 3 existing before)'
+            def parentFragment = fragmentRepository.getOne(LIST_DATA_NODE_PARENT_FRAGMENT_ID)
+            def allChildXpaths = parentFragment.getChildFragments().collect { it.getXpath() }
+            assert allChildXpaths.size() == 5
+            assert allChildXpaths.containsAll(listNodeXpaths)
+    }
+
+    @Sql([CLEAR_DATA, SET_DATA])
+    def 'Add list-node fragment error scenario: #scenario.'() {
+        given: 'list node data fragment as a collection of data nodes'
+            def listNodeCollection = buildDataNodeCollection(listNodeXpaths)
+        when: 'list-node elements added to existing parent node'
+            objectUnderTest.addListDataNodes(DATASPACE_NAME, ANCHOR_NAME3, parentNodeXpath, listNodeCollection)
+        then: 'a #expectedException is thrown'
+            thrown(expectedException)
+        where: 'following parameters were used'
+            scenario                     | parentNodeXpath | listNodeXpaths                      || expectedException
+            'parent node does not exist' | '/unknown'      | ['irrelevant']                      || DataNodeNotFoundException
+            'already existing fragment'  | '/parent-201'   | ['/parent-201/child-204[@key="A"]'] || AlreadyDefinedException
+
     }
 
     static def createDataNodeTree(String... xpaths) {
@@ -175,10 +204,10 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
             assert result.getChildDataNodes().size() == 0
             assertLeavesMaps(result.getLeaves(), expectedLeavesByXpathMap[XPATH_DATA_NODE_WITH_LEAVES])
         where: 'the following data is used'
-            scenario                      | inputXPath
-            'some xpath'                  |'/parent-100'
-            'root xpath'                  |'/'
-            'empty xpath'                 |''
+            scenario      | inputXPath
+            'some xpath'  '/parent-100'
+            'root xpath'  '/'
+            'empty xpath' ''
     }
 
     @Sql([CLEAR_DATA, SET_DATA])
@@ -196,10 +225,10 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
             mappedResult.forEach(
                     (xPath, dataNode) -> assertLeavesMaps(dataNode.getLeaves(), expectedLeavesByXpathMap[xPath]))
         where: 'the following data is used'
-            scenario                      | inputXPath
-            'some xpath'                  |'/parent-100'
-            'root xpath'                  |'/'
-            'empty xpath'                 |''
+            scenario      | inputXPath
+            'some xpath'  '/parent-100'
+            'root xpath'  '/'
+            'empty xpath' ''
     }
 
     @Sql([CLEAR_DATA, SET_DATA])
@@ -299,6 +328,10 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'NON-EXISTING XPATH' || DataNodeNotFoundException
     }
 
+    static Collection<DataNode> buildDataNodeCollection(xpaths) {
+        return xpaths.collect { new DataNodeBuilder().withXpath(it).build() }
+    }
+
     static DataNode buildDataNode(xpath, leaves, childDataNodes) {
         return new DataNodeBuilder().withXpath(xpath).withLeaves(leaves).withChildDataNodes(childDataNodes).build()
     }
@@ -323,7 +356,7 @@ class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
     def static treeToFlatMapByXpath(Map<String, DataNode> flatMap, DataNode dataNodeTree) {
         flatMap.put(dataNodeTree.getXpath(), dataNodeTree)
         dataNodeTree.getChildDataNodes()
-            .forEach(childDataNode -> treeToFlatMapByXpath(flatMap, childDataNode))
+                .forEach(childDataNode -> treeToFlatMapByXpath(flatMap, childDataNode))
         return flatMap
     }
 
index 3e2ae81..1897185 100755 (executable)
@@ -29,6 +29,6 @@ INSERT INTO FRAGMENT (ID, DATASPACE_ID, ANCHOR_ID, PARENT_ID, XPATH, ATTRIBUTES)
     (4204, 1001, 3003, 4201, '/parent-200/child-202', '{"common-leaf-name": "common-leaf value", "common-leaf-name-int" : 5}'),
     (4205, 1001, 3003, 4204, '/parent-200/child-202/grand-child-202', '{"common-leaf-name": "common-leaf value", "common-leaf-name-int" : 5}'),
     (4206, 1001, 3003, null, '/parent-201', '{"leaf-value": "original"}'),
-    (4207, 1001, 3003, 4206, '/parent-201/child-202', '{"common-leaf-name": "common-leaf other value", "common-leaf-name-int" : 5}'),
-    (4208, 1001, 3003, 4206, '/parent-201/child-203[@key1="A" and @key2=1]', '{"key1": "A", "key2" : 1, "other-leaf" : "leaf value"}'),
-    (4209, 1001, 3003, 4206, '/parent-201/child-203[@key1="A" and @key2=2]', '{"key1": "A", "key2" : 2, "other-leaf" : "other value"}');
\ No newline at end of file
+    (4207, 1001, 3003, 4206, '/parent-201/child-203', '{}'),
+    (4208, 1001, 3003, 4206, '/parent-201/child-204[@key="A"]', '{"key": "A"}'),
+    (4209, 1001, 3003, 4206, '/parent-201/child-204[@key="X"]', '{"key": "X"}');
\ No newline at end of file
index 8552c6c..8e59ebc 100644 (file)
@@ -56,6 +56,21 @@ public interface CpsDataService {
     void saveData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath,
         @NonNull String jsonData);
 
+    /**
+     * Persists child data fragment representing list-node (with one or more elements) under existing data node
+     * for the given anchor and dataspace.
+     *
+     * @param dataspaceName   dataspace name
+     * @param anchorName      anchor name
+     * @param parentNodeXpath parent node xpath
+     * @param jsonData        json data representing list element
+     * @throws DataValidationException   when json data is invalid (incl. list-node being empty)
+     * @throws DataNodeNotFoundException when parent node cannot be found by parent node xpath
+     * @throws AlreadyDefinedException   when any of child data nodes is having xpath of already existing node
+     */
+    void saveListNodeData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath,
+        @NonNull String jsonData);
+
     /**
      * Retrieves datanode by XPath for given dataspace and anchor.
      *
index fd0f76b..523657a 100755 (executable)
 
 package org.onap.cps.api.impl;
 
+import java.util.Collection;
 import org.onap.cps.api.CpsAdminService;
 import org.onap.cps.api.CpsDataService;
 import org.onap.cps.api.CpsModuleService;
 import org.onap.cps.spi.CpsDataPersistenceService;
 import org.onap.cps.spi.FetchDescendantsOption;
+import org.onap.cps.spi.exceptions.DataValidationException;
 import org.onap.cps.spi.model.DataNode;
 import org.onap.cps.spi.model.DataNodeBuilder;
 import org.onap.cps.utils.YangUtils;
@@ -64,6 +66,17 @@ public class CpsDataServiceImpl implements CpsDataService {
         cpsDataPersistenceService.addChildDataNode(dataspaceName, anchorName, parentNodeXpath, dataNode);
     }
 
+    @Override
+    public void saveListNodeData(final String dataspaceName, final String anchorName,
+        final String parentNodeXpath, final String jsonData) {
+        final Collection<DataNode> dataNodesCollection =
+            buildDataNodeCollectionFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData);
+        if (dataNodesCollection.isEmpty()) {
+            throw new DataValidationException("Invalid list data.", "List node is empty.");
+        }
+        cpsDataPersistenceService.addListDataNodes(dataspaceName, anchorName, parentNodeXpath, dataNodesCollection);
+    }
+
     @Override
     public DataNode getDataNode(final String dataspaceName, final String anchorName, final String xpath,
         final FetchDescendantsOption fetchDescendantsOption) {
@@ -103,6 +116,19 @@ public class CpsDataServiceImpl implements CpsDataService {
             .build();
     }
 
+    private Collection<DataNode> buildDataNodeCollectionFromJson(final String dataspaceName, final String anchorName,
+        final String parentNodeXpath, final String jsonData) {
+
+        final var anchor = cpsAdminService.getAnchor(dataspaceName, anchorName);
+        final var schemaContext = getSchemaContext(dataspaceName, anchor.getSchemaSetName());
+
+        final NormalizedNode<?, ?> normalizedNode = YangUtils.parseJsonData(jsonData, schemaContext, parentNodeXpath);
+        return new DataNodeBuilder()
+            .withParentNodeXpath(parentNodeXpath)
+            .withNormalizedNodeTree(normalizedNode)
+            .buildCollection();
+    }
+
     private SchemaContext getSchemaContext(final String dataspaceName, final String schemaSetName) {
         return yangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName).getSchemaContext();
     }
index 48f9763..0ed3bf0 100644 (file)
@@ -54,6 +54,18 @@ public interface CpsDataPersistenceService {
     void addChildDataNode(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentXpath,
         @NonNull DataNode dataNode);
 
+    /**
+     * Adds list node child elements to a Fragment.
+     *
+     * @param dataspaceName   dataspace name
+     * @param anchorName      anchor name
+     * @param parentNodeXpath parent node xpath
+     * @param dataNodes       collection of data nodes representing list node elements
+     */
+
+    void addListDataNodes(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath,
+        @NonNull Collection<DataNode> dataNodes);
+
     /**
      * Retrieves datanode by XPath for given dataspace and anchor.
      *
@@ -100,4 +112,5 @@ public interface CpsDataPersistenceService {
      */
     Collection<DataNode> queryDataNodes(@NonNull String dataspaceName, @NonNull String anchorName,
         @NonNull String cpsPath, @NonNull FetchDescendantsOption fetchDescendantsOption);
+
 }
index 9e54f34..1352a9b 100644 (file)
@@ -19,6 +19,8 @@
 
 package org.onap.cps.spi.exceptions;
 
+import java.util.Collection;
+
 /**
  * Already defined exception. Indicates the cps object with same name already exists.
  */
@@ -70,4 +72,10 @@ public class AlreadyDefinedException extends CpsAdminException {
         final Throwable cause) {
         return new AlreadyDefinedException("Data node", xpath, contextName, cause);
     }
+
+    public static AlreadyDefinedException forDataNodes(final Collection<String> xpaths, final String contextName,
+        final Throwable cause) {
+        final var name = String.format("(one or more) of %s", xpaths);
+        return new AlreadyDefinedException("Data node", name, contextName, cause);
+    }
 }
index 8fbf745..5f930a1 100644 (file)
@@ -25,6 +25,7 @@ import org.onap.cps.api.CpsAdminService
 import org.onap.cps.api.CpsModuleService
 import org.onap.cps.spi.CpsDataPersistenceService
 import org.onap.cps.spi.FetchDescendantsOption
+import org.onap.cps.spi.exceptions.DataValidationException
 import org.onap.cps.spi.model.Anchor
 import org.onap.cps.spi.model.DataNodeBuilder
 import org.onap.cps.yang.YangTextSchemaSourceSet
@@ -51,16 +52,8 @@ class CpsDataServiceImplSpec extends Specification {
     def schemaSetName = 'some schema set'
 
     def 'Saving json data.'() {
-        given: 'that the admin service will return an anchor'
-            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
-            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
-        and: 'the schema source set cache returns a schema source set'
-            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
-            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
-        and: 'the schema source sets returns the test-tree schema context'
-            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
-            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
-            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
         when: 'save data method is invoked with test-tree json data'
             def jsonData = TestUtils.getResourceFileContent('test-tree.json')
             objectUnderTest.saveData(dataspaceName, anchorName, jsonData)
@@ -70,24 +63,44 @@ class CpsDataServiceImplSpec extends Specification {
     }
 
     def 'Saving child data fragment under existing node.'() {
-        given: 'that the admin service will return an anchor'
-            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
-            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
-        and: 'the schema source set cache returns a schema source set'
-            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
-            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
-        and: 'the schema source sets returns the test-tree schema context'
-            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
-            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
-            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
         when: 'save data method is invoked with test-tree json data'
             def jsonData = '{"branch": [{"name": "New"}]}'
-            objectUnderTest.saveData(dataspaceName, anchorName, '/test-tree',jsonData)
+            objectUnderTest.saveData(dataspaceName, anchorName, '/test-tree', jsonData)
         then: 'the persistence service method is invoked with correct parameters'
-            1 * mockCpsDataPersistenceService.addChildDataNode(dataspaceName, anchorName,'/test-tree',
+            1 * mockCpsDataPersistenceService.addChildDataNode(dataspaceName, anchorName, '/test-tree',
                     { dataNode -> dataNode.xpath == '/test-tree/branch[@name=\'New\']' })
     }
 
+    def 'Saving list-node data fragment under existing node.'() {
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
+        when: 'save data method is invoked with list-node json data'
+            def jsonData = '{"branch": [{"name": "A"}, {"name": "B"}]}'
+            objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData)
+        then: 'the persistence service method is invoked with correct parameters'
+            1 * mockCpsDataPersistenceService.addListDataNodes(dataspaceName, anchorName, '/test-tree',
+                    { dataNodeCollection ->
+                        {
+                            assert dataNodeCollection.size() == 2
+                            assert dataNodeCollection.collect { it.getXpath() }
+                                    .containsAll(['/test-tree/branch[@name=\'A\']', '/test-tree/branch[@name=\'B\']'])
+                        }
+                    }
+            )
+    }
+
+    def 'Saving empty list-node data fragment.'() {
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
+        when: 'save data method is invoked with empty list-node data fragment'
+            def jsonData = '{"branch": []}'
+            objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData)
+        then: 'invalid data exception is thrown'
+            thrown(DataValidationException)
+    }
+
     def 'Get data node with option #fetchDescendantsOption.'() {
         def xpath = '/xpath'
         def dataNode = new DataNodeBuilder().withXpath(xpath).build()
@@ -100,45 +113,39 @@ class CpsDataServiceImplSpec extends Specification {
     }
 
     def 'Update data node leaves: #scenario.'() {
-        given: 'that the admin service will return an anchor'
-            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
-            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
-        and: 'the schema source set cache returns a schema source set'
-            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
-            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
-        and: 'the schema source sets returns the test-tree schema context'
-            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
-            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
-            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
         when: 'update data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath'
             objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData)
         then: 'the persistence service method is invoked with correct parameters'
-            1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, nodeXpath, leaves)
+            1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, expectedNodeXpath, leaves)
         where: 'following parameters were used'
-            scenario         | parentNodeXpath | jsonData                         | nodeXpath                           | leaves
-            'top level node' | '/'             | '{ "test-tree": {"branch": []}}' | '/test-tree'                        | Collections.emptyMap()
-            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}'  | '/test-tree/branch[@name=\'Name\']' | ['name': 'Name']
+            scenario         | parentNodeXpath | jsonData                        || expectedNodeXpath                   | leaves
+            'top level node' | '/'             | '{"test-tree": {"branch": []}}' || '/test-tree'                        | Collections.emptyMap()
+            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}' || '/test-tree/branch[@name=\'Name\']' | ['name': 'Name']
     }
 
     def 'Replace data node: #scenario.'() {
-        given: 'that the admin service will return an anchor'
-            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
-            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
-        and: 'the schema source set cache returns a schema source set'
-            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
-            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
-        and: 'the schema source sets returns the test-tree schema context'
-            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
-            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
-            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        given: 'schema set for given anchor and dataspace references test-tree model'
+            setupSchemaSetMocks('test-tree.yang')
         when: 'replace data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath'
             objectUnderTest.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData)
         then: 'the persistence service method is invoked with correct parameters'
             1 * mockCpsDataPersistenceService.replaceDataNodeTree(dataspaceName, anchorName,
-                    { dataNode -> dataNode.xpath == nodeXpath })
+                    { dataNode -> dataNode.xpath == expectedNodeXpath })
         where: 'following parameters were used'
-            scenario         | parentNodeXpath | jsonData                         | nodeXpath
-            'top level node' | '/'             | '{ "test-tree": {"branch": []}}' | '/test-tree'
-            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}'  | '/test-tree/branch[@name=\'Name\']'
+            scenario         | parentNodeXpath | jsonData                        || expectedNodeXpath
+            'top level node' | '/'             | '{"test-tree": {"branch": []}}' || '/test-tree'
+            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}' || '/test-tree/branch[@name=\'Name\']'
+    }
+
+    def setupSchemaSetMocks(String... yangResources) {
+        def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
+        mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
+        def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
+        mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
+        def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources)
+        def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
+        mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
     }
 }
\ No newline at end of file