Commit 000741444a0160639d002a33ad516eea8ff93aa3

Authored by Igor Kulikov
Committed by GitHub
2 parents 13893856 32dd7f04

Merge pull request #1183 from vkukhtyn/master

Cover MQTT API with black box tests
  1 +
  2 +## Black box tests execution
  3 +To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built. <br />
  4 +- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml):
  5 +
  6 + mvn clean install -Ddockerfile.skip=false
  7 +- Verify that the new local images were built:
  8 +
  9 + docker image ls
  10 +As result, in REPOSITORY column, next images should be present:
  11 +
  12 + thingsboard/tb-coap-transport
  13 + thingsboard/tb-http-transport
  14 + thingsboard/tb-mqtt-transport
  15 + thingsboard/tb-node
  16 + thingsboard/tb-web-ui
  17 + thingsboard/tb-js-executor
  18 +
  19 +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory:
  20 +
  21 + mvn clean install -DblackBoxTests.skip=false
\ No newline at end of file
... ...
  1 +<!--
  2 +
  3 + Copyright © 2016-2018 The Thingsboard Authors
  4 +
  5 + Licensed under the Apache License, Version 2.0 (the "License");
  6 + you may not use this file except in compliance with the License.
  7 + You may obtain a copy of the License at
  8 +
  9 + http://www.apache.org/licenses/LICENSE-2.0
  10 +
  11 + Unless required by applicable law or agreed to in writing, software
  12 + distributed under the License is distributed on an "AS IS" BASIS,
  13 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14 + See the License for the specific language governing permissions and
  15 + limitations under the License.
  16 +
  17 +-->
  18 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  19 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  20 + <modelVersion>4.0.0</modelVersion>
  21 +
  22 + <parent>
  23 + <groupId>org.thingsboard</groupId>
  24 + <version>2.2.0-SNAPSHOT</version>
  25 + <artifactId>msa</artifactId>
  26 + </parent>
  27 + <groupId>org.thingsboard.msa</groupId>
  28 + <artifactId>black-box-tests</artifactId>
  29 +
  30 + <name>ThingsBoard Black Box Tests</name>
  31 + <url>https://thingsboard.io</url>
  32 + <description>Project for ThingsBoard black box testing with using Docker</description>
  33 +
  34 + <properties>
  35 + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  36 + <main.dir>${basedir}/../..</main.dir>
  37 + <blackBoxTests.skip>true</blackBoxTests.skip>
  38 + <testcontainers.version>1.9.1</testcontainers.version>
  39 + <java-websocket.version>1.3.9</java-websocket.version>
  40 + <httpclient.version>4.5.6</httpclient.version>
  41 + </properties>
  42 +
  43 + <dependencies>
  44 + <dependency>
  45 + <groupId>org.testcontainers</groupId>
  46 + <artifactId>testcontainers</artifactId>
  47 + <version>${testcontainers.version}</version>
  48 + </dependency>
  49 + <dependency>
  50 + <groupId>org.java-websocket</groupId>
  51 + <artifactId>Java-WebSocket</artifactId>
  52 + <version>${java-websocket.version}</version>
  53 + </dependency>
  54 + <dependency>
  55 + <groupId>org.apache.httpcomponents</groupId>
  56 + <artifactId>httpclient</artifactId>
  57 + <version>${httpclient.version}</version>
  58 + </dependency>
  59 + <dependency>
  60 + <groupId>io.takari.junit</groupId>
  61 + <artifactId>takari-cpsuite</artifactId>
  62 + </dependency>
  63 + <dependency>
  64 + <groupId>ch.qos.logback</groupId>
  65 + <artifactId>logback-classic</artifactId>
  66 + </dependency>
  67 + <dependency>
  68 + <groupId>com.google.code.gson</groupId>
  69 + <artifactId>gson</artifactId>
  70 + </dependency>
  71 + <dependency>
  72 + <groupId>org.apache.commons</groupId>
  73 + <artifactId>commons-lang3</artifactId>
  74 + </dependency>
  75 + <dependency>
  76 + <groupId>com.google.guava</groupId>
  77 + <artifactId>guava</artifactId>
  78 + </dependency>
  79 + <dependency>
  80 + <groupId>org.thingsboard</groupId>
  81 + <artifactId>netty-mqtt</artifactId>
  82 + </dependency>
  83 + <dependency>
  84 + <groupId>org.thingsboard</groupId>
  85 + <artifactId>tools</artifactId>
  86 + </dependency>
  87 + </dependencies>
  88 +
  89 + <build>
  90 + <plugins>
  91 + <plugin>
  92 + <groupId>org.apache.maven.plugins</groupId>
  93 + <artifactId>maven-surefire-plugin</artifactId>
  94 + <configuration>
  95 + <includes>
  96 + <include>**/*TestSuite.java</include>
  97 + </includes>
  98 + <skipTests>${blackBoxTests.skip}</skipTests>
  99 + </configuration>
  100 + </plugin>
  101 + </plugins>
  102 + </build>
  103 +
  104 +</project>
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa;
  17 +
  18 +import com.fasterxml.jackson.databind.ObjectMapper;
  19 +import com.google.common.collect.ImmutableMap;
  20 +import com.google.gson.JsonArray;
  21 +import com.google.gson.JsonObject;
  22 +import lombok.extern.slf4j.Slf4j;
  23 +import org.apache.commons.lang3.RandomStringUtils;
  24 +import org.apache.http.config.Registry;
  25 +import org.apache.http.config.RegistryBuilder;
  26 +import org.apache.http.conn.socket.ConnectionSocketFactory;
  27 +import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
  28 +import org.apache.http.conn.ssl.TrustStrategy;
  29 +import org.apache.http.conn.ssl.X509HostnameVerifier;
  30 +import org.apache.http.impl.client.CloseableHttpClient;
  31 +import org.apache.http.impl.client.HttpClients;
  32 +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
  33 +import org.apache.http.ssl.SSLContextBuilder;
  34 +import org.apache.http.ssl.SSLContexts;
  35 +import org.junit.*;
  36 +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
  37 +import org.thingsboard.client.tools.RestClient;
  38 +import org.thingsboard.server.common.data.Device;
  39 +import org.thingsboard.server.common.data.EntityType;
  40 +import org.thingsboard.server.common.data.id.DeviceId;
  41 +import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
  42 +
  43 +import javax.net.ssl.*;
  44 +import java.net.URI;
  45 +import java.security.cert.X509Certificate;
  46 +import java.util.List;
  47 +import java.util.Map;
  48 +import java.util.Random;
  49 +
  50 +@Slf4j
  51 +public abstract class AbstractContainerTest {
  52 + protected static final String HTTPS_URL = "https://localhost";
  53 + protected static final String WSS_URL = "wss://localhost";
  54 + protected static RestClient restClient;
  55 + protected ObjectMapper mapper = new ObjectMapper();
  56 +
  57 + @BeforeClass
  58 + public static void before() throws Exception {
  59 + restClient = new RestClient(HTTPS_URL);
  60 + restClient.getRestTemplate().setRequestFactory(getRequestFactoryForSelfSignedCert());
  61 + }
  62 +
  63 + protected Device createDevice(String name) {
  64 + return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT");
  65 + }
  66 +
  67 + protected WsClient subscribeToWebSocket(DeviceId deviceId, String scope, CmdsType property) throws Exception {
  68 + WsClient wsClient = new WsClient(new URI(WSS_URL + "/api/ws/plugins/telemetry?token=" + restClient.getToken()));
  69 + SSLContextBuilder builder = SSLContexts.custom();
  70 + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true);
  71 + wsClient.setSocket(builder.build().getSocketFactory().createSocket());
  72 + wsClient.connectBlocking();
  73 +
  74 + JsonObject cmdsObject = new JsonObject();
  75 + cmdsObject.addProperty("entityType", EntityType.DEVICE.name());
  76 + cmdsObject.addProperty("entityId", deviceId.toString());
  77 + cmdsObject.addProperty("scope", scope);
  78 + cmdsObject.addProperty("cmdId", new Random().nextInt(100));
  79 +
  80 + JsonArray cmd = new JsonArray();
  81 + cmd.add(cmdsObject);
  82 + JsonObject wsRequest = new JsonObject();
  83 + wsRequest.add(property.toString(), cmd);
  84 + wsClient.send(wsRequest.toString());
  85 + return wsClient;
  86 + }
  87 +
  88 + protected Map<String, Long> getExpectedLatestValues(long ts) {
  89 + return ImmutableMap.<String, Long>builder()
  90 + .put("booleanKey", ts)
  91 + .put("stringKey", ts)
  92 + .put("doubleKey", ts)
  93 + .put("longKey", ts)
  94 + .build();
  95 + }
  96 +
  97 + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, Long expectedTs, String expectedValue) {
  98 + List<Object> list = wsTelemetryResponse.getDataValuesByKey(key);
  99 + return expectedTs.equals(list.get(0)) && expectedValue.equals(list.get(1));
  100 + }
  101 +
  102 + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, String expectedValue) {
  103 + List<Object> list = wsTelemetryResponse.getDataValuesByKey(key);
  104 + return expectedValue.equals(list.get(1));
  105 + }
  106 +
  107 + protected JsonObject createPayload(long ts) {
  108 + JsonObject values = createPayload();
  109 + JsonObject payload = new JsonObject();
  110 + payload.addProperty("ts", ts);
  111 + payload.add("values", values);
  112 + return payload;
  113 + }
  114 +
  115 + protected JsonObject createPayload() {
  116 + JsonObject values = new JsonObject();
  117 + values.addProperty("stringKey", "value1");
  118 + values.addProperty("booleanKey", true);
  119 + values.addProperty("doubleKey", 42.0);
  120 + values.addProperty("longKey", 73L);
  121 +
  122 + return values;
  123 + }
  124 +
  125 + protected enum CmdsType {
  126 + TS_SUB_CMDS("tsSubCmds"),
  127 + HISTORY_CMDS("historyCmds"),
  128 + ATTR_SUB_CMDS("attrSubCmds");
  129 +
  130 + private final String text;
  131 +
  132 + CmdsType(final String text) {
  133 + this.text = text;
  134 + }
  135 +
  136 + @Override
  137 + public String toString() {
  138 + return text;
  139 + }
  140 + }
  141 +
  142 + private static HttpComponentsClientHttpRequestFactory getRequestFactoryForSelfSignedCert() throws Exception {
  143 + SSLContextBuilder builder = SSLContexts.custom();
  144 + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true);
  145 + SSLContext sslContext = builder.build();
  146 + SSLConnectionSocketFactory sslSelfSigned = new SSLConnectionSocketFactory(sslContext, new X509HostnameVerifier() {
  147 + @Override
  148 + public void verify(String host, SSLSocket ssl) {
  149 + }
  150 +
  151 + @Override
  152 + public void verify(String host, X509Certificate cert) {
  153 + }
  154 +
  155 + @Override
  156 + public void verify(String host, String[] cns, String[] subjectAlts) {
  157 + }
  158 +
  159 + @Override
  160 + public boolean verify(String s, SSLSession sslSession) {
  161 + return true;
  162 + }
  163 + });
  164 +
  165 + Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
  166 + .<ConnectionSocketFactory>create()
  167 + .register("https", sslSelfSigned)
  168 + .build();
  169 +
  170 + PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
  171 + CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build();
  172 + return new HttpComponentsClientHttpRequestFactory(httpClient);
  173 + }
  174 +
  175 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa;
  17 +
  18 +import org.junit.ClassRule;
  19 +import org.junit.extensions.cpsuite.ClasspathSuite;
  20 +import org.junit.runner.RunWith;
  21 +import org.testcontainers.containers.DockerComposeContainer;
  22 +import org.testcontainers.containers.wait.strategy.Wait;
  23 +
  24 +import java.io.File;
  25 +import java.time.Duration;
  26 +
  27 +@RunWith(ClasspathSuite.class)
  28 +@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*Test"})
  29 +public class ContainerTestSuite {
  30 +
  31 + @ClassRule
  32 + public static DockerComposeContainer composeContainer = new DockerComposeContainer(
  33 + new File("./../../docker/docker-compose.yml"),
  34 + new File("./../../docker/docker-compose.postgres.yml"))
  35 + .withPull(false)
  36 + .withLocalCompose(true)
  37 + .withTailChildContainers(true)
  38 + .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login").withStartupTimeout(Duration.ofSeconds(120)));
  39 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa;
  17 +
  18 +import com.fasterxml.jackson.databind.ObjectMapper;
  19 +import lombok.extern.slf4j.Slf4j;
  20 +import org.java_websocket.client.WebSocketClient;
  21 +import org.java_websocket.handshake.ServerHandshake;
  22 +import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
  23 +
  24 +import java.io.IOException;
  25 +import java.net.URI;
  26 +import java.util.concurrent.CountDownLatch;
  27 +import java.util.concurrent.TimeUnit;
  28 +
  29 +@Slf4j
  30 +public class WsClient extends WebSocketClient {
  31 + private static final ObjectMapper mapper = new ObjectMapper();
  32 + private WsTelemetryResponse message;
  33 +
  34 + private CountDownLatch latch = new CountDownLatch(1);;
  35 +
  36 + public WsClient(URI serverUri) {
  37 + super(serverUri);
  38 + }
  39 +
  40 + @Override
  41 + public void onOpen(ServerHandshake serverHandshake) {
  42 + }
  43 +
  44 + @Override
  45 + public void onMessage(String message) {
  46 + try {
  47 + WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class);
  48 + if (!response.getData().isEmpty()) {
  49 + this.message = response;
  50 + latch.countDown();
  51 + }
  52 + } catch (IOException e) {
  53 + log.error("ws message can't be read");
  54 + }
  55 + }
  56 +
  57 + @Override
  58 + public void onClose(int code, String reason, boolean remote) {
  59 + log.info("ws is closed, due to [{}]", reason);
  60 + }
  61 +
  62 + @Override
  63 + public void onError(Exception ex) {
  64 + ex.printStackTrace();
  65 + }
  66 +
  67 + public WsTelemetryResponse getLastMessage() {
  68 + try {
  69 + latch.await(10, TimeUnit.SECONDS);
  70 + return this.message;
  71 + } catch (InterruptedException e) {
  72 + log.error("Timeout, ws message wasn't received");
  73 + }
  74 + return null;
  75 + }
  76 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa.connectivity;
  17 +
  18 +import com.google.common.collect.Sets;
  19 +import org.junit.Assert;
  20 +import org.junit.Test;
  21 +import org.springframework.http.ResponseEntity;
  22 +import org.thingsboard.server.common.data.Device;
  23 +import org.thingsboard.server.common.data.security.DeviceCredentials;
  24 +import org.thingsboard.server.msa.AbstractContainerTest;
  25 +import org.thingsboard.server.msa.WsClient;
  26 +import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
  27 +
  28 +public class HttpClientTest extends AbstractContainerTest {
  29 +
  30 + @Test
  31 + public void telemetryUpload() throws Exception {
  32 + restClient.login("tenant@thingsboard.org", "tenant");
  33 +
  34 + Device device = createDevice("http_");
  35 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  36 +
  37 + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
  38 + ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate()
  39 + .postForEntity(HTTPS_URL + "/api/v1/{credentialsId}/telemetry",
  40 + mapper.readTree(createPayload().toString()),
  41 + ResponseEntity.class,
  42 + deviceCredentials.getCredentialsId());
  43 + Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful());
  44 + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
  45 + wsClient.closeBlocking();
  46 +
  47 + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"),
  48 + actualLatestTelemetry.getLatestValues().keySet());
  49 +
  50 + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString()));
  51 + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1"));
  52 + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0)));
  53 + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73)));
  54 +
  55 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  56 + }
  57 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa.connectivity;
  17 +
  18 +import com.fasterxml.jackson.databind.JsonNode;
  19 +import com.google.common.collect.Sets;
  20 +import com.google.common.util.concurrent.ListenableFuture;
  21 +import com.google.common.util.concurrent.ListeningExecutorService;
  22 +import com.google.common.util.concurrent.MoreExecutors;
  23 +import com.google.gson.JsonObject;
  24 +import io.netty.buffer.ByteBuf;
  25 +import io.netty.buffer.Unpooled;
  26 +import io.netty.handler.codec.mqtt.MqttQoS;
  27 +import lombok.Data;
  28 +import lombok.extern.slf4j.Slf4j;
  29 +import org.apache.commons.lang3.RandomStringUtils;
  30 +import org.junit.*;
  31 +import org.springframework.core.ParameterizedTypeReference;
  32 +import org.springframework.http.HttpMethod;
  33 +import org.springframework.http.ResponseEntity;
  34 +import org.thingsboard.mqtt.MqttClient;
  35 +import org.thingsboard.mqtt.MqttClientConfig;
  36 +import org.thingsboard.mqtt.MqttHandler;
  37 +import org.thingsboard.server.common.data.Device;
  38 +import org.thingsboard.server.common.data.id.RuleChainId;
  39 +import org.thingsboard.server.common.data.page.TextPageData;
  40 +import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
  41 +import org.thingsboard.server.common.data.rule.RuleChain;
  42 +import org.thingsboard.server.common.data.rule.RuleChainMetaData;
  43 +import org.thingsboard.server.common.data.rule.RuleNode;
  44 +import org.thingsboard.server.common.data.security.DeviceCredentials;
  45 +import org.thingsboard.server.msa.AbstractContainerTest;
  46 +import org.thingsboard.server.msa.WsClient;
  47 +import org.thingsboard.server.msa.mapper.AttributesResponse;
  48 +import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
  49 +
  50 +import java.io.IOException;
  51 +import java.nio.charset.StandardCharsets;
  52 +import java.util.*;
  53 +import java.util.concurrent.*;
  54 +
  55 +@Slf4j
  56 +public class MqttClientTest extends AbstractContainerTest {
  57 +
  58 + @Test
  59 + public void telemetryUpload() throws Exception {
  60 + restClient.login("tenant@thingsboard.org", "tenant");
  61 + Device device = createDevice("mqtt_");
  62 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  63 +
  64 + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
  65 + MqttClient mqttClient = getMqttClient(deviceCredentials, null);
  66 + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes()));
  67 + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
  68 + wsClient.closeBlocking();
  69 +
  70 + Assert.assertEquals(4, actualLatestTelemetry.getData().size());
  71 + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"),
  72 + actualLatestTelemetry.getLatestValues().keySet());
  73 +
  74 + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString()));
  75 + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1"));
  76 + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0)));
  77 + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73)));
  78 +
  79 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  80 + }
  81 +
  82 + @Test
  83 + public void telemetryUploadWithTs() throws Exception {
  84 + long ts = 1451649600512L;
  85 +
  86 + restClient.login("tenant@thingsboard.org", "tenant");
  87 + Device device = createDevice("mqtt_");
  88 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  89 +
  90 + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
  91 + MqttClient mqttClient = getMqttClient(deviceCredentials, null);
  92 + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes()));
  93 + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
  94 + wsClient.closeBlocking();
  95 +
  96 + Assert.assertEquals(4, actualLatestTelemetry.getData().size());
  97 + Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues());
  98 +
  99 + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", ts, Boolean.TRUE.toString()));
  100 + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", ts, "value1"));
  101 + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", ts, Double.toString(42.0)));
  102 + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", ts, Long.toString(73)));
  103 +
  104 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  105 + }
  106 +
  107 + @Test
  108 + public void publishAttributeUpdateToServer() throws Exception {
  109 + restClient.login("tenant@thingsboard.org", "tenant");
  110 + Device device = createDevice("mqtt_");
  111 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  112 +
  113 + WsClient wsClient = subscribeToWebSocket(device.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS);
  114 + MqttMessageListener listener = new MqttMessageListener();
  115 + MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
  116 + JsonObject clientAttributes = new JsonObject();
  117 + clientAttributes.addProperty("attr1", "value1");
  118 + clientAttributes.addProperty("attr2", true);
  119 + clientAttributes.addProperty("attr3", 42.0);
  120 + clientAttributes.addProperty("attr4", 73);
  121 + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes()));
  122 + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
  123 + wsClient.closeBlocking();
  124 +
  125 + Assert.assertEquals(4, actualLatestTelemetry.getData().size());
  126 + Assert.assertEquals(Sets.newHashSet("attr1", "attr2", "attr3", "attr4"),
  127 + actualLatestTelemetry.getLatestValues().keySet());
  128 +
  129 + Assert.assertTrue(verify(actualLatestTelemetry, "attr1", "value1"));
  130 + Assert.assertTrue(verify(actualLatestTelemetry, "attr2", Boolean.TRUE.toString()));
  131 + Assert.assertTrue(verify(actualLatestTelemetry, "attr3", Double.toString(42.0)));
  132 + Assert.assertTrue(verify(actualLatestTelemetry, "attr4", Long.toString(73)));
  133 +
  134 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  135 + }
  136 +
  137 + @Test
  138 + public void requestAttributeValuesFromServer() throws Exception {
  139 + restClient.login("tenant@thingsboard.org", "tenant");
  140 + Device device = createDevice("mqtt_");
  141 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  142 +
  143 + MqttMessageListener listener = new MqttMessageListener();
  144 + MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
  145 +
  146 + // Add a new client attribute
  147 + JsonObject clientAttributes = new JsonObject();
  148 + String clientAttributeValue = RandomStringUtils.randomAlphanumeric(8);
  149 + clientAttributes.addProperty("clientAttr", clientAttributeValue);
  150 + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes()));
  151 +
  152 + // Add a new shared attribute
  153 + JsonObject sharedAttributes = new JsonObject();
  154 + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
  155 + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue);
  156 + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate()
  157 + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
  158 + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class,
  159 + device.getId());
  160 + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful());
  161 +
  162 + // Subscribe to attributes response
  163 + mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE);
  164 + // Request attributes
  165 + JsonObject request = new JsonObject();
  166 + request.addProperty("clientKeys", "clientAttr");
  167 + request.addProperty("sharedKeys", "sharedAttr");
  168 + mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes()));
  169 + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS);
  170 + AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class);
  171 +
  172 + Assert.assertEquals(1, attributes.getClient().size());
  173 + Assert.assertEquals(clientAttributeValue, attributes.getClient().get("clientAttr"));
  174 +
  175 + Assert.assertEquals(1, attributes.getShared().size());
  176 + Assert.assertEquals(sharedAttributeValue, attributes.getShared().get("sharedAttr"));
  177 +
  178 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  179 + }
  180 +
  181 + @Test
  182 + public void subscribeToAttributeUpdatesFromServer() throws Exception {
  183 + restClient.login("tenant@thingsboard.org", "tenant");
  184 + Device device = createDevice("mqtt_");
  185 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  186 +
  187 + MqttMessageListener listener = new MqttMessageListener();
  188 + MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
  189 + mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE);
  190 +
  191 + String sharedAttributeName = "sharedAttr";
  192 +
  193 + // Add a new shared attribute
  194 + JsonObject sharedAttributes = new JsonObject();
  195 + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
  196 + sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue);
  197 + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate()
  198 + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
  199 + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class,
  200 + device.getId());
  201 + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful());
  202 +
  203 + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS);
  204 + Assert.assertEquals(sharedAttributeValue,
  205 + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText());
  206 +
  207 + // Update the shared attribute value
  208 + JsonObject updatedSharedAttributes = new JsonObject();
  209 + String updatedSharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
  210 + updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue);
  211 + ResponseEntity updatedSharedAttributesResponse = restClient.getRestTemplate()
  212 + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
  213 + mapper.readTree(updatedSharedAttributes.toString()), ResponseEntity.class,
  214 + device.getId());
  215 + Assert.assertTrue(updatedSharedAttributesResponse.getStatusCode().is2xxSuccessful());
  216 +
  217 + event = listener.getEvents().poll(10, TimeUnit.SECONDS);
  218 + Assert.assertEquals(updatedSharedAttributeValue,
  219 + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText());
  220 +
  221 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  222 + }
  223 +
  224 + @Test
  225 + public void serverSideRpc() throws Exception {
  226 + restClient.login("tenant@thingsboard.org", "tenant");
  227 + Device device = createDevice("mqtt_");
  228 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  229 +
  230 + MqttMessageListener listener = new MqttMessageListener();
  231 + MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
  232 + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE);
  233 +
  234 + // Send an RPC from the server
  235 + JsonObject serverRpcPayload = new JsonObject();
  236 + serverRpcPayload.addProperty("method", "getValue");
  237 + serverRpcPayload.addProperty("params", true);
  238 + ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
  239 + ListenableFuture<ResponseEntity> future = service.submit(() -> {
  240 + try {
  241 + return restClient.getRestTemplate()
  242 + .postForEntity(HTTPS_URL + "/api/plugins/rpc/twoway/{deviceId}",
  243 + mapper.readTree(serverRpcPayload.toString()), String.class,
  244 + device.getId());
  245 + } catch (IOException e) {
  246 + return ResponseEntity.badRequest().build();
  247 + }
  248 + });
  249 +
  250 + // Wait for RPC call from the server and send the response
  251 + MqttEvent requestFromServer = listener.getEvents().poll(10, TimeUnit.SECONDS);
  252 +
  253 + Assert.assertEquals("{\"method\":\"getValue\",\"params\":true}", Objects.requireNonNull(requestFromServer).getMessage());
  254 +
  255 + Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
  256 + JsonObject clientResponse = new JsonObject();
  257 + clientResponse.addProperty("response", "someResponse");
  258 + // Send a response to the server's RPC request
  259 + mqttClient.publish("v1/devices/me/rpc/response/" + requestId, Unpooled.wrappedBuffer(clientResponse.toString().getBytes()));
  260 +
  261 + ResponseEntity serverResponse = future.get(5, TimeUnit.SECONDS);
  262 + Assert.assertTrue(serverResponse.getStatusCode().is2xxSuccessful());
  263 + Assert.assertEquals(clientResponse.toString(), serverResponse.getBody());
  264 +
  265 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  266 + }
  267 +
  268 + @Test
  269 + public void clientSideRpc() throws Exception {
  270 + restClient.login("tenant@thingsboard.org", "tenant");
  271 + Device device = createDevice("mqtt_");
  272 + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
  273 +
  274 + MqttMessageListener listener = new MqttMessageListener();
  275 + MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
  276 + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE);
  277 +
  278 + // Get the default rule chain id to make it root again after test finished
  279 + RuleChainId defaultRuleChainId = getDefaultRuleChainId();
  280 +
  281 + // Create a new root rule chain
  282 + RuleChainId ruleChainId = createRootRuleChainForRpcResponse();
  283 +
  284 + // Send the request to the server
  285 + JsonObject clientRequest = new JsonObject();
  286 + clientRequest.addProperty("method", "getResponse");
  287 + clientRequest.addProperty("params", true);
  288 + Integer requestId = 42;
  289 + mqttClient.publish("v1/devices/me/rpc/request/" + requestId, Unpooled.wrappedBuffer(clientRequest.toString().getBytes()));
  290 +
  291 + // Check the response from the server
  292 + TimeUnit.SECONDS.sleep(1);
  293 + MqttEvent responseFromServer = listener.getEvents().poll(1, TimeUnit.SECONDS);
  294 + Integer responseId = Integer.valueOf(Objects.requireNonNull(responseFromServer).getTopic().substring("v1/devices/me/rpc/response/".length()));
  295 + Assert.assertEquals(requestId, responseId);
  296 + Assert.assertEquals("requestReceived", mapper.readTree(responseFromServer.getMessage()).get("response").asText());
  297 +
  298 + // Make the default rule chain a root again
  299 + ResponseEntity<RuleChain> rootRuleChainResponse = restClient.getRestTemplate()
  300 + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root",
  301 + null,
  302 + RuleChain.class,
  303 + defaultRuleChainId);
  304 + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful());
  305 +
  306 + // Delete the created rule chain
  307 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/ruleChain/{ruleChainId}", ruleChainId);
  308 + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
  309 + }
  310 +
  311 + private RuleChainId createRootRuleChainForRpcResponse() throws Exception {
  312 + RuleChain newRuleChain = new RuleChain();
  313 + newRuleChain.setName("testRuleChain");
  314 + ResponseEntity<RuleChain> ruleChainResponse = restClient.getRestTemplate()
  315 + .postForEntity(HTTPS_URL + "/api/ruleChain",
  316 + newRuleChain,
  317 + RuleChain.class);
  318 + Assert.assertTrue(ruleChainResponse.getStatusCode().is2xxSuccessful());
  319 + RuleChain ruleChain = ruleChainResponse.getBody();
  320 +
  321 + JsonNode configuration = mapper.readTree(this.getClass().getClassLoader().getResourceAsStream("RpcResponseRuleChainMetadata.json"));
  322 + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
  323 + ruleChainMetaData.setRuleChainId(ruleChain.getId());
  324 + ruleChainMetaData.setFirstNodeIndex(configuration.get("firstNodeIndex").asInt());
  325 + ruleChainMetaData.setNodes(Arrays.asList(mapper.treeToValue(configuration.get("nodes"), RuleNode[].class)));
  326 + ruleChainMetaData.setConnections(Arrays.asList(mapper.treeToValue(configuration.get("connections"), NodeConnectionInfo[].class)));
  327 +
  328 + ResponseEntity<RuleChainMetaData> ruleChainMetadataResponse = restClient.getRestTemplate()
  329 + .postForEntity(HTTPS_URL + "/api/ruleChain/metadata",
  330 + ruleChainMetaData,
  331 + RuleChainMetaData.class);
  332 + Assert.assertTrue(ruleChainMetadataResponse.getStatusCode().is2xxSuccessful());
  333 +
  334 + // Set a new rule chain as root
  335 + ResponseEntity<RuleChain> rootRuleChainResponse = restClient.getRestTemplate()
  336 + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root",
  337 + null,
  338 + RuleChain.class,
  339 + ruleChain.getId());
  340 + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful());
  341 +
  342 + return ruleChain.getId();
  343 + }
  344 +
  345 + private RuleChainId getDefaultRuleChainId() {
  346 + ResponseEntity<TextPageData<RuleChain>> ruleChains = restClient.getRestTemplate().exchange(
  347 + HTTPS_URL + "/api/ruleChains?limit=40&textSearch=",
  348 + HttpMethod.GET,
  349 + null,
  350 + new ParameterizedTypeReference<TextPageData<RuleChain>>() {
  351 + });
  352 +
  353 + Optional<RuleChain> defaultRuleChain = ruleChains.getBody().getData()
  354 + .stream()
  355 + .filter(RuleChain::isRoot)
  356 + .findFirst();
  357 + if (!defaultRuleChain.isPresent()) {
  358 + Assert.fail("Root rule chain wasn't found");
  359 + }
  360 + return defaultRuleChain.get().getId();
  361 + }
  362 +
  363 + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException {
  364 + MqttClientConfig clientConfig = new MqttClientConfig();
  365 + clientConfig.setClientId("MQTT client from test");
  366 + clientConfig.setUsername(deviceCredentials.getCredentialsId());
  367 + MqttClient mqttClient = MqttClient.create(clientConfig, listener);
  368 + mqttClient.connect("localhost", 1883).sync();
  369 + return mqttClient;
  370 + }
  371 +
  372 + @Data
  373 + private class MqttMessageListener implements MqttHandler {
  374 + private final BlockingQueue<MqttEvent> events;
  375 +
  376 + private MqttMessageListener() {
  377 + events = new ArrayBlockingQueue<>(100);
  378 + }
  379 +
  380 + @Override
  381 + public void onMessage(String topic, ByteBuf message) {
  382 + log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic);
  383 + events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8)));
  384 + }
  385 + }
  386 +
  387 + @Data
  388 + private class MqttEvent {
  389 + private final String topic;
  390 + private final String message;
  391 + }
  392 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa.mapper;
  17 +
  18 +import lombok.Data;
  19 +
  20 +import java.util.Map;
  21 +
  22 +@Data
  23 +public class AttributesResponse {
  24 + private Map<String, Object> client;
  25 + private Map<String, Object> shared;
  26 +}
... ...
  1 +/**
  2 + * Copyright © 2016-2018 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.msa.mapper;
  17 +
  18 +import lombok.Data;
  19 +
  20 +import java.io.Serializable;
  21 +import java.util.Collection;
  22 +import java.util.List;
  23 +import java.util.Map;
  24 +import java.util.stream.Collectors;
  25 +
  26 +@Data
  27 +public class WsTelemetryResponse implements Serializable {
  28 + private int subscriptionId;
  29 + private int errorCode;
  30 + private String errorMsg;
  31 + private Map<String, List<List<Object>>> data;
  32 + private Map<String, Object> latestValues;
  33 +
  34 + public List<Object> getDataValuesByKey(String key) {
  35 + return data.entrySet().stream()
  36 + .filter(e -> e.getKey().equals(key))
  37 + .flatMap(e -> e.getValue().stream().flatMap(Collection::stream))
  38 + .collect(Collectors.toList());
  39 + }
  40 +}
... ...
  1 +{
  2 + "firstNodeIndex": 0,
  3 + "nodes": [
  4 + {
  5 + "additionalInfo": {
  6 + "layoutX": 325,
  7 + "layoutY": 150
  8 + },
  9 + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode",
  10 + "name": "msgTypeSwitch",
  11 + "debugMode": true,
  12 + "configuration": {
  13 + "version": 0
  14 + }
  15 + },
  16 + {
  17 + "additionalInfo": {
  18 + "layoutX": 60,
  19 + "layoutY": 300
  20 + },
  21 + "type": "org.thingsboard.rule.engine.transform.TbTransformMsgNode",
  22 + "name": "formResponse",
  23 + "debugMode": true,
  24 + "configuration": {
  25 + "jsScript": "if (msg.method == \"getResponse\") {\n return {msg: {\"response\": \"requestReceived\"}, metadata: metadata, msgType: msgType};\n}\n\nreturn {msg: msg, metadata: metadata, msgType: msgType};"
  26 + }
  27 + },
  28 + {
  29 + "additionalInfo": {
  30 + "layoutX": 450,
  31 + "layoutY": 300
  32 + },
  33 + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode",
  34 + "name": "rpcReply",
  35 + "debugMode": true,
  36 + "configuration": {
  37 + "requestIdMetaDataAttribute": "requestId"
  38 + }
  39 + }
  40 + ],
  41 + "connections": [
  42 + {
  43 + "fromIndex": 0,
  44 + "toIndex": 1,
  45 + "type": "RPC Request from Device"
  46 + },
  47 + {
  48 + "fromIndex": 1,
  49 + "toIndex": 2,
  50 + "type": "Success"
  51 + },
  52 + {
  53 + "fromIndex": 1,
  54 + "toIndex": 2,
  55 + "type": "Failure"
  56 + }
  57 + ],
  58 + "ruleChainConnections": null
  59 +}
\ No newline at end of file
... ...
... ... @@ -16,7 +16,7 @@
16 16
17 17 -->
18 18 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
19   - xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  19 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
20 20 <modelVersion>4.0.0</modelVersion>
21 21 <parent>
22 22 <groupId>org.thingsboard</groupId>
... ... @@ -41,6 +41,7 @@
41 41 <module>web-ui</module>
42 42 <module>tb-node</module>
43 43 <module>transport</module>
  44 + <module>black-box-tests</module>
44 45 </modules>
45 46
46 47 <build>
... ...