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
13 * http://www.apache.org/licenses/LICENSE-2.0
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.
21 * SPDX-License-Identifier: Apache-2.0
22 * ============LICENSE_END=========================================================
25 package org.onap.cps.rest.controller
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
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
55 @WebMvcTest(DataRestController)
56 class DataRestControllerSpec extends Specification {
59 CpsDataService mockCpsDataService = Mock()
62 JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
65 PrefixResolver prefixResolver = Mock()
70 @Value('${rest.api.cps-base-path}')
73 def dataNodeBaseEndpointV1
74 def dataNodeBaseEndpointV2
75 def dataspaceName = 'my_dataspace'
76 def anchorName = 'my_anchor'
77 def noTimestamp = null
80 def requestBodyJson = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}'
83 def expectedJsonData = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}'
86 def requestBodyXml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>'
89 def expectedXmlData = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>'
92 static DataNode dataNodeWithLeavesNoChildren = new DataNodeBuilder().withXpath('/parent-1')
93 .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
96 static DataNode dataNodeWithLeavesNoChildren2 = new DataNodeBuilder().withXpath('/parent-2')
97 .withLeaves([leaf: 'value']).build()
100 static DataNode dataNodeWithChild = new DataNodeBuilder().withXpath('/parent')
101 .withChildDataNodes([new DataNodeBuilder().withXpath("/parent/child").build()]).build()
104 dataNodeBaseEndpointV1 = "$basePath/v1/dataspaces/$dataspaceName"
105 dataNodeBaseEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName"
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'
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
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'
138 .contentType(contentType)
140 .param('observed-timestamp', observedTimestamp)
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)
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
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)
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)
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
182 def 'save list elements under root node #scenario.'() {
183 given: 'root node xpath '
184 def rootNodeXpath = '/'
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', rootNodeXpath )
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, rootNodeXpath, expectedJsonData,
197 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
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
205 def 'Save list elements #scenario.'() {
206 given: 'parent node xpath '
207 def parentNodeXpath = 'parent node xpath'
208 when: 'list-node endpoint is invoked with post (create) operation'
209 def postRequestBuilder = post("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
210 .contentType(MediaType.APPLICATION_JSON)
211 .param('xpath', parentNodeXpath)
212 .content(requestBodyJson)
213 if (observedTimestamp != null)
214 postRequestBuilder.param('observed-timestamp', observedTimestamp)
215 def response = mvc.perform(postRequestBuilder).andReturn().response
216 then: 'a created response is returned'
217 response.status == expectedHttpStatus.value()
218 then: 'the java API was called with the correct parameters'
219 expectedApiCount * mockCpsDataService.saveListElements(dataspaceName, anchorName, parentNodeXpath, expectedJsonData,
220 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
222 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
223 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.CREATED
224 'without observed-timestamp' | null || 1 | HttpStatus.CREATED
225 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST
228 def 'Get data node with leaves'() {
229 given: 'the service returns data node leaves'
230 def xpath = 'parent-1'
231 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/node"
232 mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> [dataNodeWithLeavesNoChildren]
233 when: 'get request is performed through REST API'
235 mvc.perform(get(endpoint).param('xpath', xpath))
236 .andReturn().response
237 then: 'a success response is returned'
238 response.status == HttpStatus.OK.value()
239 then: 'the response contains the the datanode in json format'
240 response.getContentAsString() == '{"parent-1":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}}'
241 and: 'response contains expected leaf and value'
242 response.contentAsString.contains('"leaf":"value"')
243 and: 'response contains expected leaf-list and values'
244 response.contentAsString.contains('"leafList":["leaveListElement1","leaveListElement2"]')
247 def 'Get data node with #scenario.'() {
248 given: 'the service returns data node with #scenario'
249 def xpath = 'some xPath'
250 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/node"
251 mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> [dataNode]
252 when: 'get request is performed through REST API'
256 .param('xpath', xpath)
257 .param('include-descendants', includeDescendantsOption))
258 .andReturn().response
259 then: 'a success response is returned'
260 response.status == HttpStatus.OK.value()
261 and: 'the response contains the root node identifier: #expectedRootidentifier'
262 response.contentAsString.contains(expectedRootidentifier)
263 and: 'the response contains child is #expectChildInResponse'
264 response.contentAsString.contains('"child"') == expectChildInResponse
266 scenario | dataNode | includeDescendantsOption || expectedCpsDataServiceOption | expectChildInResponse | expectedRootidentifier
267 'no descendants by default' | dataNodeWithLeavesNoChildren | '' || OMIT_DESCENDANTS | false | 'parent-1'
268 'no descendant explicitly' | dataNodeWithLeavesNoChildren | 'false' || OMIT_DESCENDANTS | false | 'parent-1'
269 'with descendants' | dataNodeWithChild | 'true' || INCLUDE_ALL_DESCENDANTS | true | 'parent'
272 def 'Get all the data trees as json array with root node xPath using V2'() {
273 given: 'the service returns all data node leaves'
275 def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
276 mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> [dataNodeWithLeavesNoChildren, dataNodeWithLeavesNoChildren2]
277 when: 'V2 of get request is performed through REST API'
279 mvc.perform(get(endpoint).param('xpath', xpath))
280 .andReturn().response
281 then: 'a success response is returned'
282 response.status == HttpStatus.OK.value()
283 and: 'the response contains the datanode in json array format'
284 response.getContentAsString() == '[{"parent-1":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}},' +
285 '{"parent-2":{"leaf":"value"}}]'
286 and: 'the json array contains expected number of data trees'
287 def numberOfDataTrees = new JsonSlurper().parseText(response.getContentAsString()).iterator().size()
288 assert numberOfDataTrees == 2
291 def 'Get data node with #scenario using V2.'() {
292 given: 'the service returns data nodes with #scenario'
293 def xpath = 'some xPath'
294 def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
295 mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> [dataNode]
296 when: 'V2 of get request is performed through REST API'
300 .param('xpath', xpath)
301 .param('descendants', includeDescendantsOption))
302 .andReturn().response
303 then: 'a success response is returned'
304 response.status == HttpStatus.OK.value()
305 and: 'the response contains the root node identifier: #expectedRootidentifier'
306 response.contentAsString.contains(expectedRootidentifier)
307 and: 'the response contains child is #expectChildInResponse'
308 response.contentAsString.contains('"child"') == expectChildInResponse
310 scenario | dataNode | includeDescendantsOption || expectedCpsDataServiceOption | expectChildInResponse | expectedRootidentifier
311 'no descendants by default' | dataNodeWithLeavesNoChildren | '' || OMIT_DESCENDANTS | false | 'parent-1'
312 'no descendant explicitly' | dataNodeWithLeavesNoChildren | '0' || OMIT_DESCENDANTS | false | 'parent-1'
313 'with descendants' | dataNodeWithChild | '-1' || INCLUDE_ALL_DESCENDANTS | true | 'parent'
316 def 'Get data node using v2 api'() {
317 given: 'the service returns data node'
318 def xpath = 'some xPath'
319 def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/node"
320 mockCpsDataService.getDataNodes(dataspaceName, anchorName, xpath, { descendantsOption -> {
321 assert descendantsOption.depth == 2}} as FetchDescendantsOption) >> [dataNodeWithChild]
322 when: 'get request is performed through REST API'
326 .param('xpath', xpath)
327 .param('descendants', '2'))
328 .andReturn().response
329 then: 'a success response is returned'
330 assert response.status == HttpStatus.OK.value()
331 and: 'the response contains the root node identifier'
332 assert response.contentAsString.contains('parent')
333 and: 'the response contains child is true'
334 assert response.contentAsString.contains('"child"') == true
337 def 'Update data node leaves: #scenario.'() {
338 given: 'endpoint to update a node '
339 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
340 when: 'patch request is performed'
344 .contentType(MediaType.APPLICATION_JSON)
345 .content(requestBodyJson)
346 .param('xpath', inputXpath)
347 ).andReturn().response
348 then: 'the service method is invoked with expected parameters'
349 1 * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, xpathServiceParameter, expectedJsonData, null)
350 and: 'response status indicates success'
351 response.status == HttpStatus.OK.value()
353 scenario | inputXpath || xpathServiceParameter
354 'root node by default' | '' || '/'
355 'root node by choice' | '/' || '/'
356 'some xpath by parent' | '/some/xpath' || '/some/xpath'
359 def 'Update data node leaves with observedTimestamp'() {
360 given: 'endpoint to update a node leaves '
361 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
362 when: 'patch request is performed'
366 .contentType(MediaType.APPLICATION_JSON)
367 .content(requestBodyJson)
369 .param('observed-timestamp', observedTimestamp)
370 ).andReturn().response
371 then: 'the service method is invoked with expected parameters'
372 expectedApiCount * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, '/', expectedJsonData,
373 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
374 and: 'response status indicates success'
375 response.status == expectedHttpStatus.value()
377 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
378 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK
379 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST
382 def 'Replace data node tree: #scenario.'() {
383 given: 'endpoint to replace node'
384 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
385 when: 'put request is performed'
389 .contentType(MediaType.APPLICATION_JSON)
390 .content(requestBodyJson)
391 .param('xpath', inputXpath))
392 .andReturn().response
393 then: 'the service method is invoked with expected parameters'
394 1 * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, xpathServiceParameter, expectedJsonData, noTimestamp)
395 and: 'response status indicates success'
396 response.status == HttpStatus.OK.value()
398 scenario | inputXpath || xpathServiceParameter
399 'root node by default' | '' || '/'
400 'root node by choice' | '/' || '/'
401 'some xpath by parent' | '/some/xpath' || '/some/xpath'
404 def 'Update data node and descendants with observedTimestamp.'() {
405 given: 'endpoint to replace node'
406 def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
407 when: 'put request is performed'
411 .contentType(MediaType.APPLICATION_JSON)
412 .content(requestBodyJson)
414 .param('observed-timestamp', observedTimestamp))
415 .andReturn().response
416 then: 'the service method is invoked with expected parameters'
417 expectedApiCount * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, '/', expectedJsonData,
418 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
419 and: 'response status indicates success'
420 response.status == expectedHttpStatus.value()
422 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
423 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK
424 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST
427 def 'Replace list content #scenario.'() {
428 when: 'list-nodes endpoint is invoked with put (update) operation'
429 def putRequestBuilder = put("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
430 .contentType(MediaType.APPLICATION_JSON)
431 .param('xpath', 'parent xpath')
432 .content(requestBodyJson)
433 if (observedTimestamp != null)
434 putRequestBuilder.param('observed-timestamp', observedTimestamp)
435 def response = mvc.perform(putRequestBuilder).andReturn().response
436 then: 'a success response is returned'
437 response.status == expectedHttpStatus.value()
438 and: 'the java API was called with the correct parameters'
439 expectedApiCount * mockCpsDataService.replaceListContent(dataspaceName, anchorName, 'parent xpath', expectedJsonData,
440 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
442 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
443 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK
444 'without observed-timestamp' | null || 1 | HttpStatus.OK
445 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST
448 def 'Delete list element #scenario.'() {
449 when: 'list-nodes endpoint is invoked with delete operation'
450 def deleteRequestBuilder = delete("$dataNodeBaseEndpointV1/anchors/$anchorName/list-nodes")
451 .param('xpath', 'list element xpath')
452 if (observedTimestamp != null)
453 deleteRequestBuilder.param('observed-timestamp', observedTimestamp)
454 def response = mvc.perform(deleteRequestBuilder).andReturn().response
455 then: 'a success response is returned'
456 response.status == expectedHttpStatus.value()
457 and: 'the java API was called with the correct parameters'
458 expectedApiCount * mockCpsDataService.deleteListOrListElement(dataspaceName, anchorName, 'list element xpath',
459 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
461 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
462 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.NO_CONTENT
463 'without observed-timestamp' | null || 1 | HttpStatus.NO_CONTENT
464 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST
467 def 'Delete data node #scenario.'() {
468 given: 'data node xpath'
469 def dataNodeXpath = '/dataNodeXpath'
470 when: 'delete data node endpoint is invoked'
471 def deleteDataNodeRequest = delete( "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes")
472 .param('xpath', dataNodeXpath)
473 and: 'observed timestamp is added to the parameters'
474 if (observedTimestamp != null)
475 deleteDataNodeRequest.param('observed-timestamp', observedTimestamp)
476 def response = mvc.perform(deleteDataNodeRequest).andReturn().response
477 then: 'a successful response is returned'
478 response.status == expectedHttpStatus.value()
479 and: 'the api is called with the correct parameters'
480 expectedApiCount * mockCpsDataService.deleteDataNode(dataspaceName, anchorName, dataNodeXpath,
481 { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
483 scenario | observedTimestamp || expectedApiCount | expectedHttpStatus
484 'with observed timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.NO_CONTENT
485 'without observed timestamp' | null || 1 | HttpStatus.NO_CONTENT
486 'with invalid observed timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST