d88a9cdf0bec458abc8c2ace88f68fca8f2c999a
[cps.git] / cps-rest / src / test / groovy / org / onap / cps / rest / controller / DataRestControllerSpec.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  *  Modifications Copyright (C) 2022 Deutsche Telekom AG
7  *  Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
8  *  ================================================================================
9  *  Licensed under the Apache License, Version 2.0 (the "License");
10  *  you may not use this file except in compliance with the License.
11  *  You may obtain a copy of the License at
12  *
13  *        http://www.apache.org/licenses/LICENSE-2.0
14  *
15  *  Unless required by applicable law or agreed to in writing, software
16  *  distributed under the License is distributed on an "AS IS" BASIS,
17  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18  *  See the License for the specific language governing permissions and
19  *  limitations under the License.
20  *
21  *  SPDX-License-Identifier: Apache-2.0
22  *  ============LICENSE_END=========================================================
23  */
24
25 package org.onap.cps.rest.controller
26
27 import com.fasterxml.jackson.databind.ObjectMapper
28 import groovy.json.JsonSlurper
29 import org.onap.cps.api.CpsDataService
30 import org.onap.cps.spi.FetchDescendantsOption
31 import org.onap.cps.spi.model.DataNode
32 import org.onap.cps.spi.model.DataNodeBuilder
33 import org.onap.cps.utils.ContentType
34 import org.onap.cps.utils.DateTimeUtility
35 import org.onap.cps.utils.JsonObjectMapper
36 import org.onap.cps.utils.PrefixResolver
37 import org.spockframework.spring.SpringBean
38 import org.springframework.beans.factory.annotation.Autowired
39 import org.springframework.beans.factory.annotation.Value
40 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
41 import org.springframework.http.HttpStatus
42 import org.springframework.http.MediaType
43 import org.springframework.test.web.servlet.MockMvc
44 import spock.lang.Shared
45 import spock.lang.Specification
46
47 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
48 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
49 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
50 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
51 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch
52 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
53 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
54
55 @WebMvcTest(DataRestController)
56 class DataRestControllerSpec extends Specification {
57
58     @SpringBean
59     CpsDataService mockCpsDataService = Mock()
60
61     @SpringBean
62     JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
63
64     @SpringBean
65     PrefixResolver prefixResolver = Mock()
66
67     @Autowired
68     MockMvc mvc
69
70     @Value('${rest.api.cps-base-path}')
71     def basePath
72
73     def dataNodeBaseEndpointV1
74     def dataNodeBaseEndpointV2
75     def dataspaceName = 'my_dataspace'
76     def anchorName = 'my_anchor'
77     def noTimestamp = null
78
79     @Shared
80     def requestBodyJson = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}'
81
82     @Shared
83     def expectedJsonData = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}'
84
85     @Shared
86     def requestBodyXml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>'
87
88     @Shared
89     def expectedXmlData = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>'
90
91     @Shared
92     static DataNode dataNodeWithLeavesNoChildren = new DataNodeBuilder().withXpath('/parent-1')
93         .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
94
95     @Shared
96     static DataNode dataNodeWithLeavesNoChildren2 = new DataNodeBuilder().withXpath('/parent-2')
97         .withLeaves([leaf: 'value']).build()
98
99     @Shared
100     static DataNode dataNodeWithChild = new DataNodeBuilder().withXpath('/parent')
101         .withChildDataNodes([new DataNodeBuilder().withXpath("/parent/child").build()]).build()
102
103     def setup() {
104         dataNodeBaseEndpointV1 = "$basePath/v1/dataspaces/$dataspaceName"
105         dataNodeBaseEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName"
106     }
107
108     def 'Create a node: #scenario.'() {
109         given: 'endpoint to create a node'
110             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
111         when: 'post is invoked with datanode endpoint and json'
112             def response =
113                 mvc.perform(
114                     post(endpoint)
115                         .contentType(contentType)
116                         .param('xpath', parentNodeXpath)
117                         .content(requestBody)
118                 ).andReturn().response
119         then: 'a created response is returned'
120             response.status == HttpStatus.CREATED.value()
121         then: 'the java API was called with the correct parameters'
122             1 * mockCpsDataService.saveData(dataspaceName, anchorName, expectedData, noTimestamp, expectedContentType)
123         where: 'following xpath parameters are are used'
124             scenario                                   | parentNodeXpath | contentType                | expectedContentType | requestBody     | expectedData
125             'JSON content: no xpath parameter'         | ''              | MediaType.APPLICATION_JSON | ContentType.JSON    | requestBodyJson | expectedJsonData
126             'JSON content: xpath parameter point root' | '/'             | MediaType.APPLICATION_JSON | ContentType.JSON    | requestBodyJson | expectedJsonData
127             'XML content: no xpath parameter'          | ''              | MediaType.APPLICATION_XML  | ContentType.XML     | requestBodyXml  | expectedXmlData
128             'XML content: xpath parameter point root'  | '/'             | MediaType.APPLICATION_XML  | ContentType.XML     | requestBodyXml  | expectedXmlData
129     }
130
131     def 'Create a node with observed-timestamp'() {
132         given: 'endpoint to create a node'
133             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
134         when: 'post is invoked with datanode endpoint and json'
135             def response =
136                 mvc.perform(
137                     post(endpoint)
138                         .contentType(contentType)
139                         .param('xpath', '')
140                         .param('observed-timestamp', observedTimestamp)
141                         .content(content)
142                 ).andReturn().response
143         then: 'a created response is returned'
144             response.status == expectedHttpStatus.value()
145         then: 'the java API was called with the correct parameters'
146             expectedApiCount * mockCpsDataService.saveData(dataspaceName, anchorName, expectedData,
147                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }, expectedContentType)
148         where:
149             scenario                          | observedTimestamp              | contentType                | content         || expectedApiCount | expectedHttpStatus     | expectedData     | expectedContentType
150             'with observed-timestamp JSON'    | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_JSON | requestBodyJson || 1                | HttpStatus.CREATED     | expectedJsonData | ContentType.JSON
151             'with observed-timestamp XML'     | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_XML  | requestBodyXml  || 1                | HttpStatus.CREATED     | expectedXmlData  | ContentType.XML
152             'with invalid observed-timestamp' | 'invalid'                      | MediaType.APPLICATION_JSON | requestBodyJson || 0                | HttpStatus.BAD_REQUEST | expectedJsonData | ContentType.JSON
153     }
154
155     def 'Create a child node #scenario'() {
156         given: 'endpoint to create a node'
157             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
158         and: 'parent node xpath'
159             def parentNodeXpath = 'some xpath'
160         when: 'post is invoked with datanode endpoint and json'
161             def postRequestBuilder = post(endpoint)
162                 .contentType(contentType)
163                 .param('xpath', parentNodeXpath)
164                 .content(requestBody)
165             if (observedTimestamp != null)
166                 postRequestBuilder.param('observed-timestamp', observedTimestamp)
167             def response =
168                 mvc.perform(postRequestBuilder).andReturn().response
169         then: 'a created response is returned'
170             response.status == HttpStatus.CREATED.value()
171         then: 'the java API was called with the correct parameters'
172             1 * mockCpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, expectedData,
173                 DateTimeUtility.toOffsetDateTime(observedTimestamp), expectedContentType)
174         where:
175             scenario                          | observedTimestamp              | contentType                | requestBody     | expectedData     | expectedContentType
176             'with observed-timestamp JSON'    | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_JSON | requestBodyJson | expectedJsonData | ContentType.JSON
177             'with observed-timestamp XML'     | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_XML  | requestBodyXml  | expectedXmlData  | ContentType.XML
178             'without observed-timestamp JSON' | null                           | MediaType.APPLICATION_JSON | requestBodyJson | expectedJsonData | ContentType.JSON
179             'without observed-timestamp XML'  | null                           | MediaType.APPLICATION_XML  | requestBodyXml  | expectedXmlData  | ContentType.XML
180     }
181
182     def 'Save list elements #scenario.'() {
183         given: 'parent node xpath '
184             def parentNodeXpath = 'parent node xpath'
185         when: 'list-node endpoint is invoked with post (create) operation'
186             def postRequestBuilder = post("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
187                 .contentType(MediaType.APPLICATION_JSON)
188                 .param('xpath', parentNodeXpath)
189                 .content(requestBodyJson)
190             if (observedTimestamp != null)
191                 postRequestBuilder.param('observed-timestamp', observedTimestamp)
192             def response = mvc.perform(postRequestBuilder).andReturn().response
193         then: 'a created response is returned'
194             response.status == expectedHttpStatus.value()
195         then: 'the java API was called with the correct parameters'
196             expectedApiCount * mockCpsDataService.saveListElements(dataspaceName, anchorName, parentNodeXpath, expectedJsonData,
197                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
198         where:
199             scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
200             'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.CREATED
201             'without observed-timestamp'      | null                           || 1                | HttpStatus.CREATED
202             'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
203     }
204
205     def 'Get data node with leaves'() {
206         given: 'the service returns data node leaves'
207             def xpath = 'parent-1'
208             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/node"
209             mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> [dataNodeWithLeavesNoChildren]
210         when: 'get request is performed through REST API'
211             def response =
212                 mvc.perform(get(endpoint).param('xpath', xpath))
213                     .andReturn().response
214         then: 'a success response is returned'
215             response.status == HttpStatus.OK.value()
216         then: 'the response contains the the datanode in json format'
217             response.getContentAsString() == '{"parent-1":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}}'
218         and: 'response contains expected leaf and value'
219             response.contentAsString.contains('"leaf":"value"')
220         and: 'response contains expected leaf-list and values'
221             response.contentAsString.contains('"leafList":["leaveListElement1","leaveListElement2"]')
222     }
223
224     def 'Get data node with #scenario.'() {
225         given: 'the service returns data node with #scenario'
226             def xpath = 'some xPath'
227             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/node"
228             mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> [dataNode]
229         when: 'get request is performed through REST API'
230             def response =
231                 mvc.perform(
232                     get(endpoint)
233                         .param('xpath', xpath)
234                         .param('include-descendants', includeDescendantsOption))
235                     .andReturn().response
236         then: 'a success response is returned'
237             response.status == HttpStatus.OK.value()
238         and: 'the response contains the root node identifier: #expectedRootidentifier'
239             response.contentAsString.contains(expectedRootidentifier)
240         and: 'the response contains child is #expectChildInResponse'
241             response.contentAsString.contains('"child"') == expectChildInResponse
242         where:
243             scenario                    | dataNode                     | includeDescendantsOption || expectedCpsDataServiceOption | expectChildInResponse | expectedRootidentifier
244             'no descendants by default' | dataNodeWithLeavesNoChildren | ''                       || OMIT_DESCENDANTS             | false                 | 'parent-1'
245             'no descendant explicitly'  | dataNodeWithLeavesNoChildren | 'false'                  || OMIT_DESCENDANTS             | false                 | 'parent-1'
246             'with descendants'          | dataNodeWithChild            | 'true'                   || INCLUDE_ALL_DESCENDANTS      | true                  | 'parent'
247     }
248
249     def 'Get all the data trees as json array with root node xPath using V2'() {
250         given: 'the service returns all data node leaves'
251             def xpath = '/'
252             def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
253             mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> [dataNodeWithLeavesNoChildren, dataNodeWithLeavesNoChildren2]
254         when: 'V2 of get request is performed through REST API'
255             def response =
256                 mvc.perform(get(endpoint).param('xpath', xpath))
257                     .andReturn().response
258         then: 'a success response is returned'
259             response.status == HttpStatus.OK.value()
260         and: 'the response contains the datanode in json array format'
261             response.getContentAsString() == '[{"parent-1":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}},' +
262                 '{"parent-2":{"leaf":"value"}}]'
263         and: 'the json array contains expected number of data trees'
264             def numberOfDataTrees = new JsonSlurper().parseText(response.getContentAsString()).iterator().size()
265             assert numberOfDataTrees == 2
266     }
267
268     def 'Get data node with #scenario using V2.'() {
269         given: 'the service returns data nodes with #scenario'
270             def xpath = 'some xPath'
271             def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
272             mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> [dataNode]
273         when: 'V2 of get request is performed through REST API'
274             def response =
275                 mvc.perform(
276                     get(endpoint)
277                         .param('xpath', xpath)
278                         .param('descendants', includeDescendantsOption))
279                     .andReturn().response
280         then: 'a success response is returned'
281             response.status == HttpStatus.OK.value()
282         and: 'the response contains the root node identifier: #expectedRootidentifier'
283             response.contentAsString.contains(expectedRootidentifier)
284         and: 'the response contains child is #expectChildInResponse'
285             response.contentAsString.contains('"child"') == expectChildInResponse
286         where:
287             scenario                    | dataNode                     | includeDescendantsOption || expectedCpsDataServiceOption | expectChildInResponse | expectedRootidentifier
288             'no descendants by default' | dataNodeWithLeavesNoChildren | ''                       || OMIT_DESCENDANTS             | false                 | 'parent-1'
289             'no descendant explicitly'  | dataNodeWithLeavesNoChildren | '0'                      || OMIT_DESCENDANTS             | false                 | 'parent-1'
290             'with descendants'          | dataNodeWithChild            | '-1'                     || INCLUDE_ALL_DESCENDANTS      | true                  | 'parent'
291     }
292
293     def 'Get data node using v2 api'() {
294         given: 'the service returns data node'
295             def xpath = 'some xPath'
296             def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
297             mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, { descendantsOption -> {
298                 assert descendantsOption.depth == 2}} as FetchDescendantsOption) >> [dataNodeWithChild]
299         when: 'get request is performed through REST API'
300             def response =
301                 mvc.perform(
302                     get(endpoint)
303                         .param('xpath', xpath)
304                         .param('descendants', '2'))
305                     .andReturn().response
306         then: 'a success response is returned'
307             assert response.status == HttpStatus.OK.value()
308         and: 'the response contains the root node identifier'
309             assert response.contentAsString.contains('parent')
310         and: 'the response contains child is true'
311             assert response.contentAsString.contains('"child"') == true
312     }
313
314     def 'Update data node leaves: #scenario.'() {
315         given: 'endpoint to update a node '
316             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
317         when: 'patch request is performed'
318             def response =
319                 mvc.perform(
320                     patch(endpoint)
321                         .contentType(MediaType.APPLICATION_JSON)
322                         .content(requestBodyJson)
323                         .param('xpath', inputXpath)
324                 ).andReturn().response
325         then: 'the service method is invoked with expected parameters'
326             1 * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, xpathServiceParameter, expectedJsonData, null)
327         and: 'response status indicates success'
328             response.status == HttpStatus.OK.value()
329         where:
330             scenario               | inputXpath    || xpathServiceParameter
331             'root node by default' | ''            || '/'
332             'root node by choice'  | '/'           || '/'
333             'some xpath by parent' | '/some/xpath' || '/some/xpath'
334     }
335
336     def 'Update data node leaves with observedTimestamp'() {
337         given: 'endpoint to update a node leaves '
338             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
339         when: 'patch request is performed'
340             def response =
341                 mvc.perform(
342                     patch(endpoint)
343                         .contentType(MediaType.APPLICATION_JSON)
344                         .content(requestBodyJson)
345                         .param('xpath', '/')
346                         .param('observed-timestamp', observedTimestamp)
347                 ).andReturn().response
348         then: 'the service method is invoked with expected parameters'
349             expectedApiCount * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, '/', expectedJsonData,
350                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
351         and: 'response status indicates success'
352             response.status == expectedHttpStatus.value()
353         where:
354             scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
355             'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.OK
356             'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
357     }
358
359     def 'Replace data node tree: #scenario.'() {
360         given: 'endpoint to replace node'
361             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
362         when: 'put request is performed'
363             def response =
364                 mvc.perform(
365                     put(endpoint)
366                         .contentType(MediaType.APPLICATION_JSON)
367                         .content(requestBodyJson)
368                         .param('xpath', inputXpath))
369                     .andReturn().response
370         then: 'the service method is invoked with expected parameters'
371             1 * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, xpathServiceParameter, expectedJsonData, noTimestamp)
372         and: 'response status indicates success'
373             response.status == HttpStatus.OK.value()
374         where:
375             scenario               | inputXpath    || xpathServiceParameter
376             'root node by default' | ''            || '/'
377             'root node by choice'  | '/'           || '/'
378             'some xpath by parent' | '/some/xpath' || '/some/xpath'
379     }
380
381     def 'Update data node and descendants with observedTimestamp.'() {
382         given: 'endpoint to replace node'
383             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
384         when: 'put request is performed'
385             def response =
386                 mvc.perform(
387                     put(endpoint)
388                         .contentType(MediaType.APPLICATION_JSON)
389                         .content(requestBodyJson)
390                         .param('xpath', '')
391                         .param('observed-timestamp', observedTimestamp))
392                     .andReturn().response
393         then: 'the service method is invoked with expected parameters'
394             expectedApiCount * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, '/', expectedJsonData,
395                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
396         and: 'response status indicates success'
397             response.status == expectedHttpStatus.value()
398         where:
399             scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
400             'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.OK
401             'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
402     }
403
404     def 'Replace list content #scenario.'() {
405         when: 'list-nodes endpoint is invoked with put (update) operation'
406             def putRequestBuilder = put("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
407                 .contentType(MediaType.APPLICATION_JSON)
408                 .param('xpath', 'parent xpath')
409                 .content(requestBodyJson)
410             if (observedTimestamp != null)
411                 putRequestBuilder.param('observed-timestamp', observedTimestamp)
412             def response = mvc.perform(putRequestBuilder).andReturn().response
413         then: 'a success response is returned'
414             response.status == expectedHttpStatus.value()
415         and: 'the java API was called with the correct parameters'
416             expectedApiCount * mockCpsDataService.replaceListContent(dataspaceName, anchorName, 'parent xpath', expectedJsonData,
417                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
418         where:
419             scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
420             'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.OK
421             'without observed-timestamp'      | null                           || 1                | HttpStatus.OK
422             'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
423     }
424
425     def 'Delete list element #scenario.'() {
426         when: 'list-nodes endpoint is invoked with delete operation'
427             def deleteRequestBuilder = delete("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
428                 .param('xpath', 'list element xpath')
429             if (observedTimestamp != null)
430                 deleteRequestBuilder.param('observed-timestamp', observedTimestamp)
431             def response = mvc.perform(deleteRequestBuilder).andReturn().response
432         then: 'a success response is returned'
433             response.status == expectedHttpStatus.value()
434         and: 'the java API was called with the correct parameters'
435             expectedApiCount * mockCpsDataService.deleteListOrListElement(dataspaceName, anchorName, 'list element xpath',
436                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
437         where:
438             scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
439             'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.NO_CONTENT
440             'without observed-timestamp'      | null                           || 1                | HttpStatus.NO_CONTENT
441             'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
442     }
443
444     def 'Delete data node #scenario.'() {
445         given: 'data node xpath'
446             def dataNodeXpath = '/dataNodeXpath'
447         when: 'delete data node endpoint is invoked'
448             def deleteDataNodeRequest = delete( "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes")
449                 .param('xpath', dataNodeXpath)
450         and: 'observed timestamp is added to the parameters'
451             if (observedTimestamp != null)
452                 deleteDataNodeRequest.param('observed-timestamp', observedTimestamp)
453             def response = mvc.perform(deleteDataNodeRequest).andReturn().response
454         then: 'a successful response is returned'
455             response.status == expectedHttpStatus.value()
456         and: 'the api is called with the correct parameters'
457             expectedApiCount * mockCpsDataService.deleteDataNode(dataspaceName, anchorName, dataNodeXpath,
458                 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
459         where:
460             scenario                            | observedTimestamp                 || expectedApiCount | expectedHttpStatus
461             'with observed timestamp'           | '2021-03-03T23:59:59.999-0400'    || 1                | HttpStatus.NO_CONTENT
462             'without observed timestamp'        | null                              || 1                | HttpStatus.NO_CONTENT
463             'with invalid observed timestamp'   | 'invalid'                         || 0                | HttpStatus.BAD_REQUEST
464     }
465 }