diff options
author | 2021-06-04 07:49:14 -0400 | |
---|---|---|
committer | 2021-07-05 18:37:15 -0400 | |
commit | c9b99347c7c425fce7a1f5f3c7e2ac500f2f0c5c (patch) | |
tree | 199b49d2db987f2c630de124356a48b1bd97657a /src/test/groovy/org | |
parent | 8b2193b7ed06e2ee6a90f7986921e72ca70ad90f (diff) |
Add kafka listener for data updated events
See "Running via Docker Compose" section from README.md file to have an
example of event processing
Issue-ID: CPS-371
Signed-off-by: Bruno Sakoto <bruno.sakoto@bell.ca>
Change-Id: Id3abfa32fb04e07102a5f28e6e43a9b533391188
Diffstat (limited to 'src/test/groovy/org')
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerIntegrationSpec.groovy | 124 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerSpec.groovy | 117 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/EventFixtures.groovy | 72 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy | 131 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/controller/web/QueryControllerSpec.groovy (renamed from src/test/groovy/org/onap/cps/temporal/controller/QueryControllerSpec.groovy) | 4 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositorySpec.groovy | 3 | ||||
-rw-r--r-- | src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy | 37 |
7 files changed, 479 insertions, 9 deletions
diff --git a/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerIntegrationSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerIntegrationSpec.groovy new file mode 100644 index 0000000..4c362ad --- /dev/null +++ b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerIntegrationSpec.groovy @@ -0,0 +1,124 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= +*/ + +package org.onap.cps.temporal.controller.event.listener.kafka + +import groovy.util.logging.Slf4j +import org.onap.cps.event.model.CpsDataUpdatedEvent +import org.onap.cps.temporal.repository.containers.TimescaleContainer +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.KafkaContainer +import spock.lang.Shared +import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import java.util.concurrent.TimeUnit + +/** + * Integration test specification for data updated event listener. + * This integration test is running database and kafka dependencies as docker containers. + */ +@SpringBootTest +@Slf4j +class DataUpdatedEventListenerIntegrationSpec extends Specification { + + @Shared + def databaseTestContainer = TimescaleContainer.getInstance() + + static kafkaTestContainer = new KafkaContainer() + static { + Runtime.getRuntime().addShutdownHook(new Thread(kafkaTestContainer::stop)) + } + + def setupSpec() { + databaseTestContainer.start() + kafkaTestContainer.start() + } + + @Autowired + KafkaTemplate<String, CpsDataUpdatedEvent> kafkaTemplate + + @Autowired + JdbcTemplate jdbcTemplate + + @Value('${app.listener.data-updated.topic}') + String topic + + // Define event data + def aTimestamp = EventFixtures.currentIsoTimestamp() + def aDataspace = 'my-dataspace' + def aSchemaSet = 'my-schema-set' + def anAnchor = 'my-anchor' + + // Define sql queries for data validation + def sqlCount = "select count(*) from network_data" + def sqlSelect = "select * from network_data" + def sqlWhereClause = + ' where observed_timestamp = to_timestamp(?, \'YYYY-MM-DD"T"HH24:MI:SS.USTZHTZM\') ' + + 'and dataspace = ? ' + + 'and schema_set = ? ' + + 'and anchor = ?' + def sqlCountWithConditions = sqlCount + sqlWhereClause + def sqlSelectWithConditions = sqlSelect + sqlWhereClause + + def 'Processing a valid event'() { + given: "no event has been proceeded" + def initialRecordsCount = + jdbcTemplate.queryForObject(sqlCountWithConditions, Integer.class, + aTimestamp, aDataspace, aSchemaSet, anAnchor) + assert (initialRecordsCount == 0) + when: 'an event is produced' + def event = + EventFixtures.buildEvent( + timestamp: aTimestamp, dataspace: aDataspace, schemaSet: aSchemaSet, anchor: anAnchor) + this.kafkaTemplate.send(topic, event) + then: 'the event is proceeded' + def pollingCondition = new PollingConditions(timeout: 10, initialDelay: 1, factor: 2) + pollingCondition.eventually { + def finalRecordsCount = + jdbcTemplate.queryForObject( + sqlCountWithConditions, Integer.class, aTimestamp, aDataspace, aSchemaSet, anAnchor) + assert (finalRecordsCount == 1) + } + Map<String, Object> result = + jdbcTemplate.queryForMap(sqlSelectWithConditions, aTimestamp, aDataspace, aSchemaSet, anAnchor) + log.debug("Data retrieved from db: {}", result) + } + + def 'Processing an invalid event'() { + given: 'the number of network data records if known' + def initialRecordsCount = jdbcTemplate.queryForObject(sqlCount, Integer.class) + when: 'an invalid event is produced' + this.kafkaTemplate.send(topic, (CpsDataUpdatedEvent) null) + then: 'the event is not proceeded and no more network data record is created' + TimeUnit.SECONDS.sleep(3) + assert (jdbcTemplate.queryForObject(sqlCount, Integer.class) == initialRecordsCount) + } + + @DynamicPropertySource + static void registerKafkaProperties(DynamicPropertyRegistry registry) { + registry.add("spring.kafka.bootstrap-servers", kafkaTestContainer::getBootstrapServers) + } + +} diff --git a/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerSpec.groovy new file mode 100644 index 0000000..d3a407c --- /dev/null +++ b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/DataUpdatedEventListenerSpec.groovy @@ -0,0 +1,117 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.temporal.controller.event.listener.kafka + +import org.mapstruct.factory.Mappers +import org.onap.cps.event.model.CpsDataUpdatedEvent +import org.onap.cps.temporal.controller.event.listener.exception.InvalidEventEnvelopException +import org.onap.cps.temporal.controller.event.model.CpsDataUpdatedEventMapper +import org.onap.cps.temporal.service.NetworkDataService +import spock.lang.Specification + +import static org.onap.cps.temporal.controller.event.listener.exception.InvalidEventEnvelopException.InvalidField.ErrorType.MISSING +import static org.onap.cps.temporal.controller.event.listener.exception.InvalidEventEnvelopException.InvalidField.ErrorType.UNEXPECTED + +/** + * Test specification for data updated event listener. + */ +class DataUpdatedEventListenerSpec extends Specification { + + // Define event data + def anEventType = 'my-event-type' + def anEventSource = new URI('my-event-source') + def aTimestamp = EventFixtures.currentIsoTimestamp() + def aDataspace = 'my-dataspace' + def aSchemaSet = 'my-schema-set' + def anAnchor = 'my-anchor' + def aDataName = 'my-data-name' + def aDataValue = 'my-data-value' + + // Define service mock + def mockService = Mock(NetworkDataService) + + // Define mapper + def mapper = Mappers.getMapper(CpsDataUpdatedEventMapper.class) + + // Define listener under test + def objectUnderTest = new DataUpdatedEventListener(mockService, mapper) + + def 'Event message consumption'() { + when: 'an event is received' + def event = + EventFixtures.buildEvent( + timestamp: aTimestamp, dataspace: aDataspace, schemaSet: aSchemaSet, anchor: anAnchor, + dataName: aDataName, dataValue: aDataValue) + objectUnderTest.consume(event) + then: 'network data service is requested to persisted the data change' + 1 * mockService.addNetworkData( + { + it.getObservedTimestamp() == EventFixtures.toOffsetDateTime(aTimestamp) + && it.getDataspace() == aDataspace + && it.getSchemaSet() == aSchemaSet + && it.getAnchor() == anAnchor + && it.getPayload() == String.format('{"%s":"%s"}', aDataName, aDataValue) + && it.getCreatedTimestamp() == null + } + ) + } + + def 'Event message consumption fails because of missing envelop'() { + when: 'an event without envelop information is received' + def invalidEvent = new CpsDataUpdatedEvent().withSchema(null) + objectUnderTest.consume(invalidEvent) + then: 'an exception is thrown with 4 invalid fields' + def e = thrown(InvalidEventEnvelopException) + e.getInvalidFields().size() == 4 + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + MISSING,"schema", null, + CpsDataUpdatedEvent.Schema.URN_CPS_ORG_ONAP_CPS_DATA_UPDATED_EVENT_SCHEMA_1_1_0_SNAPSHOT + .value())) + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + MISSING, "id", null, null)) + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + UNEXPECTED, "source", null, EventFixtures.defaultEventSource.toString())) + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + UNEXPECTED, "type", null, EventFixtures.defaultEventType)) + e.getMessage().contains(e.getInvalidFields().toString()) + } + + def 'Event message consumption fails because of invalid envelop'() { + when: 'an event with an invalid envelop is received' + def invalidEvent = + new CpsDataUpdatedEvent() + .withId('my-id').withSource(anEventSource).withType(anEventType) + objectUnderTest.consume(invalidEvent) + then: 'an exception is thrown with 2 invalid fields' + def e = thrown(InvalidEventEnvelopException) + e.getInvalidFields().size() == 2 + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + UNEXPECTED, "type", anEventType, EventFixtures.defaultEventType)) + e.getInvalidFields().contains( + new InvalidEventEnvelopException.InvalidField( + UNEXPECTED, "source", anEventSource.toString(), + EventFixtures.defaultEventSource.toString())) + } + +} diff --git a/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/EventFixtures.groovy b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/EventFixtures.groovy new file mode 100644 index 0000000..44a28de --- /dev/null +++ b/src/test/groovy/org/onap/cps/temporal/controller/event/listener/kafka/EventFixtures.groovy @@ -0,0 +1,72 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.temporal.controller.event.listener.kafka + +import org.onap.cps.event.model.Content +import org.onap.cps.event.model.CpsDataUpdatedEvent +import org.onap.cps.event.model.Data + +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +/** + * This class contains utility fixtures methods for building and manipulating event data. + */ +class EventFixtures { + + static DateTimeFormatter isoTimestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + static String defaultEventType = 'org.onap.cps.data-updated-event' + static URI defaultEventSource = new URI('urn:cps:org.onap.cps') + + static CpsDataUpdatedEvent buildEvent(final Map map) { + CpsDataUpdatedEvent event = + new CpsDataUpdatedEvent() + .withId( + map.id != null ? map.id.toString() : UUID.randomUUID().toString()) + .withType( + map.eventType != null ? map.eventType.toString() : defaultEventType) + .withSource( + map.eventSource != null ? new URI(map.eventSource.toString()) : defaultEventSource) + .withContent( + new Content() + .withObservedTimestamp( + map.timestamp != null ? map.timestamp.toString() : currentTimestamp()) + .withDataspaceName( + map.dataspace != null ? map.dataspace.toString() : 'a-dataspace') + .withSchemaSetName( + map.schemaSet != null ? map.schemaSet.toString() : 'a-schema-set') + .withAnchorName( + map.anchor != null ? map.anchor.toString() : 'an-anchor') + .withData( + new Data().withAdditionalProperty( + map.dataName != null ? map.dataName.toString() : 'a-data-name', + map.dataValue != null ? map.dataValue : 'a-data-value'))) + + return event + } + + static String currentIsoTimestamp() { + return isoTimestampFormatter.format(OffsetDateTime.now()) + } + + static OffsetDateTime toOffsetDateTime(String timestamp) { + return OffsetDateTime.parse(timestamp, isoTimestampFormatter) + } + +} diff --git a/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy new file mode 100644 index 0000000..132ff6d --- /dev/null +++ b/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy @@ -0,0 +1,131 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.temporal.controller.event.model + +import com.fasterxml.jackson.core.JsonProcessingException +import org.mapstruct.factory.Mappers +import org.onap.cps.event.model.Content +import org.onap.cps.event.model.CpsDataUpdatedEvent +import org.onap.cps.event.model.Data +import org.onap.cps.temporal.domain.NetworkData +import spock.lang.Specification + +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +/** + * Test specification for data updated event mapper. + */ +class CpsDataUpdatedEventMapperSpec extends Specification { + + def objectUnderTest = Mappers.getMapper(CpsDataUpdatedEventMapper.class); + + def 'Mapping a null event'() { + given: 'a null event' + def event = null + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'the result entity is null' + result == null + } + + def 'Mapping an event whose properties are null'() { + given: 'an event whose properties are null' + def event = new CpsDataUpdatedEvent() + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'the result entity is not null' + result != null + and: 'all result entity properties are null' + assertEntityPropertiesAreNull(result) + } + + def 'Mapping an event whose content properties are null'() { + given: 'an event whose content properties are null' + def event = new CpsDataUpdatedEvent().withContent(new Content()) + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'the result entity is not null' + result != null + and: 'all result entity properties are null' + assertEntityPropertiesAreNull(result) + } + + def 'Mapping an event whose content data is empty'() { + given: 'an event whose content data is empty' + def event = new CpsDataUpdatedEvent().withContent(new Content().withData(new Data())) + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'the result entity is not null' + result != null + and: 'the result entity payload is an empty json ' + result.getPayload() == "{}" + } + + def 'Mapping an event whose content data is invalid'() { + given: 'an event whose content data is invalid' + def event = + new CpsDataUpdatedEvent().withContent(new Content().withData( + new Data().withAdditionalProperty(null, null))) + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'an runtime exception is thrown' + def e = thrown(RuntimeException) + e.getCause() instanceof JsonProcessingException + } + + def 'Mapping a valid complete event'() { + given: 'a valid complete event' + def isoTimestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + def aDataName = 'a-data-name' + def aDataValue = 'a-data-value' + def event = + new CpsDataUpdatedEvent() + .withContent( + new Content() + .withObservedTimestamp(isoTimestampFormatter.format(OffsetDateTime.now())) + .withDataspaceName('a-dataspace') + .withSchemaSetName('a-schema-set') + .withAnchorName('an-anchor') + .withData(new Data().withAdditionalProperty(aDataName, aDataValue))) + when: 'the event is mapped to an entity' + NetworkData result = objectUnderTest.eventToEntity(event) + then: 'the result entity is not null' + result != null + and: 'all result entity properties are the ones from the event' + result.getObservedTimestamp() == + OffsetDateTime.parse(event.getContent().getObservedTimestamp(), isoTimestampFormatter) + result.getDataspace() == event.getContent().getDataspaceName() + result.getSchemaSet() == event.getContent().getSchemaSetName() + result.getAnchor() == event.getContent().getAnchorName() + result.getPayload().contains(aDataValue) + result.getPayload().contains(aDataValue) + result.getCreatedTimestamp() == null + } + + private void assertEntityPropertiesAreNull(NetworkData networkData) { + assert networkData.getObservedTimestamp() == null + assert networkData.getDataspace() == null + assert networkData.getSchemaSet() == null + assert networkData.getAnchor() == null + assert networkData.getPayload() == null + assert networkData.getCreatedTimestamp() == null + } + +} diff --git a/src/test/groovy/org/onap/cps/temporal/controller/QueryControllerSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/web/QueryControllerSpec.groovy index f718bf4..c834f38 100644 --- a/src/test/groovy/org/onap/cps/temporal/controller/QueryControllerSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/controller/web/QueryControllerSpec.groovy @@ -16,7 +16,7 @@ * ============LICENSE_END========================================================= */ -package org.onap.cps.temporal.controller +package org.onap.cps.temporal.controller.web import spock.lang.Specification @@ -34,4 +34,4 @@ class QueryControllerSpec extends Specification { ! response.empty } -}
\ No newline at end of file +} diff --git a/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositorySpec.groovy b/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositorySpec.groovy index ec976ee..f66b35e 100644 --- a/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositorySpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositorySpec.groovy @@ -29,6 +29,9 @@ import spock.lang.Specification import java.time.OffsetDateTime +/** + * Test specification for network data repository. + */ @SpringBootTest @Testcontainers class NetworkDataRepositorySpec extends Specification { diff --git a/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy b/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy index 70ac2bc..9847f54 100644 --- a/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy @@ -18,28 +18,51 @@ package org.onap.cps.temporal.service +import org.onap.cps.temporal.domain.NetworkDataId + import java.time.OffsetDateTime import org.onap.cps.temporal.domain.NetworkData import org.onap.cps.temporal.repository.NetworkDataRepository import spock.lang.Specification +/** + * Test specification for network data service. + */ class NetworkDataServiceImplSpec extends Specification { - def objectUnderTest = new NetworkDataServiceImpl() - def mockNetworkDataRepository = Mock(NetworkDataRepository) + def objectUnderTest = new NetworkDataServiceImpl(mockNetworkDataRepository) + def networkData = new NetworkData() - def setup() { - objectUnderTest.networkDataRepository = mockNetworkDataRepository + def 'Add network data successfully.'() { + given: 'network data repository is persisting network data it is asked to save' + def persistedNetworkData = new NetworkData() + persistedNetworkData.setCreatedTimestamp(OffsetDateTime.now()) + mockNetworkDataRepository.save(networkData) >> persistedNetworkData + when: 'a new network data is added' + def result = objectUnderTest.addNetworkData(networkData) + then: 'result network data is the one that has been persisted' + result == persistedNetworkData + result.getCreatedTimestamp() != null + networkData.getCreatedTimestamp() == null } - def 'Add network data in timeseries database.'() { + def 'Add network data fails because already added'() { + given: 'network data repository is not able to create data it is asked to persist ' + + 'and reveals it with null created timestamp on network data entity' + def persistedNetworkData = new NetworkData() + persistedNetworkData.setCreatedTimestamp(null) + mockNetworkDataRepository.save(networkData) >> persistedNetworkData + and: 'existing data can be retrieved' + def existing = new NetworkData() + existing.setCreatedTimestamp(OffsetDateTime.now().minusYears(1)) + mockNetworkDataRepository.findById(_ as NetworkDataId) >> Optional.of(existing) when: 'a new network data is added' objectUnderTest.addNetworkData(networkData) - then: ' repository service is called with the correct parameters' - 1 * mockNetworkDataRepository.save(networkData) + then: 'network service exception is thrown' + thrown(ServiceException) } } |