2 changed files with 390 additions and 20 deletions
@ -0,0 +1,365 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* 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. |
|||
*/ |
|||
package org.thingsboard.rule.engine.kafka; |
|||
|
|||
import org.apache.kafka.clients.producer.Callback; |
|||
import org.apache.kafka.clients.producer.Producer; |
|||
import org.apache.kafka.clients.producer.ProducerConfig; |
|||
import org.apache.kafka.clients.producer.ProducerRecord; |
|||
import org.apache.kafka.clients.producer.RecordMetadata; |
|||
import org.apache.kafka.common.header.Headers; |
|||
import org.apache.kafka.common.header.internals.RecordHeader; |
|||
import org.apache.kafka.common.header.internals.RecordHeaders; |
|||
import org.apache.kafka.common.serialization.StringSerializer; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.Arguments; |
|||
import org.junit.jupiter.params.provider.MethodSource; |
|||
import org.junit.jupiter.params.provider.NullAndEmptySource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.common.util.ListeningExecutor; |
|||
import org.thingsboard.rule.engine.TestDbCallbackExecutor; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.util.TbNodeUtils; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.RuleNodeId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.nio.charset.Charset; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.Map; |
|||
import java.util.Properties; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.Callable; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatNoException; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.BDDMockito.willAnswer; |
|||
import static org.mockito.BDDMockito.willThrow; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.never; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class TbKafkaNodeTest { |
|||
|
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("5f2eac08-bd1f-4635-a6c2-437369f996cf")); |
|||
private final ListeningExecutor executor = new TestDbCallbackExecutor(); |
|||
|
|||
private TbKafkaNode node; |
|||
private TbKafkaNodeConfiguration config; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private Producer<String, String> producerMock; |
|||
@Mock |
|||
private RecordMetadata recordMetadataMock; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
node = new TbKafkaNode(); |
|||
config = new TbKafkaNodeConfiguration().defaultConfiguration(); |
|||
} |
|||
|
|||
@Test |
|||
public void verifyDefaultConfig() { |
|||
assertThat(config.getTopicPattern()).isEqualTo("my-topic"); |
|||
assertThat(config.getKeyPattern()).isNull(); |
|||
assertThat(config.getBootstrapServers()).isEqualTo("localhost:9092"); |
|||
assertThat(config.getRetries()).isEqualTo(0); |
|||
assertThat(config.getBatchSize()).isEqualTo(16384); |
|||
assertThat(config.getLinger()).isEqualTo(0); |
|||
assertThat(config.getBufferMemory()).isEqualTo(33554432); |
|||
assertThat(config.getAcks()).isEqualTo("-1"); |
|||
assertThat(config.getKeySerializer()).isEqualTo(StringSerializer.class.getName()); |
|||
assertThat(config.getValueSerializer()).isEqualTo(StringSerializer.class.getName()); |
|||
assertThat(config.getOtherProperties()).isEmpty(); |
|||
assertThat(config.isAddMetadataKeyValuesAsKafkaHeaders()).isFalse(); |
|||
assertThat(config.getKafkaHeadersCharset()).isEqualTo("UTF-8"); |
|||
} |
|||
|
|||
@Test |
|||
public void givenAddMetadataKeyValuesAsKafkaHeadersIsTrueAndKafkaHeadersCharsetIsSet_whenInit_thenOk() { |
|||
config.setAddMetadataKeyValuesAsKafkaHeaders(true); |
|||
config.setKafkaHeadersCharset("UTF-16"); |
|||
|
|||
String ruleNodeIdStr = "0d35733c-7661-4797-819e-d9188974e3b2"; |
|||
String serviceIdStr = "test-service"; |
|||
|
|||
given(ctxMock.getSelfId()).willReturn(new RuleNodeId(UUID.fromString(ruleNodeIdStr))); |
|||
given(ctxMock.getServiceId()).willReturn(serviceIdStr); |
|||
|
|||
assertThatNoException().isThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); |
|||
|
|||
Boolean addMetadataKeyValuesAsKafkaHeaders = (Boolean) ReflectionTestUtils.getField(node, "addMetadataKeyValuesAsKafkaHeaders"); |
|||
Charset toBytesCharset = (Charset) ReflectionTestUtils.getField(node, "toBytesCharset"); |
|||
|
|||
assertThat(addMetadataKeyValuesAsKafkaHeaders).isTrue(); |
|||
assertThat(toBytesCharset).isEqualTo(StandardCharsets.UTF_16); |
|||
} |
|||
|
|||
@Test |
|||
public void verifyGetKafkaPropertiesMethod() { |
|||
String sslKeyStoreCertificateChain = "cbdvch\\nfwrg\nvgwg\\n"; |
|||
String sslKeyStoreKey = "nghmh\\nhmmnh\\\\ngreg\nvgwg\\n"; |
|||
String sslTruststoreCertificates = "grthrt\fd\\nfwrg\nvgwg\\n"; |
|||
config.setOtherProperties(Map.of( |
|||
"ssl.keystore.certificate.chain", sslKeyStoreCertificateChain, |
|||
"ssl.keystore.key", sslKeyStoreKey, |
|||
"ssl.truststore.certificates", sslTruststoreCertificates, |
|||
"ssl.protocol", "TLSv1.2" |
|||
)); |
|||
ReflectionTestUtils.setField(node, "config", config); |
|||
|
|||
String ruleNodeIdStr = "e646b885-8004-45b4-8bfb-78db21870e0f"; |
|||
String serviceIdStr = "test-service"; |
|||
given(ctxMock.getSelfId()).willReturn(new RuleNodeId(UUID.fromString(ruleNodeIdStr))); |
|||
given(ctxMock.getServiceId()).willReturn(serviceIdStr); |
|||
|
|||
Properties expectedProperties = new Properties(); |
|||
expectedProperties.put(ProducerConfig.CLIENT_ID_CONFIG, "producer-tb-kafka-node-" + ruleNodeIdStr + "-" + serviceIdStr); |
|||
expectedProperties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.getBootstrapServers()); |
|||
expectedProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, config.getValueSerializer()); |
|||
expectedProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, config.getKeySerializer()); |
|||
expectedProperties.put(ProducerConfig.ACKS_CONFIG, config.getAcks()); |
|||
expectedProperties.put(ProducerConfig.RETRIES_CONFIG, config.getRetries()); |
|||
expectedProperties.put(ProducerConfig.BATCH_SIZE_CONFIG, config.getBatchSize()); |
|||
expectedProperties.put(ProducerConfig.LINGER_MS_CONFIG, config.getLinger()); |
|||
expectedProperties.put(ProducerConfig.BUFFER_MEMORY_CONFIG, config.getBufferMemory()); |
|||
expectedProperties.put("ssl.keystore.certificate.chain", sslKeyStoreCertificateChain.replace("\\n", "\n")); |
|||
expectedProperties.put("ssl.keystore.key", sslKeyStoreKey.replace("\\n", "\n")); |
|||
expectedProperties.put("ssl.truststore.certificates", sslTruststoreCertificates.replace("\\n", "\n")); |
|||
expectedProperties.put("ssl.protocol", "TLSv1.2"); |
|||
|
|||
Properties actualsProperties = node.getKafkaProperties(ctxMock); |
|||
assertThat(actualsProperties).isEqualTo(expectedProperties); |
|||
} |
|||
|
|||
@Test |
|||
public void givenInitErrorIsNotNull_whenOnMsg_thenTellFailure() { |
|||
init(); |
|||
String errorMsg = "Error during init!"; |
|||
ReflectionTestUtils.setField(node, "initError", new RuntimeException(errorMsg)); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<Throwable> actualError = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(eq(msg), actualError.capture()); |
|||
assertThat(actualError.getValue()) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessage("Failed to initialize Kafka rule node producer: " + errorMsg); |
|||
} |
|||
|
|||
@Test |
|||
public void givenForceAckIsTrueAndExceptionWasThrown_whenOnMsg_thenTellFailure() { |
|||
init(); |
|||
ReflectionTestUtils.setField(node, "forceAck", true); |
|||
|
|||
ListeningExecutor executorMock = mock(ListeningExecutor.class); |
|||
given(ctxMock.getExternalCallExecutor()).willReturn(executorMock); |
|||
String errorMsg = "Something went wrong!"; |
|||
willThrow(new RuntimeException(errorMsg)).given(executorMock).executeAsync(any(Callable.class)); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should().ack(msg); |
|||
ArgumentCaptor<TbMsg> actualMsg = ArgumentCaptor.forClass(TbMsg.class); |
|||
ArgumentCaptor<Throwable> actualError = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(actualMsg.capture(), actualError.capture()); |
|||
assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(msg); |
|||
assertThat(actualError.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
public void givenTopicAndKeyPatternsAndAddMetadataKeyValuesAsKafkaHeadersIsFalse_whenOnMsg_thenTellSuccess |
|||
(String topicPattern, String keyPattern, TbMsgMetaData metaData, String data) { |
|||
config.setTopicPattern(topicPattern); |
|||
config.setKeyPattern(keyPattern); |
|||
init(); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data); |
|||
String topic = TbNodeUtils.processPattern(topicPattern, msg); |
|||
String key = TbNodeUtils.processPattern(keyPattern, msg); |
|||
long offset = 1; |
|||
int partition = 0; |
|||
mockSuccessfulPublishingRequest(topic, offset, partition); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verifyProducerRecord(topic, key, msg.getData()); |
|||
verifyOutboundMsg(offset, partition, topic, msg); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenTopicAndKeyPatternsAndAddMetadataKeyValuesAsKafkaHeadersIsFalse_whenOnMsg_thenTellSuccess() { |
|||
return Stream.of( |
|||
Arguments.of("test-topic", "test-key", new TbMsgMetaData(), TbMsg.EMPTY_JSON_OBJECT), |
|||
Arguments.of("${mdTopicPattern}", "${mdKeyPattern}", new TbMsgMetaData( |
|||
Map.of( |
|||
"mdTopicPattern", "md-test-topic", |
|||
"mdKeyPattern", "md-test-key" |
|||
)), TbMsg.EMPTY_JSON_OBJECT), |
|||
Arguments.of("$[msgTopicPattern]", "$[msgKeyPattern]", new TbMsgMetaData(), |
|||
"{\"msgTopicPattern\":\"msg-test-topic\",\"msgKeyPattern\":\"msg-test-key\"}") |
|||
); |
|||
} |
|||
|
|||
@Test |
|||
public void givenForceAckIsFalseAndAddMetadataKeyValuesAsKafkaHeadersIsTrueAndToBytesCharsetIsSet_whenOnMsg_thenAckAndTellSuccess() { |
|||
String topic = "test-topic"; |
|||
String key = "test-key"; |
|||
config.setTopicPattern(topic); |
|||
config.setKeyPattern(key); |
|||
config.setAddMetadataKeyValuesAsKafkaHeaders(true); |
|||
config.setKafkaHeadersCharset("UTF-16"); |
|||
init(); |
|||
ReflectionTestUtils.setField(node, "forceAck", false); |
|||
ReflectionTestUtils.setField(node, "addMetadataKeyValuesAsKafkaHeaders", true); |
|||
ReflectionTestUtils.setField(node, "toBytesCharset", StandardCharsets.UTF_16); |
|||
|
|||
TbMsgMetaData metaData = new TbMsgMetaData(); |
|||
metaData.putValue("key", "value"); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, TbMsg.EMPTY_JSON_OBJECT); |
|||
|
|||
long offset = 1; |
|||
int partition = 0; |
|||
|
|||
mockSuccessfulPublishingRequest(topic, offset, partition); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should(never()).ack(msg); |
|||
Headers expectedHeaders = new RecordHeaders(); |
|||
msg.getMetaData().values().forEach((k, v) -> expectedHeaders.add(new RecordHeader("tb_msg_md_" + k, v.getBytes(StandardCharsets.UTF_16)))); |
|||
verifyProducerRecord(topic, key, msg.getData(), expectedHeaders); |
|||
verifyOutboundMsg(offset, partition, topic, msg); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@NullAndEmptySource |
|||
public void givenKeyIsNullOrEmptyAndErrorOccursDuringPublishing_whenOnMsg_thenTellFailure(String key) { |
|||
String topic = "test-topic"; |
|||
config.setTopicPattern(topic); |
|||
config.setKeyPattern(key); |
|||
config.setAddMetadataKeyValuesAsKafkaHeaders(false); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
|
|||
String errorMsg = "Something went wrong!"; |
|||
|
|||
given(ctxMock.getExternalCallExecutor()).willReturn(executor); |
|||
willAnswer(invocation -> { |
|||
Callback callback = invocation.getArgument(1); |
|||
callback.onCompletion(recordMetadataMock, new RuntimeException(errorMsg)); |
|||
return null; |
|||
}).given(producerMock).send(any(), any(Callback.class)); |
|||
|
|||
init(); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verifyProducerRecord(topic, null, msg.getData()); |
|||
|
|||
ArgumentCaptor<TbMsg> actualMsg = ArgumentCaptor.forClass(TbMsg.class); |
|||
ArgumentCaptor<Throwable> actualError = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(actualMsg.capture(), actualError.capture()); |
|||
TbMsgMetaData metaData = new TbMsgMetaData(); |
|||
metaData.putValue("error", RuntimeException.class + ": " + errorMsg); |
|||
TbMsg expectedMsg = TbMsg.transformMsgMetadata(msg, metaData); |
|||
assertThat(actualMsg.getValue()) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("ctx") |
|||
.isEqualTo(expectedMsg); |
|||
} |
|||
|
|||
@Test |
|||
public void givenProducerIsNotNull_whenDestroy_thenShouldClose() { |
|||
ReflectionTestUtils.setField(node, "producer", producerMock); |
|||
|
|||
node.destroy(); |
|||
|
|||
then(producerMock).should().close(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenProducerIsNull_whenDestroy_thenDoNothing() { |
|||
node.destroy(); |
|||
then(producerMock).shouldHaveNoInteractions(); |
|||
} |
|||
|
|||
private void mockSuccessfulPublishingRequest(String topic, long offset, int partition) { |
|||
given(ctxMock.getExternalCallExecutor()).willReturn(executor); |
|||
willAnswer(invocation -> { |
|||
Callback callback = invocation.getArgument(1); |
|||
callback.onCompletion(recordMetadataMock, null); |
|||
return null; |
|||
}).given(producerMock).send(any(), any(Callback.class)); |
|||
given(recordMetadataMock.offset()).willReturn(offset); |
|||
given(recordMetadataMock.partition()).willReturn(partition); |
|||
given(recordMetadataMock.topic()).willReturn(topic); |
|||
} |
|||
|
|||
private void init() { |
|||
ReflectionTestUtils.setField(node, "config", config); |
|||
ReflectionTestUtils.setField(node, "producer", producerMock); |
|||
ReflectionTestUtils.setField(node, "addMetadataKeyValuesAsKafkaHeaders", false); |
|||
} |
|||
|
|||
private void verifyProducerRecord(String expectedTopic, String expectedKey, String expectedValue) { |
|||
verifyProducerRecord(expectedTopic, expectedKey, expectedValue, null); |
|||
} |
|||
|
|||
private void verifyProducerRecord(String expectedTopic, String expectedKey, String expectedValue, Headers expectedHeaders) { |
|||
ArgumentCaptor<ProducerRecord<String, String>> actualRecordCaptor = ArgumentCaptor.forClass(ProducerRecord.class); |
|||
then(producerMock).should().send(actualRecordCaptor.capture(), any()); |
|||
ProducerRecord<String, String> actualRecord = actualRecordCaptor.getValue(); |
|||
assertThat(actualRecord.topic()).isEqualTo(expectedTopic); |
|||
assertThat(actualRecord.key()).isEqualTo(expectedKey); |
|||
assertThat(actualRecord.value()).isEqualTo(expectedValue); |
|||
if (expectedHeaders != null) { |
|||
assertThat(actualRecord.headers()).isEqualTo(expectedHeaders); |
|||
} |
|||
} |
|||
|
|||
private void verifyOutboundMsg(long expectedOffset, long expectedPartition, String expectedTopic, TbMsg originalMsg) { |
|||
ArgumentCaptor<TbMsg> actualMsg = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should().tellSuccess(actualMsg.capture()); |
|||
TbMsgMetaData metaData = originalMsg.getMetaData().copy(); |
|||
metaData.putValue("offset", String.valueOf(expectedOffset)); |
|||
metaData.putValue("partition", String.valueOf(expectedPartition)); |
|||
metaData.putValue("topic", expectedTopic); |
|||
TbMsg expectedMsg = TbMsg.transformMsgMetadata(originalMsg, metaData); |
|||
assertThat(actualMsg.getValue()) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("ctx") |
|||
.isEqualTo(expectedMsg); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue