Handle partial failure (improvements)
[cps.git] / cps-ri / src / test / groovy / org / onap / cps / spi / impl / CpsDataPersistenceServiceIntegrationSpec.groovy
1 /*
2  *  ============LICENSE_START=======================================================
3  *  Copyright (C) 2021-2022 Nordix Foundation
4  *  Modifications Copyright (C) 2021 Pantheon.tech
5  *  Modifications Copyright (C) 2021-2022 Bell Canada.
6  *  ================================================================================
7  *  Licensed under the Apache License, Version 2.0 (the "License");
8  *  you may not use this file except in compliance with the License.
9  *  You may obtain a copy of the License at
10  *
11  *        http://www.apache.org/licenses/LICENSE-2.0
12  *
13  *  Unless required by applicable law or agreed to in writing, software
14  *  distributed under the License is distributed on an "AS IS" BASIS,
15  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  *  See the License for the specific language governing permissions and
17  *  limitations under the License.
18  *
19  *  SPDX-License-Identifier: Apache-2.0
20  *  ============LICENSE_END=========================================================
21  */
22 package org.onap.cps.spi.impl
23
24 import com.fasterxml.jackson.databind.ObjectMapper
25 import com.google.common.collect.ImmutableSet
26 import org.onap.cps.cpspath.parser.PathParsingException
27 import org.onap.cps.spi.CpsDataPersistenceService
28 import org.onap.cps.spi.entities.FragmentEntity
29 import org.onap.cps.spi.exceptions.AlreadyDefinedException
30 import org.onap.cps.spi.exceptions.AlreadyDefinedExceptionBatch
31 import org.onap.cps.spi.exceptions.AnchorNotFoundException
32 import org.onap.cps.spi.exceptions.CpsAdminException
33 import org.onap.cps.spi.exceptions.CpsPathException
34 import org.onap.cps.spi.exceptions.DataNodeNotFoundException
35 import org.onap.cps.spi.exceptions.DataspaceNotFoundException
36 import org.onap.cps.spi.model.DataNode
37 import org.onap.cps.spi.model.DataNodeBuilder
38 import org.onap.cps.utils.JsonObjectMapper
39 import org.springframework.beans.factory.annotation.Autowired
40 import org.springframework.test.context.jdbc.Sql
41 import javax.validation.ConstraintViolationException
42
43 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
44 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
45
46 class CpsDataPersistenceServiceIntegrationSpec extends CpsPersistenceSpecBase {
47
48     @Autowired
49     CpsDataPersistenceService objectUnderTest
50
51     static final JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
52     static final DataNodeBuilder dataNodeBuilder = new DataNodeBuilder()
53
54     static final String SET_DATA = '/data/fragment.sql'
55     static final int DATASPACE_1001_ID = 1001L
56     static final int ANCHOR_3003_ID = 3003L
57     static final long ID_DATA_NODE_WITH_DESCENDANTS = 4001
58     static final String XPATH_DATA_NODE_WITH_DESCENDANTS = '/parent-1'
59     static final String XPATH_DATA_NODE_WITH_LEAVES = '/parent-100'
60     static final long DATA_NODE_202_FRAGMENT_ID = 4202L
61     static final long CHILD_OF_DATA_NODE_202_FRAGMENT_ID = 4203L
62     static final long LIST_DATA_NODE_PARENT201_FRAGMENT_ID = 4206L
63     static final long LIST_DATA_NODE_PARENT203_FRAGMENT_ID = 4214L
64     static final long LIST_DATA_NODE_PARENT202_FRAGMENT_ID = 4211L
65     static final long PARENT_3_FRAGMENT_ID = 4003L
66
67     static final DataNode newDataNode = new DataNodeBuilder().build()
68     static DataNode existingDataNode
69     static DataNode existingChildDataNode
70
71     def expectedLeavesByXpathMap = [
72             '/parent-100'                      : ['parent-leaf': 'parent-leaf value'],
73             '/parent-100/child-001'            : ['first-child-leaf': 'first-child-leaf value'],
74             '/parent-100/child-002'            : ['second-child-leaf': 'second-child-leaf value'],
75             '/parent-100/child-002/grand-child': ['grand-child-leaf': 'grand-child-leaf value']
76     ]
77
78     static {
79         existingDataNode = createDataNodeTree(XPATH_DATA_NODE_WITH_DESCENDANTS)
80         existingChildDataNode = createDataNodeTree('/parent-1/child-1')
81     }
82
83     @Sql([CLEAR_DATA, SET_DATA])
84     def 'StoreDataNode with descendants.'() {
85         when: 'a fragment with descendants is stored'
86             def parentXpath = "/parent-new"
87             def childXpath = "/parent-new/child-new"
88             def grandChildXpath = "/parent-new/child-new/grandchild-new"
89             objectUnderTest.storeDataNode(DATASPACE_NAME, ANCHOR_NAME1,
90                     createDataNodeTree(parentXpath, childXpath, grandChildXpath))
91         then: 'it can be retrieved by its xpath'
92             def parentFragment = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME1, parentXpath)
93         and: 'it contains the children'
94             parentFragment.childFragments.size() == 1
95             def childFragment = parentFragment.childFragments[0]
96             childFragment.xpath == childXpath
97         and: "and its children's children"
98             childFragment.childFragments.size() == 1
99             def grandchildFragment = childFragment.childFragments[0]
100             grandchildFragment.xpath == grandChildXpath
101     }
102
103     @Sql([CLEAR_DATA, SET_DATA])
104     def 'Store data node for multiple anchors using the same schema.'() {
105         def xpath = "/parent-new"
106         given: 'a fragment is stored for an anchor'
107             objectUnderTest.storeDataNode(DATASPACE_NAME, ANCHOR_NAME1, createDataNodeTree(xpath))
108         when: 'another fragment is stored for an other anchor, using the same schema set'
109             objectUnderTest.storeDataNode(DATASPACE_NAME, ANCHOR_NAME3, createDataNodeTree(xpath))
110         then: 'both fragments can be retrieved by their xpath'
111             def fragment1 = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME1, xpath)
112             fragment1.anchor.name == ANCHOR_NAME1
113             fragment1.xpath == xpath
114             def fragment2 = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME3, xpath)
115             fragment2.anchor.name == ANCHOR_NAME3
116             fragment2.xpath == xpath
117     }
118
119     @Sql([CLEAR_DATA, SET_DATA])
120     def 'Store datanode error scenario: #scenario.'() {
121         when: 'attempt to store a data node with #scenario'
122             objectUnderTest.storeDataNode(dataspaceName, anchorName, dataNode)
123         then: 'a #expectedException is thrown'
124             thrown(expectedException)
125         where: 'the following data is used'
126             scenario                    | dataspaceName  | anchorName     | dataNode         || expectedException
127             'dataspace does not exist'  | 'unknown'      | 'not-relevant' | newDataNode      || DataspaceNotFoundException
128             'schema set does not exist' | DATASPACE_NAME | 'unknown'      | newDataNode      || AnchorNotFoundException
129             'anchor already exists'     | DATASPACE_NAME | ANCHOR_NAME1   | newDataNode      || ConstraintViolationException
130             'datanode already exists'   | DATASPACE_NAME | ANCHOR_NAME1   | existingDataNode || AlreadyDefinedException
131     }
132
133     @Sql([CLEAR_DATA, SET_DATA])
134     def 'Add a child to a Fragment that already has a child.'() {
135         given: ' a new child node'
136             def newChild = createDataNodeTree('xpath for new child')
137         when: 'the child is added to an existing parent with 1 child'
138             objectUnderTest.addChildDataNode(DATASPACE_NAME, ANCHOR_NAME1, XPATH_DATA_NODE_WITH_DESCENDANTS, newChild)
139         then: 'the parent is now has to 2 children'
140             def expectedExistingChildPath = '/parent-1/child-1'
141             def parentFragment = fragmentRepository.findById(ID_DATA_NODE_WITH_DESCENDANTS).orElseThrow()
142             parentFragment.childFragments.size() == 2
143         and: 'it still has the old child'
144             parentFragment.childFragments.find({ it.xpath == expectedExistingChildPath })
145         and: 'it has the new child'
146             parentFragment.childFragments.find({ it.xpath == newChild.xpath })
147     }
148
149     @Sql([CLEAR_DATA, SET_DATA])
150     def 'Add child error scenario: #scenario.'() {
151         when: 'attempt to add a child data node with #scenario'
152             objectUnderTest.addChildDataNode(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, dataNode)
153         then: 'a #expectedException is thrown'
154             thrown(expectedException)
155         where: 'the following data is used'
156             scenario                 | parentXpath                      | dataNode              || expectedException
157             'parent does not exist'  | '/unknown'                       | newDataNode           || DataNodeNotFoundException
158             'already existing child' | XPATH_DATA_NODE_WITH_DESCENDANTS | existingChildDataNode || AlreadyDefinedException
159     }
160
161     @Sql([CLEAR_DATA, SET_DATA])
162     def 'Add collection of multiple new list elements including an element with a child datanode.'() {
163         given: 'two new child list elements for an existing parent'
164             def listElementXpaths = ['/parent-201/child-204[@key="NEW1"]', '/parent-201/child-204[@key="NEW2"]']
165             def listElements = toDataNodes(listElementXpaths)
166         and: 'a (grand)child data node for one of the new list elements'
167             def grandChild = buildDataNode('/parent-201/child-204[@key="NEW1"]/grand-child-204[@key2="NEW1-CHILD"]', [leave:'value'], [])
168             listElements[0].childDataNodes = [grandChild]
169         when: 'the new data node (list elements) are added to an existing parent node'
170             objectUnderTest.addMultipleLists(DATASPACE_NAME, ANCHOR_NAME3, '/parent-201', [listElements])
171         then: 'new entries are successfully persisted, parent node now contains 5 children (2 new + 3 existing before)'
172             def parentFragment = fragmentRepository.getById(LIST_DATA_NODE_PARENT201_FRAGMENT_ID)
173             def allChildXpaths = parentFragment.childFragments.collect { it.xpath }
174             assert allChildXpaths.size() == 5
175             assert allChildXpaths.containsAll(listElementXpaths)
176         and: 'the (grand)child node of the new list entry is also present'
177             def dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME)
178             def anchorEntity = anchorRepository.getByDataspaceAndName(dataspaceEntity, ANCHOR_NAME3)
179             def grandChildFragmentEntity = fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspaceEntity, anchorEntity, grandChild.xpath)
180             assert grandChildFragmentEntity.isPresent()
181     }
182
183     @Sql([CLEAR_DATA, SET_DATA])
184     def 'Add multiple list with a mix of existing and new elements'() {
185         given: 'two new child list elements for an existing parent'
186             def existingDataNode = dataNodeBuilder.withXpath('/parent-100/child-001').withLeaves(['id': '001']).build()
187             def newDataNode1 = dataNodeBuilder.withXpath('/parent-100/child-new1').withLeaves(['id': 'new1']).build()
188             def newDataNode2 = dataNodeBuilder.withXpath('/parent-200/child-new2').withLeaves(['id': 'new2']).build()
189             def dataNodeList1 = [existingDataNode, newDataNode1]
190             def dataNodeList2 = [newDataNode2]
191         when: 'duplicate data node is requested to be added'
192             objectUnderTest.addMultipleLists(DATASPACE_NAME, ANCHOR_NAME3, '/', [dataNodeList1,dataNodeList2])
193         then: 'already defined batch exception is thrown'
194             def thrown = thrown(AlreadyDefinedExceptionBatch)
195         and: 'it only contains the xpath(s) of the duplicated elements'
196             assert thrown.alreadyDefinedXpaths.size() == 1
197             assert thrown.alreadyDefinedXpaths.contains('/parent-100/child-001')
198         and: 'it does NOT contains the xpaths of the new element that were not combined with existing elements'
199             assert !thrown.alreadyDefinedXpaths.contains('/parent-100/child-new1')
200             assert !thrown.alreadyDefinedXpaths.contains('/parent-100/child-new1')
201         and: 'the new entity is inserted correctly'
202             def dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME)
203             def anchorEntity = anchorRepository.getByDataspaceAndName(dataspaceEntity, ANCHOR_NAME3)
204             fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspaceEntity, anchorEntity, '/parent-200/child-new2').isPresent()
205     }
206
207     @Sql([CLEAR_DATA, SET_DATA])
208     def 'Add list element error scenario: #scenario.'() {
209         given: 'list element as a collection of data nodes'
210             def listElements = toDataNodes(listElementXpaths)
211         when: 'attempt to add list elements to parent node'
212             objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME3, parentNodeXpath, listElements)
213         then: 'a #expectedException is thrown'
214             thrown(expectedException)
215         where: 'following parameters were used'
216             scenario                        | parentNodeXpath | listElementXpaths                   || expectedException
217             'parent node does not exist'    | '/unknown'      | ['irrelevant']                      || DataNodeNotFoundException
218             'data fragment already exists'  | '/parent-201'   | ["/parent-201/child-204[@key='A']"] || AlreadyDefinedExceptionBatch
219     }
220
221     @Sql([CLEAR_DATA, SET_DATA])
222     def 'Get data node by xpath without descendants.'() {
223         when: 'data node is requested'
224             def result = objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
225                     inputXPath, OMIT_DESCENDANTS)
226         then: 'data node is returned with no descendants'
227             assert result.xpath == XPATH_DATA_NODE_WITH_LEAVES
228         and: 'expected leaves'
229             assert result.childDataNodes.size() == 0
230             assertLeavesMaps(result.leaves, expectedLeavesByXpathMap[XPATH_DATA_NODE_WITH_LEAVES])
231         where: 'the following data is used'
232             scenario      | inputXPath
233             'some xpath'  | '/parent-100'
234             'root xpath'  | '/'
235             'empty xpath' | ''
236     }
237
238     @Sql([CLEAR_DATA, SET_DATA])
239     def 'Cps Path query with syntax error throws a CPS Path Exception.'() {
240         when: 'trying to execute a query with a syntax (parsing) error'
241             objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, 'invalid-cps-path/child' , OMIT_DESCENDANTS)
242         then: 'exception is thrown'
243             def exceptionThrown = thrown(CpsPathException)
244             assert exceptionThrown.getDetails().contains('failed to parse at line 1 due to extraneous input \'invalid-cps-path\' expecting \'/\'')
245     }
246
247     @Sql([CLEAR_DATA, SET_DATA])
248     def 'Get data node by xpath with all descendants.'() {
249         when: 'data node is requested with all descendants'
250             def result = objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
251                     inputXPath, INCLUDE_ALL_DESCENDANTS)
252             def mappedResult = treeToFlatMapByXpath(new HashMap<>(), result)
253         then: 'data node is returned with all the descendants populated'
254             assert mappedResult.size() == 4
255             assert result.childDataNodes.size() == 2
256             assert mappedResult.get('/parent-100/child-001').childDataNodes.size() == 0
257             assert mappedResult.get('/parent-100/child-002').childDataNodes.size() == 1
258         and: 'extracted leaves maps are matching expected'
259             mappedResult.forEach(
260                     (xPath, dataNode) -> assertLeavesMaps(dataNode.leaves, expectedLeavesByXpathMap[xPath]))
261         where: 'the following data is used'
262             scenario      | inputXPath
263             'some xpath'  | '/parent-100'
264             'root xpath'  | '/'
265             'empty xpath' | ''
266     }
267
268     @Sql([CLEAR_DATA, SET_DATA])
269     def 'Get data node error scenario: #scenario.'() {
270         when: 'attempt to get data node with #scenario'
271             objectUnderTest.getDataNode(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS)
272         then: 'a #expectedException is thrown'
273             thrown(expectedException)
274         where: 'the following data is used'
275             scenario                 | dataspaceName  | anchorName                        | xpath           || expectedException
276             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | '/not relevant' || DataspaceNotFoundException
277             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | '/not relevant' || AnchorNotFoundException
278             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NO XPATH'     || DataNodeNotFoundException
279     }
280
281     @Sql([CLEAR_DATA, SET_DATA])
282     def 'Update data node leaves.'() {
283         when: 'update is performed for leaves'
284             objectUnderTest.updateDataLeaves(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
285                     "/parent-200/child-201", ['leaf-value': 'new'])
286         then: 'leaves are updated for selected data node'
287             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
288             def updatedLeaves = getLeavesMap(updatedFragment)
289             assert updatedLeaves.size() == 1
290             assert updatedLeaves.'leaf-value' == 'new'
291         and: 'existing child entry remains as is'
292             def childFragment = updatedFragment.childFragments.iterator().next()
293             def childLeaves = getLeavesMap(childFragment)
294             assert childFragment.id == CHILD_OF_DATA_NODE_202_FRAGMENT_ID
295             assert childLeaves.'leaf-value' == 'original'
296     }
297
298     @Sql([CLEAR_DATA, SET_DATA])
299     def 'Update data leaves error scenario: #scenario.'() {
300         when: 'attempt to update data node for #scenario'
301             objectUnderTest.updateDataLeaves(dataspaceName, anchorName, xpath, ['leaf-name': 'leaf-value'])
302         then: 'a #expectedException is thrown'
303             thrown(expectedException)
304         where: 'the following data is used'
305             scenario                 | dataspaceName  | anchorName                        | xpath                 || expectedException
306             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | '/not relevant'       || DataspaceNotFoundException
307             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | '/not relevant'       || AnchorNotFoundException
308             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NON-EXISTING XPATH' || DataNodeNotFoundException
309     }
310
311     @Sql([CLEAR_DATA, SET_DATA])
312     def 'Update data node and descendants by removing descendants.'() {
313         given: 'data node object with leaves updated, no children'
314             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [])
315         when: 'update data nodes and descendants is performed'
316             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
317         then: 'leaves have been updated for selected data node'
318             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
319             def updatedLeaves = getLeavesMap(updatedFragment)
320             assert updatedLeaves.size() == 1
321             assert updatedLeaves.'leaf-value' == 'new'
322         and: 'updated entry has no children'
323             updatedFragment.childFragments.isEmpty()
324         and: 'previously attached child entry is removed from database'
325             fragmentRepository.findById(CHILD_OF_DATA_NODE_202_FRAGMENT_ID).isEmpty()
326     }
327
328     @Sql([CLEAR_DATA, SET_DATA])
329     def 'Update data node and descendants with new descendants'() {
330         given: 'data node object with leaves updated, having child with old content'
331             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [
332                   buildDataNode("/parent-200/child-201/grand-child", ['leaf-value': 'original'], [])
333             ])
334         when: 'update is performed including descendants'
335             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
336         then: 'leaves have been updated for selected data node'
337             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
338             def updatedLeaves = getLeavesMap(updatedFragment)
339             assert updatedLeaves.size() == 1
340             assert updatedLeaves.'leaf-value' == 'new'
341         and: 'existing child entry is not updated as content is same'
342             def childFragment = updatedFragment.childFragments.iterator().next()
343             childFragment.xpath == '/parent-200/child-201/grand-child'
344             def childLeaves = getLeavesMap(childFragment)
345             assert childLeaves.'leaf-value' == 'original'
346     }
347
348     @Sql([CLEAR_DATA, SET_DATA])
349     def 'Update data node and descendants with same descendants but changed leaf value.'() {
350         given: 'data node object with leaves updated, having child with old content'
351             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [
352                     buildDataNode("/parent-200/child-201/grand-child", ['leaf-value': 'new'], [])
353             ])
354         when: 'update is performed including descendants'
355             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
356         then: 'leaves have been updated for selected data node'
357             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
358             def updatedLeaves = getLeavesMap(updatedFragment)
359             assert updatedLeaves.size() == 1
360             assert updatedLeaves.'leaf-value' == 'new'
361         and: 'existing child entry is updated with the new content'
362             def childFragment = updatedFragment.childFragments.iterator().next()
363             childFragment.xpath == '/parent-200/child-201/grand-child'
364             def childLeaves = getLeavesMap(childFragment)
365             assert childLeaves.'leaf-value' == 'new'
366     }
367
368     @Sql([CLEAR_DATA, SET_DATA])
369     def 'Update data node and descendants with different descendants xpath'() {
370         given: 'data node object with leaves updated, having child with old content'
371             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [
372                     buildDataNode("/parent-200/child-201/grand-child-new", ['leaf-value': 'new'], [])
373             ])
374         when: 'update is performed including descendants'
375             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
376         then: 'leaves have been updated for selected data node'
377             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
378             def updatedLeaves = getLeavesMap(updatedFragment)
379             assert updatedLeaves.size() == 1
380             assert updatedLeaves.'leaf-value' == 'new'
381         and: 'previously attached child entry is removed from database'
382             fragmentRepository.findById(CHILD_OF_DATA_NODE_202_FRAGMENT_ID).isEmpty()
383         and: 'new child entry is persisted'
384             def childFragment = updatedFragment.childFragments.iterator().next()
385             childFragment.xpath == '/parent-200/child-201/grand-child-new'
386             def childLeaves = getLeavesMap(childFragment)
387             assert childLeaves.'leaf-value' == 'new'
388     }
389
390     @Sql([CLEAR_DATA, SET_DATA])
391     def 'Update data node and descendants error scenario: #scenario.'() {
392         given: 'data node object'
393             def submittedDataNode = buildDataNode(xpath, ['leaf-name': 'leaf-value'], [])
394         when: 'attempt to update data node for #scenario'
395             objectUnderTest.updateDataNodeAndDescendants(dataspaceName, anchorName, submittedDataNode)
396         then: 'a #expectedException is thrown'
397             thrown(expectedException)
398         where: 'the following data is used'
399             scenario                 | dataspaceName  | anchorName                        | xpath                 || expectedException
400             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | '/not relevant'       || DataspaceNotFoundException
401             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | '/not relevant'       || AnchorNotFoundException
402             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NON-EXISTING XPATH' || DataNodeNotFoundException
403     }
404
405     @Sql([CLEAR_DATA, SET_DATA])
406     def 'Update existing list with #scenario.'() {
407         given: 'a parent having a list of data nodes containing: #originalKeys (ech list element has a child too)'
408             def parentXpath = '/parent-3'
409             if (originalKeys.size() > 0) {
410                 def originalListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'original value', originalKeys, true)
411                 objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, originalListEntriesAsDataNodes)
412             }
413         and: 'each original list element has one child'
414             def originalParentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
415             originalParentFragment.childFragments.each {assert it.childFragments.size() == 1 }
416         when: 'it is updated with #scenario'
417             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'new value', replacementKeys, false)
418             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, replacementListEntriesAsDataNodes)
419         then: 'the result list ONLY contains the expected replacement elements'
420             def parentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
421             def allChildXpaths = parentFragment.childFragments.collect { it.xpath }
422             def expectedListEntriesAfterUpdateAsXpaths = keysToXpaths(parentXpath, replacementKeys)
423             assert allChildXpaths.size() == replacementKeys.size()
424             assert allChildXpaths.containsAll(expectedListEntriesAfterUpdateAsXpaths)
425         and: 'all the list elements have the new values'
426             assert parentFragment.childFragments.stream().allMatch(childFragment -> childFragment.attributes.contains('new value'))
427         and: 'there are no more grandchildren as none of the replacement list entries had a child'
428             parentFragment.childFragments.each {assert it.childFragments.size() == 0 }
429         where: 'the following replacement lists are applied'
430             scenario                                            | originalKeys | replacementKeys
431             'one existing entry only'                           | []           | ['NEW']
432             'multiple new entries'                              | []           | ['NEW1', 'NEW2']
433             'one new entry only (existing entries are deleted)' | ['A', 'B']   | ['NEW1', 'NEW2']
434             'one existing on new entry'                         | ['A', 'B']   | ['A', 'NEW']
435             'one existing entry only'                           | ['A', 'B']   | ['A']
436     }
437
438     @Sql([CLEAR_DATA, SET_DATA])
439     def 'Replacing existing list element with attributes and (grand)child.'() {
440         given: 'a parent with list elements A and B with attribute and grandchild tagged as "org"'
441             def parentXpath = '/parent-3'
442             def originalListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'org', ['A','B'], true)
443             objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, originalListEntriesAsDataNodes)
444         when: 'A is replaced with an entry with attribute and grandchild tagged tagged as "new" (B is not in replacement list)'
445             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'new', ['A'], true)
446             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, replacementListEntriesAsDataNodes)
447         then: 'The updated fragment has a child-list with ONLY element "A"'
448             def parentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
449             parentFragment.childFragments.size() == 1
450             def childListElementA = parentFragment.childFragments[0]
451             childListElementA.xpath == "/parent-3/child-list[@key='A']"
452         and: 'element "A" has an attribute with the "new" (tag) value'
453             childListElementA.attributes == '{"attr1": "new"}'
454         and: 'element "A" has a only one (grand)child'
455             childListElementA.childFragments.size() == 1
456         and: 'the grandchild is the new grandchild (tag)'
457             def grandChild = childListElementA.childFragments[0]
458             grandChild.xpath == "/parent-3/child-list[@key='A']/new-grand-child"
459         and: 'the grandchild has an attribute with the "new" (tag) value'
460             grandChild.attributes == '{"attr1": "new"}'
461     }
462
463     @Sql([CLEAR_DATA, SET_DATA])
464     def 'Replace list element for a parent (parent-1) with existing one (non-list) child'() {
465         when: 'a list element is added under the parent'
466             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(XPATH_DATA_NODE_WITH_DESCENDANTS, 'new', ['A','B'], false)
467             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, XPATH_DATA_NODE_WITH_DESCENDANTS, replacementListEntriesAsDataNodes)
468         then: 'the parent will have 3 children after the replacement'
469             def parentFragment = fragmentRepository.getById(ID_DATA_NODE_WITH_DESCENDANTS)
470             parentFragment.childFragments.size() == 3
471             def xpaths = parentFragment.childFragments.collect {it.xpath}
472         and: 'one of the children is the original child fragment'
473             xpaths.contains('/parent-1/child-1')
474         and: 'it has the two new list elements'
475             xpaths.containsAll("/parent-1/child-list[@key='A']", "/parent-1/child-list[@key='B']")
476     }
477
478     @Sql([CLEAR_DATA, SET_DATA])
479     def 'Replace list content using unknown parent'() {
480         given: 'list element as a collection of data nodes'
481             def listElementCollection = toDataNodes(['irrelevant'])
482         when: 'attempt to replace list elements under unknown parent node'
483             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME3, '/unknown', listElementCollection)
484         then: 'a datanode not found exception is thrown'
485             thrown(DataNodeNotFoundException)
486     }
487
488     @Sql([CLEAR_DATA, SET_DATA])
489     def 'Replace list content with empty collection is not supported'() {
490         when: 'attempt to replace list elements with empty collection'
491             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME3, '/parent-203', [])
492         then: 'a CPS admin exception is thrown'
493             def thrown = thrown(CpsAdminException)
494             assert thrown.message == 'Invalid list replacement'
495     }
496
497     @Sql([CLEAR_DATA, SET_DATA])
498     def 'Delete list scenario: #scenario.'() {
499         when: 'deleting list is executed for: #scenario.'
500             objectUnderTest.deleteListDataNode(DATASPACE_NAME, ANCHOR_NAME3, targetXpaths)
501         then: 'only the expected children remain'
502             def parentFragment = fragmentRepository.getById(parentFragmentId)
503             def remainingChildXpaths = parentFragment.childFragments.collect { it.xpath }
504             assert remainingChildXpaths.size() == expectedRemainingChildXpaths.size()
505             assert remainingChildXpaths.containsAll(expectedRemainingChildXpaths)
506         where: 'following parameters were used'
507             scenario                          | targetXpaths                                                 | parentFragmentId                     || expectedRemainingChildXpaths
508             'list element with key'           | '/parent-203/child-204[@key="A"]'                            | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ["/parent-203/child-203", "/parent-203/child-204[@key='B']"]
509             'list element with combined keys' | '/parent-202/child-205[@key="A" and @key2="B"]'              | LIST_DATA_NODE_PARENT202_FRAGMENT_ID || ["/parent-202/child-206[@key='A']"]
510             'whole list'                      | '/parent-203/child-204'                                      | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ['/parent-203/child-203']
511             'list element under list element' | '/parent-203/child-204[@key="B"]/grand-child-204[@key2="Y"]' | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ["/parent-203/child-203", "/parent-203/child-204[@key='A']", "/parent-203/child-204[@key='B']"]
512     }
513
514     @Sql([CLEAR_DATA, SET_DATA])
515     def 'Delete list error scenario: #scenario.'() {
516         when: 'attempting to delete scenario: #scenario.'
517             objectUnderTest.deleteListDataNode(DATASPACE_NAME, ANCHOR_NAME3, targetXpaths)
518         then: 'a DataNodeNotFoundException is thrown'
519             thrown(DataNodeNotFoundException)
520         where: 'following parameters were used'
521             scenario                                   | targetXpaths
522             'whole list, parent node does not exist'   | '/unknown/some-child'
523             'list element, parent node does not exist' | '/unknown/child-204[@key="A"]'
524             'whole list does not exist'                | '/parent-200/unknown'
525             'list element, list does not exist'        | '/parent-200/unknown[@key="C"]'
526             'list element, element does not exist'     | '/parent-203/child-204[@key="C"]'
527             'valid datanode but not a list'            | '/parent-200/child-202'
528     }
529
530     @Sql([CLEAR_DATA, SET_DATA])
531     def 'Confirm deletion of #scenario.'() {
532         given: 'a valid data node'
533             def dataNode
534             def dataNodeXpath
535         when: 'data nodes are deleted'
536             objectUnderTest.deleteDataNode(DATASPACE_NAME, ANCHOR_NAME3, xpathForDeletion)
537         then: 'verify data nodes are removed'
538             try {
539                 dataNode = objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_NAME3, getDataNodesXpaths, INCLUDE_ALL_DESCENDANTS)
540                 dataNodeXpath = dataNode.xpath
541                 assert dataNodeXpath == expectedXpaths
542             } catch (DataNodeNotFoundException) {
543                 assert dataNodeXpath == expectedXpaths
544             }
545         where: 'following parameters were used'
546             scenario                                | xpathForDeletion                                   | getDataNodesXpaths                                || expectedXpaths
547             'child of target'                       | '/parent-206/child-206'                            | '/parent-206/child-206'                           || null
548             'child data node, parent still exists'  | '/parent-206/child-206'                            | '/parent-206'                                     || '/parent-206'
549             'list element'                          | '/parent-206/child-206/grand-child-206[@key="A"]'  | '/parent-206/child-206/grand-child-206[@key="A"]' || null
550             'list element, sibling still exists'    | '/parent-206/child-206/grand-child-206[@key="A"]'  | '/parent-206/child-206/grand-child-206[@key="X"]' || "/parent-206/child-206/grand-child-206[@key='X']"
551             'container node'                        | '/parent-206'                                      | '/parent-206'                                     || null
552             'container list node'                   | '/parent-206[@key="A"]'                            | '/parent-206[@key="B"]'                           || "/parent-206[@key='B']"
553             'root node with xpath /'                | '/'                                                | '/'                                               || null
554             'root node with xpath passed as blank'  | ''                                                 | ''                                                || null
555
556     }
557
558     @Sql([CLEAR_DATA, SET_DATA])
559     def 'Delete data node with #scenario.'() {
560         when: 'data node is deleted'
561             objectUnderTest.deleteDataNode(DATASPACE_NAME, ANCHOR_NAME3, datanodeXpath)
562         then: 'a #expectedException is thrown'
563             thrown(expectedException)
564         where: 'the following parameters were used'
565             scenario                                        | datanodeXpath                                    | expectedException
566             'valid data node, non existent child node'      | '/parent-203/child-non-existent'                 | DataNodeNotFoundException
567             'invalid list element'                          | '/parent-206/child-206/grand-child-206@key="A"]' | PathParsingException
568     }
569
570     @Sql([CLEAR_DATA, SET_DATA])
571     def 'Delete data node for an anchor.'() {
572         given: 'a data-node exists for an anchor'
573             assert fragmentsExistInDB(DATASPACE_1001_ID, ANCHOR_3003_ID)
574         when: 'data nodes are deleted '
575             objectUnderTest.deleteDataNodes(DATASPACE_NAME, ANCHOR_NAME3)
576         then: 'all data-nodes are deleted successfully'
577             assert !fragmentsExistInDB(DATASPACE_1001_ID, ANCHOR_3003_ID)
578     }
579
580     def fragmentsExistInDB(dataSpaceId, anchorId) {
581         !fragmentRepository.findRootsByDataspaceAndAnchor(dataSpaceId, anchorId).isEmpty()
582     }
583
584     static Collection<DataNode> toDataNodes(xpaths) {
585         return xpaths.collect { new DataNodeBuilder().withXpath(it).build() }
586     }
587
588
589     static DataNode buildDataNode(xpath, leaves, childDataNodes) {
590         return dataNodeBuilder.withXpath(xpath).withLeaves(leaves).withChildDataNodes(childDataNodes).build()
591     }
592
593     static Map<String, Object> getLeavesMap(FragmentEntity fragmentEntity) {
594         return jsonObjectMapper.convertJsonString(fragmentEntity.attributes, Map<String, Object>.class)
595     }
596
597     def static assertLeavesMaps(actualLeavesMap, expectedLeavesMap) {
598         expectedLeavesMap.forEach((key, value) -> {
599             def actualValue = actualLeavesMap[key]
600             if (value instanceof Collection<?> && actualValue instanceof Collection<?>) {
601                 assert value.size() == actualValue.size()
602                 assert value.containsAll(actualValue)
603             } else {
604                 assert value == actualValue
605             }
606         })
607         return true
608     }
609
610     def static treeToFlatMapByXpath(Map<String, DataNode> flatMap, DataNode dataNodeTree) {
611         flatMap.put(dataNodeTree.xpath, dataNodeTree)
612         dataNodeTree.childDataNodes
613                 .forEach(childDataNode -> treeToFlatMapByXpath(flatMap, childDataNode))
614         return flatMap
615     }
616
617     def keysToXpaths(parent, Collection keys) {
618         return keys.collect { "${parent}/child-list[@key='${it}']".toString() }
619     }
620
621     def static createDataNodeTree(String... xpaths) {
622         def dataNodeBuilder = new DataNodeBuilder().withXpath(xpaths[0])
623         if (xpaths.length > 1) {
624             def xPathsDescendant = Arrays.copyOfRange(xpaths, 1, xpaths.length)
625             def childDataNode = createDataNodeTree(xPathsDescendant)
626             dataNodeBuilder.withChildDataNodes(ImmutableSet.of(childDataNode))
627         }
628         dataNodeBuilder.build()
629     }
630
631     def getFragmentByXpath(dataspaceName, anchorName, xpath) {
632         def dataspace = dataspaceRepository.getByName(dataspaceName)
633         def anchor = anchorRepository.getByDataspaceAndName(dataspace, anchorName)
634         return fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspace, anchor, xpath).orElseThrow()
635     }
636
637
638     def createChildListAllHavingAttributeValue(parentXpath, tag, Collection keys, boolean addGrandChild) {
639         def listElementAsDataNodes = keysToXpaths(parentXpath, keys).collect {
640                 new DataNodeBuilder()
641                     .withXpath(it)
642                     .withLeaves([attr1: tag])
643                     .build()
644         }
645         if (addGrandChild) {
646             listElementAsDataNodes.each {it.childDataNodes = [createGrandChild(it.xpath, tag)]}
647         }
648         return listElementAsDataNodes
649     }
650
651     def createGrandChild(parentXPath, tag) {
652         new DataNodeBuilder()
653             .withXpath("${parentXPath}/${tag}-grand-child")
654             .withLeaves([attr1: tag])
655             .build()
656     }
657
658 }