From: Arpit Singh Date: Wed, 12 Mar 2025 07:03:35 +0000 (+0530) Subject: Refactor Cps Delta code from CpsDataService to CpsDeltaService X-Git-Tag: 3.6.2~10^2 X-Git-Url: https://gerrit.onap.org/r/gitweb?a=commitdiff_plain;h=63a49a32098f54a2eb83829f2543636ddc092d00;p=cps.git Refactor Cps Delta code from CpsDataService to CpsDeltaService - added new open api yaml file defining CPS delta endpoints - Added new REST controller for CPS Delta - Moved CPS Delta endpoints to new java interface, out from CPS Data - Added integration tests for CPS Delta - CPS Delta rest controller and java interface have the two delta endpoints: Delta between anchors and Delta between anchor and payload. Issue-ID: CPS-2320 Change-Id: I556e70623a8c18f8cde3cd28bac890296019c0e2 Signed-off-by: Arpit Singh --- diff --git a/cps-rest/docs/openapi/cpsDataV2.yml b/cps-rest/docs/openapi/cpsDataV2.yml index 999c5b2c19..7afda705f7 100644 --- a/cps-rest/docs/openapi/cpsDataV2.yml +++ b/cps-rest/docs/openapi/cpsDataV2.yml @@ -1,5 +1,5 @@ # ============LICENSE_START======================================================= -# Copyright (c) 2022-2024 TechMahindra Ltd. +# Copyright (c) 2022-2025 TechMahindra Ltd. # ================================================================================ # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -54,83 +54,3 @@ nodeByDataspaceAndAnchor: '500': $ref: 'components.yml#/components/responses/InternalServerError' x-codegen-request-body-name: xpath - -delta: - get: - description: Get delta between two anchors within a given dataspace - tags: - - cps-data - summary: Get delta between anchors in the same dataspace - operationId: getDeltaByDataspaceAndAnchors - parameters: - - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath' - - $ref: 'components.yml#/components/parameters/targetAnchorNameInQuery' - - $ref: 'components.yml#/components/parameters/xpathInQuery' - - $ref: 'components.yml#/components/parameters/descendantsInQuery' - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - examples: - dataSample: - $ref: 'components.yml#/components/examples/deltaReportSample' - '400': - $ref: 'components.yml#/components/responses/BadRequest' - '403': - $ref: 'components.yml#/components/responses/Forbidden' - '500': - $ref: 'components.yml#/components/responses/InternalServerError' - x-codegen-request-body-name: xpath - post: - description: Get delta between an anchor in a dataspace and JSON payload - tags: - - cps-data - summary: Get delta between an anchor and JSON payload - operationId: getDeltaByDataspaceAnchorAndPayload - parameters: - - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath' - - $ref: 'components.yml#/components/parameters/xpathInQuery' - requestBody: - content: - multipart/form-data: - schema: - type: object - properties: - json: - type: object - example: - test:bookstore: - bookstore-name: Chapters - categories: - - code: 01 - name: SciFi - - code: 02 - name: kids - file: - type: string - format: binary - required: - - json - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - examples: - dataSample: - $ref: 'components.yml#/components/examples/deltaReportSample' - '400': - $ref: 'components.yml#/components/responses/BadRequest' - '401': - $ref: 'components.yml#/components/responses/Unauthorized' - '403': - $ref: 'components.yml#/components/responses/Forbidden' - '500': - $ref: 'components.yml#/components/responses/InternalServerError' \ No newline at end of file diff --git a/cps-rest/docs/openapi/cpsDelta.yml b/cps-rest/docs/openapi/cpsDelta.yml new file mode 100644 index 0000000000..67535ce832 --- /dev/null +++ b/cps-rest/docs/openapi/cpsDelta.yml @@ -0,0 +1,97 @@ +# ============LICENSE_START======================================================= +# Copyright (c) 2025 TechMahindra Ltd. +# ================================================================================ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ============LICENSE_END========================================================= + +delta: + get: + description: Get delta between two anchors within a given dataspace + tags: + - cps-delta + summary: Get delta between anchors in the same dataspace + operationId: getDeltaByDataspaceAndAnchors + parameters: + - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' + - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath' + - $ref: 'components.yml#/components/parameters/targetAnchorNameInQuery' + - $ref: 'components.yml#/components/parameters/xpathInQuery' + - $ref: 'components.yml#/components/parameters/descendantsInQuery' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + examples: + dataSample: + $ref: 'components.yml#/components/examples/deltaReportSample' + '400': + $ref: 'components.yml#/components/responses/BadRequest' + '403': + $ref: 'components.yml#/components/responses/Forbidden' + '500': + $ref: 'components.yml#/components/responses/InternalServerError' + x-codegen-request-body-name: xpath + post: + description: Get delta between an anchor in a dataspace and JSON payload + tags: + - cps-delta + summary: Get delta between an anchor and JSON payload + operationId: getDeltaByDataspaceAnchorAndPayload + parameters: + - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' + - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath' + - $ref: 'components.yml#/components/parameters/xpathInQuery' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + json: + type: object + example: + test:bookstore: + bookstore-name: Chapters + categories: + - code: 01 + name: SciFi + - code: 02 + name: kids + file: + type: string + format: binary + required: + - json + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + examples: + dataSample: + $ref: 'components.yml#/components/examples/deltaReportSample' + '400': + $ref: 'components.yml#/components/responses/BadRequest' + '401': + $ref: 'components.yml#/components/responses/Unauthorized' + '403': + $ref: 'components.yml#/components/responses/Forbidden' + '500': + $ref: 'components.yml#/components/responses/InternalServerError' diff --git a/cps-rest/docs/openapi/openapi.yml b/cps-rest/docs/openapi/openapi.yml index 09c454b1da..c3f3a0257a 100644 --- a/cps-rest/docs/openapi/openapi.yml +++ b/cps-rest/docs/openapi/openapi.yml @@ -44,6 +44,8 @@ tags: description: cps Admin - name: cps-data description: cps Data + - name: cps-delta + description: CPS Delta paths: /v1/dataspaces: @@ -104,7 +106,7 @@ paths: $ref: 'cpsData.yml#/listElementByDataspaceAndAnchor' /v2/dataspaces/{dataspace-name}/anchors/{source-anchor-name}/delta: - $ref: 'cpsDataV2.yml#/delta' + $ref: 'cpsDelta.yml#/delta' /v1/dataspaces/{dataspace-name}/anchors/{anchor-name}/nodes/query: $ref: 'cpsQueryV1Deprecated.yml#/nodesByDataspaceAndAnchorAndCpsPath' diff --git a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java index b6a2e42a14..90500f3955 100755 --- a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java +++ b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java @@ -3,7 +3,7 @@ * Copyright (C) 2020-2022 Bell Canada. * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2025 Nordix Foundation - * Modifications Copyright (C) 2022-2024 TechMahindra Ltd. + * Modifications Copyright (C) 2022-2025 TechMahindra Ltd. * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); @@ -24,21 +24,16 @@ package org.onap.cps.rest.controller; -import static org.onap.cps.rest.utils.MultipartFileUtil.extractYangResourcesMap; - import io.micrometer.core.annotation.Timed; import jakarta.validation.ValidationException; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collection; -import java.util.Collections; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.onap.cps.api.CpsDataService; import org.onap.cps.api.CpsFacade; -import org.onap.cps.api.model.DeltaReport; import org.onap.cps.api.parameters.FetchDescendantsOption; import org.onap.cps.rest.api.CpsDataApi; import org.onap.cps.utils.ContentType; @@ -48,7 +43,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("${rest.api.cps-base-path}") @@ -193,44 +187,6 @@ public class DataRestController implements CpsDataApi { return new ResponseEntity<>(HttpStatus.NO_CONTENT); } - @Override - public ResponseEntity getDeltaByDataspaceAnchorAndPayload(final String dataspaceName, - final String sourceAnchorName, - final Object jsonPayload, - final String xpath, - final MultipartFile multipartFile) { - final FetchDescendantsOption fetchDescendantsOption = FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS; - - final Map yangResourceMap; - if (multipartFile == null) { - yangResourceMap = Collections.emptyMap(); - } else { - yangResourceMap = extractYangResourcesMap(multipartFile); - } - final Collection deltaReports = Collections.unmodifiableList( - cpsDataService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, sourceAnchorName, - xpath, yangResourceMap, jsonPayload.toString(), fetchDescendantsOption)); - - return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaReports), HttpStatus.OK); - } - - @Override - @Timed(value = "cps.data.controller.get.delta", - description = "Time taken to get delta between anchors") - public ResponseEntity getDeltaByDataspaceAndAnchors(final String dataspaceName, - final String sourceAnchorName, - final String targetAnchorName, - final String xpath, - final String descendants) { - final FetchDescendantsOption fetchDescendantsOption = - FetchDescendantsOption.getFetchDescendantsOption(descendants); - - final List deltaBetweenAnchors = - cpsDataService.getDeltaByDataspaceAndAnchors(dataspaceName, sourceAnchorName, - targetAnchorName, xpath, fetchDescendantsOption); - return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaBetweenAnchors), HttpStatus.OK); - } - private ResponseEntity buildResponseEntity(final List> dataMaps, final ContentType contentType) { final String responseData; diff --git a/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java b/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java new file mode 100644 index 0000000000..f27346cfa7 --- /dev/null +++ b/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java @@ -0,0 +1,89 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 TechMahindra Ltd. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.rest.controller; + +import static org.onap.cps.rest.utils.MultipartFileUtil.extractYangResourcesMap; + +import io.micrometer.core.annotation.Timed; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.onap.cps.api.CpsDeltaService; +import org.onap.cps.api.model.DeltaReport; +import org.onap.cps.api.parameters.FetchDescendantsOption; +import org.onap.cps.rest.api.CpsDeltaApi; +import org.onap.cps.utils.JsonObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("${rest.api.cps-base-path}") +@RequiredArgsConstructor +public class DeltaRestController implements CpsDeltaApi { + + private final CpsDeltaService cpsDeltaService; + private final JsonObjectMapper jsonObjectMapper; + + + @Timed(value = "cps.delta.controller.get.delta", + description = "Time taken to get delta between anchors") + @Override + public ResponseEntity getDeltaByDataspaceAndAnchors(final String dataspaceName, + final String sourceAnchorName, + final String targetAnchorName, + final String xpath, + final String descendants) { + final FetchDescendantsOption fetchDescendantsOption = + FetchDescendantsOption.getFetchDescendantsOption(descendants); + final List deltaBetweenAnchors = + cpsDeltaService.getDeltaByDataspaceAndAnchors(dataspaceName, sourceAnchorName, + targetAnchorName, xpath, fetchDescendantsOption); + return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaBetweenAnchors), HttpStatus.OK); + } + + @Timed(value = "cps.delta.controller.get.delta", + description = "Time taken to get delta between anchors") + @Override + public ResponseEntity getDeltaByDataspaceAnchorAndPayload(final String dataspaceName, + final String sourceAnchorName, + final Object jsonPayload, + final String xpath, + final MultipartFile multipartFile) { + final FetchDescendantsOption fetchDescendantsOption = FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS; + + final Map yangResourceMap; + if (multipartFile == null) { + yangResourceMap = Collections.emptyMap(); + } else { + yangResourceMap = extractYangResourcesMap(multipartFile); + } + final Collection deltaReports = Collections.unmodifiableList( + cpsDeltaService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, sourceAnchorName, + xpath, yangResourceMap, jsonPayload.toString(), fetchDescendantsOption)); + return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaReports), HttpStatus.OK); + } + +} diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy index e4cd8c4be6..ba5104acf9 100755 --- a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy @@ -4,7 +4,7 @@ * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Bell Canada. * Modifications Copyright (C) 2022 Deutsche Telekom AG - * Modifications Copyright (C) 2022-2024 TechMahindra Ltd. + * Modifications Copyright (C) 2022-2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,6 @@ package org.onap.cps.rest.controller import com.fasterxml.jackson.databind.ObjectMapper import org.onap.cps.api.CpsDataService import org.onap.cps.api.CpsFacade -import org.onap.cps.impl.DeltaReportBuilder import org.onap.cps.utils.ContentType import org.onap.cps.utils.DateTimeUtility import org.onap.cps.utils.JsonObjectMapper @@ -37,7 +36,6 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import org.springframework.mock.web.MockMultipartFile import org.springframework.test.web.servlet.MockMvc import spock.lang.Shared import spock.lang.Specification @@ -46,7 +44,6 @@ import static org.onap.cps.api.parameters.FetchDescendantsOption.INCLUDE_ALL_DES import static org.onap.cps.api.parameters.FetchDescendantsOption.OMIT_DESCENDANTS import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put @@ -87,9 +84,6 @@ class DataRestControllerSpec extends Specification { @Shared def expectedXmlData = '\n\n' - @Shared - def multipartYangFile = new MockMultipartFile("file", 'filename.yang', "text/plain", 'content'.getBytes()) - def setup() { dataNodeBaseEndpointV1 = "$basePath/v1/dataspaces/$dataspaceName" dataNodeBaseEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName" @@ -308,65 +302,6 @@ class DataRestControllerSpec extends Specification { 'JSON' | MediaType.APPLICATION_JSON || '[{"mocked":"result1"},{"mocked":"result2"}]' } - def 'Get delta between two anchors.'() { - given: 'the service returns a list containing delta reports' - def deltaReports = new DeltaReportBuilder().actionReplace().withXpath('some xpath').withSourceData('some key': 'some value').withTargetData('some key': 'some value').build() - def xpath = 'some xpath' - def endpoint = "$dataNodeBaseEndpointV2/anchors/sourceAnchor/delta" - mockCpsDataService.getDeltaByDataspaceAndAnchors(dataspaceName, 'sourceAnchor', 'targetAnchor', xpath, OMIT_DESCENDANTS) >> [deltaReports] - when: 'get delta request is performed using REST API' - def response = - mvc.perform(get(endpoint) - .param('target-anchor-name', 'targetAnchor') - .param('xpath', xpath)) - .andReturn().response - then: 'expected response code is returned' - assert response.status == HttpStatus.OK.value() - and: 'the response contains expected value' - assert response.contentAsString.contains("[{\"action\":\"replace\",\"xpath\":\"some xpath\",\"sourceData\":{\"some key\":\"some value\"},\"targetData\":{\"some key\":\"some value\"}}]") - } - - def 'Get delta between anchor and JSON payload with multipart file'() { - given: 'sample delta report, xpath, yang model file and json payload' - def deltaReports = new DeltaReportBuilder().actionCreate().withXpath('some xpath').build() - def xpath = 'some xpath' - def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/delta" - and: 'the service layer returns a list containing delta reports' - mockCpsDataService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, ['filename.yang':'content'], expectedJsonData, INCLUDE_ALL_DESCENDANTS) >> [deltaReports] - when: 'get delta request is performed using REST API' - def response = - mvc.perform(multipart(endpoint) - .file(multipartYangFile) - .param("json", requestBodyJson) - .param('xpath', xpath) - .contentType(MediaType.MULTIPART_FORM_DATA)) - .andReturn().response - then: 'expected response code is returned' - assert response.status == HttpStatus.OK.value() - and: 'the response contains expected value' - assert response.contentAsString.contains("[{\"action\":\"create\",\"xpath\":\"some xpath\"}]") - } - - def 'Get delta between anchor and JSON payload without multipart file.'() { - given: 'sample delta report, xpath, and json payload' - def deltaReports = new DeltaReportBuilder().actionRemove().withXpath('some xpath').build() - def xpath = 'some xpath' - def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/delta" - and: 'the service layer returns a list containing delta reports' - mockCpsDataService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, [:], expectedJsonData, INCLUDE_ALL_DESCENDANTS) >> [deltaReports] - when: 'get delta request is performed using REST API' - def response = - mvc.perform(multipart(endpoint) - .param("json", requestBodyJson) - .param('xpath', xpath) - .contentType(MediaType.MULTIPART_FORM_DATA)) - .andReturn().response - then: 'expected response code is returned' - assert response.status == HttpStatus.OK.value() - and: 'the response contains expected value' - assert response.contentAsString.contains("[{\"action\":\"remove\",\"xpath\":\"some xpath\"}]") - } - def 'Update data node leaves: #scenario.'() { given: 'endpoint to update a node ' def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes" diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy new file mode 100644 index 0000000000..18c0f1369e --- /dev/null +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy @@ -0,0 +1,129 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 TechMahindra Ltd. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.rest.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import org.onap.cps.api.CpsDeltaService +import org.onap.cps.impl.DeltaReportBuilder +import org.onap.cps.utils.JsonObjectMapper +import org.spockframework.spring.SpringBean +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.MockMvc +import org.springframework.web.multipart.MultipartFile +import spock.lang.Shared +import spock.lang.Specification + +import static org.onap.cps.api.parameters.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS +import static org.onap.cps.api.parameters.FetchDescendantsOption.OMIT_DESCENDANTS +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart + +@WebMvcTest(DeltaRestController) +class DeltaRestControllerSpec extends Specification { + + @SpringBean + CpsDeltaService mockCpsDeltaService = Mock() + + @SpringBean + JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) + + @Autowired + MockMvc mvc + + @Value('${rest.api.cps-base-path}') + def basePath + + def dataNodeBaseEndpointV2 + def dataspaceName = 'my_dataspace' + def anchorName = 'my_anchor' + + @Shared + def requestBodyJson = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}' + @Shared + def expectedJsonData = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}' + @Shared + static MultipartFile multipartYangFile = new MockMultipartFile('file', 'filename.yang', 'text/plain', 'content'.getBytes()) + + def setup() { + dataNodeBaseEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName/anchors/$anchorName/delta" + } + + def 'Get delta between two anchors'() { + given: 'the service returns a list containing delta reports' + def deltaReports = new DeltaReportBuilder().actionReplace().withXpath('some xpath').withSourceData('some key': 'some value').withTargetData('some key': 'some value').build() + def xpath = 'some xpath' + mockCpsDeltaService.getDeltaByDataspaceAndAnchors(dataspaceName, anchorName, 'targetAnchor', xpath, OMIT_DESCENDANTS) >> [deltaReports] + when: 'get delta request is performed using REST API' + def response = + mvc.perform(get(dataNodeBaseEndpointV2) + .param('target-anchor-name', 'targetAnchor') + .param('xpath', xpath)) + .andReturn().response + then: 'expected response code is returned' + assert response.status == HttpStatus.OK.value() + and: 'the response contains expected value' + assert response.contentAsString.contains('[{\"action\":\"replace\",\"xpath\":\"some xpath\",\"sourceData\":{\"some key\":\"some value\"},\"targetData\":{\"some key\":\"some value\"}}]') + } + + def 'Get delta between anchor and JSON payload with multipart file'() { + given: 'sample delta report, xpath, yang model file and json payload' + def deltaReports = new DeltaReportBuilder().actionCreate().withXpath('some xpath').build() + def xpath = 'some xpath' + and: 'the service layer returns a list containing delta reports' + mockCpsDeltaService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, ['filename.yang':'content'], expectedJsonData, INCLUDE_ALL_DESCENDANTS) >> [deltaReports] + when: 'get delta request is performed using REST API' + def response = + mvc.perform(multipart(dataNodeBaseEndpointV2) + .file(multipartYangFile) + .param('json', requestBodyJson) + .param('xpath', xpath) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andReturn().response + then: 'expected response code is returned' + assert response.status == HttpStatus.OK.value() + and: 'the response contains expected value' + assert response.contentAsString.contains('[{\"action\":\"create\",\"xpath\":\"some xpath\"}]') + } + + def 'Get delta between anchor and JSON payload without multipart file'() { + given: 'sample delta report, xpath, and json payload' + def deltaReports = new DeltaReportBuilder().actionRemove().withXpath('some xpath').build() + def xpath = 'some xpath' + and: 'the service layer returns a list containing delta reports' + mockCpsDeltaService.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, [:], expectedJsonData, INCLUDE_ALL_DESCENDANTS) >> [deltaReports] + when: 'get delta request is performed using REST API' + def response = + mvc.perform(multipart(dataNodeBaseEndpointV2) + .param('json', requestBodyJson) + .param('xpath', xpath) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andReturn().response + then: 'expected response code is returned' + assert response.status == HttpStatus.OK.value() + and: 'the response contains expected value' + assert response.contentAsString.contains('[{\"action\":\"remove\",\"xpath\":\"some xpath\"}]') + } +} diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy index 0cbdffbdc4..1d58197dcc 100644 --- a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy @@ -29,6 +29,7 @@ import groovy.json.JsonSlurper import org.onap.cps.api.CpsAnchorService import org.onap.cps.api.CpsDataService import org.onap.cps.api.CpsDataspaceService +import org.onap.cps.api.CpsDeltaService import org.onap.cps.api.CpsFacade import org.onap.cps.api.CpsModuleService import org.onap.cps.api.CpsNotificationService @@ -95,6 +96,9 @@ class CpsRestExceptionHandlerSpec extends Specification { @SpringBean CpsNotificationService mockCpsNotificationService = Stub() + @SpringBean + CpsDeltaService cpsDeltaService = Stub() + @Autowired MockMvc mvc diff --git a/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java b/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java index 41713746b7..5d48812d58 100644 --- a/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java +++ b/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java @@ -4,7 +4,7 @@ * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Bell Canada * Modifications Copyright (C) 2022 Deutsche Telekom AG - * Modifications Copyright (C) 2023-2024 TechMahindra Ltd. + * Modifications Copyright (C) 2023-2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,10 +26,8 @@ package org.onap.cps.api; import java.time.OffsetDateTime; import java.util.Collection; -import java.util.List; import java.util.Map; import org.onap.cps.api.model.DataNode; -import org.onap.cps.api.model.DeltaReport; import org.onap.cps.api.parameters.FetchDescendantsOption; import org.onap.cps.utils.ContentType; @@ -293,38 +291,6 @@ public interface CpsDataService { */ void lockAnchor(String sessionID, String dataspaceName, String anchorName, Long timeoutInMilliseconds); - /** - * Retrieves the delta between two anchors by xpath within a dataspace. - * - * @param dataspaceName dataspace name - * @param sourceAnchorName source anchor name - * @param targetAnchorName target anchor name - * @param xpath xpath - * @param fetchDescendantsOption defines the scope of data to fetch: either single node or all the descendant - * nodes (recursively) as well - * @return list containing {@link DeltaReport} objects - */ - List getDeltaByDataspaceAndAnchors(String dataspaceName, String sourceAnchorName, - String targetAnchorName, String xpath, - FetchDescendantsOption fetchDescendantsOption); - - /** - * Retrieves the delta between an anchor and JSON payload by xpath, using dataspace name and anchor name. - * - * @param dataspaceName source dataspace name - * @param sourceAnchorName source anchor name - * @param xpath xpath - * @param yangResourceContentPerName YANG resources (files) map where key is a name and value is content - * @param targetData target data to be compared in JSON string format - * @param fetchDescendantsOption defines the scope of data to fetch: defaulted to INCLUDE_ALL_DESCENDANTS - * @return list containing {@link DeltaReport} objects - */ - List getDeltaByDataspaceAnchorAndPayload(String dataspaceName, String sourceAnchorName, String xpath, - Map yangResourceContentPerName, - String targetData, - FetchDescendantsOption fetchDescendantsOption); - - /** * Validates JSON or XML data by parsing it using the schema associated to an anchor within the given dataspace. * Validation is performed without persisting the data. diff --git a/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java b/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java index 67c4da63ac..671b1d60db 100644 --- a/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java +++ b/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java @@ -1,6 +1,6 @@ /* * ============LICENSE_START======================================================= - * Copyright (C) 2023 TechMahindra Ltd. + * Copyright (C) 2023-2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,23 +20,47 @@ package org.onap.cps.api; -import java.util.Collection; import java.util.List; -import org.onap.cps.api.model.DataNode; +import java.util.Map; import org.onap.cps.api.model.DeltaReport; +import org.onap.cps.api.parameters.FetchDescendantsOption; public interface CpsDeltaService { /** - * Retrieves delta between source data nodes and target data nodes. Source data nodes contain the data which acts as - * the point of reference for delta report, whereas target data nodes contain the data being compared against - * source data node. List of {@link DeltaReport}. Each Delta Report contains information such as action, xpath, - * source-payload and target-payload. + * Retrieves the delta between two anchors by xpath within a dataspace. Returns a List of {@link DeltaReport}. + * Each Delta Report contains following information: action, xpath, source-payload and/or target-payload. * - * @param sourceDataNodes collection of {@link DataNode} as source/reference for delta generation - * @param targetDataNodes collection of {@link DataNode} as target data for delta generation - * @return list of {@link DeltaReport} containing delta information + * @param dataspaceName dataspace name + * @param sourceAnchorName source anchor name + * @param targetAnchorName target anchor name + * @param xpath xpath + * @param fetchDescendantsOption defines the scope of data to fetch: either single node or all the descendant + * nodes (recursively) as well + * @return list containing {@link DeltaReport} objects */ - List getDeltaReports(Collection sourceDataNodes, - Collection targetDataNodes); + List getDeltaByDataspaceAndAnchors(String dataspaceName, String sourceAnchorName, + String targetAnchorName, String xpath, + FetchDescendantsOption fetchDescendantsOption); + + /** + * Retrieves the delta between an anchor and JSON payload by xpath, using dataspace name and anchor name. + * Returns a List of {@link DeltaReport}. Each Delta Report contains following information: action, xpath, + * source-payload and/or target-payload. + * + * @param dataspaceName source dataspace name + * @param sourceAnchorName source anchor name + * @param xpath xpath + * @param yangResourceContentPerName YANG resources (files) map where key is a name and value is content + * @param targetData target data to be compared in JSON string format + * @param fetchDescendantsOption defines the scope of data to fetch: defaulted to INCLUDE_ALL_DESCENDANTS + * + * @return list containing {@link DeltaReport} objects + */ + List getDeltaByDataspaceAnchorAndPayload(String dataspaceName, String sourceAnchorName, String xpath, + Map yangResourceContentPerName, + String targetData, + FetchDescendantsOption fetchDescendantsOption); + + } diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java b/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java index 586941a561..16dbc7b98a 100644 --- a/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java +++ b/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java @@ -31,21 +31,17 @@ import static org.onap.cps.utils.ContentType.JSON; import io.micrometer.core.annotation.Timed; import java.io.Serializable; import java.time.OffsetDateTime; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.onap.cps.api.CpsAnchorService; import org.onap.cps.api.CpsDataService; -import org.onap.cps.api.CpsDeltaService; import org.onap.cps.api.DataNodeFactory; import org.onap.cps.api.model.Anchor; import org.onap.cps.api.model.DataNode; -import org.onap.cps.api.model.DeltaReport; import org.onap.cps.api.parameters.FetchDescendantsOption; import org.onap.cps.cpspath.parser.CpsPathUtil; import org.onap.cps.events.CpsDataUpdateEventsProducer; @@ -53,8 +49,6 @@ import org.onap.cps.events.model.Data.Operation; import org.onap.cps.spi.CpsDataPersistenceService; import org.onap.cps.utils.ContentType; import org.onap.cps.utils.CpsValidator; -import org.onap.cps.utils.DataMapper; -import org.onap.cps.utils.JsonObjectMapper; import org.onap.cps.utils.YangParser; import org.springframework.stereotype.Service; @@ -69,12 +63,8 @@ public class CpsDataServiceImpl implements CpsDataService { private final CpsDataUpdateEventsProducer cpsDataUpdateEventsProducer; private final CpsAnchorService cpsAnchorService; private final DataNodeFactory dataNodeFactory; - private final CpsValidator cpsValidator; private final YangParser yangParser; - private final CpsDeltaService cpsDeltaService; - private final DataMapper dataMapper; - private final JsonObjectMapper jsonObjectMapper; @Override public void saveData(final String dataspaceName, final String anchorName, final String nodeData, @@ -175,8 +165,7 @@ public class CpsDataServiceImpl implements CpsDataService { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName); final Collection dataNodeUpdates = dataNodeFactory - .createDataNodesWithAnchorParentXpathAndNodeData(anchor, parentNodeXpath, dataNodeUpdatesAsJson, - JSON); + .createDataNodesWithAnchorParentXpathAndNodeData(anchor, parentNodeXpath, dataNodeUpdatesAsJson, JSON); for (final DataNode dataNodeUpdate : dataNodeUpdates) { processDataNodeUpdate(anchor, dataNodeUpdate); } @@ -204,44 +193,6 @@ public class CpsDataServiceImpl implements CpsDataService { cpsDataPersistenceService.lockAnchor(sessionID, dataspaceName, anchorName, timeoutInMilliseconds); } - @Override - @Timed(value = "cps.data.service.get.delta", description = "Time taken to get delta between anchors") - public List getDeltaByDataspaceAndAnchors(final String dataspaceName, - final String sourceAnchorName, - final String targetAnchorName, final String xpath, - final FetchDescendantsOption fetchDescendantsOption) { - - final Collection sourceDataNodes = getDataNodesForMultipleXpaths(dataspaceName, - sourceAnchorName, Collections.singletonList(xpath), fetchDescendantsOption); - final Collection targetDataNodes = getDataNodesForMultipleXpaths(dataspaceName, - targetAnchorName, Collections.singletonList(xpath), fetchDescendantsOption); - - return cpsDeltaService.getDeltaReports(sourceDataNodes, targetDataNodes); - } - - @Override - @Timed(value = "cps.data.service.get.deltaBetweenAnchorAndPayload", - description = "Time taken to get delta between anchor and a payload") - public List getDeltaByDataspaceAnchorAndPayload(final String dataspaceName, - final String sourceAnchorName, final String xpath, - final Map yangResourceContentPerName, - final String targetData, - final FetchDescendantsOption fetchDescendantsOption) { - - final Anchor sourceAnchor = cpsAnchorService.getAnchor(dataspaceName, sourceAnchorName); - - final Collection sourceDataNodes = getDataNodes(dataspaceName, - sourceAnchorName, xpath, fetchDescendantsOption); - - final Collection sourceDataNodesRebuilt = - new ArrayList<>(rebuildSourceDataNodes(xpath, sourceAnchor, sourceDataNodes)); - - final Collection targetDataNodes = - new ArrayList<>(buildTargetDataNodes(sourceAnchor, xpath, yangResourceContentPerName, targetData)); - - return cpsDeltaService.getDeltaReports(sourceDataNodesRebuilt, targetDataNodes); - } - @Override @Timed(value = "cps.data.service.datanode.descendants.update", description = "Time taken to update a data node and descendants") @@ -357,31 +308,6 @@ public class CpsDataServiceImpl implements CpsDataService { yangParser.validateData(contentType, nodeData, anchor, xpath); } - private Collection rebuildSourceDataNodes(final String xpath, - final Anchor sourceAnchor, - final Collection sourceDataNodes) { - final Collection sourceDataNodesRebuilt = new ArrayList<>(); - if (sourceDataNodes != null) { - final Map sourceDataNodesAsMap = dataMapper.toFlatDataMap(sourceAnchor, sourceDataNodes); - final String sourceDataNodesAsJson = jsonObjectMapper.asJsonString(sourceDataNodesAsMap); - final Collection dataNodes = dataNodeFactory - .createDataNodesWithAnchorXpathAndNodeData(sourceAnchor, xpath, sourceDataNodesAsJson, JSON); - sourceDataNodesRebuilt.addAll(dataNodes); - } - return sourceDataNodesRebuilt; - } - - private Collection buildTargetDataNodes(final Anchor sourceAnchor, - final String xpath, - final Map yangResourceContentPerName, - final String targetData) { - if (yangResourceContentPerName.isEmpty()) { - return dataNodeFactory.createDataNodesWithAnchorXpathAndNodeData(sourceAnchor, xpath, targetData, JSON); - } - return dataNodeFactory - .createDataNodesWithYangResourceXpathAndNodeData(yangResourceContentPerName, xpath, targetData, JSON); - } - private void processDataNodeUpdate(final Anchor anchor, final DataNode dataNodeUpdate) { cpsDataPersistenceService.batchUpdateDataLeaves(anchor.getDataspaceName(), anchor.getName(), Collections.singletonMap(dataNodeUpdate.getXpath(), dataNodeUpdate.getLeaves())); diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java b/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java index d532001aec..650aa99b84 100644 --- a/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java +++ b/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java @@ -1,6 +1,6 @@ /* * ============LICENSE_START======================================================= - * Copyright (C) 2023 TechMahindra Ltd. + * Copyright (C) 2023-2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,9 @@ package org.onap.cps.impl; +import static org.onap.cps.utils.ContentType.JSON; + +import io.micrometer.core.annotation.Timed; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; @@ -28,19 +31,69 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.onap.cps.api.CpsAnchorService; +import org.onap.cps.api.CpsDataService; import org.onap.cps.api.CpsDeltaService; +import org.onap.cps.api.DataNodeFactory; +import org.onap.cps.api.model.Anchor; import org.onap.cps.api.model.DataNode; import org.onap.cps.api.model.DeltaReport; +import org.onap.cps.api.parameters.FetchDescendantsOption; +import org.onap.cps.utils.DataMapper; +import org.onap.cps.utils.JsonObjectMapper; import org.springframework.stereotype.Service; @Slf4j @Service +@RequiredArgsConstructor public class CpsDeltaServiceImpl implements CpsDeltaService { + private final CpsAnchorService cpsAnchorService; + private final CpsDataService cpsDataService; + private final DataNodeFactory dataNodeFactory; + private final DataMapper dataMapper; + private final JsonObjectMapper jsonObjectMapper; + @Override - public List getDeltaReports(final Collection sourceDataNodes, - final Collection targetDataNodes) { + @Timed(value = "cps.delta.service.get.delta", + description = "Time taken to get delta between anchors") + public List getDeltaByDataspaceAndAnchors(final String dataspaceName, + final String sourceAnchorName, + final String targetAnchorName, + final String xpath, + final FetchDescendantsOption fetchDescendantsOption) { + + final Collection sourceDataNodes = cpsDataService.getDataNodesForMultipleXpaths(dataspaceName, + sourceAnchorName, Collections.singletonList(xpath), fetchDescendantsOption); + final Collection targetDataNodes = cpsDataService.getDataNodesForMultipleXpaths(dataspaceName, + targetAnchorName, Collections.singletonList(xpath), fetchDescendantsOption); + return getDeltaReports(sourceDataNodes, targetDataNodes); + } + + @Timed(value = "cps.delta.service.get.delta", + description = "Time taken to get delta between anchor and a payload") + @Override + public List getDeltaByDataspaceAnchorAndPayload(final String dataspaceName, + final String sourceAnchorName, + final String xpath, + final Map yangResourceContentPerName, + final String targetData, + final FetchDescendantsOption fetchDescendantsOption) { + + final Anchor sourceAnchor = cpsAnchorService.getAnchor(dataspaceName, sourceAnchorName); + final Collection sourceDataNodes = cpsDataService.getDataNodesForMultipleXpaths(dataspaceName, + sourceAnchorName, Collections.singletonList(xpath), fetchDescendantsOption); + final Collection sourceDataNodesRebuilt = + rebuildSourceDataNodes(xpath, sourceAnchor, sourceDataNodes); + final Collection targetDataNodes = new ArrayList<>( + buildTargetDataNodes(sourceAnchor, xpath, yangResourceContentPerName, targetData)); + return getDeltaReports(sourceDataNodesRebuilt, targetDataNodes); + } + + private List getDeltaReports(final Collection sourceDataNodes, + final Collection targetDataNodes) { final List deltaReport = new ArrayList<>(); @@ -48,7 +101,6 @@ public class CpsDeltaServiceImpl implements CpsDeltaService { final Map xpathToTargetDataNodes = convertToXPathToDataNodesMap(targetDataNodes); deltaReport.addAll(getRemovedAndUpdatedDeltaReports(xpathToSourceDataNodes, xpathToTargetDataNodes)); - deltaReport.addAll(getAddedDeltaReports(xpathToSourceDataNodes, xpathToTargetDataNodes)); return Collections.unmodifiableList(deltaReport); @@ -165,7 +217,6 @@ public class CpsDeltaServiceImpl implements CpsDeltaService { } } else if (sourceLeaf == null) { targetDataInDeltaReport.put(key, targetLeaf); - } else { sourceDataInDeltaReport.put(key, sourceLeaf); } @@ -199,4 +250,30 @@ public class CpsDeltaServiceImpl implements CpsDeltaService { } return addedDeltaReportEntries; } + + private Collection rebuildSourceDataNodes(final String xpath, + final Anchor sourceAnchor, + final Collection sourceDataNodes) { + final Collection sourceDataNodesRebuilt = new ArrayList<>(); + if (sourceDataNodes != null) { + final Map sourceDataNodesAsMap = dataMapper.toFlatDataMap(sourceAnchor, sourceDataNodes); + final String sourceDataNodesAsJson = jsonObjectMapper.asJsonString(sourceDataNodesAsMap); + final Collection dataNodes = dataNodeFactory + .createDataNodesWithAnchorXpathAndNodeData(sourceAnchor, xpath, sourceDataNodesAsJson, JSON); + sourceDataNodesRebuilt.addAll(dataNodes); + } + return sourceDataNodesRebuilt; + } + + private Collection buildTargetDataNodes(final Anchor sourceAnchor, final String xpath, + final Map yangResourceContentPerName, + final String targetData) { + if (yangResourceContentPerName.isEmpty()) { + return dataNodeFactory + .createDataNodesWithAnchorXpathAndNodeData(sourceAnchor, xpath, targetData, JSON); + } else { + return dataNodeFactory + .createDataNodesWithYangResourceXpathAndNodeData(yangResourceContentPerName, xpath, targetData, JSON); + } + } } diff --git a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDataServiceImplSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDataServiceImplSpec.groovy index a4bfd569c5..3984743710 100644 --- a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDataServiceImplSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDataServiceImplSpec.groovy @@ -26,10 +26,8 @@ package org.onap.cps.impl import ch.qos.logback.classic.Level import ch.qos.logback.classic.Logger import ch.qos.logback.core.read.ListAppender -import com.fasterxml.jackson.databind.ObjectMapper import org.onap.cps.TestUtils import org.onap.cps.api.CpsAnchorService -import org.onap.cps.api.CpsDeltaService import org.onap.cps.api.exceptions.ConcurrencyException import org.onap.cps.api.exceptions.DataNodeNotFoundExceptionBatch import org.onap.cps.api.exceptions.DataValidationException @@ -41,9 +39,6 @@ import org.onap.cps.events.CpsDataUpdateEventsProducer import org.onap.cps.spi.CpsDataPersistenceService import org.onap.cps.utils.ContentType import org.onap.cps.utils.CpsValidator -import org.onap.cps.utils.DataMapper -import org.onap.cps.utils.JsonObjectMapper -import org.onap.cps.utils.PrefixResolver import org.onap.cps.utils.YangParser import org.onap.cps.utils.YangParserHelper import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder @@ -65,15 +60,11 @@ class CpsDataServiceImplSpec extends Specification { def mockCpsValidator = Mock(CpsValidator) def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder) def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder) - def mockCpsDeltaService = Mock(CpsDeltaService); def mockCpsDataUpdateEventsProducer = Mock(CpsDataUpdateEventsProducer) - def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) - def mockPrefixResolver = Mock(PrefixResolver) - def dataMapper = new DataMapper(mockCpsAnchorService, mockPrefixResolver) def dataNodeFactory = new DataNodeFactoryImpl(yangParser) def objectUnderTest = new CpsDataServiceImpl(mockCpsDataPersistenceService, mockCpsDataUpdateEventsProducer, mockCpsAnchorService, - dataNodeFactory, mockCpsValidator, yangParser, mockCpsDeltaService, dataMapper, jsonObjectMapper) + dataNodeFactory, mockCpsValidator, yangParser) def logger = (Logger) LoggerFactory.getLogger(objectUnderTest.class) def loggingListAppender @@ -236,76 +227,6 @@ class CpsDataServiceImplSpec extends Specification { fetchDescendantsOption << [FetchDescendantsOption.OMIT_DESCENDANTS, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS] } - def 'Get delta between 2 anchors'() { - given: 'some xpath, source and target data nodes' - def xpath = '/xpath' - def sourceDataNodes = [new DataNodeBuilder().withXpath(xpath).build()] - def targetDataNodes = [new DataNodeBuilder().withXpath(xpath).build()] - when: 'attempt to get delta between 2 anchors' - objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, ANCHOR_NAME_1, ANCHOR_NAME_2, xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) - then: 'the dataspace and anchor names are validated' - 2 * mockCpsValidator.validateNameCharacters(_) - and: 'data nodes are fetched using appropriate persistence layer method' - mockCpsDataPersistenceService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes - mockCpsDataPersistenceService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_2, [xpath], FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> targetDataNodes - and: 'appropriate delta service method is invoked once with correct source and target data nodes' - 1 * mockCpsDeltaService.getDeltaReports(sourceDataNodes, targetDataNodes) - } - - def 'Get delta between anchor and payload with user provided schema #scenario'() { - given: 'user provided schema set ' - def yangResourceContentPerName = TestUtils.getYangResourcesAsMap('bookstore.yang') - setupSchemaSetMocksForDelta(yangResourceContentPerName) - when: 'attempt to get delta between an anchor and a JSON payload' - objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, yangResourceContentPerName, jsonData, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) - then: 'dataspacename and anchor names are validated' - 1 * mockCpsValidator.validateNameCharacters(['some-dataspace', 'some-anchor']) - and: 'source data nodes are fetched using appropriate persistence layer method' - 1 * mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes - and: 'appropriate delta service method is invoked once with correct source and target data nodes' - 1 * mockCpsDeltaService.getDeltaReports({sourceDataNodesRebuilt -> sourceDataNodesRebuilt.xpath[0] == expectedNodeXpath}, {targetDataNodes -> targetDataNodes.xpath[0] == expectedNodeXpath}) - where: 'following data was used' - scenario | xpath | sourceDataNodes | jsonData || expectedNodeXpath - 'root node xpath' | '/' | [new DataNodeBuilder().withXpath('/bookstore').build()] | '{"bookstore":{"bookstore-name":"Easons"}}' || '/bookstore' - 'parent xpath' | '/bookstore' | [new DataNodeBuilder().withXpath('/bookstore').build()] | '{"bookstore":{"bookstore-name":"Easons"}}' || '/bookstore' - 'non-root xpath' | '/bookstore/categories[@code="02"]' | [new DataNodeBuilder().withXpath('/bookstore/categories[@code="02"]').withLeaves(["code":"02"]).build()] | '{"categories":[{"name":"kids","code":"02"}]}' || '/bookstore/categories[@code=\'02\']' - } - - def 'Get delta between anchor and payload by using schema from anchor #scenario'() { - given: 'schema set for a given dataspace and anchor' - setupSchemaSetMocks("bookstore.yang") - when: 'attempt to get delta between an anchor and a JSON payload' - objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, [:], jsonData, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) - then: 'dataspacename and anchor names are validated' - 1 * mockCpsValidator.validateNameCharacters(['some-dataspace', 'some-anchor']) - and: 'source data nodes are fetched using appropriate persistence layer method' - 1 * mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes - and: 'appropriate delta service method is invoked once with correct source and target data nodes' - 1 * mockCpsDeltaService.getDeltaReports({sourceDataNodesRebuilt -> sourceDataNodesRebuilt.xpath[0] == expectedNodeXpath}, {targetDataNodes -> targetDataNodes.xpath[0] == expectedNodeXpath}) - where: 'following data was used' - scenario | xpath | sourceDataNodes | jsonData || expectedNodeXpath - 'root node xpath' | '/' | [new DataNodeBuilder().withXpath('/bookstore').build()] | '{"bookstore":{"bookstore-name":"Easons"}}' || '/bookstore' - 'parent xpath' | '/bookstore' | [new DataNodeBuilder().withXpath('/bookstore').build()] | '{"bookstore":{"bookstore-name":"Easons"}}' || '/bookstore' - 'non-root xpath' | '/bookstore/categories[@code="02"]' | [new DataNodeBuilder().withXpath('/bookstore/categories[@code="02"]').withLeaves(["code":"02"]).build()] | '{"categories":[{"name":"kids","code":"02"}]}' || '/bookstore/categories[@code=\'02\']' - } - - def 'Delta between anchor and payload error scenario #scenario'() { - given: 'schema set for given anchor and dataspace references bookstore model' - def yangResourceContentPerName = TestUtils.getYangResourcesAsMap('bookstore.yang') - setupSchemaSetMocksForDelta(yangResourceContentPerName) - when: 'attempt to get delta between anchor and payload' - objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, anchorName, xpath, yangResourceContentPerName, jsonData, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) - then: 'expected exception is thrown' - thrown(DataValidationException) - where: 'following parameters were used' - scenario | xpath | jsonData - 'invalid json data with root node xpath' | '/' | '{"some-key": "some-value"' - 'empty json data with root node xpath' | '/' | '{}' - 'invalid json data with parent node xpath' | '/bookstore' | '{"some-key": "some-value"' - 'empty json data with parent node xpath' | '/bookstore' | '{}' - 'empty json data with xpath' | "/bookstore/categories[@code='02']" | '{}' - } - def 'Update data node leaves: #scenario.'() { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('test-tree.yang') @@ -652,6 +573,7 @@ class CpsDataServiceImplSpec extends Specification { def logs = loggingListAppender.list.toString() assert logs.contains('Failed to send message to notification service') } + def setupSchemaSetMocks(String... yangResources) { def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet @@ -660,12 +582,4 @@ class CpsDataServiceImplSpec extends Specification { mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext } - def setupSchemaSetMocksForDelta(Map yangResourceContentPerName) { - def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) - mockTimedYangTextSchemaSourceSetBuilder.getYangTextSchemaSourceSet(yangResourceContentPerName) >> mockYangTextSchemaSourceSet - mockYangTextSchemaSourceSetCache.get(_, _) >> mockYangTextSchemaSourceSet - def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceContentPerName).getSchemaContext() - mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext - } - } diff --git a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy index d3dfcf8826..a1bfbb06c9 100644 --- a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy @@ -1,6 +1,6 @@ /* * ============LICENSE_START======================================================= - * Copyright (C) 2023 TechMahindra Ltd. + * Copyright (C) 2023-2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,13 +20,50 @@ package org.onap.cps.impl +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.core.read.ListAppender +import com.fasterxml.jackson.databind.ObjectMapper +import org.onap.cps.TestUtils +import org.onap.cps.api.CpsAnchorService +import org.onap.cps.api.CpsDataService +import org.onap.cps.api.exceptions.DataValidationException +import org.onap.cps.api.model.Anchor import org.onap.cps.api.model.DataNode +import org.onap.cps.api.parameters.FetchDescendantsOption +import org.onap.cps.utils.ContentType +import org.onap.cps.utils.DataMapper +import org.onap.cps.utils.JsonObjectMapper +import org.onap.cps.utils.PrefixResolver +import org.onap.cps.utils.YangParser +import org.onap.cps.utils.YangParserHelper +import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder +import org.onap.cps.yang.YangTextSchemaSourceSet +import org.onap.cps.yang.YangTextSchemaSourceSetBuilder +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import spock.lang.Shared import spock.lang.Specification -class CpsDeltaServiceImplSpec extends Specification{ +class CpsDeltaServiceImplSpec extends Specification { - def objectUnderTest = new CpsDeltaServiceImpl() + def mockCpsAnchorService = Mock(CpsAnchorService) + def mockCpsDataService = Mock(CpsDataService) + def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache) + def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder) + def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder) + def dataNodeFactory = new DataNodeFactoryImpl(yangParser) + def mockPrefixResolver = Mock(PrefixResolver) + def dataMapper = new DataMapper(mockCpsAnchorService, mockPrefixResolver) + def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) + def objectUnderTest = new CpsDeltaServiceImpl(mockCpsAnchorService, mockCpsDataService, dataNodeFactory, dataMapper, jsonObjectMapper) + static def bookstoreDataNodeWithParentXpath = [new DataNode(xpath: '/bookstore', leaves: ['bookstore-name': 'Easons'])] + static def bookstoreDataNodeWithChildXpath = [new DataNode(xpath: '/bookstore/categories[@code=\'02\']', leaves: ['code': '02', 'name': 'Kids'])] + static def bookstoreDataAsMapForParentNode = [bookstore: ['bookstore-name': 'Easons']] + static def bookstoreDataAsMapForChildNode = [categories: ['code': '02', 'name': 'Kids']] + static def bookstoreJsonForParentNode = '{"bookstore":{"bookstore-name":"My Store"}}' + static def bookstoreJsonForChildNode = '{"categories":[{"name":"Child","code":"02"}]}' static def sourceDataNodeWithLeafData = [new DataNode(xpath: '/parent', leaves: ['parent-leaf': 'parent-payload-in-source'])] static def sourceDataNodeWithoutLeafData = [new DataNode(xpath: '/parent')] @@ -35,72 +72,193 @@ class CpsDeltaServiceImplSpec extends Specification{ static def sourceDataNodeWithMultipleLeaves = [new DataNode(xpath: '/parent', leaves: ['leaf-1': 'leaf-1-in-source', 'leaf-2': 'leaf-2-in-source'])] static def targetDataNodeWithMultipleLeaves = [new DataNode(xpath: '/parent', leaves: ['leaf-1': 'leaf-1-in-target', 'leaf-2': 'leaf-2-in-target'])] - def 'Get delta between data nodes for REMOVED data'() { - when: 'attempt to get delta between 2 data nodes' - def result = objectUnderTest.getDeltaReports(sourceDataNodeWithLeafData, []) - then: 'the delta report contains expected "remove" action' - assert result[0].action.equals('remove') - and : 'the delta report contains the expected xpath' - assert result[0].xpath == '/parent' - and: 'the delta report contains expected source data' - assert result[0].sourceData == ['parent-leaf': 'parent-payload-in-source'] - and: 'the delta report contains no target data' - assert result[0].targetData == null + def logger = (Logger) LoggerFactory.getLogger(objectUnderTest.class) + def loggingListAppender + def applicationContext = new AnnotationConfigApplicationContext() + + @Shared + static def ANCHOR_NAME_1 = 'some-anchor-1' + static def ANCHOR_NAME_2 = 'some-anchor-2' + static def INCLUDE_ALL_DESCENDANTS = FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS + def dataspaceName = 'some-dataspace' + def schemaSetName = 'some-schema-set' + def anchor1 = Anchor.builder().name(ANCHOR_NAME_1).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build() + def anchor2 = Anchor.builder().name(ANCHOR_NAME_2).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build() + + def setup() { + mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_1) >> anchor1 + mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_2) >> anchor2 + logger.setLevel(Level.DEBUG) + loggingListAppender = new ListAppender() + logger.addAppender(loggingListAppender) + loggingListAppender.start() + applicationContext.refresh() } - def 'Get delta between data nodes for ADDED data'() { - when: 'attempt to get delta between 2 data nodes' - def result = objectUnderTest.getDeltaReports([], targetDataNodeWithLeafData) - then: 'the delta report contains expected "create" action' - assert result[0].action.equals('create') - and: 'the delta report contains expected xpath' - assert result[0].xpath == '/parent' - and: 'the delta report contains no source data' - assert result[0].sourceData == null - and: 'the delta report contains expected target data' - assert result[0].targetData == ['parent-leaf': 'parent-payload-in-target'] + void cleanup() { + ((Logger) LoggerFactory.getLogger(CpsDataServiceImpl.class)).detachAndStopAllAppenders() + applicationContext.close() } - def 'Delta Report between leaves for parent and child nodes'() { - given: 'Two data nodes' + def 'Get Delta between 2 anchors for #scenario'() { + given: 'xpath to get delta' + def xpath = '/' + when: 'attempt to get delta between 2 anchors' + def deltaReport = objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, ANCHOR_NAME_1, ANCHOR_NAME_2, xpath, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes + and: 'cps data service is invoked again to return target data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_2, [xpath], INCLUDE_ALL_DESCENDANTS) >> targetDataNodes + and: 'the delta report contains the expected information' + deltaReport.size() == 1 + deltaReport[0].action.equals(expectedAction) + deltaReport[0].xpath.equals('/parent') + deltaReport[0].sourceData == expectedSourceData + deltaReport[0].targetData == expectedTargetData + where: 'following data was used' + scenario | sourceDataNodes | targetDataNodes || expectedAction | expectedSourceData | expectedTargetData + 'Data node is added' | [] | targetDataNodeWithLeafData || 'create' | null | ['parent-leaf': 'parent-payload-in-target'] + 'Data node is removed' | sourceDataNodeWithLeafData | [] || 'remove' | ['parent-leaf': 'parent-payload-in-source'] | null + 'Data node is updated' | sourceDataNodeWithLeafData | targetDataNodeWithLeafData || 'replace' | ['parent-leaf': 'parent-payload-in-source'] |['parent-leaf': 'parent-payload-in-target'] + } + + def 'Delta Report between parent nodes containing child nodes'() { + given: 'Two data nodes and xpath' + def xpath = '/' def sourceDataNode = [new DataNode(xpath: '/parent', leaves: ['parent-leaf': 'parent-payload'], childDataNodes: [new DataNode(xpath: '/parent/child', leaves: ['child-leaf': 'child-payload'])])] def targetDataNode = [new DataNode(xpath: '/parent', leaves: ['parent-leaf': 'parent-payload-updated'], childDataNodes: [new DataNode(xpath: '/parent/child', leaves: ['child-leaf': 'child-payload-updated'])])] - when: 'attempt to get delta between 2 data nodes' - def result = objectUnderTest.getDeltaReports(sourceDataNode, targetDataNode) - then: 'the delta report contains expected details for parent node' - assert result[0].action.equals('replace') - assert result[0].xpath == '/parent' - assert result[0].sourceData == ['parent-leaf': 'parent-payload'] - assert result[0].targetData == ['parent-leaf': 'parent-payload-updated'] + when: 'attempt to get delta between 2 anchors' + def deltaReport = objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, ANCHOR_NAME_1, ANCHOR_NAME_2, xpath, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNode + and: 'cps data service is invoked again to return target data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_2, [xpath], INCLUDE_ALL_DESCENDANTS) >> targetDataNode + and: 'the delta report contains expected details for parent node' + assert deltaReport[0].action.equals('replace') + assert deltaReport[0].xpath == '/parent' + assert deltaReport[0].sourceData == ['parent-leaf': 'parent-payload'] + assert deltaReport[0].targetData == ['parent-leaf': 'parent-payload-updated'] and: 'the delta report contains expected details for child node' - assert result[1].action.equals('replace') - assert result[1].xpath == '/parent/child' - assert result[1].sourceData == ['child-leaf': 'child-payload'] - assert result[1].targetData == ['child-leaf': 'child-payload-updated'] + assert deltaReport[1].action.equals('replace') + assert deltaReport[1].xpath == '/parent/child' + assert deltaReport[1].sourceData == ['child-leaf': 'child-payload'] + assert deltaReport[1].targetData == ['child-leaf': 'child-payload-updated'] } def 'Delta report between leaves, #scenario'() { - when: 'attempt to get delta between 2 data nodes' - def result = objectUnderTest.getDeltaReports(sourceDataNode, targetDataNode) - then: 'the delta report contains expected "replace" action' - assert result[0].action.equals('replace') - and: 'the delta report contains expected xpath' - assert result[0].xpath == '/parent' - and: 'the delta report contains expected source and target data' - assert result[0].sourceData == expectedSourceData - assert result[0].targetData == expectedTargetData - where: 'the following data was used' - scenario | sourceDataNode | targetDataNode || expectedSourceData | expectedTargetData - 'source and target data nodes have leaves' | sourceDataNodeWithLeafData | targetDataNodeWithLeafData || ['parent-leaf': 'parent-payload-in-source'] | ['parent-leaf': 'parent-payload-in-target'] - 'only source data node has leaves' | sourceDataNodeWithLeafData | targetDataNodeWithoutLeafData || ['parent-leaf': 'parent-payload-in-source'] | null - 'only target data node has leaves' | sourceDataNodeWithoutLeafData | targetDataNodeWithLeafData || null | ['parent-leaf': 'parent-payload-in-target'] - 'source and target dsta node with multiple leaves' | sourceDataNodeWithMultipleLeaves | targetDataNodeWithMultipleLeaves || ['leaf-1': 'leaf-1-in-source', 'leaf-2': 'leaf-2-in-source'] | ['leaf-1': 'leaf-1-in-target', 'leaf-2': 'leaf-2-in-target'] + given: 'xpath to fetch delta between two anchors' + def xpath = '/' + when: 'attempt to get delta between 2 anchors' + def deltaReport = objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, ANCHOR_NAME_1, ANCHOR_NAME_2, xpath, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNode + and: 'cps data service is invoked again to return target data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_2, [xpath], INCLUDE_ALL_DESCENDANTS) >> targetDataNode + and: 'the delta report contains expected "replace" action' + assert deltaReport[0].action.equals('replace') + and: 'the delta report contains expected xpath' + assert deltaReport[0].xpath == '/parent' + and: 'the delta report contains expected source and target data' + assert deltaReport[0].sourceData == expectedSourceData + assert deltaReport[0].targetData == expectedTargetData + where: 'the following data was used' + scenario | sourceDataNode | targetDataNode || expectedSourceData | expectedTargetData + 'source and target data nodes have leaves' | sourceDataNodeWithLeafData | targetDataNodeWithLeafData || ['parent-leaf': 'parent-payload-in-source'] | ['parent-leaf': 'parent-payload-in-target'] + 'only source data node has leaves' | sourceDataNodeWithLeafData | targetDataNodeWithoutLeafData || ['parent-leaf': 'parent-payload-in-source'] | null + 'only target data node has leaves' | sourceDataNodeWithoutLeafData | targetDataNodeWithLeafData || null | ['parent-leaf': 'parent-payload-in-target'] + 'source and target dsta node with multiple leaves' | sourceDataNodeWithMultipleLeaves | targetDataNodeWithMultipleLeaves || ['leaf-1': 'leaf-1-in-source', 'leaf-2': 'leaf-2-in-source'] | ['leaf-1': 'leaf-1-in-target', 'leaf-2': 'leaf-2-in-target'] } def 'Get delta between data nodes for updated data, where source and target data nodes have no leaves '() { + given: 'xpath to get delta between anchors' + def xpath = '/' when: 'attempt to get delta between 2 data nodes' - def result = objectUnderTest.getDeltaReports(sourceDataNodeWithoutLeafData, targetDataNodeWithoutLeafData) + def deltaReport = objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, ANCHOR_NAME_1, ANCHOR_NAME_2, xpath, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNodeWithoutLeafData + and: 'cps data service is invoked again to return target data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_2, [xpath], INCLUDE_ALL_DESCENDANTS) >> targetDataNodeWithoutLeafData then: 'the delta report is empty' - assert result.isEmpty() + assert deltaReport.isEmpty() + } + + def 'Get delta between anchor and payload with user provided schema #scenario'() { + given: 'user provided schema set ' + def yangResourceContentPerName = TestUtils.getYangResourcesAsMap('bookstore.yang') + setupSchemaSetMocksForDelta(yangResourceContentPerName) + when: 'attempt to get delta between an anchor and a JSON payload' + def deltaReport = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, ANCHOR_NAME_1, xpath, yangResourceContentPerName, jsonData, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes + and: 'source data nodes are rebuilt (to match the data type with target data nodes)' + dataNodeFactory.createDataNodesWithAnchorXpathAndNodeData(anchor1, xpath, jsonObjectMapper.asJsonString(sourceDataNodesAsMap), ContentType.JSON) + and: 'data node factory method is invoked to build target data nodes using user provided schema' + dataNodeFactory.createDataNodesWithYangResourceXpathAndNodeData(yangResourceContentPerName, xpath, jsonData, ContentType.JSON) + and: 'delta report contains expected xpath, action, source and target data' + deltaReport[0].getXpath() == expectedNodeXpath + deltaReport[0].getAction().equals('replace') + deltaReport[0].getSourceData().equals(expectedSourceData) + deltaReport[0].getTargetData().equals(expectedTargetData) + where: 'following data was used' + scenario | xpath | sourceDataNodes | sourceDataNodesAsMap | jsonData || expectedNodeXpath | expectedSourceData | expectedTargetData + 'root node xpath' | '/' | bookstoreDataNodeWithParentXpath | bookstoreDataAsMapForParentNode | bookstoreJsonForParentNode || '/bookstore' | ['bookstore-name':'Easons'] | ['bookstore-name':'My Store'] + 'parent xpath' | '/bookstore' | bookstoreDataNodeWithParentXpath | bookstoreDataAsMapForParentNode | bookstoreJsonForParentNode || '/bookstore' | ['bookstore-name':'Easons'] | ['bookstore-name':'My Store'] + 'non-root xpath' | '/bookstore/categories[@code=\'02\']' | bookstoreDataNodeWithChildXpath | bookstoreDataAsMapForChildNode | bookstoreJsonForChildNode || '/bookstore/categories[@code=\'02\']'| ['name':'Kids'] | ['name':'Child'] + } + + def 'Get delta between anchor and payload by using schema from anchor #scenario'() { + given: 'schema set for a given dataspace and anchor' + setupSchemaSetMocks('bookstore.yang') + when: 'attempt to get delta between an anchor and a JSON payload' + def deltaReport = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, ANCHOR_NAME_1, xpath, [:], jsonData, INCLUDE_ALL_DESCENDANTS) + then: 'cps data service is invoked and returns source data nodes' + mockCpsDataService.getDataNodesForMultipleXpaths(dataspaceName, ANCHOR_NAME_1, [xpath], INCLUDE_ALL_DESCENDANTS) >> sourceDataNodes + and: 'source data nodes are rebuilt (to match the data type with target data nodes)' + dataNodeFactory.createDataNodesWithAnchorXpathAndNodeData(anchor1, xpath, jsonObjectMapper.asJsonString(sourceDataNodesAsMap), ContentType.JSON) + and: 'data node factory method is invoked to build target data nodes using schema details fetched from anchor name' + dataNodeFactory.createDataNodesWithAnchorXpathAndNodeData(anchor1, xpath, jsonData, ContentType.JSON) + and: 'delta report contains expected xpath, action, source and target data' + deltaReport[0].getXpath() == expectedNodeXpath + deltaReport[0].getAction().equals('replace') + deltaReport[0].getSourceData().equals(expectedSourceData) + deltaReport[0].getTargetData().equals(expectedTargetData) + where: 'following data was used' + scenario | xpath | sourceDataNodes | sourceDataNodesAsMap | jsonData || expectedNodeXpath | expectedSourceData | expectedTargetData + 'root node xpath' | '/' | bookstoreDataNodeWithParentXpath | bookstoreDataAsMapForParentNode | bookstoreJsonForParentNode || '/bookstore' | ['bookstore-name':'Easons'] | ['bookstore-name':'My Store'] + 'parent xpath' | '/bookstore' | bookstoreDataNodeWithParentXpath | bookstoreDataAsMapForParentNode | bookstoreJsonForParentNode || '/bookstore' | ['bookstore-name':'Easons'] | ['bookstore-name':'My Store'] + 'non-root xpath' | '/bookstore/categories[@code=\'02\']' | bookstoreDataNodeWithChildXpath | bookstoreDataAsMapForChildNode | bookstoreJsonForChildNode || '/bookstore/categories[@code=\'02\']' | ['name':'Kids'] | ['name':'Child'] + } + + def 'Delta between anchor and payload error scenario #scenario'() { + given: 'schema set for given anchor and dataspace references bookstore model' + def yangResourceContentPerName = TestUtils.getYangResourcesAsMap('bookstore.yang') + setupSchemaSetMocksForDelta(yangResourceContentPerName) + when: 'attempt to get delta between anchor and payload' + objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, ANCHOR_NAME_1, xpath, yangResourceContentPerName, jsonData, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) + then: 'expected exception is thrown' + thrown(DataValidationException) + where: 'following parameters were used' + scenario | xpath | jsonData + 'invalid json data with root node xpath' | '/' | '{"some-key": "some-value"' + 'empty json data with root node xpath' | '/' | '{}' + 'invalid json data with parent node xpath' | '/bookstore' | '{"some-key": "some-value"' + 'empty json data with parent node xpath' | '/bookstore' | '{}' + 'empty json data with xpath' | '/bookstore/categories[@code=\'02\']' | '{}' + } + + def setupSchemaSetMocks(String... yangResources) { + def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) + mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet + def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources) + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() + mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext + } + + def setupSchemaSetMocksForDelta(Map yangResourceContentPerName) { + def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) + mockTimedYangTextSchemaSourceSetBuilder.getYangTextSchemaSourceSet(yangResourceContentPerName) >> mockYangTextSchemaSourceSet + mockYangTextSchemaSourceSetCache.get(_, _) >> mockYangTextSchemaSourceSet + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceContentPerName).getSchemaContext() + mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext } } diff --git a/cps-service/src/test/groovy/org/onap/cps/impl/E2ENetworkSliceSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/impl/E2ENetworkSliceSpec.groovy index c9c1991c53..4713283c9b 100755 --- a/cps-service/src/test/groovy/org/onap/cps/impl/E2ENetworkSliceSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/impl/E2ENetworkSliceSpec.groovy @@ -23,19 +23,14 @@ package org.onap.cps.impl -import com.fasterxml.jackson.databind.ObjectMapper import org.onap.cps.TestUtils import org.onap.cps.api.CpsAnchorService -import org.onap.cps.api.CpsDeltaService import org.onap.cps.api.model.Anchor import org.onap.cps.events.CpsDataUpdateEventsProducer import org.onap.cps.spi.CpsDataPersistenceService import org.onap.cps.spi.CpsModulePersistenceService import org.onap.cps.utils.ContentType import org.onap.cps.utils.CpsValidator -import org.onap.cps.utils.DataMapper -import org.onap.cps.utils.JsonObjectMapper -import org.onap.cps.utils.PrefixResolver import org.onap.cps.utils.YangParser import org.onap.cps.utils.YangParserHelper import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder @@ -50,15 +45,10 @@ class E2ENetworkSliceSpec extends Specification { def mockCpsValidator = Mock(CpsValidator) def timedYangTextSchemaSourceSetBuilder = new TimedYangTextSchemaSourceSetBuilder() def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, timedYangTextSchemaSourceSetBuilder) - def mockCpsDeltaService = Mock(CpsDeltaService) - def dataMapper = new DataMapper(mockCpsAnchorService, Mock(PrefixResolver)) - def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) - def cpsModuleServiceImpl = new CpsModuleServiceImpl(mockCpsModulePersistenceService, mockYangTextSchemaSourceSetCache, mockCpsAnchorService, mockCpsValidator,timedYangTextSchemaSourceSetBuilder) - def mockCpsDataUpdateEventsProducer = Mock(CpsDataUpdateEventsProducer) def dataNodeFactory = new DataNodeFactoryImpl(yangParser) - def cpsDataServiceImpl = new CpsDataServiceImpl(mockCpsDataPersistenceService, mockCpsDataUpdateEventsProducer, mockCpsAnchorService, dataNodeFactory, mockCpsValidator, yangParser, mockCpsDeltaService, dataMapper, jsonObjectMapper) + def cpsDataServiceImpl = new CpsDataServiceImpl(mockCpsDataPersistenceService, mockCpsDataUpdateEventsProducer, mockCpsAnchorService, dataNodeFactory, mockCpsValidator, yangParser) def dataspaceName = 'someDataspace' def anchorName = 'someAnchor' def schemaSetName = 'someSchemaSet' diff --git a/integration-test/src/test/groovy/org/onap/cps/integration/base/CpsIntegrationSpecBase.groovy b/integration-test/src/test/groovy/org/onap/cps/integration/base/CpsIntegrationSpecBase.groovy index bb69f2f544..b432823158 100644 --- a/integration-test/src/test/groovy/org/onap/cps/integration/base/CpsIntegrationSpecBase.groovy +++ b/integration-test/src/test/groovy/org/onap/cps/integration/base/CpsIntegrationSpecBase.groovy @@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockWebServer import org.onap.cps.api.CpsAnchorService import org.onap.cps.api.CpsDataService import org.onap.cps.api.CpsDataspaceService +import org.onap.cps.api.CpsDeltaService import org.onap.cps.api.CpsModuleService import org.onap.cps.api.CpsQueryService import org.onap.cps.api.exceptions.DataspaceNotFoundException @@ -92,6 +93,9 @@ abstract class CpsIntegrationSpecBase extends Specification { @Autowired CpsDataService cpsDataService + @Autowired + CpsDeltaService cpsDeltaService + @Autowired CpsModuleService cpsModuleService diff --git a/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DataServiceIntegrationSpec.groovy b/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DataServiceIntegrationSpec.groovy index 4823d58af9..6ecc3a509c 100644 --- a/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DataServiceIntegrationSpec.groovy +++ b/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DataServiceIntegrationSpec.groovy @@ -26,13 +26,11 @@ import org.onap.cps.integration.base.FunctionalSpecBase import org.onap.cps.api.parameters.FetchDescendantsOption import org.onap.cps.api.exceptions.AlreadyDefinedException import org.onap.cps.api.exceptions.AnchorNotFoundException -import org.onap.cps.api.exceptions.CpsAdminException import org.onap.cps.api.exceptions.CpsPathException import org.onap.cps.api.exceptions.DataNodeNotFoundException import org.onap.cps.api.exceptions.DataNodeNotFoundExceptionBatch import org.onap.cps.api.exceptions.DataValidationException import org.onap.cps.api.exceptions.DataspaceNotFoundException -import org.onap.cps.api.model.DeltaReport import org.onap.cps.utils.ContentType import static org.onap.cps.api.parameters.FetchDescendantsOption.DIRECT_CHILDREN_ONLY @@ -523,191 +521,6 @@ class DataServiceIntegrationSpec extends FunctionalSpecBase { restoreBookstoreDataAnchor(2) } - def 'Get delta between 2 anchors'() { - when: 'attempt to get delta report between anchors' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, '/', OMIT_DESCENDANTS) - and: 'report is ordered based on xpath' - result = result.toList().sort { it.xpath } - then: 'delta report contains expected number of changes' - result.size() == 3 - and: 'delta report contains REPLACE action with expected xpath' - assert result[0].getAction() == 'replace' - assert result[0].getXpath() == '/bookstore' - and: 'delta report contains CREATE action with expected xpath' - assert result[1].getAction() == 'create' - assert result[1].getXpath() == "/bookstore-address[@bookstore-name='Crossword Bookstores']" - and: 'delta report contains REMOVE action with expected xpath' - assert result[2].getAction() == 'remove' - assert result[2].getXpath() == "/bookstore-address[@bookstore-name='Easons-1']" - } - - def 'Get delta between 2 anchors returns empty response when #scenario'() { - when: 'attempt to get delta report between anchors' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, targetAnchor, xpath, INCLUDE_ALL_DESCENDANTS) - then: 'delta report is empty' - assert result.isEmpty() - where: 'following data was used' - scenario | targetAnchor | xpath - 'anchors with identical data are queried' | BOOKSTORE_ANCHOR_4 | '/' - 'same anchor name is passed as parameter' | BOOKSTORE_ANCHOR_3 | '/' - 'non existing xpath' | BOOKSTORE_ANCHOR_5 | '/non-existing-xpath' - } - - def 'Get delta between anchors error scenario: #scenario'() { - when: 'attempt to get delta between anchors' - objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, sourceAnchor, targetAnchor, '/some-xpath', INCLUDE_ALL_DESCENDANTS) - then: 'expected exception is thrown' - thrown(expectedException) - where: 'following data was used' - scenario | dataspaceName | sourceAnchor | targetAnchor || expectedException - 'invalid dataspace name' | 'Invalid dataspace' | 'not-relevant' | 'not-relevant' || DataValidationException - 'invalid anchor 1 name' | FUNCTIONAL_TEST_DATASPACE_3 | 'invalid anchor' | 'not-relevant' || DataValidationException - 'invalid anchor 2 name' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | 'invalid anchor' || DataValidationException - 'non-existing dataspace' | 'non-existing' | 'not-relevant1' | 'not-relevant2' || DataspaceNotFoundException - 'non-existing dataspace with same anchor name' | 'non-existing' | 'not-relevant' | 'not-relevant' || DataspaceNotFoundException - 'non-existing anchor 1' | FUNCTIONAL_TEST_DATASPACE_3 | 'non-existing-anchor' | 'not-relevant' || AnchorNotFoundException - 'non-existing anchor 2' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | 'non-existing-anchor' || AnchorNotFoundException - } - - def 'Get delta between anchors for remove action, where source data node #scenario'() { - when: 'attempt to get delta between leaves of data nodes present in 2 anchors' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_5, BOOKSTORE_ANCHOR_3, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) - then: 'expected action is present in delta report' - assert result.get(0).getAction() == 'remove' - where: 'following data was used' - scenario | parentNodeXpath - 'has leaves and child nodes' | "/bookstore/categories[@code='6']" - 'has leaves only' | "/bookstore/categories[@code='5']/books[@title='Book 11']" - 'has child data node only' | "/bookstore/support-info/contact-emails" - 'is empty' | "/bookstore/container-without-leaves" - } - - def 'Get delta between anchors for "create" action, where target data node #scenario'() { - when: 'attempt to get delta between leaves of data nodes present in 2 anchors' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) - then: 'the expected action is present in delta report' - result.get(0).getAction() == 'create' - and: 'the expected xapth is present in delta report' - result.get(0).getXpath() == parentNodeXpath - where: 'following data was used' - scenario | parentNodeXpath - 'has leaves and child nodes' | "/bookstore/categories[@code='6']" - 'has leaves only' | "/bookstore/categories[@code='5']/books[@title='Book 11']" - 'has child data node only' | "/bookstore/support-info/contact-emails" - 'is empty' | "/bookstore/container-without-leaves" - } - - def 'Get delta between anchors when leaves of existing data nodes are updated,: #scenario'() { - when: 'attempt to get delta between leaves of existing data nodes' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, sourceAnchor, targetAnchor, xpath, OMIT_DESCENDANTS) - then: 'expected action is "replace"' - assert result[0].getAction() == 'replace' - and: 'the payload has expected leaf values' - def sourceData = result[0].getSourceData() - def targetData = result[0].getTargetData() - assert sourceData == expectedSourceValue - assert targetData == expectedTargetValue - where: 'following data was used' - scenario | sourceAnchor | targetAnchor | xpath || expectedSourceValue | expectedTargetValue - 'leaf is updated in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore' || ['bookstore-name': 'Easons-1'] | ['bookstore-name': 'Crossword Bookstores'] - 'leaf is removed in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | "/bookstore/categories[@code='5']/books[@title='Book 1']" || [price:1] | null - 'leaf is added in target anchor' | BOOKSTORE_ANCHOR_5 | BOOKSTORE_ANCHOR_3 | "/bookstore/categories[@code='5']/books[@title='Book 1']" || null | [price:1] - } - - def 'Get delta between anchors when child data nodes under existing parent data nodes are updated: #scenario'() { - when: 'attempt to get delta between leaves of existing data nodes' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, sourceAnchor, targetAnchor, xpath, DIRECT_CHILDREN_ONLY) - then: 'expected action is "replace"' - assert result[0].getAction() == 'replace' - and: 'the delta report has expected child node xpaths' - def deltaReportEntities = getDeltaReportEntities(result) - def childNodeXpathsInDeltaReport = deltaReportEntities.get('xpaths') - assert childNodeXpathsInDeltaReport.contains(expectedChildNodeXpath) - where: 'following data was used' - scenario | sourceAnchor | targetAnchor | xpath || expectedChildNodeXpath - 'source and target anchors have child data nodes' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore/premises' || '/bookstore/premises/addresses[@house-number=\'2\' and @street=\'Main Street\']' - 'removed child data nodes in target anchor' | BOOKSTORE_ANCHOR_5 | BOOKSTORE_ANCHOR_3 | '/bookstore' || '/bookstore/support-info' - 'added child data nodes in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore' || '/bookstore/support-info' - } - - def 'Get delta between anchors where source and target data nodes have leaves and child data nodes'() { - given: 'parent node xpath and expected data in delta report' - def parentNodeXpath = "/bookstore/categories[@code='1']" - def expectedSourceDataInParentNode = ['name':'Children'] - def expectedTargetDataInParentNode = ['name':'Kids'] - def expectedSourceDataInChildNode = [['lang' : 'English'],['price':20, 'editions':[1988, 2000]]] - def expectedTargetDataInChildNode = [['lang':'English/German'], ['price':200, 'editions':[1988, 2000, 2023]]] - when: 'attempt to get delta between leaves of existing data nodes' - def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) - def deltaReportEntities = getDeltaReportEntities(result) - then: 'expected action is "replace"' - assert result[0].getAction() == 'replace' - and: 'the payload has expected parent node xpath' - assert deltaReportEntities.get('xpaths').contains(parentNodeXpath) - and: 'delta report has expected source and target data' - assert deltaReportEntities.get('sourcePayload').contains(expectedSourceDataInParentNode) - assert deltaReportEntities.get('targetPayload').contains(expectedTargetDataInParentNode) - and: 'the delta report also has expected child node xpaths' - assert deltaReportEntities.get('xpaths').containsAll(["/bookstore/categories[@code='1']/books[@title='The Gruffalo']", "/bookstore/categories[@code='1']/books[@title='Matilda']"]) - and: 'the delta report also has expected source and target data of child nodes' - assert deltaReportEntities.get('sourcePayload').containsAll(expectedSourceDataInChildNode) - assert deltaReportEntities.get('targetPayload').containsAll(expectedTargetDataInChildNode) - } - - def 'Get delta between anchor and JSON payload'() { - when: 'attempt to get delta report between anchor and JSON payload' - def jsonPayload = "{\"book-store:bookstore\":{\"bookstore-name\":\"Crossword Bookstores\"},\"book-store:bookstore-address\":{\"address\":\"Bangalore, India\",\"postal-code\":\"560062\",\"bookstore-name\":\"Crossword Bookstores\"}}" - def result = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, '/', [:], jsonPayload, OMIT_DESCENDANTS) - then: 'delta report contains expected number of changes' - result.size() == 3 - and: 'delta report contains "replace" action with expected xpath' - assert result[0].getAction() == 'replace' - assert result[0].getXpath() == '/bookstore' - and: 'delta report contains "remove" action with expected xpath' - assert result[1].getAction() == 'remove' - assert result[1].getXpath() == "/bookstore-address[@bookstore-name='Easons-1']" - and: 'delta report contains "create" action with expected xpath' - assert result[2].getAction() == 'create' - assert result[2].getXpath() == "/bookstore-address[@bookstore-name='Crossword Bookstores']" - } - - def 'Get delta between anchor and payload returns empty response when JSON payload is identical to anchor data'() { - when: 'attempt to get delta report between anchor and JSON payload (replacing the string Easons with Easons-1 because the data in JSON file is modified, to append anchor number, during the setup process of the integration tests)' - def jsonPayload = readResourceDataFile('bookstore/bookstoreData.json').replace('Easons', 'Easons-1') - def result = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, '/', [:], jsonPayload, INCLUDE_ALL_DESCENDANTS) - then: 'delta report is empty' - assert result.isEmpty() - } - - def 'Get delta between anchor and payload error scenario: #scenario'() { - when: 'attempt to get delta between anchor and json payload' - objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, sourceAnchor, xpath, [:], jsonPayload, INCLUDE_ALL_DESCENDANTS) - then: 'expected exception is thrown' - thrown(expectedException) - where: 'following data was used' - scenario | dataspaceName | sourceAnchor | xpath | jsonPayload || expectedException - 'invalid dataspace name' | 'Invalid dataspace' | 'not-relevant' | '/' | '{some-json}' || DataValidationException - 'invalid anchor name' | FUNCTIONAL_TEST_DATASPACE_3 | 'invalid anchor' | '/' | '{some-json}' || DataValidationException - 'non-existing dataspace' | 'non-existing' | 'not-relevant' | '/' | '{some-json}' || DataspaceNotFoundException - 'non-existing anchor' | FUNCTIONAL_TEST_DATASPACE_3 | 'non-existing-anchor' | '/' | '{some-json}' || AnchorNotFoundException - 'empty json payload with root node xpath' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | '/' | '' || DataValidationException - 'empty json payload with non-root node xpath' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | '/bookstore' | '' || DataValidationException - } - - def getDeltaReportEntities(List deltaReport) { - def xpaths = [] - def action = [] - def sourcePayload = [] - def targetPayload = [] - deltaReport.each { - delta -> xpaths.add(delta.getXpath()) - action.add(delta.getAction()) - sourcePayload.add(delta.getSourceData()) - targetPayload.add(delta.getTargetData()) - } - return ['xpaths':xpaths, 'action':action, 'sourcePayload':sourcePayload, 'targetPayload':targetPayload] - } - def countDataNodesInBookstore() { return countDataNodesInTree(objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/bookstore', INCLUDE_ALL_DESCENDANTS)) } diff --git a/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DeltaServiceIntegrationSpec.groovy b/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DeltaServiceIntegrationSpec.groovy new file mode 100644 index 0000000000..691e71427c --- /dev/null +++ b/integration-test/src/test/groovy/org/onap/cps/integration/functional/cps/DeltaServiceIntegrationSpec.groovy @@ -0,0 +1,245 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 TechMahindra Ltd. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.integration.functional.cps + +import org.onap.cps.api.CpsDeltaService +import org.onap.cps.api.exceptions.AnchorNotFoundException +import org.onap.cps.api.exceptions.DataValidationException +import org.onap.cps.api.exceptions.DataspaceNotFoundException +import org.onap.cps.api.model.DeltaReport +import org.onap.cps.api.parameters.FetchDescendantsOption +import org.onap.cps.integration.base.FunctionalSpecBase + +class DeltaServiceIntegrationSpec extends FunctionalSpecBase { + CpsDeltaService objectUnderTest + def originalCountBookstoreChildNodes + def originalCountXmlBookstoreChildNodes + def originalCountBookstoreTopLevelListNodes + + static def INCLUDE_ALL_DESCENDANTS = FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS + static def OMIT_DESCENDANTS = FetchDescendantsOption.OMIT_DESCENDANTS + static def DIRECT_CHILDREN_ONLY = FetchDescendantsOption.DIRECT_CHILDREN_ONLY + + def setup() { + objectUnderTest = cpsDeltaService + originalCountBookstoreChildNodes = countDataNodesInBookstore() + originalCountBookstoreTopLevelListNodes = countTopLevelListDataNodesInBookstore() + originalCountXmlBookstoreChildNodes = countXmlDataNodesInBookstore() + } + + def 'Get delta between 2 anchors'() { + when: 'attempt to get delta report between anchors' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, '/', OMIT_DESCENDANTS) + and: 'report is ordered based on xpath' + result = result.toList().sort { it.xpath } + then: 'delta report contains expected number of changes' + result.size() == 3 + and: 'delta report contains REPLACE action with expected xpath' + assert result[0].getAction() == 'replace' + assert result[0].getXpath() == '/bookstore' + and: 'delta report contains CREATE action with expected xpath' + assert result[1].getAction() == 'create' + assert result[1].getXpath() == '/bookstore-address[@bookstore-name=\'Crossword Bookstores\']' + and: 'delta report contains REMOVE action with expected xpath' + assert result[2].getAction() == 'remove' + assert result[2].getXpath() == '/bookstore-address[@bookstore-name=\'Easons-1\']' + } + + def 'Get delta between 2 anchors returns empty response when #scenario'() { + when: 'attempt to get delta report between anchors' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, targetAnchor, xpath, INCLUDE_ALL_DESCENDANTS) + then: 'delta report is empty' + assert result.isEmpty() + where: 'following data was used' + scenario | targetAnchor | xpath + 'anchors with identical data are queried' | BOOKSTORE_ANCHOR_4 | '/' + 'same anchor name is passed as parameter' | BOOKSTORE_ANCHOR_3 | '/' + 'non existing xpath' | BOOKSTORE_ANCHOR_5 | '/non-existing-xpath' + } + + def 'Get delta between anchors error scenario: #scenario'() { + when: 'attempt to get delta between anchors' + objectUnderTest.getDeltaByDataspaceAndAnchors(dataspaceName, sourceAnchor, targetAnchor, '/some-xpath', INCLUDE_ALL_DESCENDANTS) + then: 'expected exception is thrown' + thrown(expectedException) + where: 'following data was used' + scenario | dataspaceName | sourceAnchor | targetAnchor || expectedException + 'invalid dataspace name' | 'Invalid dataspace' | 'not-relevant' | 'not-relevant' || DataValidationException + 'invalid anchor 1 name' | FUNCTIONAL_TEST_DATASPACE_3 | 'invalid anchor' | 'not-relevant' || DataValidationException + 'invalid anchor 2 name' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | 'invalid anchor' || DataValidationException + 'non-existing dataspace' | 'non-existing' | 'not-relevant1' | 'not-relevant2' || DataspaceNotFoundException + 'non-existing dataspace with same anchor name' | 'non-existing' | 'not-relevant' | 'not-relevant' || DataspaceNotFoundException + 'non-existing anchor 1' | FUNCTIONAL_TEST_DATASPACE_3 | 'non-existing-anchor' | 'not-relevant' || AnchorNotFoundException + 'non-existing anchor 2' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | 'non-existing-anchor' || AnchorNotFoundException + } + + def 'Get delta between anchors for remove action, where source data node #scenario'() { + when: 'attempt to get delta between leaves of data nodes present in 2 anchors' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_5, BOOKSTORE_ANCHOR_3, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) + then: 'expected action is present in delta report' + assert result.get(0).getAction() == 'remove' + where: 'following data was used' + scenario | parentNodeXpath + 'has leaves and child nodes' | '/bookstore/categories[@code=\'6\']' + 'has leaves only' | '/bookstore/categories[@code=\'5\']/books[@title=\'Book 11\']' + 'has child data node only' | '/bookstore/support-info/contact-emails' + 'is empty' | '/bookstore/container-without-leaves' + } + + def 'Get delta between anchors for "create" action, where target data node #scenario'() { + when: 'attempt to get delta between leaves of data nodes present in 2 anchors' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) + then: 'the expected action is present in delta report' + result.get(0).getAction() == 'create' + and: 'the expected xapth is present in delta report' + result.get(0).getXpath() == parentNodeXpath + where: 'following data was used' + scenario | parentNodeXpath + 'has leaves and child nodes' | '/bookstore/categories[@code=\'6\']' + 'has leaves only' | '/bookstore/categories[@code=\'5\']/books[@title=\'Book 11\']' + 'has child data node only' | '/bookstore/support-info/contact-emails' + 'is empty' | '/bookstore/container-without-leaves' + } + + def 'Get delta between anchors when leaves of existing data nodes are updated,: #scenario'() { + when: 'attempt to get delta between leaves of existing data nodes' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, sourceAnchor, targetAnchor, xpath, OMIT_DESCENDANTS) + then: 'expected action is "replace"' + assert result[0].getAction() == 'replace' + and: 'the payload has expected leaf values' + def sourceData = result[0].getSourceData() + def targetData = result[0].getTargetData() + assert sourceData == expectedSourceValue + assert targetData == expectedTargetValue + where: 'following data was used' + scenario | sourceAnchor | targetAnchor | xpath || expectedSourceValue | expectedTargetValue + 'leaf is updated in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore' || ['bookstore-name': 'Easons-1'] | ['bookstore-name': 'Crossword Bookstores'] + 'leaf is removed in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore/categories[@code=\'5\']/books[@title=\'Book 1\']' || [price:1] | null + 'leaf is added in target anchor' | BOOKSTORE_ANCHOR_5 | BOOKSTORE_ANCHOR_3 | '/bookstore/categories[@code=\'5\']/books[@title=\'Book 1\']' || null | [price:1] + } + + def 'Get delta between anchors when child data nodes under existing parent data nodes are updated: #scenario'() { + when: 'attempt to get delta between leaves of existing data nodes' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, sourceAnchor, targetAnchor, xpath, DIRECT_CHILDREN_ONLY) + then: 'expected action is "replace"' + assert result[0].getAction() == 'replace' + and: 'the delta report has expected child node xpaths' + def deltaReportEntities = getDeltaReportEntities(result) + def childNodeXpathsInDeltaReport = deltaReportEntities.get('xpaths') + assert childNodeXpathsInDeltaReport.contains(expectedChildNodeXpath) + where: 'following data was used' + scenario | sourceAnchor | targetAnchor | xpath || expectedChildNodeXpath + 'source and target anchors have child data nodes' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore/premises' || '/bookstore/premises/addresses[@house-number=\'2\' and @street=\'Main Street\']' + 'removed child data nodes in target anchor' | BOOKSTORE_ANCHOR_5 | BOOKSTORE_ANCHOR_3 | '/bookstore' || '/bookstore/support-info' + 'added child data nodes in target anchor' | BOOKSTORE_ANCHOR_3 | BOOKSTORE_ANCHOR_5 | '/bookstore' || '/bookstore/support-info' + } + + def 'Get delta between anchors where source and target data nodes have leaves and child data nodes'() { + given: 'parent node xpath and expected data in delta report' + def parentNodeXpath = '/bookstore/categories[@code=\'1\']' + def expectedSourceDataInParentNode = ['name':'Children'] + def expectedTargetDataInParentNode = ['name':'Kids'] + def expectedSourceDataInChildNode = [['lang' : 'English'],['price':20, 'editions':[1988, 2000]]] + def expectedTargetDataInChildNode = [['lang':'English/German'], ['price':200, 'editions':[1988, 2000, 2023]]] + when: 'attempt to get delta between leaves of existing data nodes' + def result = objectUnderTest.getDeltaByDataspaceAndAnchors(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, BOOKSTORE_ANCHOR_5, parentNodeXpath, INCLUDE_ALL_DESCENDANTS) + def deltaReportEntities = getDeltaReportEntities(result) + then: 'expected action is "replace"' + assert result[0].getAction() == 'replace' + and: 'the payload has expected parent node xpath' + assert deltaReportEntities.get('xpaths').contains(parentNodeXpath) + and: 'delta report has expected source and target data' + assert deltaReportEntities.get('sourcePayload').contains(expectedSourceDataInParentNode) + assert deltaReportEntities.get('targetPayload').contains(expectedTargetDataInParentNode) + and: 'the delta report also has expected child node xpaths' + assert deltaReportEntities.get('xpaths').containsAll(['/bookstore/categories[@code=\'1\']/books[@title=\'The Gruffalo\']', '/bookstore/categories[@code=\'1\']/books[@title=\'Matilda\']']) + and: 'the delta report also has expected source and target data of child nodes' + assert deltaReportEntities.get('sourcePayload').containsAll(expectedSourceDataInChildNode) + assert deltaReportEntities.get('targetPayload').containsAll(expectedTargetDataInChildNode) + } + + def 'Get delta between anchor and JSON payload'() { + when: 'attempt to get delta report between anchor and JSON payload' + def jsonPayload = '{\"book-store:bookstore\":{\"bookstore-name\":\"Crossword Bookstores\"},\"book-store:bookstore-address\":{\"address\":\"Bangalore, India\",\"postal-code\":\"560062\",\"bookstore-name\":\"Crossword Bookstores\"}}' + def result = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, '/', [:], jsonPayload, OMIT_DESCENDANTS) + then: 'delta report contains expected number of changes' + result.size() == 3 + and: 'delta report contains "replace" action with expected xpath' + assert result[0].getAction() == 'replace' + assert result[0].getXpath() == '/bookstore' + and: 'delta report contains "remove" action with expected xpath' + assert result[1].getAction() == 'remove' + assert result[1].getXpath() == '/bookstore-address[@bookstore-name=\'Easons-1\']' + and: 'delta report contains "create" action with expected xpath' + assert result[2].getAction() == 'create' + assert result[2].getXpath() == '/bookstore-address[@bookstore-name=\'Crossword Bookstores\']' + } + + def 'Get delta between anchor and payload returns empty response when JSON payload is identical to anchor data'() { + when: 'attempt to get delta report between anchor and JSON payload (replacing the string Easons with Easons-1 because the data in JSON file is modified, to append anchor number, during the setup process of the integration tests)' + def jsonPayload = readResourceDataFile('bookstore/bookstoreData.json').replace('Easons', 'Easons-1') + def result = objectUnderTest.getDeltaByDataspaceAnchorAndPayload(FUNCTIONAL_TEST_DATASPACE_3, BOOKSTORE_ANCHOR_3, '/', [:], jsonPayload, INCLUDE_ALL_DESCENDANTS) + then: 'delta report is empty' + assert result.isEmpty() + } + + def 'Get delta between anchor and payload error scenario: #scenario'() { + when: 'attempt to get delta between anchor and json payload' + objectUnderTest.getDeltaByDataspaceAnchorAndPayload(dataspaceName, sourceAnchor, xpath, [:], jsonPayload, INCLUDE_ALL_DESCENDANTS) + then: 'expected exception is thrown' + thrown(expectedException) + where: 'following data was used' + scenario | dataspaceName | sourceAnchor | xpath | jsonPayload || expectedException + 'invalid dataspace name' | 'Invalid dataspace' | 'not-relevant' | '/' | '{some-json}' || DataValidationException + 'invalid anchor name' | FUNCTIONAL_TEST_DATASPACE_3 | 'invalid anchor' | '/' | '{some-json}' || DataValidationException + 'non-existing dataspace' | 'non-existing' | 'not-relevant' | '/' | '{some-json}' || DataspaceNotFoundException + 'non-existing anchor' | FUNCTIONAL_TEST_DATASPACE_3 | 'non-existing-anchor' | '/' | '{some-json}' || AnchorNotFoundException + 'empty json payload with root node xpath' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | '/' | '' || DataValidationException + 'empty json payload with non-root node xpath' | FUNCTIONAL_TEST_DATASPACE_3 | BOOKSTORE_ANCHOR_3 | '/bookstore' | '' || DataValidationException + } + + def getDeltaReportEntities(List deltaReport) { + def xpaths = [] + def action = [] + def sourcePayload = [] + def targetPayload = [] + deltaReport.each { + delta -> xpaths.add(delta.getXpath()) + action.add(delta.getAction()) + sourcePayload.add(delta.getSourceData()) + targetPayload.add(delta.getTargetData()) + } + return ['xpaths':xpaths, 'action':action, 'sourcePayload':sourcePayload, 'targetPayload':targetPayload] + } + + def countDataNodesInBookstore() { + return countDataNodesInTree(cpsDataService.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/bookstore', INCLUDE_ALL_DESCENDANTS)) + } + + def countTopLevelListDataNodesInBookstore() { + return countDataNodesInTree(cpsDataService.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/', INCLUDE_ALL_DESCENDANTS)) + } + + def countXmlDataNodesInBookstore() { + return countDataNodesInTree(cpsDataService.getDataNodes(FUNCTIONAL_TEST_DATASPACE_4, BOOKSTORE_ANCHOR_6, '/bookstore', INCLUDE_ALL_DESCENDANTS)) + } + +}