diff --git a/application/pom.xml b/application/pom.xml
index 67b61a5737..9489c627a1 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -572,6 +572,29 @@
${project.build.directory}
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ verify-openapi-spec-generated
+ verify
+
+ enforce
+
+
+
+
+
+ ${project.build.directory}/openapi.json
+
+ OpenAPI spec was not generated — target/openapi.json is missing. The springdoc-openapi-maven-plugin logs HTTP failures but does not fail the build; scan the log above for "An error has occured" or a 5xx response from /v3/api-docs/thingsboard and fix the underlying issue (e.g. duplicate @Schema names rejected by SwaggerConfiguration).
+
+
+
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json
index 81cb2fdbb1..11d9bfbb9d 100644
--- a/application/src/main/data/json/system/widget_bundles/cards.json
+++ b/application/src/main/data/json/system/widget_bundles/cards.json
@@ -24,6 +24,7 @@
"cards.html_value_card",
"cards.markdown_card",
"cards.simple_card",
- "unread_notifications"
+ "unread_notifications",
+ "html_container"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json
index c8215d7fe1..e242bb00e7 100644
--- a/application/src/main/data/json/system/widget_bundles/html_widgets.json
+++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json
@@ -11,6 +11,7 @@
"widgetTypeFqns": [
"cards.html_card",
"cards.html_value_card",
- "cards.markdown_card"
+ "cards.markdown_card",
+ "html_container"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json
new file mode 100644
index 0000000000..25b9e5ac5d
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/html_container.json
@@ -0,0 +1,52 @@
+{
+ "fqn": "html_container",
+ "name": "HTML Container",
+ "deprecated": false,
+ "image": "tb-image;/api/images/system/html-container.png",
+ "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.",
+ "descriptor": {
+ "type": "static",
+ "sizeX": 9.5,
+ "sizeY": 5.5,
+ "resources": [],
+ "templateHtml": "\n",
+ "templateCss": "",
+ "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n",
+ "settingsDirective": "tb-html-container-widget-settings",
+ "hasBasicMode": true,
+ "basicModeDirective": "tb-html-container-basic-config",
+ "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}"
+ },
+ "resources": [
+ {
+ "link": "/api/images/system/html-container.png",
+ "title": "\"HTML Container\" system widget image",
+ "type": "IMAGE",
+ "subType": "IMAGE",
+ "fileName": "html-container.png",
+ "publicResourceKey": "0CBg8htTwiFsclrm44sIp5pQNS4MM35l",
+ "mediaType": "image/png",
+ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAADt9JREFUeAHtnU9oHccdx39OnCCVOrYcCESmCKxDITYkUB9aYkNb4tySQ0rsWxzowT600Bwa4h56yMW9tb05t6a3+Bjf7EAP8qEF6RCIAg2VSgtRoCVSHAfbKcbtfrrzy47Wu6NdvSe9Wfn7gZH2zZvdtzsz3/m785t9VrK/cE8W7vHCPWJiUhwI/2+ZmCS3C7dRuHt8QBzfKdwhkzgmzcHgxORAA09YqYn9iGOmcDcL96UJIe5bqQWE8iR/vlW4r0wIEYNIHvcm1X0TQsSgiUfU5xAigQQiRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCfabEP1gecTZwh2N/FhcdKkl/MVwjrNSuCvhnOxRDSL6csE2i6Mv8+Eag0ACEX1AGDM2Olxj3gaAmliiD4ejY28qbcXl6Phc4WbD8TiEtuMMoQYhQs8XbqrHOVPhnFkTO8lG5LqEuWsDI3eBkMFpr3q7tYtIpqJzJBIxEjkLxMUxFX0+0eG841aJYtokEjECuQqkLg64XrgbHc5dDGEdiURsmxwF0iaO690v8UB4iURsi9wEMg5xtJ0nkYje5CSQcYqj7XyJZDRGmSCs87QNgFwEshPiaLuORLI9GBXsMkiS4nZ0fMoGMFn4qJU2eW/a5NhJcTir4b8nyGOFe7Zwn1hehqI9Dr62PDhduGOF+3E4jiF9PrN+ENexyDgm/Z+yMm1WLS8OTVoguyEOZwgiyU0gpM2cbZ71ZsKP9PmL9ccnDGleTQc/F8e87Uy6j8IhvYsltsMoM+IIY58NhEnXIJTcfy3cc1a9F+Yl/Lir29O2uZlwp3DvFG7N8iG3GgRIBwTxVPhMBj8W/Pu+sk6T6hWrag/gna5lK2tyNbEa2A2RDEEckJtAVoP70MqMHPcfpoJ/H162zc01XmT8wPIUB2TTxCKjEllx1V3P1NtlKOLIHTJw/CbDtI3GouUpik3k1AfZCZFIHONlnPGWvTggt076OEUicYiRyXEUaxwikTjEWMh1mLdNJF1mck+axCHGRM7zIHWRfFq4jzqct2SVGCQOMRK5TxS6SBhiJKN3maByUayaxCFGZAhGG8jg7/Q75f8iuWxiJ2GYt/4KShODMM7QhqyaiD6sR8e8R3cxHKcMx/E+V5NIZDhO7Dlotq7b6HCNFRsAEojoi/fvtov3JwfDnIlcOBicyIM51SBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRIMfX3bG3xFqD+HVoXpe+Y82mZu6E//Vz/DwL/n6Nwe2TlymkE5t6Eqf1eHfD4Gsdw2dLjgJxe71vRn5sXI8dJdakk9HdwBqZnaW4HxfujD1o19f3474UrrFi+dl/HSKeRry2TloQ/+9bmTZvRP689XslET57hrZgylcJIgY3ogwIh5Ip3r/C9/QeTGk1INjO+ZqVhuRcFJgPfd5KuwGIghqdBVWI4WSDP+GzXxOScx9kJnJdoDo/YpXZUkSzaGLcYJmdNPG4pQD6g5U1OdshuKV2CqxfWymMlQb/QSyYyrkGuRAdxwnSBiXTQuGesbIqRyCUWNlv0jIwvB8Y9+V8ARU1OoJ51UoL7r6h6vUQ3v2v20AKr5wFEq9xvtAhPAlHlf9W4b6wMgHGsTxUbMabrPQlXCRYe/cO+WJwNHFft2qZ7kJwhD0brqMm1i5D9U01j+G4JRM7ARmbDP9i+EztTp+EESoy/svB3wUzFb53/5XIP3v2olUTSq8XrEyI+qaTsUnSwa2Nzog/Fu41K2sDYESK+EQ47P/oFjDpl9AZ3wjh3d/3BBkEWpOeD0Nbk942gDJlzTVEm3+uzMkulhiFtiH0uz39s0WvmgiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBLyseMC0q1EuDOlN14eBA6pBhEhADXKrcDdN5ITSIw8OqQYRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoE20BHjgLX0x63cItp3wWVznbXgBrtXvQQiRgFhsDnncUsbnPDtoAcnFAlEbBffzrmLJZYTITz71g9m8054tHCHTEYCcsEz29eWN6cKd8Y2F7DsXMs+9X8OjqbVvcI9Fb5/rHDPWblV9z9tGBwaSg0yW7iT0Wc2p19sCPM9q9rAbTSdK7pDPL8UfWarZ7aFrjefiGcEw064bA19OPjTJGMv+xUbAEMRCCXriZpfnMlJhAvWvbpHSNojfXuci44RB/GY2r0W4fzeSpHMBj9qn9/aAHa93SvDvGT6PlYJ5619j2/RDvHs8bZuZc3RJZPTrHonnGPhGidsAGgeRPQhztRto1JT1lxYIZIPos/HbADs1VEsSrZPa360m4+b6AuZ/VRwccZfagl7IRxftgdrl+XgR7hZGwB7VSCUVvXSjQT9rBZGpCETN/XtmjrYLg7P+BzXRUKcr4cwDKbQ1Mp6bmSvCuTpBj8SipEVEmiwM7u7SNPAB8Lw2fGYujjMKnHVRRIf02RbbrheNuwVgdSr8pe3CP++lUOQop1YHBQqTPKtNoRrEofTJhLndHAr4frZFVx7pZPOkG+fUohE0V4c7dRHqxiB6isOp62ZFsOo4i8sw35JTjUIY+Px0GufeQratr8r3NFEGCYavZM+HVz24/ATostoFVBTx5magZEj4Xgt+m42hKWWoDDzPgzp7e9xkR7MsWQ1P5KTQEaZmzgazndIhHqiDmLcPRPiTJ96d+pqCEu6vWtlGhyJzqMp+3rhPg/HUH+LAQH6TDvXORX8siDnPkgslq1eHyFhTkefEUd9pEVNqu54XBGPqdLcJwBJK2qM+dr3NMvof6wnrsNvULP48DDzIxJIA0RULIqLibBbDdGeMTEKPlfRpVC5Y+n06NI3XLVM50dy6qT3eXltyUZH8yDt+Csh1Nzz9hCTk0CoVru8ZUu4eqnEeX06dh+ZOugp4n7HC7bzMG/ltVVWcyK59UF8rD1euulQ4i9b83AjzTNGP07a1v0Vwi6YSMEckb9aQg1Ck5VO9k4UKvzG2eizBLIF212rQca/amIcUBhds2rClRFAhIJw1hrCNmXqGdu6eebrfGaia2XTQQctuRVtIAZqYx8dJBO/1BCOAu1KOI6H1k9Y/6H17GbTJRCRwicJ6Ycc7hDe36vqOxKVepVlouwr3Fzh/mEiBw6G/znaCPDJ2PpkLqOP8agitc4xS7/V4Hi/ElHlOGgy980fkQUHrRKJmDxzWlEoRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCSQQIRJIIEIkmKRAxrk/B9fquwaB8EM0BTRTc5N4Bpbj1uM7vqc9w6QWTBGJmPW5ZKOtIOM6Z62yEs7/y9btmqyzZkFQm2E0VsPdsfw2nSTe4ufDAEXTUmPWbrAu430bPyyTrS+1je+LdLhumS2f3Q65rCj0hTgsvtkInzFjeTf4swhnrSUcm5BeCtd5zcrEI8P43t1uRI7Ph8O1SNwr4b9ffzqEWbZqB6R1y3Ofb+49NpNEae5bPnjJ/oyVAnG7xfX4gPlwHt/dDceHrYoHi87rUlj4fbFYCkNwC+G68+E6cVzW09IS9+h5wZ8TpqPzreUe23637tfKpHa55eGopt16xvOF+8rK2oAdUFkHvT8cv164L62MgHo4IuRE8CMzs7LtEysj7s1w3g/Db5LwP7UykomYV6wS2U/C733fykyF/7NWRuS/g9sNuuxyS9ywNJVnd/vC3PfJ4P9LK3eZdQPUfM/z/DycTzji4l9WGoz+bvj+VDjnQLjedHQevzUf/JdCWH5rrXZfS+EcnuMHhftT4X5k5ZJddrnFCMSH1pzmbuT6llVphv/F8Dw8x89sc5rNhvtw49jz4RmWwm89G57nRSsLCtL8WM3vnrWTxS63NBG4URKNmydRl8MxD0pp9J6ViRGHwzQQAqO2IFLOhO9pUpyyqumxFL7nfDdyDbGpUkqoK1Y1/bAzS8RvWJ77evu2AUCTkqYMmd1LbZ6V5z4cviPsejjmGc9ZZfrIm6QUQMtReBfXuyEcme/kFveFUQfi+Ei4zt1wLd/xi0zsafd3K9PLbZSdDefcCPdxztJbVPwtXJfr/TccT4d7OBru9VJ079yTC+qadeyz5iAQEobmwMdW2bQi4ohYHt6r4NO1cN4kcoF5wiMOakXfTYrMgmgQyVbrnr3K3cq21qSpN7GAhPdapI4PYrj9qXinrVQzg/glTonjLnGCKG+H31qwqsNODeJxz38KLtLzvFUinInCbFjVhGrDw3pNB9509vXwZ6PrWfgdfpfCZM0qayyt7OYoFhn3fDh29XLjZFw2d6T08PaldwApDdxOVlM4qslz0bUoJUlUMoBHEiLbylavJ/7R6PfNhjPKRUZCHIvWXMoTH8TP5eCuWTe8j0KG7lKTes3F771olfHrq+F3KagQmzevfxPOI2297+K/G+8E5oVhk0h9Sze+9z3ZPf3es6qG5fqnwz3wu0esskTfym7WIEQM7c63rawSY3P4KP1O8Pft00hEmgxeIi7UwhEhPPhL4ZpuRJnEIFHYC/1iCEdJkSoBvWk1bVXTgMSmHfuF5WeJ8Xx0vBj95znfslIk3g4nLM0P4vXtEHbBuhnoI1NR+hM3PqDRpdDwtON3yKSvWmlBh3TDGjzNrXNWDRAsRs8VpxncCNdqM5K9Gp71V1Y9GwK9btXI2rpVgnvDqr0SO9mD3m2rJk1Ww7taEm8L56VHk/9WeFVvI9zXuBi3VZP6/W/3ecYxtzEzgt8oeWOmY7gm5ibRB7nb0a/rue4/ynXbwg/dwPW4nmccw9wbI/h1ue/U/iNdwjUiy4p7YDJL7Bx6F0uIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCBHLfJBQh6jzif/5j5QISIUTFtwt3G4F8XrgnrBSJahLxsIMG/KXRjX3Bk3eyfL22mBxek98yMSnoctCqouK49z8r21UdWLc82QAAAABJRU5ErkJggg==",
+ "public": true
+ }
+ ],
+ "scada": false,
+ "tags": [
+ "html",
+ "css",
+ "javascript",
+ "custom",
+ "script",
+ "code",
+ "container",
+ "angular",
+ "template",
+ "external resources",
+ "widget api",
+ "advanced",
+ "custom visualization",
+ "custom action",
+ "web",
+ "markup"
+ ]
+}
\ No newline at end of file
diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql
new file mode 100644
index 0000000000..790dc50b52
--- /dev/null
+++ b/application/src/main/data/upgrade/lts/schema_update.sql
@@ -0,0 +1,25 @@
+--
+-- Copyright © 2016-2026 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.
+--
+
+-- LTS cumulative schema update file.
+-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.).
+-- This file is executed by SystemPatchApplier on every version increase within the LTS family.
+
+-- CALCULATED FIELD ADDITIONAL INFO ADDITION START
+
+ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
+
+-- CALCULATED FIELD ADDITIONAL INFO ADDITION END
diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index 67ef7f6ab1..4266c97303 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
@@ -36,6 +36,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.SmsService;
@@ -691,6 +692,10 @@ public class ActorSystemContext {
@Getter
private MqttClientSettings mqttClientSettings;
+ @Autowired(required = false)
+ @Getter
+ private TbHttpClientSettings tbHttpClientSettings;
+
@Getter
@Setter
private TbActorSystem actorSystem;
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
index a2dc9b50f4..b69719f1a0 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
@@ -485,7 +485,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
- state.init(false);
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isCfHasRelationPathQuerySource()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
@@ -494,6 +493,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
Map arguments = fetchArguments(ctx);
state.update(arguments, ctx);
+ state.init(false);
state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
states.put(ctx.getCfId(), state);
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index f93bb34af8..76f79bf1f9 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
@@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
@@ -1068,6 +1069,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getMqttClientSettings();
}
+ @Override
+ public TbHttpClientSettings getTbHttpClientSettings() {
+ return mainCtx.getTbHttpClientSettings();
+ }
+
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString());
diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
index d1c036d38e..db867234e2 100644
--- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
@@ -15,6 +15,7 @@
*/
package org.thingsboard.server.config;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
@@ -22,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.core.converter.ModelConverters;
+import io.swagger.v3.core.converter.ResolvedSchema;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.oas.models.Components;
@@ -62,12 +64,14 @@ import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse;
import org.thingsboard.server.exception.ThingsboardErrorResponse;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
import org.thingsboard.server.service.security.auth.rest.LoginResponse;
+import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.ByteBuffer;
@@ -76,6 +80,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Deque;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -115,6 +120,8 @@ public class SwaggerConfiguration {
// Keyed by the schema name that swagger-core generates (see resolveSchemaName).
private final Map> schemaPropertyOrders = new ConcurrentHashMap<>();
private final Map> schemaOwnProps = new ConcurrentHashMap<>();
+ // Tracks schema name → fully-qualified class names to detect collisions.
+ private final Map> schemaNameToClasses = new ConcurrentHashMap<>();
@Value("${swagger.api_path:/api/**}")
private String apiPath;
@@ -160,6 +167,9 @@ public class SwaggerConfiguration {
if (StringUtils.isEmpty(apiVersion)) {
apiVersion = appVersion;
}
+ if (apiVersion != null && apiVersion.endsWith("-SNAPSHOT")) {
+ apiVersion = apiVersion.substring(0, apiVersion.length() - "-SNAPSHOT".length());
+ }
Info info = new Info()
.title(title)
@@ -294,6 +304,19 @@ public class SwaggerConfiguration {
@Lazy(false)
ModelConverter mapAwareConverter() {
return (type, context, chain) -> {
+ // Strip field-level @JsonIgnoreProperties from context annotations so it
+ // doesn't pollute the global schema. The OpenAPI schema should show all
+ // properties; field-level ignore is a serialization concern only.
+ Annotation[] ctxAnnotations = type.getCtxAnnotations();
+ if (ctxAnnotations != null) {
+ Annotation[] filtered = Arrays.stream(ctxAnnotations)
+ .filter(a -> !(a instanceof JsonIgnoreProperties))
+ .toArray(Annotation[]::new);
+ if (filtered.length != ctxAnnotations.length) {
+ type.ctxAnnotations(filtered);
+ }
+ }
+
JavaType javaType = Json.mapper().constructType(type.getType());
if (javaType != null) {
Class> cls = javaType.getRawClass();
@@ -326,6 +349,10 @@ public class SwaggerConfiguration {
try {
var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType);
String schemaName = resolveSchemaName(javaType);
+ Set classes = schemaNameToClasses.computeIfAbsent(schemaName, k -> ConcurrentHashMap.newKeySet());
+ if (classes.add(cls.getName()) && classes.size() > 1) {
+ log.error("Duplicate OpenAPI schema name '{}' mapped by: {}. Use @Schema(name = ...) to disambiguate.", schemaName, classes);
+ }
schemaPropertyOrders.put(schemaName, resolvePropertyOrder(cls, beanDesc));
Set ownProps = computeOwnPropNames(cls, beanDesc);
if (!ownProps.isEmpty()) {
@@ -350,12 +377,26 @@ public class SwaggerConfiguration {
._enum(Arrays.stream(ThingsboardErrorCode.values())
.map(ThingsboardErrorCode::getErrorCode)
.collect(Collectors.toList()));
- openAPI.getComponents()
- .addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema)
- .addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema)
- .addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema)
- .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema)
- .addSchemas("ThingsboardErrorCode", errorCodeSchema);
+ Components components = openAPI.getComponents();
+ registerSchema(components, "LoginRequest", LoginRequest.class);
+ registerSchema(components, "LoginResponse", LoginResponse.class);
+ registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class);
+ registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class);
+ components.addSchemas("ThingsboardErrorCode", errorCodeSchema);
+ registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class);
+ }
+
+ private static void registerSchema(Components components, String name, Class> cls) {
+ ResolvedSchema resolved = ModelConverters.getInstance()
+ .readAllAsResolvedSchema(new AnnotatedType().type(cls));
+ components.addSchemas(name, resolved.schema);
+ if (resolved.referencedSchemas != null) {
+ resolved.referencedSchemas.forEach((refName, refSchema) -> {
+ if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) {
+ components.addSchemas(refName, refSchema);
+ }
+ });
+ }
}
private OperationCustomizer operationCustomizer() {
@@ -372,6 +413,19 @@ public class SwaggerConfiguration {
var apiKeyRequirement = createSecurityRequirement(API_KEY_SCHEME);
return openAPI -> {
+ // Fail fast on duplicate schema names — two different classes resolving to the same
+ // OpenAPI schema name causes one to silently overwrite the other.
+ List duplicates = schemaNameToClasses.entrySet().stream()
+ .filter(e -> e.getValue().size() > 1)
+ .map(e -> "'" + e.getKey() + "' mapped by: " + e.getValue())
+ .sorted()
+ .toList();
+ if (!duplicates.isEmpty()) {
+ throw new IllegalStateException(
+ "Duplicate OpenAPI schema names detected. Use @Schema(name = ...) to disambiguate:\n "
+ + String.join("\n ", duplicates));
+ }
+
var paths = openAPI.getPaths();
paths.entrySet().stream()
.peek(entry -> {
@@ -407,6 +461,64 @@ public class SwaggerConfiguration {
}
});
+ // Springdoc creates duplicate schemas with an "Object" suffix when a type is
+ // resolved through multiple inheritance paths or via generic type resolution.
+ // Remove the "*Object" duplicate when the base schema exists (either
+ // pre-registered in addDefaultSchemas or generated by springdoc).
+ for (String name : new ArrayList<>(schemas.keySet())) {
+ if (!name.endsWith("Object")) continue;
+ String baseName = name.substring(0, name.length() - "Object".length());
+ if (!schemas.containsKey(baseName)) continue;
+
+ schemas.remove(name);
+ String refToRemove = "#/components/schemas/" + name;
+ schemas.values().forEach(s -> {
+ if (s.getAllOf() != null) {
+ s.getAllOf().removeIf(allOfEntry -> refToRemove.equals(((Schema>) allOfEntry).get$ref()));
+ }
+ });
+ log.debug("Removed duplicate schema '{}' (base '{}' exists)", name, baseName);
+ }
+
+ // Remove duplicate or redundant inline entries in allOf. Springdoc can
+ // generate multiple inline property blocks when resolving a type through
+ // multiple parent paths (e.g. record + sealed interface). One block may be
+ // a strict subset of another (same properties, but the superset has extras
+ // like "modelType"). Keep only the superset in that case.
+ schemas.values().forEach(schema -> {
+ if (schema.getAllOf() != null && schema.getAllOf().size() > 1) {
+ List allOf = schema.getAllOf();
+ Set redundant = new HashSet<>();
+ for (int i = 0; i < allOf.size(); i++) {
+ if (redundant.contains(i)) continue;
+ Schema a = allOf.get(i);
+ if (a.get$ref() != null || a.getProperties() == null) continue;
+ for (int j = i + 1; j < allOf.size(); j++) {
+ if (redundant.contains(j)) continue;
+ Schema b = allOf.get(j);
+ if (b.get$ref() != null || b.getProperties() == null) continue;
+ if (a.getProperties().entrySet().containsAll(b.getProperties().entrySet())) {
+ redundant.add(j); // b is a subset of a
+ } else if (b.getProperties().entrySet().containsAll(a.getProperties().entrySet())) {
+ redundant.add(i); // a is a subset of b
+ break;
+ }
+ }
+ }
+ if (!redundant.isEmpty()) {
+ List filtered = new ArrayList<>();
+ for (int i = 0; i < allOf.size(); i++) {
+ if (!redundant.contains(i)) {
+ filtered.add(allOf.get(i));
+ }
+ }
+ allOf.clear();
+ allOf.addAll(filtered);
+ }
+ }
+ });
+
+
// Fix polymorphic properties: replace inline oneOf with base type $ref
schemas.values().forEach(schema -> {
replaceInlineOneOfProperties(schema, schemas);
@@ -433,6 +545,12 @@ public class SwaggerConfiguration {
reorderSchemaProperties(schema, propOrder);
});
+ // Synthesize a request-body example for every schema that uses a discriminator.
+ // Without this, Swagger UI shows only the discriminator-property field for
+ // polymorphic types (the parent schema doesn't know which oneOf branch to pick).
+ // We resolve the first declared subtype and inline its full property tree.
+ schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas));
+
// Fix polymorphic request/response bodies: replace inline oneOf with base type $ref
paths.values().stream()
.flatMap(pathItem -> pathItem.readOperationsMap().values().stream())
@@ -505,6 +623,22 @@ public class SwaggerConfiguration {
if (baseType != null) {
return baseType;
}
+
+ // Check if other oneOf items extend this candidate via allOf (parent-child without discriminator)
+ if (candidate != null) {
+ boolean isParent = oneOfSchemas.stream()
+ .filter(s -> s.get$ref() != null && !s.get$ref().equals(ref))
+ .anyMatch(s -> {
+ String otherName = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1);
+ Schema> otherSchema = schemas.get(otherName);
+ return otherSchema != null && otherSchema.getAllOf() != null &&
+ otherSchema.getAllOf().stream().anyMatch(
+ a -> a.get$ref() != null && a.get$ref().endsWith("/" + refName));
+ });
+ if (isParent) {
+ return refName;
+ }
+ }
}
return null;
}
@@ -546,6 +680,16 @@ public class SwaggerConfiguration {
log.debug("Replaced oneOf in additionalProperties with $ref to {} in property {}", baseType, propName);
}
}
+ // Check if additionalProperties is an array whose items has oneOf (e.g. Map>)
+ if (additionalProps.getItems() != null && additionalProps.getItems().getOneOf() != null && !additionalProps.getItems().getOneOf().isEmpty()) {
+ String baseType = findBaseTypeForOneOf(allSchemas, additionalProps.getItems().getOneOf());
+ if (baseType != null) {
+ Schema> refSchema = new Schema<>();
+ refSchema.set$ref("#/components/schemas/" + baseType);
+ additionalProps.setItems(refSchema);
+ log.debug("Replaced oneOf in additionalProperties.items with $ref to {} in property {}", baseType, propName);
+ }
+ }
}
// If property has oneOf, try to find the base discriminated type
@@ -737,6 +881,145 @@ public class SwaggerConfiguration {
}
}
+ private static final int MAX_EXAMPLE_DEPTH = 4;
+
+ /**
+ * If {@code schema} has a discriminator, populate examples for the parent and every
+ * concrete subtype it maps to. Each subtype gets its own example with the discriminator
+ * field set to the mapping value that points at it, so fields typed as a specific
+ * subtype (e.g. {@code EntityView.id} → {@code EntityViewId}) resolve to a correct
+ * example without falling back to the parent's.
+ */
+ @SuppressWarnings("unchecked")
+ private void fillDiscriminatorExample(Schema> schema, Map allSchemas) {
+ var discriminator = schema.getDiscriminator();
+ if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) {
+ return;
+ }
+ // 1. Populate an example on each mapped subtype.
+ for (var entry : discriminator.getMapping().entrySet()) {
+ String discriminatorValue = entry.getKey();
+ String subtypeRef = entry.getValue();
+ String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1);
+ Schema> subtype = allSchemas.get(subtypeName);
+ if (subtype == null || subtype.getExample() != null) {
+ continue;
+ }
+ Map example = new LinkedHashMap<>();
+ buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0);
+ if (example.isEmpty()) {
+ continue;
+ }
+ example.put(discriminator.getPropertyName(), discriminatorValue);
+ subtype.setExample(example);
+ }
+ // 2. Mirror a subtype's example onto the parent so a field typed as the parent
+ // interface still gets a complete example. Prefer the subtype whose mapping key
+ // matches the example declared on the discriminator property itself
+ // (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not
+ // the alphabetically first AdminSettingsId). Fall back to the first mapping entry.
+ if (schema.getExample() == null) {
+ String preferredValue = null;
+ if (schema.getProperties() != null) {
+ Schema> discProp = (Schema>) schema.getProperties().get(discriminator.getPropertyName());
+ if (discProp != null && discProp.getExample() != null) {
+ preferredValue = discProp.getExample().toString();
+ }
+ }
+ String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null;
+ if (chosenRef == null) {
+ chosenRef = discriminator.getMapping().values().iterator().next();
+ }
+ String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1);
+ Schema> chosenSubtype = allSchemas.get(chosenSubtypeName);
+ if (chosenSubtype != null && chosenSubtype.getExample() != null) {
+ schema.setExample(chosenSubtype.getExample());
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void buildSchemaExample(String schemaName, Map allSchemas,
+ Map result, Set visited, int depth) {
+ if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) {
+ return;
+ }
+ Schema> schema = allSchemas.get(schemaName);
+ if (schema == null) {
+ return;
+ }
+ // Walk parents first so own properties (added later) override inherited entries.
+ if (schema.getAllOf() != null) {
+ String selfRef = "#/components/schemas/" + schemaName;
+ for (Schema> allOfElement : schema.getAllOf()) {
+ String ref = allOfElement.get$ref();
+ if (ref != null) {
+ String refName = ref.substring(ref.lastIndexOf('/') + 1);
+ buildSchemaExample(refName, allSchemas, result, visited, depth);
+ // If the parent uses a discriminator, this schema is one of its mapping
+ // targets — override the discriminator field with the value that points
+ // back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS").
+ Schema> parentSchema = allSchemas.get(refName);
+ if (parentSchema != null && parentSchema.getDiscriminator() != null
+ && parentSchema.getDiscriminator().getMapping() != null) {
+ parentSchema.getDiscriminator().getMapping().entrySet().stream()
+ .filter(e -> selfRef.equals(e.getValue()))
+ .map(Map.Entry::getKey)
+ .findFirst()
+ .ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value));
+ }
+ } else if (allOfElement.getProperties() != null) {
+ allOfElement.getProperties().forEach((k, v) ->
+ result.put(k, sampleValue((Schema>) v, allSchemas, visited, depth + 1)));
+ }
+ }
+ }
+ if (schema.getProperties() != null) {
+ schema.getProperties().forEach((k, v) ->
+ result.put(k, sampleValue((Schema>) v, allSchemas, visited, depth + 1)));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Object sampleValue(Schema> propSchema, Map allSchemas,
+ Set visited, int depth) {
+ if (propSchema == null) {
+ return null;
+ }
+ if (propSchema.getExample() != null) {
+ return propSchema.getExample();
+ }
+ String ref = propSchema.get$ref();
+ if (ref != null) {
+ String refName = ref.substring(ref.lastIndexOf('/') + 1);
+ Schema> refSchema = allSchemas.get(refName);
+ if (refSchema != null && refSchema.getExample() != null) {
+ return refSchema.getExample();
+ }
+ if (depth >= MAX_EXAMPLE_DEPTH) {
+ return Map.of();
+ }
+ Map nested = new LinkedHashMap<>();
+ buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1);
+ return nested;
+ }
+ if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) {
+ return propSchema.getEnum().get(0);
+ }
+ String type = propSchema.getType();
+ if (type == null) {
+ return null;
+ }
+ return switch (type) {
+ case "string" -> "string";
+ case "integer", "number" -> 0;
+ case "boolean" -> false;
+ case "array" -> List.of();
+ case "object" -> Map.of();
+ default -> null;
+ };
+ }
+
@SuppressWarnings("unchecked")
private void deduplicateAllOfProperties(Schema> schema, Map allSchemas, Set ownProps) {
if (schema.getAllOf() == null) {
@@ -799,7 +1082,13 @@ public class SwaggerConfiguration {
* This matches the naming convention used by swagger-core's {@code TypeNameResolver}.
*/
private static String resolveSchemaName(JavaType javaType) {
- StringBuilder sb = new StringBuilder(javaType.getRawClass().getSimpleName());
+ Class> cls = javaType.getRawClass();
+ io.swagger.v3.oas.annotations.media.Schema schemaAnnotation =
+ cls.getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class);
+ if (schemaAnnotation != null && !schemaAnnotation.name().isEmpty()) {
+ return schemaAnnotation.name();
+ }
+ StringBuilder sb = new StringBuilder(cls.getSimpleName());
if (javaType.hasGenericTypes()) {
for (int i = 0; i < javaType.containedTypeCount(); i++) {
JavaType param = javaType.containedType(i);
@@ -904,8 +1193,14 @@ public class SwaggerConfiguration {
// Map backing field names to their JSON property names (respects @JsonProperty)
Map fieldToJsonName = new LinkedHashMap<>();
for (var prop : beanDesc.findProperties()) {
- if (prop.getField() != null && prop.couldSerialize()) {
- fieldToJsonName.put(prop.getField().getName(), prop.getName());
+ if (prop.couldSerialize()) {
+ if (prop.getField() != null) {
+ fieldToJsonName.put(prop.getField().getName(), prop.getName());
+ } else {
+ // For transient fields, Jackson may not associate the field with the property.
+ // Fall back to using the property name as the field name key.
+ fieldToJsonName.putIfAbsent(prop.getName(), prop.getName());
+ }
}
}
@@ -940,4 +1235,4 @@ public class SwaggerConfiguration {
.value(node);
}
-}
+}
\ No newline at end of file
diff --git a/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
new file mode 100644
index 0000000000..a5ad4e8742
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright © 2016-2026 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.server.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
+import org.thingsboard.server.queue.util.TbRuleEngineComponent;
+
+@TbRuleEngineComponent
+@Component
+public class TbHttpClientSettingsComponent implements TbHttpClientSettings {
+
+ @Value("${actors.rule.external.http_client.max_parallel_requests:0}")
+ private int maxParallelRequests;
+
+ @Value("${actors.rule.external.http_client.max_pending_requests:0}")
+ private int maxPendingRequests;
+
+ @Value("${actors.rule.external.http_client.pool_max_connections:0}")
+ private int poolMaxConnections;
+
+ @Override
+ public int getMaxParallelRequests() {
+ return maxParallelRequests;
+ }
+
+ @Override
+ public int getMaxPendingRequests() {
+ return maxPendingRequests;
+ }
+
+ @Override
+ public int getPoolMaxConnections() {
+ return poolMaxConnections;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
index ae8305c070..8a113fb424 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
@@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
+import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
@@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController {
AlarmId alarmId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE);
alarmComment.setAlarmId(alarmId);
+ alarmComment.setType(AlarmCommentType.OTHER);
return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser());
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java
new file mode 100644
index 0000000000..957d05d1f4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java
@@ -0,0 +1,259 @@
+/**
+ * Copyright © 2016-2026 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.server.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.EventInfo;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinition;
+import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldFilter;
+import org.thingsboard.server.common.data.cf.CalculatedFieldInfo;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.event.EventType;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.EntityIdFactory;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.config.annotations.ApiOperation;
+import org.thingsboard.server.dao.event.EventService;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.permission.Operation;
+
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END;
+import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START;
+import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
+
+@RestController
+@TbCoreComponent
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class AlarmRuleController extends BaseController {
+
+ private final TbCalculatedFieldService tbCalculatedFieldService;
+ private final EventService eventService;
+
+ public static final String ALARM_RULE_ID = "alarmRuleId";
+
+ private static final String TEST_SCRIPT_EXPRESSION =
+ "Execute the alarm rule TBEL condition expression and return the result. " +
+ "Alarm rule expressions must return a boolean value. The format of request: \n\n"
+ + MARKDOWN_CODE_BLOCK_START
+ + "{\n" +
+ " \"expression\": \"return temperature > 50;\",\n" +
+ " \"arguments\": {\n" +
+ " \"temperature\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 55 }\n" +
+ " }\n" +
+ "}"
+ + MARKDOWN_CODE_BLOCK_END
+ + "\n\n Expected result JSON contains \"output\" and \"error\".";
+
+ @ApiOperation(value = "Create Or Update Alarm Rule (saveAlarmRule)",
+ notes = "Creates or Updates the Alarm Rule. When creating alarm rule, platform generates Alarm Rule Id as " + UUID_WIKI_LINK +
+ "The newly created Alarm Rule Id will be present in the response. " +
+ "Specify existing Alarm Rule Id to update the alarm rule. " +
+ "Referencing non-existing Alarm Rule Id will cause 'Not Found' error. " +
+ "Remove 'id', 'tenantId' from the request body example (below) to create new Alarm Rule entity. "
+ + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @PostMapping("/alarm/rule")
+ public AlarmRuleDefinition saveAlarmRule(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the alarm rule.")
+ @RequestBody AlarmRuleDefinition alarmRuleDefinition) throws Exception {
+ alarmRuleDefinition.setTenantId(getTenantId());
+ checkEntityId(alarmRuleDefinition.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
+ if (alarmRuleDefinition.getId() != null) {
+ checkAlarmRule(alarmRuleDefinition.getId());
+ }
+ CalculatedField calculatedField = alarmRuleDefinition.toCalculatedField();
+ checkReferencedEntities(calculatedField.getConfiguration());
+ CalculatedField saved = tbCalculatedFieldService.save(calculatedField, getCurrentUser());
+ return AlarmRuleDefinition.fromCalculatedField(saved);
+ }
+
+ @ApiOperation(value = "Get Alarm Rule (getAlarmRuleById)",
+ notes = "Fetch the Alarm Rule object based on the provided Alarm Rule Id." + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping("/alarm/rule/{alarmRuleId}")
+ public AlarmRuleDefinition getAlarmRuleById(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException {
+ checkParameter(ALARM_RULE_ID, strAlarmRuleId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId));
+ CalculatedField calculatedField = checkAlarmRule(calculatedFieldId);
+ checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
+ return AlarmRuleDefinition.fromCalculatedField(calculatedField);
+ }
+
+ @ApiOperation(value = "Get Alarm Rules by Entity Id (getAlarmRulesByEntityId)",
+ notes = "Fetch the Alarm Rules based on the provided Entity Id." + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping(value = "/alarm/rules/{entityType}/{entityId}")
+ public PageData getAlarmRulesByEntityId(
+ @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType,
+ @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr,
+ @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page,
+ @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
+ checkParameter("entityId", entityIdStr);
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr);
+ checkEntityId(entityId, Operation.READ_CALCULATED_FIELD);
+ PageData result = checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, CalculatedFieldType.ALARM, pageLink));
+ return result.mapData(AlarmRuleDefinition::fromCalculatedField);
+ }
+
+ @ApiOperation(value = "Get alarm rules (getAlarmRules)",
+ notes = "Fetch tenant alarm rules based on the filter." + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping(value = "/alarm/rules")
+ public PageData getAlarmRules(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = "Entity type filter. If not specified, alarm rules for all supported entity types will be returned.")
+ @RequestParam(required = false) EntityType entityType,
+ @Parameter(description = "Entities filter. If not specified, alarm rules for entity type filter will be returned.")
+ @RequestParam(required = false) Set entities,
+ @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION)
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"}))
+ @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}))
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
+ SecurityUser user = getCurrentUser();
+
+ Set entityTypes;
+ if (entityType == null) {
+ entityTypes = CalculatedField.SUPPORTED_ENTITIES.entrySet().stream()
+ .filter(entry -> entry.getValue().contains(CalculatedFieldType.ALARM))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ } else {
+ entityTypes = EnumSet.of(entityType);
+ }
+
+ CalculatedFieldFilter filter = CalculatedFieldFilter.builder()
+ .types(EnumSet.of(CalculatedFieldType.ALARM))
+ .entityTypes(entityTypes)
+ .entityIds(entities)
+ .build();
+ PageData result = calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink);
+ return result.mapData(AlarmRuleDefinitionInfo::fromCalculatedFieldInfo);
+ }
+
+ @ApiOperation(value = "Get alarm rule names (getAlarmRuleNames)",
+ notes = "Fetch the list of alarm rule names." + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping(value = "/alarm/rules/names")
+ public PageData getAlarmRuleNames(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION)
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}))
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ PageLink pageLink = createPageLink(pageSize, page, textSearch, "name", sortOrder);
+ return calculatedFieldService.findCalculatedFieldNamesByTenantIdAndType(getTenantId(), CalculatedFieldType.ALARM, pageLink);
+ }
+
+ @ApiOperation(value = "Delete Alarm Rule (deleteAlarmRule)",
+ notes = "Deletes the alarm rule. Referencing non-existing Alarm Rule Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @DeleteMapping("/alarm/rule/{alarmRuleId}")
+ @ResponseStatus(HttpStatus.OK)
+ public void deleteAlarmRule(@PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws Exception {
+ checkParameter(ALARM_RULE_ID, strAlarmRuleId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId));
+ CalculatedField calculatedField = checkAlarmRule(calculatedFieldId);
+ checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
+ tbCalculatedFieldService.delete(calculatedField, getCurrentUser());
+ }
+
+ @ApiOperation(value = "Get latest alarm rule debug event (getLatestAlarmRuleDebugEvent)",
+ notes = "Gets latest alarm rule debug event for specified alarm rule id. " +
+ "Referencing non-existing alarm rule id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping("/alarm/rule/{alarmRuleId}/debug")
+ public JsonNode getLatestAlarmRuleDebugEvent(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException {
+ checkParameter(ALARM_RULE_ID, strAlarmRuleId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId));
+ CalculatedField calculatedField = checkAlarmRule(calculatedFieldId);
+ checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
+ TenantId tenantId = getCurrentUser().getTenantId();
+ return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1))
+ .flatMap(events -> events.stream().map(EventInfo::getBody).findFirst())
+ .orElse(null);
+ }
+
+ @ApiOperation(value = "Test alarm rule TBEL expression (testAlarmRuleScript)",
+ notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @PostMapping("/alarm/rule/testScript")
+ public JsonNode testAlarmRuleScript(
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test alarm rule TBEL condition expression. The expression must return a boolean value.")
+ @RequestBody JsonNode inputParams) throws ThingsboardException {
+ checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null);
+ return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams);
+ }
+
+ private CalculatedField checkAlarmRule(CalculatedFieldId calculatedFieldId) throws ThingsboardException {
+ CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
+ checkNotNull(calculatedField);
+ if (calculatedField.getType() != CalculatedFieldType.ALARM) {
+ throw new ThingsboardException("Alarm rule not found", ThingsboardErrorCode.ITEM_NOT_FOUND);
+ }
+ return calculatedField;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
index 50766106d5..4a20e2fcd2 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
@@ -518,11 +518,11 @@ public class AssetController extends BaseController {
return checkNotNull(filteredResult);
}
- @ApiOperation(value = "Import the bulk of assets (processAssetsBulkImport)",
+ @ApiOperation(value = "Import the bulk of assets (processAssetBulkImport)",
notes = "There's an ability to import the bulk of assets using the only .csv file.")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
@PostMapping("/asset/bulk_import")
- public BulkImportResult processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception {
+ public BulkImportResult processAssetBulkImport(@RequestBody BulkImportRequest request) throws Exception {
SecurityUser user = getCurrentUser();
return assetBulkImportService.processBulkImport(request, user);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java
index 48e0f11552..56f1cf6a4b 100644
--- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java
@@ -71,6 +71,7 @@ import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.domain.Domain;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeInfo;
@@ -681,6 +682,17 @@ public abstract class BaseController {
return entity;
}
+ protected void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException {
+ for (EntityId referencedEntityId : calculatedFieldConfig.getReferencedEntities()) {
+ EntityType refEntityType = referencedEntityId.getEntityType();
+ switch (refEntityType) {
+ case TENANT -> {}
+ case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
+ default -> throw new IllegalArgumentException("Unsupported referenced entity type: '" + refEntityType + "'.");
+ }
+ }
+ }
+
Device checkDeviceId(DeviceId deviceId, Operation operation) throws ThingsboardException {
return checkEntityId(deviceId, deviceService::findDeviceById, operation);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
index 14a9728c6b..84ff4b49a4 100644
--- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
@@ -15,7 +15,6 @@
*/
package org.thingsboard.server.controller;
-import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Parameter;
@@ -24,10 +23,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
-import org.apache.commons.lang3.ObjectUtils;
-import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.MultiValueMap;
@@ -40,13 +36,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
-import org.thingsboard.common.util.JacksonUtil;
-import org.thingsboard.script.api.tbel.TbelCfArg;
-import org.thingsboard.script.api.tbel.TbelCfCtx;
-import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
-import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal;
-import org.thingsboard.script.api.tbel.TbelCfTsRollingArg;
-import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EventInfo;
import org.thingsboard.server.common.data.cf.CalculatedField;
@@ -65,21 +54,15 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.event.EventService;
import org.thingsboard.server.queue.util.TbCoreComponent;
-import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine;
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
-import java.util.ArrayList;
-import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
-import java.util.Map;
-import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION;
@@ -98,17 +81,13 @@ import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LI
@TbCoreComponent
@RequestMapping("/api")
@RequiredArgsConstructor
-@Slf4j
public class CalculatedFieldController extends BaseController {
private final TbCalculatedFieldService tbCalculatedFieldService;
private final EventService eventService;
- private final TbelInvokeService tbelInvokeService;
public static final String CALCULATED_FIELD_ID = "calculatedFieldId";
- public static final int TIMEOUT = 20;
-
private static final String TEST_SCRIPT_EXPRESSION =
"Execute the Script expression and return the result. The format of request: \n\n"
+ MARKDOWN_CODE_BLOCK_START
@@ -171,13 +150,13 @@ public class CalculatedFieldController extends BaseController {
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
@GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"})
public PageData getCalculatedFieldsByEntityIdV1(@PathVariable("entityType") String entityType,
- @PathVariable("entityId") String entityIdStr,
- @RequestParam int pageSize,
- @RequestParam int page,
- @RequestParam(required = false) CalculatedFieldType type,
- @RequestParam(required = false) String textSearch,
- @RequestParam(required = false) String sortProperty,
- @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ @PathVariable("entityId") String entityIdStr,
+ @RequestParam int pageSize,
+ @RequestParam int page,
+ @RequestParam(required = false) CalculatedFieldType type,
+ @RequestParam(required = false) String textSearch,
+ @RequestParam(required = false) String sortProperty,
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
checkParameter("entityId", entityIdStr);
EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr);
@@ -305,86 +284,9 @@ public class CalculatedFieldController extends BaseController {
@PostMapping("/calculatedField/testScript")
public JsonNode testCalculatedFieldScript(
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.")
- @RequestBody JsonNode inputParams) {
- String expression = inputParams.get("expression").asText();
- Map arguments = Objects.requireNonNullElse(
- JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}),
- Collections.emptyMap()
- );
-
- ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1);
- ctxAndArgNames.add("ctx");
- ctxAndArgNames.addAll(arguments.keySet());
-
- String output = "";
- String errorText = "";
-
- CalculatedFieldTbelScriptEngine engine = null;
- try {
- if (tbelInvokeService == null) {
- throw new IllegalArgumentException("TBEL script engine is disabled!");
- }
-
- engine = new CalculatedFieldTbelScriptEngine(
- getTenantId(),
- tbelInvokeService,
- expression,
- ctxAndArgNames.toArray(String[]::new)
- );
-
- Object[] args = new Object[ctxAndArgNames.size()];
- args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments));
- for (int i = 1; i < ctxAndArgNames.size(); i++) {
- var arg = arguments.get(ctxAndArgNames.get(i));
- if (arg instanceof TbelCfSingleValueArg svArg) {
- args[i] = svArg.getValue();
- } else {
- args[i] = arg;
- }
- }
-
- JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS);
- output = JacksonUtil.toString(json);
- } catch (Exception e) {
- log.error("Error evaluating expression", e);
- Throwable rootCause = ExceptionUtils.getRootCause(e);
- errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName());
- } finally {
- if (engine != null) {
- engine.destroy();
- }
- }
- return JacksonUtil.newObjectNode()
- .put("output", output)
- .put("error", errorText);
- }
-
- private long getLatestTimestamp(Map arguments) {
- long lastUpdateTimestamp = -1;
- for (TbelCfArg entry : arguments.values()) {
- if (entry instanceof TbelCfSingleValueArg singleValueArg) {
- long ts = singleValueArg.getTs();
- lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts);
- } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) {
- long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1);
- lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs);
- }
- }
- return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp;
- }
-
- private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException {
- Set referencedEntityIds = calculatedFieldConfig.getReferencedEntities();
- for (EntityId referencedEntityId : referencedEntityIds) {
- EntityType entityType = referencedEntityId.getEntityType();
- switch (entityType) {
- case TENANT -> {
- return;
- }
- case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
- default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
- }
- }
+ @RequestBody JsonNode inputParams) throws ThingsboardException {
+ checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null);
+ return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
index ab5c1c0ae4..04afb1330e 100644
--- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
+++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
@@ -444,106 +444,6 @@ public class ControllerConstants {
" * 'BOOLEAN' - used for boolean values. Operations: EQUAL, NOT_EQUAL;\n" +
" * 'DATE_TIME' - similar to numeric, transforms value to milliseconds since epoch. Operations: EQUAL, NOT_EQUAL, GREATER, LESS, GREATER_OR_EQUAL, LESS_OR_EQUAL; \n";
- protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE = MARKDOWN_CODE_BLOCK_START +
- "{\n" +
- " \"schedule\":{\n" +
- " \"type\":\"SPECIFIC_TIME\",\n" +
- " \"endsOn\":64800000,\n" +
- " \"startsOn\":43200000,\n" +
- " \"timezone\":\"Europe/Kiev\",\n" +
- " \"daysOfWeek\":[\n" +
- " 1,\n" +
- " 3,\n" +
- " 5\n" +
- " ]\n" +
- " }\n" +
- "}" +
- MARKDOWN_CODE_BLOCK_END;
- protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE = MARKDOWN_CODE_BLOCK_START +
- "{\n" +
- " \"schedule\":{\n" +
- " \"type\":\"CUSTOM\",\n" +
- " \"items\":[\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":1\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":64800000,\n" +
- " \"enabled\":true,\n" +
- " \"startsOn\":43200000,\n" +
- " \"dayOfWeek\":2\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":3\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":57600000,\n" +
- " \"enabled\":true,\n" +
- " \"startsOn\":36000000,\n" +
- " \"dayOfWeek\":4\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":5\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":6\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":7\n" +
- " }\n" +
- " ],\n" +
- " \"timezone\":\"Europe/Kiev\"\n" +
- " }\n" +
- "}" +
- MARKDOWN_CODE_BLOCK_END;
- protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "\"schedule\": null" + MARKDOWN_CODE_BLOCK_END;
-
- protected static final String DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE = MARKDOWN_CODE_BLOCK_START +
- "{\n" +
- " \"spec\":{\n" +
- " \"type\":\"REPEATING\",\n" +
- " \"predicate\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":5,\n" +
- " \"dynamicValue\":{\n" +
- " \"inherit\":true,\n" +
- " \"sourceType\":\"CURRENT_DEVICE\",\n" +
- " \"sourceAttribute\":\"tempAttr\"\n" +
- " }\n" +
- " }\n" +
- " }\n" +
- "}" +
- MARKDOWN_CODE_BLOCK_END;
-
- protected static final String DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE = MARKDOWN_CODE_BLOCK_START +
- "{\n" +
- " \"spec\":{\n" +
- " \"type\":\"DURATION\",\n" +
- " \"unit\":\"MINUTES\",\n" +
- " \"predicate\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":30,\n" +
- " \"dynamicValue\":null\n" +
- " }\n" +
- " }\n" +
- "}" +
- MARKDOWN_CODE_BLOCK_END;
-
protected static final String RELATION_TYPE_PARAM_DESCRIPTION = "A string value representing relation type between entities. For example, 'Contains', 'Manages'. It can be any string value.";
protected static final String RELATION_TYPE_GROUP_PARAM_DESCRIPTION = "A string value representing relation type group. For example, 'COMMON'";
@@ -1328,8 +1228,6 @@ public class ControllerConstants {
ALARM_FILTER_KEY + FILTER_VALUE_TYPE + NEW_LINE + DEVICE_PROFILE_FILTER_PREDICATE + NEW_LINE;
protected static final String DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" +
- " \"alarms\":[\n" +
- " ],\n" +
" \"configuration\":{\n" +
" \"type\":\"DEFAULT\"\n" +
" },\n" +
@@ -1343,219 +1241,6 @@ public class ControllerConstants {
"}" + MARKDOWN_CODE_BLOCK_END;
protected static final String CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" +
- " \"alarms\":[\n" +
- " {\n" +
- " \"id\":\"2492b935-1226-59e9-8615-17d8978a4f93\",\n" +
- " \"alarmType\":\"Temperature Alarm\",\n" +
- " \"clearRule\":{\n" +
- " \"schedule\":null,\n" +
- " \"condition\":{\n" +
- " \"spec\":{\n" +
- " \"type\":\"SIMPLE\"\n" +
- " },\n" +
- " \"condition\":[\n" +
- " {\n" +
- " \"key\":{\n" +
- " \"key\":\"temperature\",\n" +
- " \"type\":\"TIME_SERIES\"\n" +
- " },\n" +
- " \"value\":null,\n" +
- " \"predicate\":{\n" +
- " \"type\":\"NUMERIC\",\n" +
- " \"value\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":30.0,\n" +
- " \"dynamicValue\":null\n" +
- " },\n" +
- " \"operation\":\"LESS\"\n" +
- " },\n" +
- " \"valueType\":\"NUMERIC\"\n" +
- " }\n" +
- " ]\n" +
- " },\n" +
- " \"dashboardId\":null,\n" +
- " \"alarmDetails\":null\n" +
- " },\n" +
- " \"propagate\":false,\n" +
- " \"createRules\":{\n" +
- " \"MAJOR\":{\n" +
- " \"schedule\":{\n" +
- " \"type\":\"SPECIFIC_TIME\",\n" +
- " \"endsOn\":64800000,\n" +
- " \"startsOn\":43200000,\n" +
- " \"timezone\":\"Europe/Kiev\",\n" +
- " \"daysOfWeek\":[\n" +
- " 1,\n" +
- " 3,\n" +
- " 5\n" +
- " ]\n" +
- " },\n" +
- " \"condition\":{\n" +
- " \"spec\":{\n" +
- " \"type\":\"DURATION\",\n" +
- " \"unit\":\"MINUTES\",\n" +
- " \"predicate\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":30,\n" +
- " \"dynamicValue\":null\n" +
- " }\n" +
- " },\n" +
- " \"condition\":[\n" +
- " {\n" +
- " \"key\":{\n" +
- " \"key\":\"temperature\",\n" +
- " \"type\":\"TIME_SERIES\"\n" +
- " },\n" +
- " \"value\":null,\n" +
- " \"predicate\":{\n" +
- " \"type\":\"COMPLEX\",\n" +
- " \"operation\":\"OR\",\n" +
- " \"predicates\":[\n" +
- " {\n" +
- " \"type\":\"NUMERIC\",\n" +
- " \"value\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":50.0,\n" +
- " \"dynamicValue\":null\n" +
- " },\n" +
- " \"operation\":\"LESS_OR_EQUAL\"\n" +
- " },\n" +
- " {\n" +
- " \"type\":\"NUMERIC\",\n" +
- " \"value\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":30.0,\n" +
- " \"dynamicValue\":null\n" +
- " },\n" +
- " \"operation\":\"GREATER\"\n" +
- " }\n" +
- " ]\n" +
- " },\n" +
- " \"valueType\":\"NUMERIC\"\n" +
- " }\n" +
- " ]\n" +
- " },\n" +
- " \"dashboardId\":null,\n" +
- " \"alarmDetails\":null\n" +
- " },\n" +
- " \"WARNING\":{\n" +
- " \"schedule\":{\n" +
- " \"type\":\"CUSTOM\",\n" +
- " \"items\":[\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":1\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":64800000,\n" +
- " \"enabled\":true,\n" +
- " \"startsOn\":43200000,\n" +
- " \"dayOfWeek\":2\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":3\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":57600000,\n" +
- " \"enabled\":true,\n" +
- " \"startsOn\":36000000,\n" +
- " \"dayOfWeek\":4\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":5\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":6\n" +
- " },\n" +
- " {\n" +
- " \"endsOn\":0,\n" +
- " \"enabled\":false,\n" +
- " \"startsOn\":0,\n" +
- " \"dayOfWeek\":7\n" +
- " }\n" +
- " ],\n" +
- " \"timezone\":\"Europe/Kiev\"\n" +
- " },\n" +
- " \"condition\":{\n" +
- " \"spec\":{\n" +
- " \"type\":\"REPEATING\",\n" +
- " \"predicate\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":5,\n" +
- " \"dynamicValue\":null\n" +
- " }\n" +
- " },\n" +
- " \"condition\":[\n" +
- " {\n" +
- " \"key\":{\n" +
- " \"key\":\"tempConstant\",\n" +
- " \"type\":\"CONSTANT\"\n" +
- " },\n" +
- " \"value\":30,\n" +
- " \"predicate\":{\n" +
- " \"type\":\"NUMERIC\",\n" +
- " \"value\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":0.0,\n" +
- " \"dynamicValue\":{\n" +
- " \"inherit\":false,\n" +
- " \"sourceType\":\"CURRENT_DEVICE\",\n" +
- " \"sourceAttribute\":\"tempThreshold\"\n" +
- " }\n" +
- " },\n" +
- " \"operation\":\"EQUAL\"\n" +
- " },\n" +
- " \"valueType\":\"NUMERIC\"\n" +
- " }\n" +
- " ]\n" +
- " },\n" +
- " \"dashboardId\":null,\n" +
- " \"alarmDetails\":null\n" +
- " },\n" +
- " \"CRITICAL\":{\n" +
- " \"schedule\":null,\n" +
- " \"condition\":{\n" +
- " \"spec\":{\n" +
- " \"type\":\"SIMPLE\"\n" +
- " },\n" +
- " \"condition\":[\n" +
- " {\n" +
- " \"key\":{\n" +
- " \"key\":\"temperature\",\n" +
- " \"type\":\"TIME_SERIES\"\n" +
- " },\n" +
- " \"value\":null,\n" +
- " \"predicate\":{\n" +
- " \"type\":\"NUMERIC\",\n" +
- " \"value\":{\n" +
- " \"userValue\":null,\n" +
- " \"defaultValue\":50.0,\n" +
- " \"dynamicValue\":null\n" +
- " },\n" +
- " \"operation\":\"GREATER\"\n" +
- " },\n" +
- " \"valueType\":\"NUMERIC\"\n" +
- " }\n" +
- " ]\n" +
- " },\n" +
- " \"dashboardId\":null,\n" +
- " \"alarmDetails\":null\n" +
- " }\n" +
- " },\n" +
- " \"propagateRelationTypes\":null\n" +
- " }\n" +
- " ],\n" +
" \"configuration\":{\n" +
" \"type\":\"DEFAULT\"\n" +
" },\n" +
@@ -1577,40 +1262,11 @@ public class ControllerConstants {
" }\n" +
"}" + MARKDOWN_CODE_BLOCK_END;
protected static final String DEVICE_PROFILE_DATA_DEFINITION = NEW_LINE + "# Device profile data definition" + NEW_LINE +
- "Device profile data object contains alarm rules configuration, device provision strategy and transport type configuration for device connectivity. Let's review some examples. " +
+ "Device profile data object contains device provision strategy and transport type configuration for device connectivity. Let's review some examples. " +
"First one is the default device profile data configuration and second one - the custom one. " +
NEW_LINE + DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE +
NEW_LINE + "Let's review some specific objects examples related to the device profile configuration:";
- protected static final String ALARM_SCHEDULE = NEW_LINE + "# Alarm Schedule" + NEW_LINE +
- "Alarm Schedule JSON object represents the time interval during which the alarm rule is active. Note, " +
- NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE + NEW_LINE + "means alarm rule is active all the time. " +
- "**'daysOfWeek'** field represents Monday as 1, Tuesday as 2 and so on. **'startsOn'** and **'endsOn'** fields represent hours in millis (e.g. 64800000 = 18:00 or 6pm). " +
- "**'enabled'** flag specifies if item in a custom rule is active for specific day of the week:" + NEW_LINE +
- "## Specific Time Schedule" + NEW_LINE +
- DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE + NEW_LINE +
- "## Custom Schedule" +
- NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE + NEW_LINE;
-
- protected static final String ALARM_CONDITION_TYPE = "# Alarm condition type (**'spec'**)" + NEW_LINE +
- "Alarm condition type can be either simple, duration, or repeating. For example, 5 times in a row or during 5 minutes." + NEW_LINE +
- "Note, **'userValue'** field is not used and reserved for future usage, **'dynamicValue'** is used for condition appliance by using the value of the **'sourceAttribute'** " +
- "or else **'defaultValue'** is used (if **'sourceAttribute'** is absent).\n" +
- "\n**'sourceType'** of the **'sourceAttribute'** can be: \n" +
- " * 'CURRENT_DEVICE';\n" +
- " * 'CURRENT_CUSTOMER';\n" +
- " * 'CURRENT_TENANT'." + NEW_LINE +
- "**'sourceAttribute'** can be inherited from the owner if **'inherit'** is set to true (for CURRENT_DEVICE and CURRENT_CUSTOMER)." + NEW_LINE +
- "## Repeating alarm condition" + NEW_LINE +
- DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE + NEW_LINE +
- "## Duration alarm condition" + NEW_LINE +
- DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE + NEW_LINE +
- "**'unit'** can be: \n" +
- " * 'SECONDS';\n" +
- " * 'MINUTES';\n" +
- " * 'HOURS';\n" +
- " * 'DAYS'." + NEW_LINE;
-
protected static final String PROVISION_CONFIGURATION = "# Provision Configuration" + NEW_LINE +
"There are 3 types of device provision configuration for the device profile: \n" +
" * 'DISABLED';\n" +
@@ -1618,8 +1274,8 @@ public class ControllerConstants {
" * 'CHECK_PRE_PROVISIONED_DEVICES'." + NEW_LINE +
"Please refer to the [docs](https://thingsboard.io/docs/user-guide/device-provisioning/) for more details." + NEW_LINE;
- protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + ALARM_SCHEDULE + ALARM_CONDITION_TYPE +
- KEY_FILTERS_DESCRIPTION + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION;
+ protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION +
+ PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION;
protected static final String DEVICE_PROFILE_ID = "deviceProfileId";
diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
index 0aa74607be..9f59044356 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
@@ -151,6 +151,8 @@ public class DashboardController extends BaseController {
@ApiOperation(value = "Get Dashboard (getDashboardById)",
notes = "Get the dashboard based on 'dashboardId' parameter. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH
)
+ @ApiResponse(responseCode = "200", description = "OK",
+ content = @Content(schema = @Schema(implementation = Dashboard.class)))
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/dashboard/{dashboardId}")
public void getDashboardById(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION)
@@ -415,6 +417,8 @@ public class DashboardController extends BaseController {
"If 'homeDashboardId' parameter is not set on the User level and the User has authority 'CUSTOMER_USER', check the same parameter for the corresponding Customer. " +
"If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. "
+ DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @ApiResponse(responseCode = "200", description = "OK",
+ content = @Content(schema = @Schema(implementation = HomeDashboard.class)))
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/dashboard/home")
public void getHomeDashboard(@RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader,
diff --git a/application/src/main/java/org/thingsboard/server/controller/DomainController.java b/application/src/main/java/org/thingsboard/server/controller/DomainController.java
index 04e750d0e4..c34bbe6ac9 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java
@@ -93,19 +93,19 @@ public class DomainController extends BaseController {
tbDomainService.updateOauth2Clients(domain, oAuth2ClientIds, getCurrentUser());
}
- @ApiOperation(value = "Get Domain infos (getTenantDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH)
+ @ApiOperation(value = "Get Domain infos (getDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
@GetMapping(value = "/domain/infos")
- public PageData getTenantDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
- @RequestParam int pageSize,
- @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
- @RequestParam int page,
- @Parameter(description = "Case-insensitive 'substring' filter based on domain's name")
- @RequestParam(required = false) String textSearch,
- @Parameter(description = SORT_PROPERTY_DESCRIPTION)
- @RequestParam(required = false) String sortProperty,
- @Parameter(description = SORT_ORDER_DESCRIPTION)
- @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ public PageData getDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = "Case-insensitive 'substring' filter based on domain's name")
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION)
+ @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION)
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
accessControlService.checkPermission(getCurrentUser(), Resource.DOMAIN, Operation.READ);
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return domainService.findDomainInfosByTenantId(getTenantId(), pageLink);
diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
index a1d6f667f9..5e51e0bbb6 100644
--- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
+++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
@@ -116,19 +116,19 @@ public class OAuth2Controller extends BaseController {
return tbOauth2ClientService.save(oAuth2Client, getCurrentUser());
}
- @ApiOperation(value = "Get OAuth2 Client infos (findTenantOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
+ @ApiOperation(value = "Get OAuth2 Client infos (findOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = "/oauth2/client/infos")
- public PageData findTenantOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
- @RequestParam int pageSize,
- @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
- @RequestParam int page,
- @Parameter(description = "Case-insensitive 'substring' filter based on client's title")
- @RequestParam(required = false) String textSearch,
- @Parameter(description = SORT_PROPERTY_DESCRIPTION)
- @RequestParam(required = false) String sortProperty,
- @Parameter(description = SORT_ORDER_DESCRIPTION)
- @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ public PageData findOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = "Case-insensitive 'substring' filter based on client's title")
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION)
+ @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION)
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return oAuth2ClientService.findOAuth2ClientInfosByTenantId(getTenantId(), pageLink);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java
index 08ef1a85c8..5e555cbcbe 100644
--- a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java
+++ b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java
@@ -16,6 +16,8 @@
package org.thingsboard.server.controller;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -50,7 +52,8 @@ public class RpcV1Controller extends AbstractRpcController {
public DeferredResult handleOneWayDeviceRPCRequestV1(
@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION)
@PathVariable("deviceId") String deviceIdStr,
- @Parameter(description = "A JSON value representing the RPC request.")
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.",
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT);
}
@@ -62,7 +65,8 @@ public class RpcV1Controller extends AbstractRpcController {
public DeferredResult handleTwoWayDeviceRPCRequestV1(
@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION)
@PathVariable("deviceId") String deviceIdStr,
- @Parameter(description = "A JSON value representing the RPC request.")
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.",
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java
index fdcb853343..4366134927 100644
--- a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java
+++ b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.controller;
import com.google.common.util.concurrent.FutureCallback;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -127,7 +128,8 @@ public class RpcV2Controller extends AbstractRpcController {
public DeferredResult handleOneWayDeviceRPCRequestV2(
@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION)
@PathVariable("deviceId") String deviceIdStr,
- @Parameter(description = "A JSON value representing the RPC request.")
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.",
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
}
@@ -146,7 +148,8 @@ public class RpcV2Controller extends AbstractRpcController {
public DeferredResult handleTwoWayDeviceRPCRequestV2(
@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION)
@PathVariable(DEVICE_ID) String deviceIdStr,
- @Parameter(description = "A JSON value representing the RPC request.")
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.",
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
index ec39bb97aa..d63e0f5d19 100644
--- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
@@ -17,6 +17,8 @@ package org.thingsboard.server.controller;
import com.google.common.util.concurrent.FutureCallback;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -87,7 +89,8 @@ public class RuleEngineController extends BaseController {
@RequestMapping(value = "/", method = RequestMethod.POST)
@ResponseBody
public DeferredResult handleRuleEngineRequestForUser(
- @Parameter(description = "A JSON value representing the message.", required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(null, null, null, defaultResponseTimeout, requestBody);
}
@@ -106,7 +109,8 @@ public class RuleEngineController extends BaseController {
@PathVariable("entityType") String entityType,
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true)
@PathVariable("entityId") String entityIdStr,
- @Parameter(description = "A JSON value representing the message.", required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, defaultResponseTimeout, requestBody);
}
@@ -127,7 +131,8 @@ public class RuleEngineController extends BaseController {
@PathVariable("entityId") String entityIdStr,
@Parameter(description = "Timeout to process the request in milliseconds", required = true)
@PathVariable("timeout") int timeout,
- @Parameter(description = "A JSON value representing the message.", required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, timeout, requestBody);
}
@@ -151,7 +156,8 @@ public class RuleEngineController extends BaseController {
@PathVariable("queueName") String queueName,
@Parameter(description = "Timeout to process the request in milliseconds", required = true)
@PathVariable("timeout") int timeout,
- @Parameter(description = "A JSON value representing the message.", required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
try {
SecurityUser currentUser = getCurrentUser();
diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
index dc66e4a7b6..754c9253d9 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
@@ -386,7 +386,8 @@ public class TelemetryController extends BaseController {
@PathVariable("deviceId") String deviceIdStr,
@Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED))
@PathVariable("scope") AttributeScope scope,
- @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String request) throws ThingsboardException {
EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr);
return saveAttributes(getTenantId(), entityId, scope, request);
@@ -411,7 +412,8 @@ public class TelemetryController extends BaseController {
@PathVariable("entityId") String entityIdStr,
@Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}))
@PathVariable("scope") AttributeScope scope,
- @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String request) throws ThingsboardException {
EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
return saveAttributes(getTenantId(), entityId, scope, request);
@@ -436,7 +438,8 @@ public class TelemetryController extends BaseController {
@PathVariable("entityId") String entityIdStr,
@Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED))
@PathVariable("scope") AttributeScope scope,
- @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true)
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String request) throws ThingsboardException {
EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
return saveAttributes(getTenantId(), entityId, scope, request);
@@ -460,7 +463,8 @@ public class TelemetryController extends BaseController {
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType,
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr,
@Parameter(description = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope,
- @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException {
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException {
EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
return saveTelemetry(getTenantId(), entityId, requestBody, 0L);
}
@@ -484,7 +488,8 @@ public class TelemetryController extends BaseController {
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr,
@Parameter(description = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope,
@Parameter(description = "A long value representing TTL (Time to Live) parameter.", required = true) @PathVariable("ttl") Long ttl,
- @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException {
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true,
+ content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException {
EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
return saveTelemetry(getTenantId(), entityId, requestBody, ttl);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java
index 1796e959ab..b919b664e5 100644
--- a/application/src/main/java/org/thingsboard/server/controller/UserController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java
@@ -549,8 +549,7 @@ public class UserController extends BaseController {
userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), type, Arrays.asList(paths.split(",")));
}
- @ApiOperation(value = "Get information about last visited and starred dashboards (getUserDashboardsInfo)",
- notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @Hidden
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/user/dashboards")
public UserDashboardsInfo getUserDashboardsInfo() throws ThingsboardException {
@@ -558,6 +557,14 @@ public class UserController extends BaseController {
return userSettingsService.findUserDashboardsInfo(currentUser.getTenantId(), currentUser.getId());
}
+ @ApiOperation(value = "Get information about last visited and starred dashboards (getLastVisitedDashboards)",
+ notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+ @GetMapping(value = "/user/lastVisitedDashboards")
+ public UserDashboardsInfo getLastVisitedDashboards() throws ThingsboardException {
+ return getUserDashboardsInfo();
+ }
+
@ApiOperation(value = "Report action of User over the dashboard (reportUserDashboardAction)",
notes = "Report action of User over the dashboard. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
index cfc50543a1..a89d870bf0 100644
--- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
@@ -242,7 +242,7 @@ public class WidgetTypeController extends AutoCommitController {
@ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)",
notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
- @GetMapping(value = "/widgetsBundles/{widgetsBundleId}/widgetTypes")
+ @GetMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes")
public List getBundleWidgetTypes(
@Parameter(description = "Widget Bundle Id", required = true)
@PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException {
diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
index 48d3a907bc..cf56d051ca 100644
--- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
+++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
@@ -118,7 +118,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
private final ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>();
- private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>();
+ private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>();
private Cache pendingSessions;
@@ -631,7 +631,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0
&& UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser();
if (limitAllowed) {
@@ -675,7 +675,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
}
if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
publicUserSessions.remove(sessionId);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
index 212d363280..15be6f3734 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
@@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override
public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) {
- ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
+ ChatModel langChainChatModel;
+ try {
+ langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
+ } catch (Throwable t) {
+ return FluentFuture.from(Futures.immediateFailedFuture(t));
+ }
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 28d696468c..8b569f052c 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
+import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
@@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
@@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
+ validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey())
@@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
+ validateBaseUrl(providerConfig.endpoint());
return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion())
@@ -177,8 +181,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
if (chatModelConfig.frequencyPenalty() != null) {
generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
}
- if (chatModelConfig.frequencyPenalty() != null) {
- generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue());
+ if (chatModelConfig.presencePenalty() != null) {
+ generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
}
if (chatModelConfig.maxOutputTokens() != null) {
generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());
@@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
+ validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId())
@@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
return builder.build();
}
+ private static void validateBaseUrl(String url) {
+ SsrfProtectionValidator.validateUri(URI.create(url));
+ }
+
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
index 3ea6925695..6e9da33d0b 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
@@ -61,7 +61,7 @@ public interface CalculatedFieldCache {
void addOwnerEntity(TenantId tenantId, EntityId entityId);
- void evictEntity(EntityId entityId);
+ void evictOwnerEntity(EntityId entityId);
void evictOwner(EntityId owner);
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
index 8a7d38970f..3a989aaa60 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
@@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.server.actors.ActorSystemContext;
@@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.util.AfterStartUp;
@@ -45,7 +48,9 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -53,6 +58,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@@ -268,18 +274,25 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
@Override
public void updateOwnerEntity(TenantId tenantId, EntityId entityId) {
- evictEntity(entityId);
+ evictOwnerEntity(entityId);
addOwnerEntity(tenantId, entityId);
}
@Override
- public void evictEntity(EntityId entityId) {
+ public void evictOwnerEntity(EntityId entityId) {
ownerEntities.values().forEach(entities -> entities.remove(entityId));
}
@Override
public void evictOwner(EntityId owner) {
- ownerEntities.remove(owner);
+ Set removedEntities = ownerEntities.remove(owner);
+ if (removedEntities != null) {
+ Set removedCustomers = removedEntities
+ .stream()
+ .filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER)
+ .collect(Collectors.toSet());
+ removedCustomers.forEach(this::evictOwner);
+ }
}
private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) {
@@ -290,6 +303,113 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
});
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT_PROFILE:
+ if (event.getEvent() == ComponentLifecycleEvent.UPDATED) {
+ TenantProfileId tenantProfileId = new TenantProfileId(event.getEntityId().getId());
+ handleTenantProfileUpdate(tenantProfileId);
+ }
+ break;
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ evictTenantCfs(tenantId);
+ evictOwner(tenantId);
+ }
+ break;
+ case CUSTOMER:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) {
+ updateOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictOwner(event.getEntityId());
+ evictOwnerEntity(event.getEntityId());
+ }
+ break;
+ case DEVICE, ASSET:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) {
+ updateOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictOwnerEntity(event.getEntityId());
+ evictEntityCfs(event.getEntityId());
+ }
+ break;
+ case DEVICE_PROFILE, ASSET_PROFILE:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictEntityCfs(event.getEntityId());
+ }
+ break;
+ case CALCULATED_FIELD:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) {
+ updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
+ } else {
+ evict((CalculatedFieldId) event.getEntityId());
+ }
+ break;
+ }
+ }
+
+ private void evictTenantCfs(TenantId tenantId) {
+ var removedCfEntityIds = new HashSet();
+ var removedLinkEntityIds = new HashSet();
+ var toRemove = calculatedFields.entrySet().stream()
+ .filter(e -> e.getValue().getTenantId().equals(tenantId))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ toRemove.forEach(cfId -> {
+ CalculatedField cf = calculatedFields.remove(cfId);
+ List links = calculatedFieldLinks.remove(cfId);
+ if (links != null) {
+ links.forEach(link -> removedLinkEntityIds.add(link.entityId()));
+ }
+ calculatedFieldsCtx.remove(cfId);
+ if (cf != null) {
+ removedCfEntityIds.add(cf.getEntityId());
+ }
+ });
+ removedCfEntityIds.forEach(entityId -> {
+ entityIdCalculatedFields.compute(entityId, (k, cfs) -> {
+ if (cfs != null) {
+ cfs.removeIf(cf -> toRemove.contains(cf.getId()));
+ return cfs.isEmpty() ? null : cfs;
+ }
+ return null;
+ });
+ });
+ removedLinkEntityIds.forEach(entityId -> {
+ entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> {
+ if (links != null) {
+ links.removeIf(link -> toRemove.contains(link.calculatedFieldId()));
+ return links.isEmpty() ? null : links;
+ }
+ return null;
+ }));
+ });
+ }
+
+ private void evictEntityCfs(EntityId entityId) {
+ List cfs = entityIdCalculatedFields.remove(entityId);
+ if (cfs != null) {
+ var cfIds = new HashSet();
+ cfs.forEach(cf -> {
+ calculatedFields.remove(cf.getId());
+ calculatedFieldLinks.remove(cf.getId());
+ calculatedFieldsCtx.remove(cf.getId());
+ cfIds.add(cf.getId());
+ log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf);
+ });
+ entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId())));
+ }
+ entityIdCalculatedFieldLinks.remove(entityId);
+ }
+
private Lock getFetchLock(CalculatedFieldId id) {
return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock());
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
index 373480e161..1a0b609c5a 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
@@ -305,7 +305,8 @@ public class CalculatedFieldCtx implements Closeable {
public void setTenantProfileProperties() {
TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenantId);
if (tenantProfile == null) {
- throw new IllegalStateException("Tenant Profile not found for tenant: " + tenantId);
+ log.warn("[{}][{}][{}] Tenant Profile not found for tenant: {}. CF limits and thresholds will not be updated.", tenantId, entityId, cfId, tenantId);
+ return;
}
tenantProfile.getProfileConfiguration().ifPresent(config -> {
this.maxStateSize = config.getMaxStateSizeInKBytes() * 1024L;
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
index 930d030d71..f9c06dbda7 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
@@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
ruleState.setActive(null);
AlarmCondition condition = rule.getCondition();
if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) {
+ ruleState.cancelDurationCheckFuture();
reevalNeeded.set(true);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
index 19c48272cc..ada94dacda 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
@@ -256,10 +256,7 @@ public class AlarmRuleState {
firstEventTs = 0L;
lastCheckTs = 0L;
duration = 0L;
- if (durationCheckFuture != null) {
- durationCheckFuture.cancel(true);
- durationCheckFuture = null;
- }
+ cancelDurationCheckFuture();
}
public void setDurationCheckFuture(ScheduledFuture> durationCheckFuture) {
@@ -270,6 +267,13 @@ public class AlarmRuleState {
this.durationCheckFuture = durationCheckFuture;
}
+ public void cancelDurationCheckFuture() {
+ if (durationCheckFuture != null) {
+ durationCheckFuture.cancel(true);
+ durationCheckFuture = null;
+ }
+ }
+
public boolean isEmpty() {
return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java
index f175c46706..4adb97f380 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java
@@ -15,10 +15,22 @@
*/
package org.thingsboard.server.service.entitiy.cf;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.script.api.tbel.TbelCfCtx;
+import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
+import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal;
+import org.thingsboard.script.api.tbel.TbelCfTsRollingArg;
+import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField;
@@ -31,10 +43,16 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
import org.thingsboard.server.service.security.model.SecurityUser;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
@TbCoreComponent
@Service
@@ -42,8 +60,13 @@ import java.util.Set;
@RequiredArgsConstructor
public class DefaultTbCalculatedFieldService extends AbstractTbEntityService implements TbCalculatedFieldService {
+ private static final int TIMEOUT = 20;
+
private final CalculatedFieldService calculatedFieldService;
+ @Autowired(required = false)
+ private TbelInvokeService tbelInvokeService;
+
@Override
public CalculatedField save(CalculatedField calculatedField, SecurityUser user) throws ThingsboardException {
ActionType actionType = calculatedField.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
@@ -89,6 +112,75 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp
}
}
+ @Override
+ public JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams) {
+ String expression = inputParams.get("expression").asText();
+ Map arguments = Objects.requireNonNullElse(
+ JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}),
+ Collections.emptyMap()
+ );
+
+ ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1);
+ ctxAndArgNames.add("ctx");
+ ctxAndArgNames.addAll(arguments.keySet());
+
+ String output = "";
+ String errorText = "";
+
+ CalculatedFieldTbelScriptEngine engine = null;
+ try {
+ if (tbelInvokeService == null) {
+ throw new IllegalArgumentException("TBEL script engine is disabled!");
+ }
+
+ engine = new CalculatedFieldTbelScriptEngine(
+ tenantId,
+ tbelInvokeService,
+ expression,
+ ctxAndArgNames.toArray(String[]::new)
+ );
+
+ Object[] args = new Object[ctxAndArgNames.size()];
+ args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments));
+ for (int i = 1; i < ctxAndArgNames.size(); i++) {
+ var arg = arguments.get(ctxAndArgNames.get(i));
+ if (arg instanceof TbelCfSingleValueArg svArg) {
+ args[i] = svArg.getValue();
+ } else {
+ args[i] = arg;
+ }
+ }
+
+ JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS);
+ output = JacksonUtil.toString(json);
+ } catch (Exception e) {
+ log.error("Error evaluating expression", e);
+ Throwable rootCause = ObjectUtils.firstNonNull(ExceptionUtils.getRootCause(e), e);
+ errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getClass().getSimpleName());
+ } finally {
+ if (engine != null) {
+ engine.destroy();
+ }
+ }
+ return JacksonUtil.newObjectNode()
+ .put("output", output)
+ .put("error", errorText);
+ }
+
+ private static long getLatestTimestamp(Map arguments) {
+ long lastUpdateTimestamp = -1;
+ for (TbelCfArg entry : arguments.values()) {
+ if (entry instanceof TbelCfSingleValueArg singleValueArg) {
+ long ts = singleValueArg.getTs();
+ lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts);
+ } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) {
+ long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1);
+ lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs);
+ }
+ }
+ return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp;
+ }
+
private void checkForEntityChange(CalculatedField oldCalculatedField, CalculatedField newCalculatedField) {
if (!oldCalculatedField.getEntityId().equals(newCalculatedField.getEntityId())) {
throw new IllegalArgumentException("Changing the calculated field target entity after initialization is prohibited.");
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java
index 5fbb96e636..9dbb20dc2f 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java
@@ -15,6 +15,7 @@
*/
package org.thingsboard.server.service.entitiy.cf;
+import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -35,4 +36,6 @@ public interface TbCalculatedFieldService {
void delete(CalculatedField calculatedField, SecurityUser user);
+ JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams);
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
index 96f58707b5..aaf066acdc 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
@@ -135,6 +135,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
}
+ public Path getWidgetBundlesDir() {
+ return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
+ }
+
public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@@ -207,7 +211,7 @@ public class InstallScripts {
public void loadSystemWidgets() {
log.info("Loading system widgets");
Map widgetsBundlesMap = new HashMap<>();
- Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
+ Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(
path -> {
diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
index 2babc3feeb..3932aa9dcf 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
@@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
public void upgradeDatabase() {
log.info("Updating schema...");
loadSql(getSchemaUpdateFile("basic"));
+ loadSql(getSchemaUpdateFile("lts"));
log.info("Schema updated.");
}
diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
index 28fa68d803..e0a9917509 100644
--- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
@@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
@Service
@Slf4j
@@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache {
}
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ Set toRemove = assetProfilesMap.values().stream()
+ .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId))
+ .map(AssetProfile::getId)
+ .collect(Collectors.toSet());
+ assetProfilesMap.keySet().removeAll(toRemove);
+ assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
+ profileListeners.remove(tenantId);
+ assetProfileListeners.remove(tenantId);
+ }
+ break;
+ }
+ }
+
private void notifyProfileListeners(AssetProfile profile) {
ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {
diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
index 6b356adf94..4729a8c118 100644
--- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
@@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
@Service
@Slf4j
@@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache {
}
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ Set toRemove = deviceProfilesMap.values().stream()
+ .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId))
+ .map(DeviceProfile::getId)
+ .collect(Collectors.toSet());
+ deviceProfilesMap.keySet().removeAll(toRemove);
+ devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
+ profileListeners.remove(tenantId);
+ deviceProfileListeners.remove(tenantId);
+ }
+ break;
+ }
+ }
+
private void notifyProfileListeners(DeviceProfile profile) {
ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
index 24fa3aafb3..8a3f05f0b1 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
@@ -51,7 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory;
import org.thingsboard.server.queue.util.TbRuleEngineComponent;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
-import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.cf.CalculatedFieldStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
@@ -91,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService,
- CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) {
- super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
+ super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory;
this.stateService = stateService;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index 9bf2d027cb..519601c30a 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -86,7 +86,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
-import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.notification.NotificationSchedulerService;
import org.thingsboard.server.service.ota.OtaPackageStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@@ -179,9 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumer = requests.remove(requestId);
if (consumer != null) {
- consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), restApiCallResponseMsg.getResponse(), TbMsgCallback.EMPTY));
+ consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), TbMsgCallback.EMPTY));
} else {
log.trace("[{}] Unknown or stale rest api call response received", requestId);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
index 22fdd77d7a..5b4287acd1 100644
--- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
@@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
- return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperation());
+ return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
index cf8d9b1139..27bd88fc1a 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
@@ -64,7 +64,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS
private final RateLimitService rateLimitService;
private final TbLogEntityActionService logEntityActionService;
- protected static final List SUPPORTED_ENTITY_TYPES = List.of(
+ public static final List SUPPORTED_ENTITY_TYPES = List.of(
EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE,
EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET,
EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE,
@@ -131,7 +131,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS
@Override
public Comparator getEntityTypeComparatorForImport() {
- return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf);
+ return Comparator.comparingInt(type -> {
+ int index = SUPPORTED_ENTITY_TYPES.indexOf(type);
+ return index >= 0 ? index : Integer.MAX_VALUE;
+ });
}
@SuppressWarnings("unchecked")
diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
index d80961923d..a256888107 100644
--- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
+++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
@@ -28,7 +28,10 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
@@ -40,7 +43,12 @@ import java.net.URL;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@@ -48,7 +56,7 @@ import java.util.stream.Stream;
/**
* Runs at application startup and applies no-downtime data updates
- * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1).
+ * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1).
*/
@Slf4j
@Component
@@ -64,6 +72,8 @@ public class SystemPatchApplier {
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService;
+ private final WidgetsBundleService widgetsBundleService;
+ private final ImageService imageService;
@PostConstruct
private void init() {
@@ -91,11 +101,19 @@ public class SystemPatchApplier {
}
try {
+ updateLtsSqlSchema();
+
updateSqlViews();
log.info("Updated sql database views");
- int updated = updateWidgetTypes();
- log.info("Updated {} widget types", updated);
+ WidgetTypeStats widgetStats = updateWidgetTypes();
+ log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated());
+
+ int updatedBundles = updateWidgetBundles();
+ log.info("System widget bundles: {} updated", updatedBundles);
+
+ int createdImages = createMissingSystemImages();
+ log.info("Created {} new system images", createdImages);
schemaSettingsService.updateSchemaVersion();
log.info("System data patch update completed successfully");
@@ -119,17 +137,37 @@ public class SystemPatchApplier {
return false;
}
- if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) {
+ if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
return false;
}
- log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
+ log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
return true;
}
- private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) {
- return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor
- && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch;
+ private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
+ if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
+ return false;
+ }
+ if (packageVersion.maintenance != dbVersion.maintenance) {
+ return packageVersion.maintenance > dbVersion.maintenance;
+ }
+ return packageVersion.patch > dbVersion.patch;
+ }
+
+ private void updateLtsSqlSchema() {
+ Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
+ if (!Files.exists(sqlFile)) {
+ log.trace("LTS schema update file does not exist: {}", sqlFile);
+ return;
+ }
+ try {
+ String sql = Files.readString(sqlFile);
+ jdbcTemplate.execute(sql);
+ log.info("Applied LTS SQL schema update from {}", sqlFile);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
+ }
}
private void updateSqlViews() {
@@ -142,20 +180,24 @@ public class SystemPatchApplier {
}
}
- private int updateWidgetTypes() {
+ private WidgetTypeStats updateWidgetTypes() {
+ AtomicInteger created = new AtomicInteger();
AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir);
- return 0;
+ return new WidgetTypeStats(0, 0);
}
try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(
path -> {
try {
- if (updateWidgetTypeFromFile(path)) {
+ WidgetTypeChange change = updateWidgetTypeFromFile(path);
+ if (change == WidgetTypeChange.CREATED) {
+ created.incrementAndGet();
+ } else if (change == WidgetTypeChange.UPDATED) {
updated.incrementAndGet();
}
} catch (Exception e) {
@@ -166,18 +208,26 @@ public class SystemPatchApplier {
);
}
- return updated.get();
+ return new WidgetTypeStats(created.get(), updated.get());
}
- private boolean updateWidgetTypeFromFile(Path filePath) {
+ private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
+ return saveOrUpdateSystemWidgetType(fileWidgetType);
+ }
+
+ private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) {
String fqn = fileWidgetType.getFqn();
+ if (fqn == null || fqn.isBlank()) {
+ throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName());
+ }
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) {
- // We expect only update here, so it's probably never happening, but for test purpose leave it like this:
- throw new RuntimeException("Widget type not found: " + fqn);
+ widgetTypeService.saveWidgetType(fileWidgetType);
+ log.trace("Created widget type: {}", fqn);
+ return WidgetTypeChange.CREATED;
}
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription());
@@ -185,11 +235,128 @@ public class SystemPatchApplier {
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn);
- return true;
+ return WidgetTypeChange.UPDATED;
}
log.trace("Widget type unchanged: {}", fqn);
- return false;
+ return WidgetTypeChange.UNCHANGED;
+ }
+
+ private int updateWidgetBundles() {
+ AtomicInteger updated = new AtomicInteger();
+ Path widgetBundlesDir = installScripts.getWidgetBundlesDir();
+
+ if (!Files.exists(widgetBundlesDir)) {
+ log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir);
+ return 0;
+ }
+
+ try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
+ dirStream.forEach(path -> {
+ try {
+ if (processWidgetBundleFile(path)) {
+ updated.incrementAndGet();
+ }
+ } catch (Exception e) {
+ log.error("Unable to process widgets bundle from json: [{}]", path);
+ throw new RuntimeException("Unable to process widgets bundle from json", e);
+ }
+ });
+ }
+
+ return updated.get();
+ }
+
+ private boolean processWidgetBundleFile(Path filePath) {
+ JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile());
+ if (bundleJson == null || !bundleJson.has("widgetsBundle")) {
+ throw new RuntimeException("Invalid widgets bundle json: " + filePath);
+ }
+ WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class);
+ String alias = fileBundle.getAlias();
+ if (alias == null || alias.isBlank()) {
+ throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath);
+ }
+
+ WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
+ if (existingBundle == null) {
+ log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias);
+ return false;
+ }
+
+ if (bundleJson.has("widgetTypes")) {
+ throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " +
+ "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath);
+ }
+ List fileWidgetFqns = new ArrayList<>();
+ if (bundleJson.has("widgetTypeFqns")) {
+ bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText()));
+ }
+
+ boolean changed = false;
+ if (isWidgetsBundleChanged(existingBundle, fileBundle)) {
+ existingBundle.setTitle(fileBundle.getTitle());
+ existingBundle.setDescription(fileBundle.getDescription());
+ existingBundle.setImage(fileBundle.getImage());
+ existingBundle.setOrder(fileBundle.getOrder());
+ existingBundle.setScada(fileBundle.isScada());
+ widgetsBundleService.saveWidgetsBundle(existingBundle);
+ log.trace("Updated widgets bundle metadata: {}", alias);
+ changed = true;
+ }
+
+ List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
+ LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns);
+ if (mergedFqns.addAll(fileWidgetFqns)) {
+ widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns));
+ log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias);
+ changed = true;
+ }
+ return changed;
+ }
+
+ private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) {
+ // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores
+ // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare
+ // would always report a diff and re-save every system bundle on every patch run. Image content changes
+ // are out of scope for the patch applier — full reinstall covers them.
+ return !Objects.equals(existing.getTitle(), file.getTitle())
+ || !Objects.equals(existing.getDescription(), file.getDescription())
+ || !Objects.equals(existing.getOrder(), file.getOrder())
+ || existing.isScada() != file.isScada();
+ }
+
+ private int createMissingSystemImages() {
+ AtomicInteger created = new AtomicInteger();
+ Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images");
+
+ if (!Files.exists(imagesDir)) {
+ log.warn("System images directory does not exist: {}", imagesDir);
+ return 0;
+ }
+
+ Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+
+ try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) {
+ dirStream.forEach(path -> {
+ String resourceKey = path.getFileName().toString();
+ if (existingKeys.contains(resourceKey)) {
+ log.trace("System image already exists, skipping: {}", resourceKey);
+ return;
+ }
+ try {
+ byte[] data = Files.readAllBytes(path);
+ imageService.createOrUpdateSystemImage(resourceKey, data);
+ created.incrementAndGet();
+ log.trace("Created system image: {}", resourceKey);
+ } catch (Exception e) {
+ log.error("Unable to create system image from file: [{}]", path);
+ throw new RuntimeException("Unable to create system image " + resourceKey, e);
+ }
+ });
+ }
+
+ return created.get();
}
private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) {
@@ -287,4 +454,8 @@ public class SystemPatchApplier {
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
+ public record WidgetTypeStats(int created, int updated) {}
+
+ private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
index 4b1a81dd2d..27a8ca275c 100644
--- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
+++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
@@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService {
private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>();
- private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
+ private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>();
private ExecutorService executor;
@@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
- private void processSessionClose(WebSocketSessionRef sessionRef) {
+ void processSessionClose(WebSocketSessionRef sessionRef) {
var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef);
if (tenantProfileConfiguration != null) {
String sessionId = "[" + sessionRef.getSessionId() + "]";
@@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
publicUserSessions.removeIf(subId -> subId.startsWith(sessionId));
}
@@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
- private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) {
+ boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) {
var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef);
if (tenantProfileConfiguration == null) return true;
@@ -401,9 +401,11 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
- if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) {
+ if (cmd.isUnsubscribe()) {
+ publicUserSessions.remove(subId);
+ } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) {
publicUserSessions.add(subId);
} else {
log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached"
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index f21cd5c7dd..6ceef7bc2e 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -59,6 +59,14 @@ server:
http2:
# Enable/disable HTTP/2 support
enabled: "${HTTP2_ENABLED:true}"
+ # HTTP response compression
+ compression:
+ # Enable/disable HTTP response compression
+ enabled: "${HTTP_COMPRESSION_ENABLED:false}"
+ # Minimum size (in bytes) required for a response before compression is applied
+ min_response_size: "${HTTP_COMPRESSION_MIN_RESPONSE_SIZE:2048}"
+ # Comma-separated list of MIME types that should be compressed
+ mime_types: "${HTTP_COMPRESSION_MIME_TYPES:text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml}"
# Log errors with stacktrace when REST API throws an exception with the message "Please contact sysadmin"
log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}"
ws:
@@ -469,6 +477,7 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
+ entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order.
query:
key-filters-or-conditions:
# Enable/disable OR conditions between key filters in entity data queries
@@ -598,6 +607,18 @@ actors:
# Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24).
# Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp"
ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}"
+ http_client:
+ # Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance.
+ # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins).
+ max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}"
+ # Server-level ceiling for the pending-request queue depth per external HTTP rule node instance.
+ # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction.
+ max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}"
+ # Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance.
+ # Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2
+ # (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections.
+ # 0 = use reactor-netty default.
+ pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}"
rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@@ -1443,6 +1464,15 @@ transport:
branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.3.0}"
# Fetch frequency in hours for gateways dashboard repository
fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}"
+ ssl:
+ # SSL/TLS settings for the transport layer
+ certificate:
+ # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
+ reload:
+ # Enable/disable automatic SSL certificates reload
+ enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
+ # Interval in seconds for certificate reload
+ check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings.
@@ -1721,8 +1751,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
- # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
- compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
+ # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
+ compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record
diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
index 5824b832df..9a001e0575 100644
--- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
+++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
@@ -48,8 +48,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.predic
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule;
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule;
-import org.thingsboard.server.common.data.cf.CalculatedField;
-import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinition;
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
@@ -128,32 +127,32 @@ public class AlarmRulesTest extends AbstractControllerTest {
);
Condition clearRule = new Condition("return temperature <= 25;", null, null);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, clearRule);
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
postTelemetry(deviceId, "{\"temperature\":100}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isSeverityUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
postTelemetry(deviceId, "{\"temperature\":101}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
postTelemetry(deviceId, "{\"temperature\":20}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCleared()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
@@ -185,11 +184,11 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":100}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -209,11 +208,11 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}"));
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -236,15 +235,15 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
for (int i = 0; i < 4; i++) {
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(10);
}
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -255,12 +254,12 @@ public class AlarmRulesTest extends AbstractControllerTest {
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(10);
}
- checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> {
assertThat(alarmResult.isUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
});
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isSeverityUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -288,14 +287,14 @@ public class AlarmRulesTest extends AbstractControllerTest {
new AlarmConditionValue<>(null, "eventsCount"), null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}");
for (int i = 0; i < eventsCount; i++) {
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(10);
}
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -319,13 +318,13 @@ public class AlarmRulesTest extends AbstractControllerTest {
);
Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 5 seconds",
arguments, createRules, clearRule);
postTelemetry(deviceId, "{\"powerConsumption\":3500}");
Thread.sleep(createDurationMs - 2000);
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -334,9 +333,9 @@ public class AlarmRulesTest extends AbstractControllerTest {
postTelemetry(deviceId, "{\"powerConsumption\":2000}");
Thread.sleep(clearDurationMs - 2000);
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCleared()).isTrue();
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000);
@@ -363,12 +362,12 @@ public class AlarmRulesTest extends AbstractControllerTest {
new AlarmConditionValue(null, "duration"), null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds",
arguments, createRules, null);
postTelemetry(deviceId, "{\"powerConsumption\":3500}");
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -391,10 +390,10 @@ public class AlarmRulesTest extends AbstractControllerTest {
new AlarmConditionValue(2000L, null), null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds",
arguments, createRules, null);
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -402,6 +401,57 @@ public class AlarmRulesTest extends AbstractControllerTest {
});
}
+ @Test
+ public void testChangeDurationConditionFromStaticToDynamic() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ long staticDurationMs = 5000L;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, staticDurationMs)
+ );
+
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+ CalculatedFieldId alarmRuleId = alarmRule.getId();
+
+ // post telemetry to trigger condition and wait for the static-phase eval to produce a debug event,
+ // which guarantees firstEventTs > 0 in AlarmRuleState before we trigger REINIT
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ await().atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> getDebugEvents(alarmRuleId, 1),
+ events -> !events.isEmpty() && !events.get(0).getId().equals(latestEventId));
+
+ // update CF: add attribute argument and switch duration from static to dynamic
+ AlarmCalculatedFieldConfiguration configuration = alarmRule.getConfiguration();
+
+ Argument durationArgument = new Argument();
+ durationArgument.setRefEntityKey(new ReferencedEntityKey("durationThreshold",
+ ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ durationArgument.setDefaultValue("-1");
+ configuration.getArguments().put("durationThreshold", durationArgument);
+
+ DurationAlarmCondition durationCondition = (DurationAlarmCondition)
+ configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition();
+ durationCondition.setValue(new AlarmConditionValue<>(null, "durationThreshold"));
+
+ alarmRule = saveAlarmRule(alarmRule);
+
+ long dynamicDurationMs = 3000L;
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE,
+ "{\"durationThreshold\":" + dynamicDurationMs + "}");
+
+ checkAlarmResult(alarmRule, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
@Test
public void testCreateAlarm_currentOwnerArgument() throws Exception {
Argument temperatureArgument = new Argument();
@@ -424,12 +474,12 @@ public class AlarmRulesTest extends AbstractControllerTest {
device.setCustomerId(customerId);
device = doPost("/api/device", device, Device.class);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}");
postTelemetry(deviceId, "{\"temperature\":51}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -462,21 +512,21 @@ public class AlarmRulesTest extends AbstractControllerTest {
"location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter")
), null, null);
- CalculatedField calculatedField = createAlarmCf(customerId, "New resident",
+ AlarmRuleDefinition alarmRule = createAlarmRule(customerId, "New resident",
arguments, createRules, clearRule);
loginSysAdmin();
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}");
loginTenantAdmin();
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCleared()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
@@ -501,7 +551,7 @@ public class AlarmRulesTest extends AbstractControllerTest {
new AlarmConditionValue<>(null, "schedule"))
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
String schedule = """
{"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]}
@@ -510,14 +560,14 @@ public class AlarmRulesTest extends AbstractControllerTest {
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(1000);
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
schedule = schedule.replace("\"enabled\":false", "\"enabled\":true");
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}");
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
// checking multiple debug events due to scheduled reevaluation (which also produces debug events)
- CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream()
+ CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream()
.filter(event -> event.getResult() != null)
.findFirst().orElse(null);
assertThat(debugEvent).isNotNull();
@@ -565,18 +615,18 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "No Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isSeverityUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -596,19 +646,19 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
- calculatedField.setName("New alarm type");
- calculatedField = saveCalculatedField(calculatedField);
- checkAlarmResult(calculatedField, alarmResult -> {
+ alarmRule.setName("New alarm type");
+ alarmRule = saveAlarmRule(alarmRule);
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -628,18 +678,18 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(1000);
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
- AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration();
+ AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration();
((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression())
.setExpression("return temperature >= 50;");
- calculatedField = saveCalculatedField(calculatedField);
- checkAlarmResult(calculatedField, alarmResult -> {
+ alarmRule = saveAlarmRule(alarmRule);
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -662,13 +712,13 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
for (int i = 0; i < eventsCountMajor; i++) {
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(10);
}
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -676,18 +726,18 @@ public class AlarmRulesTest extends AbstractControllerTest {
});
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
assertThat(alarmResult.getConditionRepeats()).isEqualTo(6);
});
// decreasing required events count for critical rule
- AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration();
+ AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration();
((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition())
.setCount(new AlarmConditionValue<>(6, null));
- calculatedField = saveCalculatedField(calculatedField);
- checkAlarmResult(calculatedField, alarmResult -> {
+ alarmRule = saveAlarmRule(alarmRule);
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isSeverityUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -719,20 +769,20 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
Thread.sleep(1000);
// not created because tenant's threshold 100 is used
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
- ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold")
+ ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getArguments().get("temperatureThreshold")
.setRefDynamicSourceConfiguration(null);
// using threshold 50 on device level
- calculatedField = saveCalculatedField(calculatedField);
+ alarmRule = saveAlarmRule(alarmRule);
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -755,7 +805,7 @@ public class AlarmRulesTest extends AbstractControllerTest {
Map createRules = Map.of(
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature and Humidity Alarm",
arguments, createRules, null, configuration -> {
configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails(
"temperature is ${temperature}, humidity is ${humidity}"
@@ -765,18 +815,18 @@ public class AlarmRulesTest extends AbstractControllerTest {
postTelemetry(deviceId, "{\"temperature\":50}");
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getDetails().get("data").asText())
.isEqualTo("temperature is 50, humidity is 50");
});
- ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails(
+ ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails(
"UPDATED temperature is ${temperature}, humidity is ${humidity}"
);
- calculatedField = saveCalculatedField(calculatedField);
- checkAlarmResult(calculatedField, alarmResult -> {
+ alarmRule = saveAlarmRule(alarmRule);
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isFalse();
assertThat(alarmResult.isUpdated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
@@ -805,16 +855,16 @@ public class AlarmRulesTest extends AbstractControllerTest {
new AlarmConditionValue<>(schedule, null))
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Illegal parking alarm",
arguments, createRules, null);
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}");
Thread.sleep(10000);
- assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+ assertThat(getLatestAlarmResult(alarmRule.getId())).isNull();
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
- CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream()
+ CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream()
.filter(event -> event.getResult() != null)
.findFirst().orElse(null);
assertThat(debugEvent).isNotNull();
@@ -838,11 +888,11 @@ public class AlarmRulesTest extends AbstractControllerTest {
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
);
- CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
- Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> {
+ Alarm alarm = checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
@@ -851,7 +901,7 @@ public class AlarmRulesTest extends AbstractControllerTest {
doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class);
Thread.sleep(1000);
postTelemetry(deviceId, "{\"temperature\":50}");
- checkAlarmResult(calculatedField, alarmResult -> {
+ checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId());
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
@@ -861,21 +911,21 @@ public class AlarmRulesTest extends AbstractControllerTest {
// TODO: MSA tests
- private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) {
- return checkAlarmResult(calculatedField, null, assertion);
+ private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Consumer assertion) {
+ return checkAlarmResult(alarmRule, null, assertion);
}
- private TbAlarmResult checkAlarmResult(CalculatedField calculatedField,
+ private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule,
Predicate waitFor,
Consumer assertion) {
TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS)
- .until(() -> getLatestAlarmResult(calculatedField.getId()), result ->
+ .until(() -> getLatestAlarmResult(alarmRule.getId()), result ->
result != null && (waitFor == null || waitFor.test(result)));
assertion.accept(alarmResult);
Alarm alarm = alarmResult.getAlarm();
assertThat(alarm.getOriginator()).isEqualTo(originatorId);
- assertThat(alarm.getType()).isEqualTo(calculatedField.getName());
+ assertThat(alarm.getType()).isEqualTo(alarmRule.getName());
return alarmResult;
}
@@ -895,38 +945,37 @@ public class AlarmRulesTest extends AbstractControllerTest {
return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class);
}
- private CalculatedField createAlarmCf(EntityId entityId,
- String alarmType,
- Map arguments,
- Map createConditions,
- Condition clearCondition,
- Consumer... modifier) {
+ private AlarmRuleDefinition createAlarmRule(EntityId entityId,
+ String alarmType,
+ Map arguments,
+ Map createConditions,
+ Condition clearCondition,
+ Consumer... modifier) {
Map createRules = new HashMap<>();
createConditions.forEach((severity, condition) -> {
createRules.put(severity, toAlarmRule(condition));
});
AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null;
- CalculatedField calculatedField = new CalculatedField();
- calculatedField.setEntityId(entityId);
- calculatedField.setName(alarmType);
- calculatedField.setType(CalculatedFieldType.ALARM);
+ AlarmRuleDefinition alarmRule = new AlarmRuleDefinition();
+ alarmRule.setEntityId(entityId);
+ alarmRule.setName(alarmType);
AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration();
configuration.setArguments(arguments);
configuration.setCreateRules(createRules);
configuration.setClearRule(clearRule);
- calculatedField.setConfiguration(configuration);
- calculatedField.setDebugSettings(DebugSettings.all());
+ alarmRule.setConfiguration(configuration);
+ alarmRule.setDebugSettings(DebugSettings.all());
if (modifier.length > 0) {
modifier[0].accept(configuration);
}
- CalculatedField savedCalculatedField = saveCalculatedField(calculatedField);
+ AlarmRuleDefinition savedAlarmRule = saveAlarmRule(alarmRule);
CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS)
- .until(() -> getDebugEvents(savedCalculatedField.getId(), 1),
+ .until(() -> getDebugEvents(savedAlarmRule.getId(), 1),
events -> !events.isEmpty()).get(0);
latestEventId = debugEvent.getId();
- return savedCalculatedField;
+ return savedAlarmRule;
}
private AlarmRule toAlarmRule(Condition condition) {
diff --git a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
index d17dd9d0f6..7594485c14 100644
--- a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
@@ -17,10 +17,8 @@ package org.thingsboard.server.client;
import org.junit.Test;
import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration;
-import org.thingsboard.client.model.AlarmConditionValueAlarmRuleSchedule;
-import org.thingsboard.client.model.AlarmRuleDefinition;
-import org.thingsboard.client.model.AlarmRuleSimpleCondition;
-import org.thingsboard.client.model.AlarmRuleSpecificTimeSchedule;
+import org.thingsboard.client.model.AlarmConditionValueAlarmSchedule;
+import org.thingsboard.client.model.AlarmRule;
import org.thingsboard.client.model.AlarmSeverity;
import org.thingsboard.client.model.Argument;
import org.thingsboard.client.model.ArgumentType;
@@ -30,7 +28,9 @@ import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.PageDataCalculatedField;
import org.thingsboard.client.model.ReferencedEntityKey;
+import org.thingsboard.client.model.SimpleAlarmCondition;
import org.thingsboard.client.model.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.client.model.SpecificTimeSchedule;
import org.thingsboard.client.model.TbelAlarmConditionExpression;
import org.thingsboard.client.model.TimeSeriesOutput;
import org.thingsboard.server.dao.service.DaoSqlTest;
@@ -198,12 +198,12 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression)
TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression();
createExpression.setExpression("return temp > 50;");
- AlarmRuleSimpleCondition createCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition createCondition = new SimpleAlarmCondition();
createCondition.setExpression(createExpression);
- AlarmRuleSpecificTimeSchedule specificTimeSchedule = new AlarmRuleSpecificTimeSchedule().addDaysOfWeekItem(3);
- AlarmConditionValueAlarmRuleSchedule schedule = new AlarmConditionValueAlarmRuleSchedule().staticValue(specificTimeSchedule);
+ SpecificTimeSchedule specificTimeSchedule = new SpecificTimeSchedule().addDaysOfWeekItem(3);
+ AlarmConditionValueAlarmSchedule schedule = new AlarmConditionValueAlarmSchedule().staticValue(specificTimeSchedule);
createCondition.setSchedule(schedule);
- AlarmRuleDefinition createRule = new AlarmRuleDefinition();
+ AlarmRule createRule = new AlarmRule();
createRule.setCondition(createCondition);
createRule.setAlarmDetails("Temperature is too high: ${temp}");
config.setCreateRules(Map.of(
@@ -213,9 +213,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// clear rule: when temp drops below 30
TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression();
clearExpression.setExpression("return temp < 30;");
- AlarmRuleSimpleCondition clearCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition clearCondition = new SimpleAlarmCondition();
clearCondition.setExpression(clearExpression);
- AlarmRuleDefinition clearRule = new AlarmRuleDefinition();
+ AlarmRule clearRule = new AlarmRule();
clearRule.setCondition(clearCondition);
config.setClearRule(clearRule);
@@ -236,8 +236,8 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
assertEquals(cf.getName(), created.getName());
assertEquals(CalculatedFieldType.ALARM, created.getType());
AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration();
- AlarmConditionValueAlarmRuleSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule();
- AlarmRuleSpecificTimeSchedule staticSchedule = (AlarmRuleSpecificTimeSchedule) createdSchedule.getStaticValue();
+ AlarmConditionValueAlarmSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule();
+ SpecificTimeSchedule staticSchedule = (SpecificTimeSchedule) createdSchedule.getStaticValue();
assertEquals(Set.of(3), staticSchedule.getDaysOfWeek());
// get by id and verify configuration
@@ -257,9 +257,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// update: add a second create rule for CRITICAL_TEMPERATURE
TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression();
criticalExpression.setExpression("return temp > 80;");
- AlarmRuleSimpleCondition criticalCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition criticalCondition = new SimpleAlarmCondition();
criticalCondition.setExpression(criticalExpression);
- AlarmRuleDefinition criticalRule = new AlarmRuleDefinition();
+ AlarmRule criticalRule = new AlarmRule();
criticalRule.setCondition(criticalCondition);
fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule);
fetched.setConfiguration(fetchedConfig);
diff --git a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
index bd10a3deca..e9498a1c50 100644
--- a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
@@ -69,7 +69,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
}
// list tenant domains with text search
- PageDataDomainInfo filteredDomains = client.getTenantDomainInfos(100, 0,
+ PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0,
"domain.", null, null);
assertNotNull(filteredDomains);
assertEquals(5, filteredDomains.getData().size());
@@ -97,7 +97,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
client.getDomainInfoById(domainToDeleteId)
);
- PageDataDomainInfo domainsAfterDelete = client.getTenantDomainInfos(100, 0,
+ PageDataDomainInfo domainsAfterDelete = client.getDomainInfos(100, 0,
"domain.", null, null);
assertEquals(4, domainsAfterDelete.getData().size());
}
diff --git a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
index 63f05710ae..2099f405f0 100644
--- a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
@@ -90,7 +90,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
}
// list tenant OAuth2 client infos
- PageDataOAuth2ClientInfo clientInfos = client.findTenantOAuth2ClientInfos(100, 0,
+ PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertNotNull(clientInfos);
assertEquals(5, clientInfos.getData().size());
@@ -130,7 +130,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
client.getOAuth2ClientById(clientToDeleteId)
);
- PageDataOAuth2ClientInfo clientsAfterDelete = client.findTenantOAuth2ClientInfos(100, 0,
+ PageDataOAuth2ClientInfo clientsAfterDelete = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertEquals(4, clientsAfterDelete.getData().size());
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
index 6a658ffd66..055e1e19a0 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -94,6 +94,7 @@ import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinition;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@@ -1482,6 +1483,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return doPost("/api/calculatedField", calculatedField, CalculatedField.class);
}
+ protected AlarmRuleDefinition saveAlarmRule(AlarmRuleDefinition alarmRule) {
+ return doPost("/api/alarm/rule", alarmRule, AlarmRuleDefinition.class);
+ }
+
protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception {
return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" +
(type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink);
diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
index 099ef05752..100f44d4b7 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
@@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions;
+import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.ai.dto.TbChatRequest;
+import org.thingsboard.server.common.data.ai.dto.TbChatResponse;
+import org.thingsboard.server.common.data.ai.dto.TbContent;
+import org.thingsboard.server.common.data.ai.dto.TbUserMessage;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
@@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
@@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest {
assertThat(updatedModel.getExternalId()).isNull();
}
+ @Test
+ public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception {
+ // GIVEN
+ loginTenantAdmin();
+ SsrfProtectionValidator.setEnabled(true);
+
+ try {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://172.17.0.1:22/")
+ .apiKey("test-api-key")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ AiModel model = AiModel.builder()
+ .tenantId(tenantId)
+ .name("SSRF test model")
+ .configuration(modelConfig)
+ .build();
+
+ // WHEN
+ ResultActions result = doPost("/api/ai/model", model);
+
+ // THEN
+ result.andExpect(status().isBadRequest());
+ } finally {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+ }
+
+ @Test
+ public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception {
+ // GIVEN
+ loginTenantAdmin();
+ SsrfProtectionValidator.setEnabled(true);
+
+ try {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://10.0.0.1:8080/")
+ .apiKey("test-api-key")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ var chatRequest = new TbChatRequest(
+ null,
+ new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))),
+ modelConfig);
+
+ // WHEN
+ TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk());
+
+ // THEN
+ assertThat(response).isInstanceOf(TbChatResponse.Failure.class);
+ assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid");
+ } finally {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+ }
+
/* --- Get by ID API tests --- */
@Test
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
index 17a30cac35..3bb5fd5647 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
@@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -226,6 +228,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
.andExpect(status().isOk());
+ Optional systemCommentOpt = doGetTyped(
+ "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() {
+ }
+ ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
+ assertThat(systemCommentOpt).isPresent();
+ AlarmCommentInfo systemComment = systemCommentOpt.get();
+
+ assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
+ assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
+ assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment",
+ TENANT_ADMIN_EMAIL));
+
AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM)
@@ -361,6 +375,39 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
Assert.assertTrue("Created alarm doesn't match the found one!", equals);
}
+ @Test
+ public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception {
+ loginTenantAdmin();
+
+ AlarmComment alarmComment = AlarmComment.builder()
+ .type(AlarmCommentType.SYSTEM)
+ .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin")))
+ .build();
+ AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class);
+ assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER);
+
+ // acknowledge alarm to create system comment
+ doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk());
+
+ Optional systemCommentOpt = doGetTyped(
+ "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() {
+ }
+ ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
+ assertThat(systemCommentOpt).isPresent();
+ AlarmCommentInfo systemComment = systemCommentOpt.get();
+
+ // system comment can't be updated with other type
+ systemComment.setType(AlarmCommentType.OTHER);
+ doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("System alarm comment can't be updated!")));
+
+ // system comment can't be updated with other text
+ systemComment.setType(AlarmCommentType.SYSTEM);
+ systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment")));
+ doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("System alarm comment can't be updated!")));
+ }
+
private AlarmComment createAlarmComment(AlarmId alarmId, String text) {
AlarmComment alarmComment = AlarmComment.builder()
.comment(JacksonUtil.newObjectNode().set("text", new TextNode(text)))
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java
new file mode 100644
index 0000000000..7c64fd6525
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java
@@ -0,0 +1,420 @@
+/**
+ * Copyright © 2016-2026 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.server.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.Tenant;
+import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.alarm.AlarmSeverity;
+import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
+import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinition;
+import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.common.data.page.SortOrder;
+import org.thingsboard.server.common.data.security.Authority;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DaoSqlTest
+public class AlarmRuleControllerTest extends AbstractControllerTest {
+
+ private Tenant savedTenant;
+
+ @Before
+ public void beforeTest() throws Exception {
+ loginSysAdmin();
+
+ Tenant tenant = new Tenant();
+ tenant.setTitle("My tenant");
+ savedTenant = saveTenant(tenant);
+ assertThat(savedTenant).isNotNull();
+
+ User tenantAdmin = new User();
+ tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
+ tenantAdmin.setTenantId(savedTenant.getId());
+ tenantAdmin.setEmail("tenant2@thingsboard.org");
+ tenantAdmin.setFirstName("Joe");
+ tenantAdmin.setLastName("Downs");
+
+ createUserAndLogin(tenantAdmin, "testPassword1");
+ }
+
+ @After
+ public void afterTest() throws Exception {
+ loginSysAdmin();
+ deleteTenant(savedTenant.getId());
+ }
+
+ @Test
+ public void testSaveAlarmRule() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "High Temperature");
+
+ AlarmRuleDefinition saved = saveAlarmRule(alarmRule);
+
+ assertThat(saved).isNotNull();
+ assertThat(saved.getId()).isNotNull();
+ assertThat(saved.getCreatedTime()).isGreaterThan(0);
+ assertThat(saved.getTenantId()).isEqualTo(savedTenant.getId());
+ assertThat(saved.getEntityId()).isEqualTo(testDevice.getId());
+ assertThat(saved.getName()).isEqualTo("High Temperature");
+ assertThat(saved.getConfiguration()).isNotNull();
+ assertThat(saved.getConfiguration().getCreateRules()).containsKey(AlarmSeverity.CRITICAL);
+
+ saved.setName("Updated Alarm Rule");
+ AlarmRuleDefinition updated = saveAlarmRule(saved);
+
+ assertThat(updated.getName()).isEqualTo("Updated Alarm Rule");
+ assertThat(updated.getVersion()).isEqualTo(saved.getVersion() + 1);
+
+ doDelete("/api/alarm/rule/" + saved.getId().getId())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetAlarmRuleById() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "Test Alarm");
+
+ AlarmRuleDefinition saved = saveAlarmRule(alarmRule);
+ AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + saved.getId().getId(), AlarmRuleDefinition.class);
+
+ assertThat(fetched).isNotNull();
+ assertThat(fetched).isEqualTo(saved);
+
+ doDelete("/api/alarm/rule/" + saved.getId().getId())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetAlarmRuleById_notFound() throws Exception {
+ doGet("/api/alarm/rule/" + UUID.randomUUID())
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void testGetAlarmRuleById_calculatedFieldNotAlarm() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField cf = createSimpleCalculatedField(testDevice.getId());
+ CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ doGet("/api/alarm/rule/" + savedCf.getId().getId())
+ .andExpect(status().isNotFound());
+
+ doDelete("/api/calculatedField/" + savedCf.getId().getId())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetAlarmRulesByEntityId() throws Exception {
+ Device device1 = createDevice("Device 1", "1234567890");
+ Device device2 = createDevice("Device 2", "0987654321");
+ AlarmRuleDefinition rule1 = saveAlarmRule(createTestAlarmRule(device1.getId(), "Rule 1"));
+ saveAlarmRule(createTestAlarmRule(device2.getId(), "Rule 2"));
+
+ PageData result = doGetTypedWithPageLink(
+ "/api/alarm/rules/" + EntityType.DEVICE + "/" + device1.getUuidId() + "?",
+ new TypeReference<>() {}, new PageLink(10));
+
+ assertThat(result.getData()).hasSize(1);
+ assertThat(result.getData().get(0).getId()).isEqualTo(rule1.getId());
+ assertThat(result.getData().get(0).getName()).isEqualTo("Rule 1");
+ }
+
+ @Test
+ public void testGetAlarmRules() throws Exception {
+ Device device = createDevice("Device A", "1234567890");
+ AlarmRuleDefinition deviceRule = saveAlarmRule(createTestAlarmRule(device.getId(), "Device Alarm"));
+
+ DeviceProfile profile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class);
+ AlarmRuleDefinition profileRule = saveAlarmRule(createTestAlarmRule(profile.getId(), "Profile Alarm"));
+
+ // All alarm rules
+ List all = getAlarmRules(null, null);
+ assertThat(all).extracting(AlarmRuleDefinition::getName)
+ .contains("Device Alarm", "Profile Alarm");
+
+ // Filter by entity type: DEVICE
+ List deviceRules = getAlarmRules(EntityType.DEVICE, null);
+ assertThat(deviceRules).extracting(AlarmRuleDefinition::getName)
+ .containsOnly("Device Alarm");
+
+ // Filter by entity type: DEVICE_PROFILE
+ List profileRules = getAlarmRules(EntityType.DEVICE_PROFILE, null);
+ assertThat(profileRules).extracting(AlarmRuleDefinition::getName)
+ .containsOnly("Profile Alarm");
+
+ // Filter by specific entity IDs
+ List specificRules = getAlarmRules(EntityType.DEVICE, List.of(device.getUuidId()));
+ assertThat(specificRules).extracting(AlarmRuleDefinition::getName)
+ .containsOnly("Device Alarm");
+
+ // Verify entity names are populated
+ AlarmRuleDefinitionInfo deviceInfo = all.stream()
+ .filter(r -> r.getName().equals("Device Alarm")).findFirst().orElseThrow();
+ assertThat(deviceInfo.getEntityName()).isEqualTo("Device A");
+
+ AlarmRuleDefinitionInfo profileInfo = all.stream()
+ .filter(r -> r.getName().equals("Profile Alarm")).findFirst().orElseThrow();
+ assertThat(profileInfo.getEntityName()).isEqualTo("Profile A");
+ }
+
+ @Test
+ public void testGetAlarmRules_textSearch() throws Exception {
+ Device device = createDevice("Device A", "1234567890");
+ saveAlarmRule(createTestAlarmRule(device.getId(), "Temperature Alarm"));
+ saveAlarmRule(createTestAlarmRule(device.getId(), "Humidity Alarm"));
+
+ PageData result = doGetTypedWithPageLink(
+ "/api/alarm/rules?textSearch=Temp&",
+ new TypeReference<>() {}, new PageLink(10));
+
+ assertThat(result.getData()).hasSize(1);
+ assertThat(result.getData().get(0).getName()).isEqualTo("Temperature Alarm");
+ }
+
+ @Test
+ public void testGetAlarmRuleNames() throws Exception {
+ Device device = createDevice("Device A", "1234567890");
+ saveAlarmRule(createTestAlarmRule(device.getId(), "Alpha Alarm"));
+ saveAlarmRule(createTestAlarmRule(device.getId(), "Beta Alarm"));
+
+ PageData names = getAlarmRuleNames(new PageLink(10, 0,
+ null, new SortOrder("", SortOrder.Direction.ASC)));
+ assertThat(names.getTotalElements()).isEqualTo(2);
+ assertThat(names.getData()).isSortedAccordingTo(Comparator.naturalOrder());
+ assertThat(names.getData()).contains("Alpha Alarm", "Beta Alarm");
+
+ names = getAlarmRuleNames(new PageLink(10, 0,
+ null, new SortOrder("", SortOrder.Direction.DESC)));
+ assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder());
+
+ names = getAlarmRuleNames(new PageLink(10, 0,
+ "Alpha", new SortOrder("", SortOrder.Direction.ASC)));
+ assertThat(names.getTotalElements()).isEqualTo(1);
+ assertThat(names.getData()).containsOnly("Alpha Alarm");
+ }
+
+ @Test
+ public void testDeleteAlarmRule() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "To Delete"));
+
+ assertThat(saved).isNotNull();
+
+ doDelete("/api/alarm/rule/" + saved.getId().getId())
+ .andExpect(status().isOk());
+ doGet("/api/alarm/rule/" + saved.getId().getId())
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void testDeleteAlarmRule_notFound() throws Exception {
+ doDelete("/api/alarm/rule/" + UUID.randomUUID())
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void testDeleteAlarmRule_calculatedFieldNotAlarm() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField cf = createSimpleCalculatedField(testDevice.getId());
+ CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ doDelete("/api/alarm/rule/" + savedCf.getId().getId())
+ .andExpect(status().isNotFound());
+
+ doDelete("/api/calculatedField/" + savedCf.getId().getId())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetLatestAlarmRuleDebugEvent() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "Debug Test"));
+
+ doGet("/api/alarm/rule/" + saved.getId().getId() + "/debug")
+ .andExpect(status().isOk());
+
+ doDelete("/api/alarm/rule/" + saved.getId().getId())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetLatestAlarmRuleDebugEvent_notFound() throws Exception {
+ doGet("/api/alarm/rule/" + UUID.randomUUID() + "/debug")
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void testTestAlarmRuleScript() throws Exception {
+ JsonNode request = JacksonUtil.toJsonNode("""
+ {
+ "expression": "return temperature > 50;",
+ "arguments": {
+ "temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 55 }
+ }
+ }
+ """);
+
+ JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class);
+
+ assertThat(result).isNotNull();
+ assertThat(result.has("output")).isTrue();
+ assertThat(result.has("error")).isTrue();
+ assertThat(result.get("error").asText()).isEmpty();
+ assertThat(result.get("output").asText()).isEqualTo("true");
+ }
+
+ @Test
+ public void testTestAlarmRuleScript_returnsFalse() throws Exception {
+ JsonNode request = JacksonUtil.toJsonNode("""
+ {
+ "expression": "return temperature > 50;",
+ "arguments": {
+ "temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 30 }
+ }
+ }
+ """);
+
+ JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class);
+
+ assertThat(result).isNotNull();
+ assertThat(result.get("error").asText()).isEmpty();
+ assertThat(result.get("output").asText()).isEqualTo("false");
+ }
+
+ @Test
+ public void testTestAlarmRuleScript_missingExpression() throws Exception {
+ JsonNode request = JacksonUtil.toJsonNode("""
+ {
+ "arguments": {}
+ }
+ """);
+
+ doPost("/api/alarm/rule/testScript", request)
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ public void testTestAlarmRuleScript_invalidExpression() throws Exception {
+ JsonNode request = JacksonUtil.toJsonNode("""
+ {
+ "expression": "invalid syntax {{{{",
+ "arguments": {}
+ }
+ """);
+
+ JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class);
+
+ assertThat(result).isNotNull();
+ assertThat(result.get("error").asText()).isNotEmpty();
+ }
+
+ // --- Helper methods ---
+
+ private AlarmRuleDefinition createTestAlarmRule(EntityId entityId, String name) {
+ AlarmRuleDefinition alarmRule = new AlarmRuleDefinition();
+ alarmRule.setEntityId(entityId);
+ alarmRule.setName(name);
+ alarmRule.setConfigurationVersion(1);
+ alarmRule.setAdditionalInfo(JacksonUtil.newObjectNode());
+
+ AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration();
+
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("0");
+ configuration.setArguments(Map.of("temperature", argument));
+
+ AlarmRule rule = new AlarmRule();
+ TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression();
+ expression.setExpression("return temperature >= 50;");
+ SimpleAlarmCondition condition = new SimpleAlarmCondition();
+ condition.setExpression(expression);
+ rule.setCondition(condition);
+ configuration.setCreateRules(Map.of(AlarmSeverity.CRITICAL, rule));
+
+ alarmRule.setConfiguration(configuration);
+ return alarmRule;
+ }
+
+ private CalculatedField createSimpleCalculatedField(EntityId entityId) {
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(entityId);
+ cf.setType(CalculatedFieldType.SIMPLE);
+ cf.setName("Simple CF");
+ cf.setConfigurationVersion(1);
+ cf.setAdditionalInfo(JacksonUtil.newObjectNode());
+
+ SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
+ Argument arg = new Argument();
+ arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ config.setArguments(Map.of("T", arg));
+ config.setExpression("T * 2");
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setName("result");
+ config.setOutput(output);
+ cf.setConfiguration(config);
+
+ return cf;
+ }
+
+ private List getAlarmRules(EntityType entityType, List entities) throws Exception {
+ StringBuilder url = new StringBuilder("/api/alarm/rules?");
+ if (entityType != null) {
+ url.append("entityType=").append(entityType).append("&");
+ }
+ if (entities != null) {
+ url.append("entities=").append(String.join(",",
+ entities.stream().map(UUID::toString).toList())).append("&");
+ }
+ return doGetTypedWithPageLink(url.toString(),
+ new TypeReference>() {}, new PageLink(10)).getData();
+ }
+
+ private PageData getAlarmRuleNames(PageLink pageLink) throws Exception {
+ return doGetTypedWithPageLink("/api/alarm/rules/names?",
+ new TypeReference>() {}, pageLink);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
index 3966bbb97a..0ee1a65cd8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
+import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -52,8 +53,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.device.DeviceProfileDao;
-import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.exception.DataValidationException;
import java.util.ArrayList;
import java.util.Arrays;
@@ -62,6 +63,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -93,6 +95,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) {
return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao));
}
+
}
@Before
@@ -640,364 +643,364 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " required int32 parameter = 1;\n" +
- "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " required int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " required int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " required int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
}
@Test
public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "option java_package = \"com.test.schemavalidation\";\n" +
- "option java_multiple_files = true;\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
+ "\n" +
+ "option java_package = \"com.test.schemavalidation\";\n" +
+ "option java_multiple_files = true;\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
}
@Test
public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "import public \"oldschema.proto\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
+ "\n" +
+ "import public \"oldschema.proto\";\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "import \"oldschema.proto\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
+ "\n" +
+ "import \"oldschema.proto\";\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "extend google.protobuf.MethodOptions {\n" +
- " MyMessage my_method_option = 50007;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "extend google.protobuf.MethodOptions {\n" +
+ " MyMessage my_method_option = 50007;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
}
@Test
public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "enum testEnum {\n" +
- " option allow_alias = true;\n" +
- " DEFAULT = 0;\n" +
- " STARTED = 1;\n" +
- " RUNNING = 2;\n" +
- "}\n" +
- "\n" +
- "message testMessage {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "enum testEnum {\n" +
+ " option allow_alias = true;\n" +
+ " DEFAULT = 0;\n" +
+ " STARTED = 1;\n" +
+ " RUNNING = 2;\n" +
+ "}\n" +
+ "\n" +
+ "message testMessage {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
}
@Test
public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "enum testEnum {\n" +
- " DEFAULT = 0;\n" +
- " STARTED = 1;\n" +
- " RUNNING = 2;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "enum testEnum {\n" +
+ " DEFAULT = 0;\n" +
+ " STARTED = 1;\n" +
+ " RUNNING = 2;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message testMessage {\n" +
- " option allow_alias = true;\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message testMessage {\n" +
+ " option allow_alias = true;\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message TestMessage {\n" +
- " extensions 100 to 199;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message TestMessage {\n" +
+ " extensions 100 to 199;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message Foo {\n" +
- " reserved 2, 15, 9 to 11;\n" +
- " reserved \"foo\", \"bar\";\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message Foo {\n" +
+ " reserved 2, 15, 9 to 11;\n" +
+ " reserved \"foo\", \"bar\";\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message TestMessage {\n" +
- " repeated group Result = 1 {\n" +
- " optional string url = 2;\n" +
- " optional string title = 3;\n" +
- " repeated string snippets = 4;\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message TestMessage {\n" +
+ " repeated group Result = 1 {\n" +
+ " optional string url = 2;\n" +
+ " optional string title = 3;\n" +
+ " repeated string snippets = 4;\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SampleMessage {\n" +
- " oneof test_oneof {\n" +
- " string name = 1;\n" +
- " group Result = 2 {\n" +
- " \tstring url = 3;\n" +
- " \tstring title = 4;\n" +
- " \trepeated string snippets = 5;\n" +
- " }\n" +
- " }" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SampleMessage {\n" +
+ " oneof test_oneof {\n" +
+ " string name = 1;\n" +
+ " group Result = 2 {\n" +
+ " \tstring url = 3;\n" +
+ " \tstring title = 4;\n" +
+ " \trepeated string snippets = 5;\n" +
+ " }\n" +
+ " }" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " int64 ts = 1;\n" +
- " Values values = 2;\n" +
- " \n" +
- " message Values {\n" +
- " string key1 = 3;\n" +
- " bool key2 = 4;\n" +
- " double key3 = 5;\n" +
- " int32 key4 = 6;\n" +
- " JsonObject key5 = 7;\n" +
- " }\n" +
- " \n" +
- " message JsonObject {\n" +
- " optional int32 someNumber = 8;\n" +
- " repeated int32 someArray = 9;\n" +
- " NestedJsonObject someNestedObject = 10;\n" +
- " message NestedJsonObject {\n" +
- " optional string key = 11;\n" +
- " }\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " int64 ts = 1;\n" +
+ " Values values = 2;\n" +
+ " \n" +
+ " message Values {\n" +
+ " string key1 = 3;\n" +
+ " bool key2 = 4;\n" +
+ " double key3 = 5;\n" +
+ " int32 key4 = 6;\n" +
+ " JsonObject key5 = 7;\n" +
+ " }\n" +
+ " \n" +
+ " message JsonObject {\n" +
+ " optional int32 someNumber = 8;\n" +
+ " repeated int32 someArray = 9;\n" +
+ " NestedJsonObject someNestedObject = 10;\n" +
+ " message NestedJsonObject {\n" +
+ " optional string key = 11;\n" +
+ " }\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " optional int32 ts = 1;\n" +
- " Values values = 2;\n" +
- " \n" +
- " message Values {\n" +
- " string key1 = 3;\n" +
- " bool key2 = 4;\n" +
- " double key3 = 5;\n" +
- " int32 key4 = 6;\n" +
- " JsonObject key5 = 7;\n" +
- " }\n" +
- " \n" +
- " message JsonObject {\n" +
- " optional int32 someNumber = 8;\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " optional int32 ts = 1;\n" +
+ " Values values = 2;\n" +
+ " \n" +
+ " message Values {\n" +
+ " string key1 = 3;\n" +
+ " bool key2 = 4;\n" +
+ " double key3 = 5;\n" +
+ " int32 key4 = 6;\n" +
+ " JsonObject key5 = 7;\n" +
+ " }\n" +
+ " \n" +
+ " message JsonObject {\n" +
+ " optional int32 someNumber = 8;\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " optional int64 ts = 1;\n" +
- " string values = 2;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " optional int64 ts = 1;\n" +
+ " string values = 2;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional int32 method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional int32 method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int64 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int64 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " repeated string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " repeated string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " repeated int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " repeated int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " repeated string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " repeated string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string methodName = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string methodName = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestIdentifier = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestIdentifier = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string parameters = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string parameters = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
}
@Test
@@ -1068,11 +1071,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
- Mockito.reset(tbClusterService, auditLogService);
-
- doPost("/api/deviceProfile", deviceProfile)
- .andExpect(status().isBadRequest())
- .andExpect(statusReason(containsString(errorMsg)));
+ // The request may hit a transient TenantNotFoundException right after the @Before tenant creation
+ // if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the
+ // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the
+ // retry loop so the subsequent verify* assertions see only the invocations from the last attempt.
+ Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
+ .ignoreExceptions().untilAsserted(() -> {
+ Mockito.reset(tbClusterService, auditLogService);
+ doPost("/api/deviceProfile", deviceProfile)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString(errorMsg)));
+ });
testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(),
tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg));
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
index 46ebf31f9d..5de4c9ffcf 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
@@ -1737,234 +1737,198 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
// --- OR conditions tests ---
- @Test
- public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
- // Create 3 devices with different temperature attributes
- Device deviceA = new Device();
- deviceA.setName("OrTestDeviceA");
- deviceA.setType("orTestType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- String payloadA = "{\"temperature\":60}";
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, payloadA, String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrTestDeviceB");
- deviceB.setType("orTestType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- String payloadB = "{\"temperature\":5}";
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, payloadB, String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrTestDeviceC");
- deviceC.setType("orTestType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- String payloadC = "{\"temperature\":30}";
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, payloadC, String.class, status().isOk());
+ private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception {
+ Device device = new Device();
+ device.setName(name);
+ device.setType(type);
+ device = doPost("/api/device", device, Device.class);
+ if (sharedAttributesPayload != null) {
+ doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
+ sharedAttributesPayload, String.class, status().isOk());
+ }
+ return device;
+ }
+
+ private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception {
+ Device device = new Device();
+ device.setName(name);
+ device.setType(type);
+ device = doPost("/api/device", device, Device.class);
+ JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload);
+ doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload)
+ .andExpect(status().isOk());
+ return device;
+ }
+
+ private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception {
+ Alarm alarm = new Alarm();
+ alarm.setOriginator(originator);
+ alarm.setType(type);
+ alarm.setSeverity(severity);
+ return doPost("/api/alarm", alarm, Alarm.class);
+ }
+ private static DeviceTypeFilter deviceTypeFilter(String type) {
DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orTestType"));
+ filter.setDeviceTypes(List.of(type));
filter.setDeviceNameFilter("");
+ return filter;
+ }
- // Filter 1: temperature > 50
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
+ private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key,
+ NumericFilterPredicate.NumericOperation operation, double value) {
+ KeyFilter keyFilter = new KeyFilter();
+ keyFilter.setKey(new EntityKey(keyType, key));
+ keyFilter.setValueType(EntityKeyValueType.NUMERIC);
+ NumericFilterPredicate predicate = new NumericFilterPredicate();
+ predicate.setValue(FilterPredicateValue.fromDouble(value));
+ predicate.setOperation(operation);
+ keyFilter.setPredicate(predicate);
+ return keyFilter;
+ }
+
+ private static KeyFilter numericAttributeKeyFilter(String key,
+ NumericFilterPredicate.NumericOperation operation, double value) {
+ return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
+ }
+
+ private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key,
+ StringFilterPredicate.StringOperation operation, String value) {
+ KeyFilter keyFilter = new KeyFilter();
+ keyFilter.setKey(new EntityKey(keyType, key));
+ keyFilter.setValueType(EntityKeyValueType.STRING);
+ StringFilterPredicate predicate = new StringFilterPredicate();
+ predicate.setValue(FilterPredicateValue.fromString(value));
+ predicate.setOperation(operation);
+ keyFilter.setPredicate(predicate);
+ return keyFilter;
+ }
+
+ private static KeyFilter stringAttributeKeyFilter(String key,
+ StringFilterPredicate.StringOperation operation, String value) {
+ return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
+ }
+
+ private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) {
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC);
+ return new EntityDataPageLink(pageSize, page, textSearch, sortOrder);
+ }
+
+ private static List nameEntityField() {
+ return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ }
+
+ private static List extractNames(PageData result) {
+ return result.getData().stream()
+ .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
+ .collect(Collectors.toList());
+ }
- // Filter 2: temperature < 10
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
+ @Test
+ public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
+ String type = "orTestType";
+ createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}");
+ createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}");
- List keyFilters = List.of(tempGt50, tempLt10);
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has temperature both >50 AND <10 => count=0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception {
- // Create devices with different attributes
- Device deviceX = new Device();
- deviceX.setName("OrDataDeviceX");
- deviceX.setType("orDataType");
- deviceX = doPost("/api/device", deviceX, Device.class);
- doPost("/api/plugins/telemetry/" + deviceX.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"status\":\"active\"}", String.class, status().isOk());
-
- Device deviceY = new Device();
- deviceY.setName("OrDataDeviceY");
- deviceY.setType("orDataType");
- deviceY = doPost("/api/device", deviceY, Device.class);
- doPost("/api/plugins/telemetry/" + deviceY.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"humidity\":80}", String.class, status().isOk());
-
- Device deviceZ = new Device();
- deviceZ.setName("OrDataDeviceZ");
- deviceZ.setType("orDataType");
- deviceZ = doPost("/api/device", deviceZ, Device.class);
- // deviceZ has neither matching attribute
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orDataType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: status = "active"
- KeyFilter statusFilter = new KeyFilter();
- statusFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "status"));
- statusFilter.setValueType(EntityKeyValueType.STRING);
- StringFilterPredicate statusPred = new StringFilterPredicate();
- statusPred.setValue(FilterPredicateValue.fromString("active"));
- statusPred.setOperation(StringFilterPredicate.StringOperation.EQUAL);
- statusFilter.setPredicate(statusPred);
-
- // Filter 2: humidity > 70
- KeyFilter humidityFilter = new KeyFilter();
- humidityFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity"));
- humidityFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate humidityPred = new NumericFilterPredicate();
- humidityPred.setValue(FilterPredicateValue.fromDouble(70));
- humidityPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- humidityFilter.setPredicate(humidityPred);
-
- List keyFilters = List.of(statusFilter, humidityFilter);
+ String type = "orDataType";
+ createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}");
+ createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}");
+ createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute
- EntityDataSortOrder sortOrder = new EntityDataSortOrder(
- new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC
- );
- EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
- List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70));
// OR: deviceX matches status=active, deviceY matches humidity>70
- EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR);
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData result = findByQueryAndCheck(orQuery, 2);
- List names = result.getData().stream()
- .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
- .collect(Collectors.toList());
- assertThat(names).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
}
@Test
- public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
- // Create devices with temperature values that test same-key OR ungrouping
- Device deviceA = new Device();
- deviceA.setName("OrSameKeyDeviceA");
- deviceA.setType("orSameKeyType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrSameKeyDeviceB");
- deviceB.setType("orSameKeyType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":5}", String.class, status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orSameKeyType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: temperature > 50
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: temperature < 10 (same key, different predicate)
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
+ public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception {
+ // Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in
+ // entityFields must not leak its value into EntityData.latest[ENTITY_FIELD].
+ String type = "orLeakGuardType";
+ Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}");
+ d1.setLabel("leak-label-A");
+ doPost("/api/device", d1, Device.class);
+
+ Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null);
+ d2.setLabel("leak-label-B");
+ doPost("/api/device", d2, Device.class);
+
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
+ buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B"));
+
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
+ PageData result = findByQueryAndCheck(orQuery, 2);
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB");
+ for (EntityData entity : result.getData()) {
+ assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name");
+ }
+ }
- List keyFilters = List.of(tempGt50, tempLt10);
+ @Test
+ public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
+ String type = "orSameKeyType";
+ createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}");
- EntityDataSortOrder sortOrder = new EntityDataSortOrder(
- new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC
- );
- EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
- List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ // Same key "temperature" with two different predicates exercises the OR-ungrouping path
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
- // OR on same key: deviceA (60>50) and deviceB (5<10) should both be returned
- EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR);
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData result = findByQueryAndCheck(orQuery, 2);
- List names = result.getData().stream()
- .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
- .collect(Collectors.toList());
- assertThat(names).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
}
@Test
public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception {
- // Create 2 devices with temperature attributes
- Device deviceA = new Device();
- deviceA.setName("BackCompatDeviceA");
- deviceA.setType("backCompatType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("BackCompatDeviceB");
- deviceB.setType("backCompatType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":5}", String.class, status().isOk());
+ String type = "backCompatType";
+ createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}");
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("backCompatType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: temperature > 50
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: temperature < 10
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
-
- List keyFilters = List.of(tempGt50, tempLt10);
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
- // Await attribute propagation: verify with an OR query that should find 2 when propagated
- EntityCountQuery orCheckQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ // Await attribute propagation via an OR query that should find 2 when propagated
+ EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2));
// Query without keyFiltersOperation (null) -- should behave as AND
- EntityCountQuery nullOpQuery = new EntityCountQuery(filter, keyFilters);
+ EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters);
Long nullResult = countByQueryAndCheck(nullOpQuery, 0);
// Query with explicit AND -- should produce the same result
- EntityCountQuery andOpQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andOpQuery, 0);
Assert.assertEquals(nullResult, andResult);
@@ -1974,87 +1938,34 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
- // Create devices with different temperatures and alarms
- Device deviceHot = new Device();
- deviceHot.setName("OrAlarmDeviceHot");
- deviceHot.setType("orAlarmType");
- deviceHot = doPost("/api/device", deviceHot, Device.class);
- doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceCold = new Device();
- deviceCold.setName("OrAlarmDeviceCold");
- deviceCold.setType("orAlarmType");
- deviceCold = doPost("/api/device", deviceCold, Device.class);
- doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":5}", String.class, status().isOk());
-
- Device deviceMid = new Device();
- deviceMid.setName("OrAlarmDeviceMid");
- deviceMid.setType("orAlarmType");
- deviceMid = doPost("/api/device", deviceMid, Device.class);
- doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":30}", String.class, status().isOk());
-
- // Create alarms for each device
- Alarm alarmHot = new Alarm();
- alarmHot.setOriginator(deviceHot.getId());
- alarmHot.setType("highTemp");
- alarmHot.setSeverity(AlarmSeverity.CRITICAL);
- doPost("/api/alarm", alarmHot, Alarm.class);
-
- Alarm alarmCold = new Alarm();
- alarmCold.setOriginator(deviceCold.getId());
- alarmCold.setType("lowTemp");
- alarmCold.setSeverity(AlarmSeverity.WARNING);
- doPost("/api/alarm", alarmCold, Alarm.class);
-
- Alarm alarmMid = new Alarm();
- alarmMid.setOriginator(deviceMid.getId());
- alarmMid.setType("normalTemp");
- alarmMid.setSeverity(AlarmSeverity.WARNING);
- doPost("/api/alarm", alarmMid, Alarm.class);
-
- // Filter 1: temperature > 50
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: temperature < 10
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
+ String type = "orAlarmType";
+ Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}");
+ Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}");
+ Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}");
- List keyFilters = List.of(tempGt50, tempLt10);
+ createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL);
+ createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
+ createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
- DeviceTypeFilter entityFilter = new DeviceTypeFilter();
- entityFilter.setDeviceTypes(List.of("orAlarmType"));
- entityFilter.setDeviceNameFilter("");
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
AlarmDataPageLink pageLink = new AlarmDataPageLink();
pageLink.setPage(0);
pageLink.setPageSize(100);
pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime")));
-
List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
// OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms
- AlarmDataQuery orAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
+ AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2));
PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList());
assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp");
// AND query: no device has temp both >50 AND <10 => 0 alarms
- AlarmDataQuery andAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
+ AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
findAlarmsByQueryAndCheck(andAlarmQuery, 0);
}
@@ -2062,232 +1973,86 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
- Device deviceHot = new Device();
- deviceHot.setName("OrAlarmCntDeviceHot");
- deviceHot.setType("orAlarmCntType");
- deviceHot = doPost("/api/device", deviceHot, Device.class);
- doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceCold = new Device();
- deviceCold.setName("OrAlarmCntDeviceCold");
- deviceCold.setType("orAlarmCntType");
- deviceCold = doPost("/api/device", deviceCold, Device.class);
- doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":5}", String.class, status().isOk());
-
- Device deviceMid = new Device();
- deviceMid.setName("OrAlarmCntDeviceMid");
- deviceMid.setType("orAlarmCntType");
- deviceMid = doPost("/api/device", deviceMid, Device.class);
- doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":30}", String.class, status().isOk());
-
- // Create 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
- Alarm alarm1 = new Alarm();
- alarm1.setOriginator(deviceHot.getId());
- alarm1.setType("highTemp1");
- alarm1.setSeverity(AlarmSeverity.CRITICAL);
- doPost("/api/alarm", alarm1, Alarm.class);
-
- Alarm alarm2 = new Alarm();
- alarm2.setOriginator(deviceHot.getId());
- alarm2.setType("highTemp2");
- alarm2.setSeverity(AlarmSeverity.CRITICAL);
- doPost("/api/alarm", alarm2, Alarm.class);
-
- Alarm alarm3 = new Alarm();
- alarm3.setOriginator(deviceCold.getId());
- alarm3.setType("lowTemp");
- alarm3.setSeverity(AlarmSeverity.WARNING);
- doPost("/api/alarm", alarm3, Alarm.class);
-
- Alarm alarm4 = new Alarm();
- alarm4.setOriginator(deviceMid.getId());
- alarm4.setType("normalTemp");
- alarm4.setSeverity(AlarmSeverity.WARNING);
- doPost("/api/alarm", alarm4, Alarm.class);
-
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
+ String type = "orAlarmCntType";
+ Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}");
+ Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}");
+ Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}");
- List keyFilters = List.of(tempGt50, tempLt10);
+ // 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
+ createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL);
+ createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL);
+ createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
+ createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
- DeviceTypeFilter entityFilter = new DeviceTypeFilter();
- entityFilter.setDeviceTypes(List.of("orAlarmCntType"));
- entityFilter.setDeviceNameFilter("");
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total
- AlarmCountQuery orQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.OR);
+ AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3));
// AND: no device matches both filters => 0
- AlarmCountQuery andQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.AND);
+ AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countAlarmsByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception {
- // Tests the entity field predicate relocation to middle-layer WHERE under OR
- Device deviceA = new Device();
- deviceA.setName("OrMixedAlpha");
- deviceA.setType("orMixedType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":10}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrMixedBeta");
- deviceB.setType("orMixedType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrMixedGamma");
- deviceC.setType("orMixedType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":10}", String.class, status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orMixedType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: entity field name CONTAINS "Alpha"
- KeyFilter nameFilter = new KeyFilter();
- nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
- nameFilter.setValueType(EntityKeyValueType.STRING);
- StringFilterPredicate namePred = new StringFilterPredicate();
- namePred.setValue(FilterPredicateValue.fromString("Alpha"));
- namePred.setOperation(StringFilterPredicate.StringOperation.CONTAINS);
- nameFilter.setPredicate(namePred);
-
- // Filter 2: attribute temperature > 50
- KeyFilter tempFilter = new KeyFilter();
- tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate tempPred = new NumericFilterPredicate();
- tempPred.setValue(FilterPredicateValue.fromDouble(50));
- tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempFilter.setPredicate(tempPred);
-
- List keyFilters = List.of(nameFilter, tempFilter);
-
- // OR: deviceA matches name contains "Alpha", deviceB matches temp>50 => count=2
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ // Exercises the entity field predicate relocation to middle-layer WHERE under OR
+ String type = "orMixedType";
+ createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}");
+ createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}");
+
+ List keyFilters = List.of(
+ stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
+
+ // OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
- // AND: only deviceA has name "Alpha" AND temp is 10 (not >50) => count=0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ // AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrStringAttributes() throws Exception {
- Device deviceA = new Device();
- deviceA.setName("OrStrDeviceA");
- deviceA.setType("orStrType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"color\":\"red\"}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrStrDeviceB");
- deviceB.setType("orStrType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"color\":\"blue\"}", String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrStrDeviceC");
- deviceC.setType("orStrType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"color\":\"green\"}", String.class, status().isOk());
+ String type = "orStrType";
+ createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}");
+ createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}");
+ createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}");
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orStrType"));
- filter.setDeviceNameFilter("");
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"),
+ stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue"));
- // Filter 1: color = "red"
- KeyFilter redFilter = new KeyFilter();
- redFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color"));
- redFilter.setValueType(EntityKeyValueType.STRING);
- StringFilterPredicate redPred = new StringFilterPredicate();
- redPred.setValue(FilterPredicateValue.fromString("red"));
- redPred.setOperation(StringFilterPredicate.StringOperation.EQUAL);
- redFilter.setPredicate(redPred);
-
- // Filter 2: color = "blue"
- KeyFilter blueFilter = new KeyFilter();
- blueFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color"));
- blueFilter.setValueType(EntityKeyValueType.STRING);
- StringFilterPredicate bluePred = new StringFilterPredicate();
- bluePred.setValue(FilterPredicateValue.fromString("blue"));
- bluePred.setOperation(StringFilterPredicate.StringOperation.EQUAL);
- blueFilter.setPredicate(bluePred);
-
- List keyFilters = List.of(redFilter, blueFilter);
-
- // OR: deviceA (red) and deviceB (blue) match => count=2
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ // OR: red and blue match => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device is both red AND blue => count=0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrSingleFilter() throws Exception {
- Device deviceA = new Device();
- deviceA.setName("OrSingleDeviceA");
- deviceA.setType("orSingleType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrSingleDeviceB");
- deviceB.setType("orSingleType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":30}", String.class, status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orSingleType"));
- filter.setDeviceNameFilter("");
-
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
+ String type = "orSingleType";
+ createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}");
- List keyFilters = List.of(tempGt50);
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// Single filter with OR should behave identically to AND
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1));
Long orResult = countByQueryAndCheck(orQuery, 1);
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andQuery, 1);
Assert.assertEquals(orResult, andResult);
@@ -2295,193 +2060,80 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Test
public void testCountEntitiesWithOrThreeFilters() throws Exception {
- Device deviceA = new Device();
- deviceA.setName("Or3fDeviceA");
- deviceA.setType("or3fType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("Or3fDeviceB");
- deviceB.setType("or3fType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}", String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("Or3fDeviceC");
- deviceC.setType("or3fType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}", String.class, status().isOk());
-
- Device deviceD = new Device();
- deviceD.setName("Or3fDeviceD");
- deviceD.setType("or3fType");
- deviceD = doPost("/api/device", deviceD, Device.class);
- doPost("/api/plugins/telemetry/" + deviceD.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk());
+ String type = "or3fType";
+ createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}");
+ createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}");
+ createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}");
+ createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}");
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("or3fType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: temperature > 50
- KeyFilter tempFilter = new KeyFilter();
- tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate tempPred = new NumericFilterPredicate();
- tempPred.setValue(FilterPredicateValue.fromDouble(50));
- tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempFilter.setPredicate(tempPred);
-
- // Filter 2: humidity > 80
- KeyFilter humFilter = new KeyFilter();
- humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity"));
- humFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate humPred = new NumericFilterPredicate();
- humPred.setValue(FilterPredicateValue.fromDouble(80));
- humPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- humFilter.setPredicate(humPred);
-
- // Filter 3: pressure > 1040
- KeyFilter pressFilter = new KeyFilter();
- pressFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "pressure"));
- pressFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate pressPred = new NumericFilterPredicate();
- pressPred.setValue(FilterPredicateValue.fromDouble(1040));
- pressPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- pressFilter.setPredicate(pressPred);
-
- List keyFilters = List.of(tempFilter, humFilter, pressFilter);
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80),
+ numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040));
// OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3));
// AND: no device matches all three => 0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrPagination() throws Exception {
- // Create 5 devices, 4 match OR filters (1,2: temp>50; 3,4: temp<10; 5: no match)
- // Device 1: temp=61, Device 2: temp=62, Device 3: temp=2, Device 4: temp=1, Device 5: temp=25
- for (int i = 1; i <= 5; i++) {
- Device device = new Device();
- device.setName(String.format("OrPageDevice%02d", i));
- device.setType("orPageType");
- device = doPost("/api/device", device, Device.class);
- int temp = (i <= 2) ? 60 + i : (i <= 4) ? 5 - i : 25;
- doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":" + temp + "}", String.class, status().isOk());
+ // 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read.
+ String type = "orPageType";
+ int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match
+ for (int i = 0; i < temperatures.length; i++) {
+ createDeviceWithSharedAttributes(
+ String.format("OrPageDevice%02d", i + 1), type,
+ "{\"temperature\":" + temperatures[i] + "}");
}
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orPageType"));
- filter.setDeviceNameFilter("");
-
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
-
- List keyFilters = List.of(tempGt50, tempLt10);
-
- EntityDataSortOrder sortOrder = new EntityDataSortOrder(
- new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC
- );
- List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Page 1: pageSize=2, totalElements=4, data.size()=2
- EntityDataPageLink pageLink1 = new EntityDataPageLink(2, 0, null, sortOrder);
- EntityDataQuery orQuery1 = new EntityDataQuery(filter, pageLink1, entityFields, null, keyFilters, ComplexOperation.OR);
+ EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4));
PageData page1 = findByQueryAndCheck(orQuery1, 4);
Assert.assertEquals(2, page1.getData().size());
Assert.assertTrue(page1.hasNext());
// Page 2: remaining 2 of 4
- EntityDataPageLink pageLink2 = new EntityDataPageLink(2, 1, null, sortOrder);
- EntityDataQuery orQuery2 = new EntityDataQuery(filter, pageLink2, entityFields, null, keyFilters, ComplexOperation.OR);
+ EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
PageData page2 = findByQueryAndCheck(orQuery2, 4);
Assert.assertEquals(2, page2.getData().size());
Assert.assertFalse(page2.hasNext());
- // All names across both pages should be from the 4 matching devices (01,02,03,04)
- List allNames = new ArrayList<>();
- page1.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()));
- page2.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()));
- assertThat(allNames).hasSize(4);
- assertThat(allNames).doesNotContain("OrPageDevice05");
+ // All names across both pages should be from the 4 matching devices (01..04)
+ List allNames = new ArrayList<>(extractNames(page1));
+ allNames.addAll(extractNames(page2));
+ assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05");
}
@Test
public void testCountEntitiesWithOrZeroMatches() throws Exception {
- Device deviceA = new Device();
- deviceA.setName("OrZeroDeviceA");
- deviceA.setType("orZeroType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":30}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrZeroDeviceB");
- deviceB.setType("orZeroType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":40}", String.class, status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orZeroType"));
- filter.setDeviceNameFilter("");
-
- // Await attribute propagation: verify devices are queryable by a filter that matches
- KeyFilter tempGt20 = new KeyFilter();
- tempGt20.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt20.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt20 = new NumericFilterPredicate();
- gt20.setValue(FilterPredicateValue.fromDouble(20));
- gt20.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt20.setPredicate(gt20);
- EntityCountQuery propagationCheck = new EntityCountQuery(filter, List.of(tempGt20), ComplexOperation.OR);
+ String type = "orZeroType";
+ createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}");
+ createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}");
+
+ // Await attribute propagation via a filter that actually matches both devices
+ EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)),
+ ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2));
- // Filter 1: temperature > 50 (no match)
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: temperature < 10 (no match)
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
-
- List keyFilters = List.of(tempGt50, tempLt10);
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR with no matches: neither filter matches any device => count=0
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
countByQueryAndCheck(orQuery, 0);
}
@@ -2491,9 +2143,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false);
try {
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("default"));
- filter.setDeviceNameFilter("");
+ DeviceTypeFilter filter = deviceTypeFilter("default");
// POST a query with OR operation -- should be rejected with 400
EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR);
@@ -2516,172 +2166,66 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Test
public void testFindEntityDataWithOrAndTextSearch() throws Exception {
- // Create 3 devices: 2 match OR filters, but only 1 also matches textSearch
- Device deviceA = new Device();
- deviceA.setName("OrTextAlpha");
- deviceA.setType("orTextType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":60}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrTextBeta");
- deviceB.setType("orTextType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":5}", String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrTextGamma");
- deviceC.setType("orTextType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":30}", String.class, status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orTextType"));
- filter.setDeviceNameFilter("");
-
- // Filter 1: temperature > 50 (matches deviceA)
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: temperature < 10 (matches deviceB)
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
-
- List keyFilters = List.of(tempGt50, tempLt10);
+ // 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time
+ String type = "orTextType";
+ createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}");
+ createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR without textSearch: Alpha and Beta match => 2
+ EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
- EntityDataSortOrder sortOrder = new EntityDataSortOrder(
- new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC
- );
- List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ // OR with textSearch="Alpha": only Alpha matches both OR filter AND text search
+ EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha");
- // OR without textSearch: deviceA and deviceB match => 2
- EntityDataPageLink pageLinkNoText = new EntityDataPageLink(10, 0, null, sortOrder);
- EntityDataQuery orQueryNoText = new EntityDataQuery(filter, pageLinkNoText, entityFields, null, keyFilters, ComplexOperation.OR);
- await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
+ // OR with textSearch="Beta": only Beta matches both OR filter AND text search
+ EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta");
- // OR with textSearch="Alpha": only deviceA matches both OR filter AND text search
- EntityDataPageLink pageLinkWithText = new EntityDataPageLink(10, 0, "Alpha", sortOrder);
- EntityDataQuery orQueryWithText = new EntityDataQuery(filter, pageLinkWithText, entityFields, null, keyFilters, ComplexOperation.OR);
- PageData result = findByQueryAndCheck(orQueryWithText, 1);
- String name = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue();
- assertThat(name).isEqualTo("OrTextAlpha");
-
- // OR with textSearch="Beta": only deviceB matches both OR filter AND text search
- EntityDataPageLink pageLinkBeta = new EntityDataPageLink(10, 0, "Beta", sortOrder);
- EntityDataQuery orQueryBeta = new EntityDataQuery(filter, pageLinkBeta, entityFields, null, keyFilters, ComplexOperation.OR);
- PageData resultBeta = findByQueryAndCheck(orQueryBeta, 1);
- String nameBeta = resultBeta.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue();
- assertThat(nameBeta).isEqualTo("OrTextBeta");
-
- // OR with textSearch="Gamma": deviceC doesn't match any OR filter => 0
- EntityDataPageLink pageLinkGamma = new EntityDataPageLink(10, 0, "Gamma", sortOrder);
- EntityDataQuery orQueryGamma = new EntityDataQuery(filter, pageLinkGamma, entityFields, null, keyFilters, ComplexOperation.OR);
+ // OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0
+ EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
findByQueryAndCheck(orQueryGamma, 0);
}
@Test
public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception {
- // Create devices and post time-series telemetry (not attributes)
- Device deviceA = new Device();
- deviceA.setName("OrTsDeviceA");
- deviceA.setType("orTsType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- JsonNode tsPayloadA = JacksonUtil.toJsonNode("{\"temperature\": 60}");
- doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceA.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadA)
- .andExpect(status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrTsDeviceB");
- deviceB.setType("orTsType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- JsonNode tsPayloadB = JacksonUtil.toJsonNode("{\"temperature\": 5}");
- doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceB.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadB)
- .andExpect(status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrTsDeviceC");
- deviceC.setType("orTsType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- JsonNode tsPayloadC = JacksonUtil.toJsonNode("{\"temperature\": 30}");
- doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceC.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadC)
- .andExpect(status().isOk());
-
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orTsType"));
- filter.setDeviceNameFilter("");
+ String type = "orTsType";
+ createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}");
+ createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}");
- // Filter 1: TIME_SERIES temperature > 50
- KeyFilter tempGt50 = new KeyFilter();
- tempGt50.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature"));
- tempGt50.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate gt50 = new NumericFilterPredicate();
- gt50.setValue(FilterPredicateValue.fromDouble(50));
- gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- tempGt50.setPredicate(gt50);
-
- // Filter 2: TIME_SERIES temperature < 10
- KeyFilter tempLt10 = new KeyFilter();
- tempLt10.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature"));
- tempLt10.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate lt10 = new NumericFilterPredicate();
- lt10.setValue(FilterPredicateValue.fromDouble(10));
- lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS);
- tempLt10.setPredicate(lt10);
-
- List keyFilters = List.of(tempGt50, tempLt10);
+ List keyFilters = List.of(
+ numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has ts temperature both >50 AND <10 => count=0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception {
- // Tests key-level ComplexFilterPredicate combined with query-level OR
- Device deviceA = new Device();
- deviceA.setName("OrCplxDeviceA");
- deviceA.setType("orCplxType");
- deviceA = doPost("/api/device", deviceA, Device.class);
- doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":65,\"humidity\":50}", String.class, status().isOk());
-
- Device deviceB = new Device();
- deviceB.setName("OrCplxDeviceB");
- deviceB.setType("orCplxType");
- deviceB = doPost("/api/device", deviceB, Device.class);
- doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":25,\"humidity\":90}", String.class, status().isOk());
-
- Device deviceC = new Device();
- deviceC.setName("OrCplxDeviceC");
- deviceC.setType("orCplxType");
- deviceC = doPost("/api/device", deviceC, Device.class);
- doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE,
- "{\"temperature\":25,\"humidity\":50}", String.class, status().isOk());
+ // Key-level ComplexFilterPredicate combined with query-level OR
+ String type = "orCplxType";
+ createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}");
+ createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}");
+ createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}");
- DeviceTypeFilter filter = new DeviceTypeFilter();
- filter.setDeviceTypes(List.of("orCplxType"));
- filter.setDeviceNameFilter("");
-
- // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter)
- // Matches deviceA (65) only
+ // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only
NumericFilterPredicate gt50 = new NumericFilterPredicate();
gt50.setValue(FilterPredicateValue.fromDouble(50));
gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
@@ -2691,30 +2235,22 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate();
complexTempPred.setOperation(ComplexOperation.AND);
complexTempPred.setPredicates(List.of(gt50, lt70));
-
KeyFilter tempComplexFilter = new KeyFilter();
tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC);
tempComplexFilter.setPredicate(complexTempPred);
- // Key filter 2: humidity > 80 (simple predicate)
- // Matches deviceB (90) only
- KeyFilter humFilter = new KeyFilter();
- humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity"));
- humFilter.setValueType(EntityKeyValueType.NUMERIC);
- NumericFilterPredicate humGt80 = new NumericFilterPredicate();
- humGt80.setValue(FilterPredicateValue.fromDouble(80));
- humGt80.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
- humFilter.setPredicate(humGt80);
-
- List keyFilters = List.of(tempComplexFilter, humFilter);
+ // Key filter 2: humidity > 80 (simple predicate) — matches B only
+ List keyFilters = List.of(
+ tempComplexFilter,
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80));
- // Query-level OR: deviceA matches key filter 1, deviceB matches key filter 2 => 2
- EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR);
+ // Query-level OR: A matches key filter 1, B matches key filter 2 => 2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// Query-level AND: no device matches both key filters => 0
- EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND);
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
index accbbd1d29..970c244fad 100644
--- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
@@ -27,12 +27,19 @@ import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.adapter.NativeWebSocketSession;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider;
import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.ws.WebSocketService;
import org.thingsboard.server.service.ws.WebSocketSessionRef;
import org.thingsboard.server.service.ws.WebSocketSessionType;
@@ -193,6 +200,72 @@ class TbWebSocketHandlerTest {
assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100);
}
+ // Regression test for the bug where publicUserSessionsMap was keyed by UserId(NULL_UUID),
+ // making maxWsSessionsPerPublicUser a global limit shared across all tenants.
+ // The limit is now scoped per-tenant.
+ @Test
+ void checkLimits_publicUserSessions_limitIsPerTenantNotGlobal() throws Exception {
+ TbTenantProfileCache tenantProfileCache = mock(TbTenantProfileCache.class);
+ ReflectionTestUtils.setField(wsHandler, "tenantProfileCache", tenantProfileCache);
+
+ int maxPublicSessions = 2;
+
+ TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile1 = new TenantProfile();
+ profile1.createDefaultTenantProfileData();
+ profile1.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions);
+ willReturn(profile1).given(tenantProfileCache).get(tenant1);
+
+ TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile2 = new TenantProfile();
+ profile2.createDefaultTenantProfileData();
+ profile2.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions);
+ willReturn(profile2).given(tenantProfileCache).get(tenant2);
+
+ Method checkLimits = TbWebSocketHandler.class.getDeclaredMethod(
+ "checkLimits", WebSocketSession.class, WebSocketSessionRef.class);
+ checkLimits.setAccessible(true);
+
+ // tenant1 fills up its limit
+ for (int i = 0; i < maxPublicSessions; i++) {
+ assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t1-" + i), mockPublicSessionRef(tenant1))).isTrue();
+ }
+
+ // tenant2 must get its own independent quota — this was the bug: with NULL_UUID as key
+ // all tenants shared one global counter, so tenant2 would be blocked here
+ for (int i = 0; i < maxPublicSessions; i++) {
+ assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t2-" + i), mockPublicSessionRef(tenant2)))
+ .as("tenant2 session %d should not be affected by tenant1's sessions", i + 1)
+ .isTrue();
+ }
+
+ // tenant1's (maxPublicSessions + 1)-th session must be rejected
+ NativeWebSocketSession overLimit = mockWsSession("t1-over");
+ assertThat((boolean) checkLimits.invoke(wsHandler, overLimit, mockPublicSessionRef(tenant1))).isFalse();
+ verify(overLimit).close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached"));
+ }
+
+ private NativeWebSocketSession mockWsSession(String id) {
+ NativeWebSocketSession s = mock(NativeWebSocketSession.class);
+ willReturn(id).given(s).getId();
+ return s;
+ }
+
+ private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId) {
+ CustomerId customerId = new CustomerId(UUID.randomUUID());
+ SecurityUser securityUser = mock(SecurityUser.class);
+ willReturn(tenantId).given(securityUser).getTenantId();
+ willReturn(customerId).given(securityUser).getCustomerId();
+ willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId();
+ willReturn(true).given(securityUser).isCustomerUser();
+ willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal();
+
+ WebSocketSessionRef ref = mock(WebSocketSessionRef.class);
+ willReturn(securityUser).given(ref).getSecurityCtx();
+ willReturn(UUID.randomUUID().toString()).given(ref).getSessionId();
+ return ref;
+ }
+
private AuthTestFixture createAuthTestFixture() throws IOException {
TbWebSocketHandler handler = spy(new TbWebSocketHandler());
willDoNothing().given(handler).close(any(), any());
diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
index f344335748..91dd0669b1 100644
--- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
+++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
@@ -20,7 +20,6 @@ import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
-import org.springframework.test.web.servlet.ResultMatcher;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
@@ -224,7 +223,12 @@ public class UserEdgeTest extends AbstractEdgeTest {
User savedUser = createUser(user, password);
Assert.assertTrue(edgeImitator.waitForMessages());
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
- Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size());
+ // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when
+ // user activation completes before the event is processed, in addition to the 2
+ // messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3.
+ int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size();
+ Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount,
+ credMsgCount == 2 || credMsgCount == 3);
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
new file mode 100644
index 0000000000..fb9807a2a8
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
@@ -0,0 +1,144 @@
+/**
+ * Copyright © 2016-2026 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.server.service.ai;
+
+import com.google.cloud.vertexai.api.GenerationConfig;
+import dev.langchain4j.model.chat.ChatModel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.common.util.SsrfProtectionValidator;
+import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@ResourceLock("SsrfProtectionValidator")
+class Langchain4jChatModelConfigurerImplTest {
+
+ private static final String TEST_SERVICE_ACCOUNT_KEY = """
+ {
+ "type": "service_account",
+ "project_id": "test-project",
+ "private_key_id": "key-id",
+ "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n",
+ "client_email": "test@test-project.iam.gserviceaccount.com",
+ "client_id": "123456789",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token"
+ }
+ """;
+
+ private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
+
+ @BeforeEach
+ void enableSsrfProtection() {
+ SsrfProtectionValidator.setEnabled(true);
+ }
+
+ @AfterEach
+ void disableSsrfProtection() {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+
+ @Test
+ void configureChatModel_openAi_withPrivateIp_shouldThrow() {
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://172.17.0.1:8080/")
+ .apiKey("test")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://localhost:22/")
+ .apiKey("test")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
+ var config = AzureOpenAiChatModelConfig.builder()
+ .providerConfig(new AzureOpenAiProviderConfig(
+ "http://10.0.0.1:8080/", null, "test-key"))
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_ollama_withPrivateIp_shouldThrow() {
+ var config = OllamaChatModelConfig.builder()
+ .providerConfig(new OllamaProviderConfig(
+ "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
+ .modelId("llama3")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
+ // GIVEN
+ var providerConfig = new GoogleVertexAiGeminiProviderConfig(
+ "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
+ );
+ var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
+ .providerConfig(providerConfig)
+ .modelId("gemini-2.0-flash")
+ .frequencyPenalty(0.3)
+ .presencePenalty(0.7)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
+
+ // THEN
+ var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
+ assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
+ assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
new file mode 100644
index 0000000000..3ee229e7a0
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
@@ -0,0 +1,517 @@
+/**
+ * Copyright © 2016-2026 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.server.service.cf;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.TenantProfileId;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.asset.AssetService;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.customer.CustomerService;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
+import org.thingsboard.server.service.profile.TbAssetProfileCache;
+import org.thingsboard.server.service.profile.TbDeviceProfileCache;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultCalculatedFieldCacheTest {
+
+ @Mock
+ private CalculatedFieldService calculatedFieldService;
+ @Mock
+ private TbAssetProfileCache assetProfileCache;
+ @Mock
+ private TbDeviceProfileCache deviceProfileCache;
+ @Mock
+ private TbTenantProfileCache tenantProfileCache;
+ @Mock
+ private DeviceService deviceService;
+ @Mock
+ private AssetService assetService;
+ @Mock
+ private CustomerService customerService;
+
+ private DefaultCalculatedFieldCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ // ActorSystemContext is only used in getCalculatedFieldCtx (not tested here), so null is safe
+ OwnerService ownerService = new OwnerService(deviceService, assetService, customerService);
+ cache = new DefaultCalculatedFieldCache(calculatedFieldService, assetProfileCache,
+ deviceProfileCache, tenantProfileCache, null, ownerService);
+
+ }
+
+ // --- Tenant deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAllTenantCfsFromAllMaps() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceId device1 = new DeviceId(UUID.randomUUID());
+ DeviceId device2 = new DeviceId(UUID.randomUUID());
+
+ CalculatedField cf1 = addCfToCache(tenant1, device1);
+ CalculatedField cf2 = addCfToCache(tenant2, device2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(device2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, tenant);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After eviction, getDynamicEntities triggers a fresh load from ownerService (empty)
+ assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(device);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesLinksToLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId cfEntity = new DeviceId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+
+ CalculatedField cf = addCfToCache(tenant, cfEntity, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ }
+
+ // --- Device/Asset deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_evictsCfsForThatDevice() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+ addCfToCache(tenant, device, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_evictsDeviceFromOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetDeleted_evictsCfsForThatAsset() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetId asset = new AssetId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, asset);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, asset, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceCreated_addsDeviceToOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.CREATED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+ }
+
+ // --- Customer deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_customerDeleted_evictsCustomerOwnerEntries() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED));
+
+ // The customer's owned-entities entry is evicted; fresh load returns empty
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ // --- DeviceProfile/AssetProfile deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+ addCfToCache(tenant, profileId, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf1 = addCfToCache(tenant, profile1);
+ CalculatedField cf2 = addCfToCache(tenant, profile2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ AssetId linkedAsset = new AssetId(UUID.randomUUID());
+ addCfToCache(tenant, profileId, linkedAsset);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf1 = addCfToCache(tenant, profile1);
+ CalculatedField cf2 = addCfToCache(tenant, profile2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
+ }
+
+ // --- CalculatedField lifecycle tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldCreated_addsCfToCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedField cf = buildCalculatedField(cfId, tenant, device, simpleCfConfig());
+ when(calculatedFieldService.findById(tenant, cfId)).thenReturn(cf);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cfId, ComponentLifecycleEvent.CREATED));
+
+ assertThat(cache.getCalculatedField(cfId)).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).containsExactly(cf);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldDeleted_evictsCfFromCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldUpdated_refreshesCfInCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ CalculatedField updatedCf = buildCalculatedField(cf.getId(), tenant, device, simpleCfConfig());
+ updatedCf.setName("updated-name");
+ when(calculatedFieldService.findById(tenant, cf.getId())).thenReturn(updatedCf);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf);
+ }
+
+ // --- evictOwner recursive traversal tests ---
+
+ @Test
+ public void evictOwner_customerDeleted_recursivelyEvictsDevicesOwnedByThatCustomer() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+
+ stubDeviceOwner(tenant, device, customer);
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+
+ // tenant owns customer (getOwner for CUSTOMER returns tenantId)
+ cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
+ cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
+
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ // deleting the customer evicts the customer key and recursively cleans its owned set
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ @Test
+ public void evictOwner_tenantDeleted_recursivelyEvictsCustomerAndItsOwnedDevices() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+
+ stubDeviceOwner(tenant, device, customer);
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+
+ cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
+ cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
+
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ // deleting the tenant: evictOwner(tenant) finds customer (CUSTOMER type) and recurses into it
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // both levels must be gone
+ assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ // --- TenantProfile lifecycle tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_tenantProfileUpdated_callsHandleTenantProfileUpdate() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ TenantProfileId profileId = new TenantProfileId(UUID.randomUUID());
+ DefaultCalculatedFieldCache spyCache = spy(cache);
+
+ spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ verify(spyCache).handleTenantProfileUpdate(profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantProfileDeleted_doesNotCallHandleTenantProfileUpdate() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ TenantProfileId profileId = new TenantProfileId(UUID.randomUUID());
+ DefaultCalculatedFieldCache spyCache = spy(cache);
+
+ spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ verify(spyCache, never()).handleTenantProfileUpdate(any());
+ }
+
+ // --- Helpers ---
+
+ private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) {
+ Device device = new Device();
+ device.setId(deviceId);
+ device.setTenantId(tenantId);
+ if (ownerId instanceof CustomerId customerId) {
+ device.setCustomerId(customerId);
+ }
+ // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId
+ when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device);
+ // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly)
+ when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData());
+ when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData());
+ if (ownerId instanceof TenantId) {
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+ }
+ }
+
+ private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) {
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig());
+ when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
+ cache.addCalculatedField(tenantId, cfId);
+ return cf;
+ }
+
+ private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId, EntityId linkedEntity) {
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedFieldConfiguration config = linkedEntityCfConfig(tenantId, cfId, linkedEntity);
+ CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, config);
+ when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
+ cache.addCalculatedField(tenantId, cfId);
+ return cf;
+ }
+
+ private CalculatedField buildCalculatedField(CalculatedFieldId id, TenantId tenantId, EntityId entityId, CalculatedFieldConfiguration config) {
+ CalculatedField cf = new CalculatedField();
+ cf.setId(id);
+ cf.setTenantId(tenantId);
+ cf.setEntityId(entityId);
+ cf.setType(CalculatedFieldType.SIMPLE);
+ cf.setName("test-cf-" + id.getId());
+ cf.setConfiguration(config);
+ return cf;
+ }
+
+ private CalculatedFieldConfiguration simpleCfConfig() {
+ CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
+ when(config.getReferencedEntities()).thenReturn(Collections.emptySet());
+ when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(Collections.emptyList());
+ return config;
+ }
+
+ private CalculatedFieldConfiguration linkedEntityCfConfig(TenantId tenantId, CalculatedFieldId cfId, EntityId linkedEntity) {
+ CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
+ CalculatedFieldLink link = new CalculatedFieldLink(tenantId, linkedEntity, cfId);
+ when(config.getReferencedEntities()).thenReturn(Set.of(linkedEntity));
+ when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link));
+ when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link);
+ return config;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
index adf664e7cf..eb672a790f 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
@@ -16,19 +16,27 @@
package org.thingsboard.server.service.entitiy;
import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.IdBased;
+import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData;
+import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
+import org.thingsboard.server.common.data.query.EntityDataSortOrder;
+import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@@ -43,10 +51,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
+import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
@DaoSqlTest
@TestPropertySource(properties = {
@@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest {
assetService.deleteAssetsByTenantId(tenantId);
}
+ // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
+ @Override
+ @Test
+ public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(1L, 0L, 0L);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1"));
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", ""));
+ }
+
+ // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
+ @Override
+ @Test
+ public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(true, false, false);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true"));
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", ""));
+ }
+
@Override
protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) {
return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query),
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
index 05ab1165f6..b3230b3d45 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
@@ -26,6 +26,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.ResultSetExtractor;
+import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
@@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
@@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation;
-import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
@@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao;
import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository;
import org.thingsboard.server.dao.sql.relation.RelationRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
@@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
-import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE;
@Slf4j
@DaoSqlTest
@@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest {
@Autowired
RelationRepository relationRepository;
@Autowired
+ DefaultEntityQueryRepository entityQueryRepository;
+ @Autowired
RelationService relationService;
@Autowired
TimeseriesService timeseriesService;
@@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
+ @Test
+ public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ try {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(1L, 0L, 0L);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ assertNullsOrdering("default",
+ List.of("0", "0", "1", "", ""),
+ List.of("", "", "1", "0", "0"),
+ devices.size());
+
+ assertNullsOrdering("nulls_first",
+ List.of("", "", "0", "0", "1"),
+ List.of("", "", "1", "0", "0"),
+ devices.size());
+
+ assertNullsOrdering("nulls_last",
+ List.of("0", "0", "1", "", ""),
+ List.of("1", "0", "0", "", ""),
+ devices.size());
+ } finally {
+ deviceService.deleteDevicesByTenantId(tenantId);
+ }
+ }
+
+ @Test
+ public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ try {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(true, false, false);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ assertNullsOrdering("default",
+ List.of("false", "false", "true", "", ""),
+ List.of("", "", "true", "false", "false"),
+ devices.size());
+
+ assertNullsOrdering("nulls_first",
+ List.of("", "", "false", "false", "true"),
+ List.of("", "", "true", "false", "false"),
+ devices.size());
+
+ assertNullsOrdering("nulls_last",
+ List.of("false", "false", "true", "", ""),
+ List.of("true", "false", "false", "", ""),
+ devices.size());
+ } finally {
+ deviceService.deleteDevicesByTenantId(tenantId);
+ }
+ }
+
+ private void assertNullsOrdering(String strategy, List expectedAsc, List expectedDesc, int deviceSize) {
+ String originalStrategy = entityQueryRepository.getNullsOrderStrategy();
+ ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy);
+ try {
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, deviceSize).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc);
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, deviceSize).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc);
+ } finally {
+ ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy);
+ }
+ }
+
@Test
public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin
@@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest {
return timeseriesService.save(tenantId, entityId, timeseries);
}
- private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) {
+ protected ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) {
KvEntry telemetryValue = new LongDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries);
}
+ protected ListenableFuture saveTimeseries(EntityId entityId, String key, Boolean value) {
+ KvEntry telemetryValue = new BooleanDataEntry(key, value);
+ BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
+ return timeseriesService.save(tenantId, entityId, timeseries);
+ }
+
protected void createMultiRootHierarchy(List buildings, List apartments,
Map> entityNameByTypeMap,
Map childParentRelationMap) throws InterruptedException {
diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
new file mode 100644
index 0000000000..f9b8d428d7
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
@@ -0,0 +1,159 @@
+/**
+ * Copyright © 2016-2026 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.server.service.profile;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.asset.AssetProfileService;
+import org.thingsboard.server.dao.asset.AssetService;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbAssetProfileCacheTest {
+
+ @Mock
+ private AssetProfileService assetProfileService;
+ @Mock
+ private AssetService assetService;
+
+ private DefaultTbAssetProfileCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ cache = new DefaultTbAssetProfileCache(assetProfileService, assetService);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ // After deletion tenant1 profile should be reloaded from service on next get
+ when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant1, profileId1)).isNull();
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ AssetId assetId = new AssetId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant, profileId);
+ loadAssetMappingIntoCache(tenant, assetId, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload
+ when(assetService.findAssetById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant, assetId)).isNull();
+ verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ EntityId listenerId = new AssetId(UUID.randomUUID());
+ AtomicInteger callCount = new AtomicInteger();
+
+ cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // Evicting a profile after tenant deletion should not trigger the removed listener
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+ cache.evict(tenant, profileId);
+
+ assertThat(callCount.get()).isZero();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ // Profile should still be served from cache without hitting the service again
+ cache.get(tenant, profileId);
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
+
+ AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1);
+ }
+
+ // --- Helpers ---
+
+ private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) {
+ AssetProfile profile = new AssetProfile();
+ profile.setId(profileId);
+ profile.setTenantId(tenantId);
+ when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile);
+ cache.get(tenantId, profileId);
+ return profile;
+ }
+
+ private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) {
+ Asset asset = new Asset();
+ asset.setId(assetId);
+ asset.setAssetProfileId(profileId);
+ when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset);
+ cache.get(tenantId, assetId);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
new file mode 100644
index 0000000000..a26413514c
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
@@ -0,0 +1,160 @@
+/**
+ * Copyright © 2016-2026 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.server.service.profile;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.device.DeviceProfileService;
+import org.thingsboard.server.dao.device.DeviceService;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbDeviceProfileCacheTest {
+
+ @Mock
+ private DeviceProfileService deviceProfileService;
+ @Mock
+ private DeviceService deviceService;
+
+ private DefaultTbDeviceProfileCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ // After deletion tenant1 profile should be reloaded from service on next get
+ when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant1, profileId1)).isNull();
+ // tenant2 profile should still be served from cache (no extra service call)
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ DeviceId deviceId = new DeviceId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant, profileId);
+ loadDeviceMappingIntoCache(tenant, deviceId, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload
+ when(deviceService.findDeviceById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant, deviceId)).isNull();
+ verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ EntityId listenerId = new DeviceId(UUID.randomUUID());
+ AtomicInteger callCount = new AtomicInteger();
+
+ cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // Evicting a profile after tenant deletion should not trigger the removed listener
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+ cache.evict(tenant, profileId);
+
+ assertThat(callCount.get()).isZero();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ // Profile should still be served from cache without hitting the service again
+ cache.get(tenant, profileId);
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
+
+ DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1);
+ }
+
+ // --- Helpers ---
+
+ private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) {
+ DeviceProfile profile = new DeviceProfile();
+ profile.setId(profileId);
+ profile.setTenantId(tenantId);
+ when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile);
+ cache.get(tenantId, profileId);
+ return profile;
+ }
+
+ private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) {
+ Device device = new Device();
+ device.setId(deviceId);
+ device.setDeviceProfileId(profileId);
+ when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device);
+ cache.get(tenantId, deviceId);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
index 54e1a39769..8618e515c8 100644
--- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
@@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest {
assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull();
}
+ @Test
+ void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler");
+ });
+ }
+
+ @Test
+ void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile");
+ });
+ }
+
+ @Test
+ void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler");
+ });
+ }
+
+ @Test
+ void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile");
+ });
+ }
+
+ @Test
+ void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getMessage()).contains("unresolvable property or identifier: java");
+ });
+ }
+
+ @Test
+ void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider");
+ });
+ }
+
private void assertThatScriptIsBlocked(UUID scriptId) {
assertThatThrownBy(() -> {
invokeScriptResultString(scriptId, "{}");
diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java
new file mode 100644
index 0000000000..a533e0369f
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java
@@ -0,0 +1,277 @@
+/**
+ * Copyright © 2016-2026 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.server.service.ws;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
+import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.service.security.AccessValidator;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
+import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService;
+import org.thingsboard.server.service.subscription.TbLocalSubscriptionService;
+import org.thingsboard.server.service.ws.notification.NotificationCommandsHandler;
+import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd;
+
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.willReturn;
+import static org.mockito.Mockito.mock;
+
+class DefaultWebSocketServiceTest {
+
+ DefaultWebSocketService service;
+ TbTenantProfileCache tenantProfileCache;
+ WebSocketMsgEndpoint msgEndpoint;
+
+ @BeforeEach
+ void setUp() {
+ tenantProfileCache = mock(TbTenantProfileCache.class);
+ msgEndpoint = mock(WebSocketMsgEndpoint.class);
+
+ service = new DefaultWebSocketService(
+ mock(TbLocalSubscriptionService.class),
+ mock(TbEntityDataSubscriptionService.class),
+ mock(NotificationCommandsHandler.class),
+ msgEndpoint,
+ mock(AccessValidator.class),
+ mock(AttributesService.class),
+ mock(TimeseriesService.class),
+ mock(TbServiceInfoProvider.class),
+ tenantProfileCache
+ );
+ }
+
+ // Regression test: publicUserSubscriptionsMap must be keyed by TenantId, not UserId(NULL_UUID).
+ // With the old UserId(NULL_UUID) key, all tenants shared one global subscription counter.
+ @Test
+ void processSubscription_publicUserSubscriptionsMap_isPerTenantNotGlobal() throws Exception {
+ int maxPublicSubscriptions = 2;
+
+ TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile1 = new TenantProfile();
+ profile1.createDefaultTenantProfileData();
+ profile1.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile1).given(tenantProfileCache).get(tenant1);
+
+ TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile2 = new TenantProfile();
+ profile2.createDefaultTenantProfileData();
+ profile2.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile2).given(tenantProfileCache).get(tenant2);
+
+ // tenant1 fills up its quota
+ for (int i = 0; i < maxPublicSubscriptions; i++) {
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-" + i), subscriptionCmd(i)))
+ .as("tenant1 subscription %d should be accepted", i + 1)
+ .isTrue();
+ }
+
+ // tenant2 must have its own independent quota — this was the bug:
+ // with UserId(NULL_UUID) as key all tenants shared one counter, so tenant2 would be blocked here
+ for (int i = 0; i < maxPublicSubscriptions; i++) {
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant2, "t2-session-" + i), subscriptionCmd(i)))
+ .as("tenant2 subscription %d should not be affected by tenant1's subscriptions", i + 1)
+ .isTrue();
+ }
+
+ // tenant1's (maxPublicSubscriptions + 1)-th subscription must be rejected
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-over"), subscriptionCmd(99)))
+ .as("tenant1 should be rejected after exceeding its limit")
+ .isFalse();
+
+ // Verify that publicUserSubscriptionsMap has separate entries per tenant
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+
+ assertThat(publicUserSubscriptionsMap).as("map should contain tenant1").containsKey(tenant1);
+ assertThat(publicUserSubscriptionsMap).as("map should contain tenant2").containsKey(tenant2);
+ assertThat(publicUserSubscriptionsMap).as("map must not have a single NULL_UUID entry for all tenants")
+ .doesNotContainKey(new TenantId(EntityId.NULL_UUID));
+
+ assertThat(publicUserSubscriptionsMap.get(tenant1))
+ .as("tenant1 should have exactly %d subscriptions", maxPublicSubscriptions)
+ .hasSize(maxPublicSubscriptions);
+ assertThat(publicUserSubscriptionsMap.get(tenant2))
+ .as("tenant2 should have exactly %d subscriptions", maxPublicSubscriptions)
+ .hasSize(maxPublicSubscriptions);
+ }
+
+ @Test
+ void processSubscription_publicUserSubscriptionsMap_subscriptionIdFormat() {
+ int maxPublicSubscriptions = 5;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "my-session-id";
+ int cmdId = 42;
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+ service.processSubscription(sessionRef, subscriptionCmd(cmdId));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+
+ Set subs = publicUserSubscriptionsMap.get(tenantId);
+ assertThat(subs).hasSize(1);
+ assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]");
+ }
+
+ @Test
+ void processSubscription_unsubscribe_removesEntryFromPublicUserSubscriptionsMap() {
+ int maxPublicSubscriptions = 5;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "session-1";
+ int cmdId = 1;
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+
+ service.processSubscription(sessionRef, subscriptionCmd(cmdId));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(1);
+
+ AttributesSubscriptionCmd unsubCmd = subscriptionCmd(cmdId);
+ unsubCmd.setUnsubscribe(true);
+ service.processSubscription(sessionRef, unsubCmd);
+
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty();
+ }
+
+ @Test
+ void processSubscription_unsubscribe_freesSlotForNewSubscription() {
+ int maxPublicSubscriptions = 1;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, "session-1");
+ service.processSubscription(sessionRef, subscriptionCmd(1));
+
+ // slot is full — second subscription on same session should be rejected
+ assertThat(service.processSubscription(sessionRef, subscriptionCmd(2))).isFalse();
+
+ // unsubscribe cmd 1 to free the slot
+ AttributesSubscriptionCmd unsubCmd = subscriptionCmd(1);
+ unsubCmd.setUnsubscribe(true);
+ service.processSubscription(sessionRef, unsubCmd);
+
+ // now a new subscription should succeed
+ assertThat(service.processSubscription(sessionRef, subscriptionCmd(3)))
+ .as("new subscription should succeed after unsubscribe freed the slot")
+ .isTrue();
+ }
+
+ @Test
+ void processSessionClose_removesAllSessionSubscriptionsFromPublicUserSubscriptionsMap() {
+ int maxPublicSubscriptions = 10;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "closing-session";
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+
+ service.processSubscription(sessionRef, subscriptionCmd(1));
+ service.processSubscription(sessionRef, subscriptionCmd(2));
+ service.processSubscription(sessionRef, subscriptionCmd(3));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3);
+
+ service.processSessionClose(sessionRef);
+
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty();
+ }
+
+ @Test
+ void processSessionClose_onlyRemovesClosedSessionSubscriptions() {
+ int maxPublicSubscriptions = 10;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ WebSocketSessionRef session1 = mockPublicSessionRef(tenantId, "session-1");
+ WebSocketSessionRef session2 = mockPublicSessionRef(tenantId, "session-2");
+
+ service.processSubscription(session1, subscriptionCmd(1));
+ service.processSubscription(session1, subscriptionCmd(2));
+ service.processSubscription(session2, subscriptionCmd(1));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3);
+
+ service.processSessionClose(session1);
+
+ Set remaining = publicUserSubscriptionsMap.get(tenantId);
+ assertThat(remaining).hasSize(1);
+ assertThat(remaining).allMatch(subId -> subId.startsWith("[session-2]"));
+ }
+
+ private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) {
+ CustomerId customerId = new CustomerId(UUID.randomUUID());
+ SecurityUser securityUser = mock(SecurityUser.class);
+ willReturn(tenantId).given(securityUser).getTenantId();
+ willReturn(customerId).given(securityUser).getCustomerId();
+ willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId();
+ willReturn(true).given(securityUser).isCustomerUser();
+ willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal();
+
+ WebSocketSessionRef ref = mock(WebSocketSessionRef.class);
+ willReturn(securityUser).given(ref).getSecurityCtx();
+ willReturn(sessionId).given(ref).getSessionId();
+ return ref;
+ }
+
+ private AttributesSubscriptionCmd subscriptionCmd(int cmdId) {
+ AttributesSubscriptionCmd cmd = new AttributesSubscriptionCmd();
+ cmd.setCmdId(cmdId);
+ return cmd;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
index 67e5e946c8..f2485dc7d5 100644
--- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
+++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
@@ -31,13 +31,21 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
+import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -71,9 +79,18 @@ public class SystemPatchApplierTest {
@Mock
private InstallScripts installScripts;
+ @Mock
+ private DatabaseSchemaSettingsService schemaSettingsService;
+
@Mock
private WidgetTypeService widgetTypeService;
+ @Mock
+ private WidgetsBundleService widgetsBundleService;
+
+ @Mock
+ private ImageService imageService;
+
@InjectMocks
private SystemPatchApplier reconciler;
@@ -145,19 +162,72 @@ public class SystemPatchApplierTest {
}
@Test
- void whenWidgetNotFound_thenThrowException() throws Exception {
+ void whenWidgetNotFound_thenCreateNewWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
- WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget");
- String json = JacksonUtil.toString(testWidget);
+ WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget");
+ String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
- Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
+ Files.writeString(widgetTypesDir.resolve("new_widget.json"), json);
+
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null);
+
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+
+ assertNotNull(stats);
+ assertEquals(1, stats.created());
+ assertEquals(0, stats.updated());
+ verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn())));
+ }
+
+ @Test
+ void whenFqnIsBlank_thenThrowException() throws Exception {
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
- when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null);
+ WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget");
+ String json = JacksonUtil.toString(brokenWidget);
+ assertNotNull(json);
+ Files.writeString(widgetTypesDir.resolve("broken.json"), json);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ }
+
+ @Test
+ void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception {
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+
+ WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New");
+ Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget));
+
+ WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name");
+ Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget));
+
+ WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same");
+ Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget));
+
+ WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name");
+ existingChanged.setId(new WidgetTypeId(UUID.randomUUID()));
+
+ WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same");
+ existingSame.setId(new WidgetTypeId(UUID.randomUUID()));
+
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null);
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged);
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame);
+
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+
+ assertNotNull(stats);
+ assertEquals(1, stats.created());
+ assertEquals(1, stats.updated());
+ verify(widgetTypeService, times(2)).saveWidgetType(any());
}
@Test
@@ -179,9 +249,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(1, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2
));
@@ -204,9 +276,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(1, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
}
@@ -227,9 +301,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(0, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(0, stats.updated());
verify(widgetTypeService, never()).saveWidgetType(any());
}
@@ -329,8 +405,8 @@ public class SystemPatchApplierTest {
// Simulate work while holding lock
Thread.sleep(100);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- firstThreadSavedWidget.set(updated != null && updated > 0);
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ firstThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@@ -350,8 +426,8 @@ public class SystemPatchApplierTest {
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) {
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- secondThreadSavedWidget.set(updated != null && updated > 0);
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ secondThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@@ -373,6 +449,249 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
+ // --- isVersionIncreased tests ---
+
+ @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
+ @MethodSource("provideVersionComparisonTestCases")
+ void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
+ SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
+ assertEquals(expected, result, testName);
+ }
+
+ private static Stream provideVersionComparisonTestCases() {
+ return Stream.of(
+ // Maintenance digit increases within same LTS family
+ Arguments.of("maintenance increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+ Arguments.of("maintenance increased by more than one",
+ new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+
+ // Patch digit increases within same maintenance
+ Arguments.of("patch increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+ Arguments.of("patch increased by more than one",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
+
+ // Both maintenance and patch increased
+ Arguments.of("maintenance and patch both increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+
+ // Maintenance increased, patch value is lower (irrelevant — maintenance wins)
+ Arguments.of("maintenance increased, patch is lower",
+ new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
+
+ // Same version — no increase
+ Arguments.of("same version",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("same version with non-zero parts",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
+
+ // Decreased versions — no increase
+ Arguments.of("maintenance decreased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
+ Arguments.of("patch decreased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
+
+ // Different major — different family, skip
+ Arguments.of("different major",
+ new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("major decreased",
+ new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+
+ // Different minor — different LTS family, skip
+ Arguments.of("minor increased (different LTS family)",
+ new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("minor decreased",
+ new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
+ );
+ }
+
+ // --- isVersionChanged tests ---
+
+ @Test
+ void whenVersionIncreased_thenVersionChangedReturnsTrue() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertTrue(result);
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ @Test
+ void whenVersionUnparseable_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ @Test
+ void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ // --- updateLtsSqlSchema tests ---
+
+ @Test
+ void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
+ }
+
+ @Test
+ void whenLtsSqlFileDoesNotExist_thenSkips() {
+ Path dataDir = tempDir.resolve("data");
+ // Don't create the file
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate, never()).execute(anyString());
+ }
+
+ @Test
+ void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ String sql = "DO $$ BEGIN\n" +
+ " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
+ " CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
+ " END IF;\n" +
+ "END $$;\n" +
+ "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
+ Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate).execute(sql);
+ }
+
+ // --- applyPatchIfNeeded flow tests ---
+
+ @Test
+ void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ // LTS SQL was executed
+ verify(jdbcTemplate).execute("SELECT 1;");
+ // Schema version was updated
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenSkipsEverything() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ // No lock acquired
+ verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
+ // No schema update
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ }
+
+ @Test
+ void whenLockNotAcquired_thenSkipsPatchApplication() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false);
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ verify(jdbcTemplate, never()).execute(anyString());
+ }
+
+ @Test
+ void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenDifferentLtsFamily_thenSkipsPatch() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ }
+
private static Stream provideDescriptorComparisonTestCases() {
return Stream.of(
Arguments.of("Both null", null, null, true),
@@ -407,4 +726,398 @@ public class SystemPatchApplierTest {
return widget;
}
+ // --- createMissingSystemImages tests ---
+
+ @Test
+ void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() {
+ Path dataDir = tempDir.resolve("data");
+ // Intentionally do not create resources/images dir
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService, never()).getAllImageKeysByTenantId(any());
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] imageBytes = new byte[]{1, 2, 3, 4, 5};
+ Files.write(imagesDir.resolve("gateway.png"), imageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes));
+ }
+
+ @Test
+ void whenSystemImageExistsInDb_thenSkipIt() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png"));
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] newImageBytes = new byte[]{9, 9, 9};
+ byte[] existingImageBytes = new byte[]{1, 1, 1};
+ Files.write(imagesDir.resolve("new.png"), newImageBytes);
+ Files.write(imagesDir.resolve("existing.svg"), existingImageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg"));
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes));
+ verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class));
+ }
+
+ @Test
+ void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ Files.createDirectories(imagesDir.resolve("nested"));
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] imageBytes = new byte[]{5, 6, 7};
+ Files.write(imagesDir.resolve("logo.png"), imageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes));
+ verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class));
+ }
+
+ @Test
+ void whenMultipleNewImages_thenCreatesAll() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("a.png"), new byte[]{1});
+ Files.write(imagesDir.resolve("b.svg"), new byte[]{2});
+ Files.write(imagesDir.resolve("c.jpg"), new byte[]{3});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(3, created);
+ verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class));
+ verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class));
+ verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class));
+ }
+
+ @Test
+ void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+ when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class)))
+ .thenThrow(new RuntimeException("DB error"));
+
+ RuntimeException thrown = assertThrows(RuntimeException.class,
+ () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
+ assertTrue(thrown.getMessage().contains("broken.png"));
+ }
+
+ @Test
+ void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("img.png"), new byte[]{1});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID))
+ .thenThrow(new RuntimeException("lookup failed"));
+
+ assertThrows(RuntimeException.class,
+ () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ // --- applyPatchIfNeeded integration with createMissingSystemImages ---
+
+ @Test
+ void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ Path imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ byte[] imgBytes = new byte[]{7, 7, 7};
+ Files.write(imagesDir.resolve("new_icon.svg"), imgBytes);
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes));
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenImagesAreNotTouched() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(imageService, never()).getAllImageKeysByTenantId(any());
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ // --- updateWidgetBundles tests ---
+
+ @Test
+ void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() {
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\"]}");
+
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null);
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart", "bar_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(1, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService).updateWidgetsBundleWidgetFqns(
+ eq(TenantId.SYS_TENANT_ID),
+ eq(existingBundle.getId()),
+ argThat(fqns -> fqns.size() == 3
+ && fqns.get(0).equals("line_chart")
+ && fqns.get(1).equals("bar_chart")
+ && fqns.get(2).equals("new_chart"))
+ );
+ }
+
+ @Test
+ void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart", "bar_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ // File carries a base64 data URI; DB has the resolved system-image URL — same content, different format.
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," +
+ "\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," +
+ "\"widgetTypeFqns\":[]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ existingBundle.setImage("tb-image;/api/images/system/charts.png");
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of());
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleMetadataChanged_thenUpdateBundle() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," +
+ "\"widgetTypeFqns\":[\"line_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Old Title");
+ existingBundle.setDescription("old");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(1, updated);
+ verify(widgetsBundleService).saveWidgetsBundle(argThat(b ->
+ "New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20
+ ));
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleAliasIsBlank_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("broken.json"),
+ "{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}");
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ @Test
+ void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}");
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ @Test
+ void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypes\":[" +
+ "{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" +
+ "]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ private WidgetsBundle createTestBundle(String alias, String title) {
+ WidgetsBundle bundle = new WidgetsBundle();
+ bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
+ bundle.setAlias(alias);
+ bundle.setTitle(title);
+ bundle.setTenantId(TenantId.SYS_TENANT_ID);
+ return bundle;
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java
index 191bc97579..7d4c4b75e5 100644
--- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java
+++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java
@@ -160,7 +160,13 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl
if (!arguments.isEmpty())
withArguments = " with arguments " + arguments;
log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments);
- return ExecuteResponse.success();
+ switch (resourceId) {
+ case 4:
+ case 5:
+ return ExecuteResponse.success();
+ default:
+ return super.execute(identity, resourceId, arguments);
+ }
}
@Override
diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java
index f5b4538c1c..92e25a793e 100644
--- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java
+++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java
@@ -29,6 +29,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4;
+import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_5;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9;
@@ -90,26 +91,101 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
/**
- * execute_resource_with_parameters (execute reboot after 60 seconds on device)
- * Execute {"id":"3/0/4","value":60}
+ * execute_resource_with_parameters (execute reboot if digit = 5 on device)
+ * Execute {"id":"3/0/4","value":5}
* {"result":"CHANGED"}
*/
@Test
- public void testExecuteResourceWithParametersById_Result_CHANGED() throws Exception {
+ public void testExecuteResourceWithParametersSingleDigitValueById_Result_Ok() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4;
- Object expectedValue = 60;
+ Object expectedValue = 5;
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
}
+ /**
+ * execute_resource_with_parameters (execute Factory Reset if digit = 2 -> after 60 seconds on device)
+ * Execute {"id":"3/0/5","value":"2='60'"}
+
+ */
+ @Test
+ public void testExecuteResourceWithParametersArgumentIdAndValueById_Result_Ok() throws Exception {
+ String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
+ Object expectedValue = "2='60'";
+ String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
+ ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
+ assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
+ }
+
+ /**
+ * execute_resource_with_parameters (execute Factory Reset with two arguments:
+ * digit 2 without a value and digit 0 with the link value on device)
+ * Execute {"id":"3/0/5","value":"2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"}
+ */
+ @Test
+ public void testExecuteResourceWithParametersMultipleArgumentsIncludingLinkById_Result_Ok() throws Exception {
+ String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
+ Object expectedValue = "2,0='https://thingsboard.io/docs/reference/lwm2m-api/'";
+ String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
+ ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
+ assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
+ }
+
+ /**
+ * execute_resource_with_parameters (execute Factory Reset with multiple arguments without values)
+ * According to the OMA LwM2M execute arguments format, this represents ten arguments (digits 0-9), all without values.
+ * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9"}
+ */
+ @Test
+ public void testExecuteResourceWithParametersMultipleArgumentsById_Result_Ok() throws Exception {
+ String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
+ Object expectedValue = "0,1,2,3,4,5,6,7,8,9";
+ String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
+ ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
+ assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
+ }
+
+
+ /**
+ * execute_resource_with_parameters (execute Factory Reset after 60 seconds on device)
+ * Execute {"id":"3/0/5","value":"'60'"}
+ */
+ @Test
+ public void testExecuteResourceWithParametersSingleDigitValueInvalidById_Result_BAD_REQUEST_Error_IntegerBetween_0_And_9_Expected() throws Exception {
+ String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
+ Object expectedValue = "'60'";
+ String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
+ ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
+ assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText());
+ String expected = "Unable to parse Arguments [" + expectedValue + "] : Invalid digit ['] (an integer between 0 and 9 is expected)";
+ String actual = rpcActualResult.get("error").asText();
+ assertTrue(actual.contains(expected));
+ }
+
+ /**
+ * execute_resource_with_parameters (execute Bad with Unable to parse Arguments)
+ * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9,60"}
+ */
+ @Test
+ public void testExecuteResourceWithParametersMultipleArgumentsById_Result_BAD_REQUEST_Error_UnableParseArguments() throws Exception {
+ String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
+ Object expectedValue = "0,1,2,3,4,5,6,7,8,9,60";
+ String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);;
+ ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
+ assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText());
+ String expected = "Unable to parse Arguments [" + expectedValue + "] : [,] separator expected at index 21 after [0,1,2,3,4,5,6,7,8,9,6]";
+ String actual = rpcActualResult.get("error").asText();
+ assertTrue(actual.contains(expected));
+ }
+
/**
* Bootstrap-Request Trigger
* Execute {"id":"1/0/9"}
* {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"}
*/
@Test
- public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServerConfigured() throws Exception {
+ public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServer() throws Exception {
String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9;
String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@@ -125,7 +201,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."}
*/
@Test
- public void testExecuteResourceWithOperationNotExecuteById_Result_METHOD_NOT_ALLOWED() throws Exception {
+ public void testExecuteResourceWithOperationNotExecuteById_Result_BAD_REQUEST_Error_Is_Not_Executable() throws Exception {
String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@@ -141,7 +217,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"}
*/
@Test
- public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST() throws Exception {
+ public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST_Error_Specified_Object_Absent_List_Supported() throws Exception {
String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@@ -159,7 +235,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"}
*/
@Test
- public void testExecuteSecurityObjectById_Result_NOT_FOUND() throws Exception {
+ public void testExecuteSecurityObjectById_Result_BAD_REQUEST_Error_SpecifiedObjectAbsent() throws Exception {
String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@@ -178,8 +254,26 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
}
private String sendRPCExecuteWithValueById(String path, Object value) throws Exception {
- String setRpcRequest = "{\"method\": \"Execute\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}";
- return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk());
+ ObjectNode params = JacksonUtil.newObjectNode();
+ params.put("id", path);
+
+ // Jackson сам вирішить: ставити лапки (рядок) чи ні (число/boolean/null)
+ if (value instanceof String) {
+ params.put("value", (String) value);
+ } else if (value instanceof Integer) {
+ params.put("value", (Integer) value);
+ } else if (value instanceof Boolean) {
+ params.put("value", (Boolean) value);
+ } else {
+ params.set("value", JacksonUtil.valueToTree(value));
+ }
+
+ ObjectNode setRpcRequest = JacksonUtil.newObjectNode();
+ setRpcRequest.put("method", "Execute");
+ setRpcRequest.set("params", params);
+
+ return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(),
+ JacksonUtil.toString(setRpcRequest), String.class, status().isOk());
}
}
diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
index bf5fb7e448..3e8aef83a1 100644
--- a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
+++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
@@ -18,6 +18,8 @@ package org.thingsboard.server.cache;
import org.springframework.data.redis.serializer.SerializationException;
import org.thingsboard.common.util.JacksonUtil;
+import java.io.IOException;
+
public class TbJsonRedisSerializer implements TbRedisSerializer {
private final Class clazz;
@@ -33,6 +35,13 @@ public class TbJsonRedisSerializer implements TbRedisSerializer {
@Override
public V deserialize(K key, byte[] bytes) throws SerializationException {
- return JacksonUtil.fromBytes(bytes, clazz);
+ if (bytes == null) {
+ return null;
+ }
+ try {
+ return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz);
+ } catch (IOException e) {
+ throw new SerializationException("Failed to deserialize cached value", e);
+ }
}
}
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
index 271866d23c..3b7248ee72 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
@@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
+import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
@@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S
@Slf4j
@Component
@TbCoapServerComponent
-public class DefaultCoapServerService implements CoapServerService {
+public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton {
@Autowired
private CoapServerContext coapServerContext;
private CoapServer server;
- private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
+ private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
private ScheduledExecutorService dtlsSessionsExecutor;
+ private volatile DTLSConnector dtlsConnector;
+
+ private volatile CoapEndpoint dtlsCoapEndpoint;
+
@PostConstruct
public void init() throws UnknownHostException {
createCoapServer();
}
+ @Override
+ public void afterSingletonsInstantiated() {
+ if (isDtlsEnabled()) {
+ coapServerContext.getDtlsSettings().registerReloadCallback(() -> {
+ try {
+ log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint...");
+ recreateDtlsEndpoint();
+ log.info("CoAP DTLS endpoint recreated successfully with new certificates.");
+ } catch (Exception e) {
+ log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e);
+ }
+ });
+ }
+ }
+
@PreDestroy
public void shutdown() {
if (dtlsSessionsExecutor != null) {
@@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService {
}
private CoapServer createCoapServer() throws UnknownHostException {
- Configuration networkConfig = new Configuration();
- networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
- networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
- networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
- networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
- networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
- networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
- networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
- networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
- networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
+ Configuration networkConfig = createNetworkConfiguration();
server = new CoapServer(networkConfig);
CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
@@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService {
CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
server.addEndpoint(noSecCoapEndpoint);
if (isDtlsEnabled()) {
- CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
- TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
- DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
- networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
- dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
- DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig);
- dtlsCoapEndpointBuilder.setConnector(connector);
- CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build();
- server.addEndpoint(dtlsCoapEndpoint);
- tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+ createDtlsEndpoint(networkConfig);
dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
}
@@ -137,4 +140,106 @@ public class DefaultCoapServerService implements CoapServerService {
return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout();
}
+ private Configuration createNetworkConfiguration() {
+ Configuration networkConfig = new Configuration();
+ networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
+ networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
+ networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
+ networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
+ networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
+ networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
+ networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
+ networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
+ networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
+ return networkConfig;
+ }
+
+ // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig.
+ private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException {
+ TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
+ DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
+ networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
+ return dtlsConnectorConfig;
+ }
+
+ private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) {
+ CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
+ dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
+ dtlsCoapEndpointBuilder.setConnector(connector);
+ return dtlsCoapEndpointBuilder.build();
+ }
+
+ private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException {
+ DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
+ DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
+ CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
+ server.addEndpoint(newEndpoint);
+
+ dtlsConnector = newConnector;
+ dtlsCoapEndpoint = newEndpoint;
+ tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+ }
+
+ private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) {
+ return new DTLSConnector(config);
+ }
+
+ private synchronized void recreateDtlsEndpoint() throws IOException {
+ CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint;
+ DTLSConnector oldDtlsConnector = dtlsConnector;
+
+ Configuration networkConfig = createNetworkConfiguration();
+
+ log.info("Creating new DTLS endpoint with updated certificates...");
+
+ DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
+ DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
+ CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
+
+ // We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port.
+ // This creates a brief window where the port is unbound;
+ // if the new endpoint fails to start, we attempt to restore the old one (see rollback below).
+ if (oldDtlsEndpoint != null) {
+ log.info("Stopping old DTLS endpoint to release the port...");
+ server.getEndpoints().remove(oldDtlsEndpoint);
+ oldDtlsEndpoint.stop();
+ }
+
+ server.addEndpoint(newEndpoint);
+ try {
+ newEndpoint.start();
+ } catch (IOException e) {
+ log.error("Failed to start new DTLS endpoint, restoring old endpoint", e);
+ server.getEndpoints().remove(newEndpoint);
+ newEndpoint.destroy();
+ newConnector.destroy();
+ // Attempt to restore the old endpoint
+ if (oldDtlsEndpoint != null) {
+ try {
+ server.addEndpoint(oldDtlsEndpoint);
+ oldDtlsEndpoint.start();
+ log.info("Old DTLS endpoint restored successfully.");
+ } catch (IOException restoreEx) {
+ log.error("Failed to restore old DTLS endpoint", restoreEx);
+ }
+ }
+ throw e;
+ }
+ log.info("New DTLS endpoint started successfully.");
+
+ // Only swap instance fields after a successful start
+ dtlsConnector = newConnector;
+ dtlsCoapEndpoint = newEndpoint;
+ tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+
+ // Destroy old resources after a successful swap
+ if (oldDtlsEndpoint != null) {
+ if (oldDtlsConnector != null) {
+ oldDtlsConnector.destroy();
+ }
+ oldDtlsEndpoint.destroy();
+ log.info("Old DTLS endpoint destroyed.");
+ }
+ }
+
}
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
index af9dc2829f..7f63953fd3 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
@@ -100,6 +100,10 @@ public class TbCoapDtlsSettings {
@Autowired(required = false)
private TbServiceInfoProvider serviceInfoProvider;
+ public void registerReloadCallback(Runnable callback) {
+ coapDtlsCredentialsConfig.registerReloadCallback(callback);
+ }
+
public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException {
DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration);
configBuilder.setAddress(getInetSocketAddress());
@@ -154,5 +158,5 @@ public class TbCoapDtlsSettings {
}
return null;
}
-}
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
new file mode 100644
index 0000000000..11e278b4f5
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
@@ -0,0 +1,349 @@
+/**
+ * Copyright © 2016-2026 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.server.coapserver;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.bouncycastle.util.io.pem.PemWriter;
+import org.eclipse.californium.core.CoapClient;
+import org.eclipse.californium.core.CoapResource;
+import org.eclipse.californium.core.CoapResponse;
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.config.CoapConfig;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.server.resources.CoapExchange;
+import org.eclipse.californium.elements.config.Configuration;
+import org.eclipse.californium.elements.util.SslContextUtil;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConfig;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.eclipse.californium.scandium.dtls.CertificateType;
+import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider;
+import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType;
+
+import java.io.OutputStreamWriter;
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY;
+
+public class CoapDtlsCertificateReloadIntegrationTest {
+
+ private static final String TEST_RESOURCE_PATH = "test";
+ private static final String TEST_PAYLOAD = "hello-dtls";
+
+ @TempDir
+ Path tempDir;
+
+ private CoapServer coapServer;
+
+ @AfterEach
+ public void teardown() {
+ if (coapServer != null) {
+ coapServer.destroy();
+ }
+ }
+
+ @Test
+ public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception {
+ KeyPair keyPairA = generateKeyPair();
+ X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
+ KeyPair keyPairB = generateKeyPair();
+ X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, certA);
+ writeKeyPem(keyFile, keyPairA);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointA);
+ coapServer.start();
+
+ CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
+ assertThat(responseA).isNotNull();
+ assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD);
+
+ writeCertPem(certFile, certB);
+ writeKeyPem(keyFile, keyPairB);
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpointA);
+ endpointA.stop();
+
+ CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointB);
+ endpointB.start();
+ endpointA.destroy();
+
+ CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
+ assertThat(responseB).isNotNull();
+ assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD);
+ }
+
+ @Test
+ public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception {
+ KeyPair keyPairA = generateKeyPair();
+ X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
+ KeyPair keyPairB = generateKeyPair();
+ X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, certA);
+ writeKeyPem(keyFile, keyPairA);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointA);
+ coapServer.start();
+
+ CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
+ assertThat(responseA).isNotNull();
+
+ writeCertPem(certFile, certB);
+ writeKeyPem(keyFile, keyPairB);
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpointA);
+ endpointA.stop();
+ CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointB);
+ endpointB.start();
+ endpointA.destroy();
+
+ CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA);
+ assertThat(failedResponse).isNull();
+
+ CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
+ assertThat(responseB).isNotNull();
+ assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ }
+
+ @Test
+ public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception {
+ KeyPair keyPair = generateKeyPair();
+ X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, cert);
+ writeKeyPem(keyFile, keyPair);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpoint1);
+ coapServer.start();
+
+ CoapResponse response1 = doDtlsRequest(dtlsPort, cert);
+ assertThat(response1).isNotNull();
+ assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpoint1);
+ endpoint1.stop();
+ CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpoint2);
+ endpoint2.start();
+ endpoint1.destroy();
+
+ CoapResponse response2 = doDtlsRequest(dtlsPort, cert);
+ assertThat(response2).isNotNull();
+ assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ }
+
+ private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) {
+ PemSslCredentials pem = new PemSslCredentials();
+ pem.setCertFile(certFile.toAbsolutePath().toString());
+ pem.setKeyFile(keyFile.toAbsolutePath().toString());
+
+ SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false);
+ config.setEnabled(true);
+ config.setType(SslCredentialsType.PEM);
+ config.setPem(pem);
+ config.setKeystore(new KeystoreSslCredentials());
+ config.init();
+ return config;
+ }
+
+ private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) {
+ DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config);
+ dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port));
+ dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY);
+ dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS);
+ dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE,
+ org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED);
+
+ SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials(
+ credentials.getPrivateKey(), null, credentials.getCertificateChain());
+
+ dtlsBuilder.setCertificateIdentityProvider(
+ new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(),
+ Collections.singletonList(CertificateType.X_509)));
+
+ dtlsBuilder.setAdvancedCertificateVerifier(
+ StaticNewAdvancedCertificateVerifier.builder()
+ .setTrustAllCertificates()
+ .build());
+
+ DTLSConnector connector = new DTLSConnector(dtlsBuilder.build());
+
+ CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder();
+ endpointBuilder.setConfiguration(config);
+ endpointBuilder.setConnector(connector);
+ return endpointBuilder.build();
+ }
+
+ private KeyPair generateKeyPair() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
+ kpg.initialize(256);
+ return kpg.generateKeyPair();
+ }
+
+ private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception {
+ X500Name subject = new X500Name(subjectDn);
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1));
+ return new JcaX509CertificateConverter().getCertificate(
+ new JcaX509v3CertificateBuilder(
+ subject, BigInteger.valueOf(System.nanoTime()), now, expiry,
+ subject, kp.getPublic())
+ .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate())));
+ }
+
+ private void writeCertPem(Path path, X509Certificate cert) throws Exception {
+ try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
+ writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded()));
+ }
+ }
+
+ private void writeKeyPem(Path path, KeyPair keyPair) throws Exception {
+ try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
+ writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded()));
+ }
+ }
+
+ private Configuration createServerConfig() {
+ Configuration config = new Configuration();
+ config.set(CoapConfig.MAX_RETRANSMIT, 2);
+ config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
+ return config;
+ }
+
+ private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) {
+ try {
+ Configuration clientConfig = new Configuration();
+ clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1);
+ clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY);
+ clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS);
+ clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false);
+ clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false);
+
+ DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig);
+ clientDtls.setAdvancedCertificateVerifier(
+ StaticNewAdvancedCertificateVerifier.builder()
+ .setTrustedCertificates(trustedCert)
+ .build());
+
+ DTLSConnector clientConnector = new DTLSConnector(clientDtls.build());
+ CoapEndpoint clientEndpoint = new CoapEndpoint.Builder()
+ .setConfiguration(clientConfig)
+ .setConnector(clientConnector)
+ .build();
+
+ CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH);
+ client.setEndpoint(clientEndpoint);
+ client.setTimeout((long) 5000);
+
+ try {
+ clientEndpoint.start();
+ return client.get();
+ } finally {
+ client.shutdown();
+ clientEndpoint.destroy();
+ }
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private int findAvailablePort() throws Exception {
+ try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private static class TestResource extends CoapResource {
+ TestResource() {
+ super(TEST_RESOURCE_PATH);
+ }
+
+ @Override
+ public void handleGET(CoapExchange exchange) {
+ exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD);
+ }
+
+ }
+
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
new file mode 100644
index 0000000000..f22aaf5c7e
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
@@ -0,0 +1,246 @@
+/**
+ * Copyright © 2016-2026 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.server.coapserver;
+
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.network.Endpoint;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class CoapDtlsCertificateReloadTest {
+
+ @Mock
+ private CoapServerContext mockCoapServerContext;
+
+ @Mock
+ private TbCoapDtlsSettings mockDtlsSettings;
+
+ @Mock
+ private CoapServer mockCoapServer;
+
+ @Mock
+ private CoapEndpoint mockDtlsEndpoint;
+
+ @Mock
+ private DTLSConnector mockDtlsConnector;
+
+ private DefaultCoapServerService coapServerService;
+
+ @BeforeEach
+ public void setup() {
+ coapServerService = new DefaultCoapServerService();
+ ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext);
+
+ when(mockCoapServerContext.getHost()).thenReturn("localhost");
+ when(mockCoapServerContext.getPort()).thenReturn(5683);
+ doAnswer(invocation -> {
+ invocation.getArgument(0);
+ return null;
+ }).when(mockDtlsSettings).registerReloadCallback(any());
+ }
+
+ @Test
+ public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+ assertThat(callbackCaptor.getValue()).isNotNull();
+ }
+
+ @Test
+ public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
+
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ verify(mockDtlsSettings, never()).registerReloadCallback(any());
+ }
+
+ @Test
+ public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+
+ Runnable reloadCallback = callbackCaptor.getValue();
+ // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw.
+ // The old endpoint should not be stopped/destroyed when creation of the new one fails.
+ reloadCallback.run();
+
+ verify(mockDtlsEndpoint, never()).stop();
+ verify(mockDtlsConnector, never()).destroy();
+ }
+
+ @Test
+ public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+ when(mockCoapServerContext.getHost()).thenReturn("localhost");
+ when(mockCoapServerContext.getPort()).thenReturn(5683);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class));
+ }
+
+ @Test
+ public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+
+ Runnable reloadCallback = callbackCaptor.getValue();
+ assertThat(reloadCallback).isNotNull();
+ }
+
+ @Test
+ public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception {
+ // GIVEN
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class);
+ when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ List endpointsList = new CopyOnWriteArrayList<>();
+ endpointsList.add(mockDtlsEndpoint);
+ when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
+
+ CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
+
+ try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class,
+ (builder, context) -> {
+ when(builder.build()).thenReturn(mockNewEndpoint);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ })) {
+
+ // WHEN
+ ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint");
+
+ // THEN
+ assertThat(endpointsList).doesNotContain(mockDtlsEndpoint);
+ verify(mockDtlsEndpoint).stop();
+ verify(mockDtlsEndpoint).destroy();
+ verify(mockDtlsConnector).destroy();
+ verify(mockCoapServer).addEndpoint(mockNewEndpoint);
+ verify(mockNewEndpoint).start();
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint);
+ }
+ }
+
+ @Test
+ public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception {
+ // GIVEN
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ List endpointsList = new CopyOnWriteArrayList<>();
+ endpointsList.add(mockDtlsEndpoint);
+ when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
+
+ CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
+ doThrow(new IOException("start failed")).when(mockNewEndpoint).start();
+
+ try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class,
+ (builder, context) -> {
+ when(builder.build()).thenReturn(mockNewEndpoint);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ })) {
+
+ // WHEN
+ coapServerService.afterSingletonsInstantiated();
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+ Runnable reloadCallback = callbackCaptor.getValue();
+ reloadCallback.run();
+
+ // THEN - new resources cleaned up
+ DTLSConnector constructedConnector = dtlsMock.constructed().get(0);
+ verify(mockNewEndpoint).destroy();
+ verify(constructedConnector).destroy();
+ assertThat(endpointsList).doesNotContain(mockNewEndpoint);
+ // Old endpoint was stopped to release port, then restored after new one failed
+ verify(mockDtlsEndpoint).stop();
+ verify(mockDtlsEndpoint).start();
+ // Old fields preserved
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint);
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector);
+ }
+ }
+
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
index c75348d97e..9538670e25 100644
--- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
@@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
@@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest {
@Autowired
TbCoapDtlsSettings coapDtlsSettings;
- @MockBean
+ @MockitoBean
SslCredentialsConfig sslCredentialsConfig;
- @MockBean
+ @MockitoBean
private TransportService transportService;
- @MockBean
+ @MockitoBean
private TbServiceInfoProvider serviceInfoProvider;
@Test
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
index f412c5488a..406263aaed 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
@@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import java.util.Collection;
+import java.util.Set;
public interface ImageService {
@@ -38,6 +39,8 @@ public interface ImageService {
TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key);
+ Set getAllImageKeysByTenantId(TenantId tenantId);
+
TbResourceInfo getPublicImageInfoByKey(String publicResourceKey);
PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink);
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java
index 8931e6a270..956cf054dd 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java
@@ -134,7 +134,7 @@ public class Customer extends ContactBased implements HasTenantId, E
return super.getPhone();
}
- @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email", example = "example@company.com")
+ @Schema(description = "Email", example = "example@company.com")
@Override
public String getEmail() {
return super.getEmail();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java
index cc0b413025..707d9f1148 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java
@@ -176,7 +176,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL
this.label = label;
}
- @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Device Profile Id.")
+ @Schema(description = "JSON object with Device Profile Id. If not provided, the type will be used to determine the profile. If neither deviceProfileId nor type is specified, the default device profile will be used.")
public DeviceProfileId getDeviceProfileId() {
return deviceProfileId;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java
index 8bf65873a3..e21b8d3cd0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java
@@ -22,6 +22,7 @@ import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.Value;
import org.thingsboard.server.common.data.id.DashboardId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
@@ -75,4 +76,10 @@ public class DeviceProfileInfo extends EntityInfo {
profile.getType(), profile.getTransportType());
}
+ @Override
+ @Schema(implementation = DeviceProfileId.class, description = "JSON object with the Device Profile Id.")
+ public EntityId getId() {
+ return super.getId();
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
index 725aa7921c..13c75feed0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
@@ -51,11 +51,9 @@ public class ResourceUtils {
return true;
} else {
try {
- URL url = Resources.getResource(path);
- if (url != null) {
- return true;
- }
- } catch (IllegalArgumentException e) {}
+ Resources.getResource(path);
+ return true;
+ } catch (IllegalArgumentException ignored) {}
}
return false;
}
@@ -93,9 +91,9 @@ public class ResourceUtils {
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
- log.warn("Unable to find resource: " + filePath);
+ log.warn("Unable to find resource: {}", filePath);
} else {
- log.warn("Unable to find resource: " + filePath, e);
+ log.warn("Unable to find resource: {}", filePath, e);
}
}
throw new RuntimeException("Unable to find resource: " + filePath);
@@ -113,15 +111,19 @@ public class ResourceUtils {
return resourceFile.getAbsolutePath();
} else {
URL url = classLoader.getResource(filePath);
+ if (url == null) {
+ throw new RuntimeException("Unable to find resource: " + filePath);
+ }
return url.toURI().toString();
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
- log.warn("Unable to find resource: " + filePath);
+ log.warn("Unable to find resource: {}", filePath);
} else {
- log.warn("Unable to find resource: " + filePath, e);
+ log.warn("Unable to find resource: {}", filePath, e);
}
throw new RuntimeException("Unable to find resource: " + filePath);
}
}
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java
index ffca023b23..4d4fcfa58e 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java
@@ -46,7 +46,6 @@ public final class AiModel extends BaseData implements HasTenantId, H
private static final long serialVersionUID = 9017108678716011604L;
@Schema(
- requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "JSON object representing the ID of the tenant associated with this AI model",
example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d"
@@ -54,7 +53,6 @@ public final class AiModel extends BaseData implements HasTenantId, H
private TenantId tenantId;
@Schema(
- requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "Version of the AI model record; increments automatically whenever the record is changed",
example = "7",
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
index c456323120..65e943c574 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
@@ -39,6 +39,7 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques
private TenantId tenantId;
@Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY)
private CustomerId customerId;
+ @NoXss
@NotNull
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm")
@Length(fieldName = "type")
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java
index b7dc5059a7..3ab3f0a526 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java
@@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition;
import io.swagger.v3.oas.annotations.media.Schema;
import org.thingsboard.server.common.data.id.DashboardId;
-@Schema(name = "AlarmRuleDefinition")
+@Schema
@Data
@AllArgsConstructor
@NoArgsConstructor
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java
index 88dc7f5859..87bd44ce1f 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java
@@ -32,7 +32,6 @@ import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSch
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule;
@Schema(
- name = "AlarmRuleCondition",
discriminatorProperty = "type",
discriminatorMapping = {
@DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmCondition.class),
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java
index bee112d25d..3599ee5c3c 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java
@@ -25,7 +25,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.util.concurrent.TimeUnit;
-@Schema(name = "AlarmRuleDurationCondition")
+@Schema
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java
index ca03d257a1..42cba04ec0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java
@@ -22,7 +22,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
-@Schema(name = "AlarmRuleRepeatingCondition")
+@Schema
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java
index 38e0434b1d..f91ff1f3f0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java
@@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition;
import io.swagger.v3.oas.annotations.media.Schema;
-@Schema(name = "AlarmRuleSimpleCondition")
+@Schema
public class SimpleAlarmCondition extends AlarmCondition {
@Override
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
index d80eccb9fc..578b7909c0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
@@ -32,7 +32,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.List;
-@Schema(name = "AlarmRuleConditionFilter")
+@Schema
@Data
public class AlarmConditionFilter implements Serializable {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java
index 0ff72c4297..64a73d2f6d 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java
@@ -37,6 +37,7 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate
return FilterPredicateType.BOOLEAN;
}
+ @Schema(name = "AlarmRuleBooleanOperation")
public enum BooleanOperation {
EQUAL,
NOT_EQUAL
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java
index 6463aa7f81..1f719cb80c 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java
@@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionVal
import java.util.concurrent.TimeUnit;
-@Schema(name = "AlarmRuleNoDataFilterPredicate")
+@Schema
@Data
@AllArgsConstructor
@NoArgsConstructor
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java
index 2fb249fc96..5bddbc4387 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java
@@ -41,6 +41,7 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate