diff --git a/.github/release.yml b/.github/release.yml
index 53b2e3ae65..70852dcbb2 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -1,5 +1,5 @@
#
-# Copyright © 2016-2025 The Thingsboard Authors
+# 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.
@@ -23,27 +23,19 @@ changelog:
labels:
- 'Major Core'
- 'Major Rule Engine'
- exclude:
- labels:
- - 'Bug'
+
- title: 'Major UI'
labels:
- 'Major UI'
- exclude:
- labels:
- - 'Bug'
+
- title: 'Major Transport'
labels:
- 'Major Transport'
- exclude:
- labels:
- - 'Bug'
+
- title: 'Major Edge'
labels:
- 'Major Edge'
- exclude:
- labels:
- - 'Bug'
+
- title: 'Core & Rule Engine'
labels:
- 'Core'
@@ -51,38 +43,63 @@ changelog:
exclude:
labels:
- 'Bug'
+
- title: 'UI'
labels:
- 'UI'
exclude:
labels:
- 'Bug'
+
- title: 'Transport'
labels:
- 'Transport'
exclude:
labels:
- 'Bug'
+
- title: 'Edge'
labels:
- 'Edge'
exclude:
labels:
- 'Bug'
+
- title: 'Bug: Core & Rule Engine'
labels:
- - 'Core'
- - 'Rule Engine'
- 'Bug'
+ exclude:
+ labels:
+ - 'UI'
+ - 'Transport'
+ - 'Edge'
+
- title: 'Bug: UI'
labels:
- - 'UI'
- 'Bug'
+ exclude:
+ labels:
+ - 'Core'
+ - 'Rule Engine'
+ - 'Transport'
+ - 'Edge'
+
- title: 'Bug: Transport'
labels:
- - 'Transport'
- 'Bug'
+ exclude:
+ labels:
+ - 'Core'
+ - 'Rule Engine'
+ - 'UI'
+ - 'Edge'
+
- title: 'Bug: Edge'
labels:
- - 'Edge'
- 'Bug'
+ exclude:
+ labels:
+ - 'Core'
+ - 'Rule Engine'
+ - 'UI'
+ - 'Transport'
diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml
index 561b7d0019..0ea9d61923 100644
--- a/.github/workflows/check-configuration-files.yml
+++ b/.github/workflows/check-configuration-files.yml
@@ -1,5 +1,5 @@
#
-# Copyright © 2016-2025 The Thingsboard Authors
+# 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.
diff --git a/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml
index 20a1c1350a..0ac41c7bc8 100644
--- a/.github/workflows/license-header-format.yml
+++ b/.github/workflows/license-header-format.yml
@@ -1,5 +1,5 @@
#
-# Copyright © 2016-2025 The Thingsboard Authors
+# 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.
diff --git a/README.md b/README.md
index bffd87ffa5..6267cc3a7a 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,150 @@
-# ThingsBoard
-[](https://builds.thingsboard.io/viewType.html?buildTypeId=ThingsBoard_Build&guest=1)
+
+
+
+
+# Open-source IoT platform for data collection, processing, visualization, and device management.
+
+
+
+
+
+💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
+
+
+
+## 🚀 Installation options
+
+* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise)
+* Try [ThingsBoard Cloud](https://thingsboard.io/installations/)
+* or [Use our Live demo](https://demo.thingsboard.io/signup)
+
+## 💡 Getting started with ThingsBoard
+
+Check out our [Getting Started guide](https://thingsboard.io/docs/getting-started-guides/helloworld/) or [watch the video](https://www.youtube.com/watch?v=80L0ubQLXsc) to learn the basics of ThingsBoard and create your first dashboard! You will learn to:
+
+* Connect devices to ThingsBoard
+* Push data from devices to ThingsBoard
+* Build real-time dashboards
+* Create a Customer and assign the dashboard with them.
+* Define thresholds and trigger alarms
+* Set up notifications via email, SMS, mobile apps, or integrate with third-party services.
+
+## ✨ Features
+
+
+
+
+
+
+ 
+ Provision and manage devices and assets
+
+
+ Provision, monitor and control your IoT entities in secure way using rich server-side APIs. Define relations between your devices, assets, customers or any other entities.
+
+
+
+
+ |
+
+
+
+ 
+ Collect and visualize your data
+
+
+ Collect and store telemetry data in scalable and fault-tolerant way. Visualize your data with built-in or custom widgets and flexible dashboards. Share dashboards with your customers.
+
+
+
+
+ |
+
+
+
+
+
+ 
+ SCADA Dashboards
+
+
+ Monitor and control your industrial processes in real time with SCADA. Use SCADA symbols on dashboards to create and manage any workflow, offering full flexibility to design and oversee operations according to your requirements.
+
+
+
+
+ |
+
+
+
+ 
+ Process and React
+
+
+ Define data processing rule chains. Transform and normalize your device data. Raise alarms on incoming telemetry events, attribute updates, device inactivity and user actions.
+
+
+
+
+
+ |
+
+
+
+## ⚙️ Powerful IoT Rule Engine
+
+ThingsBoard allows you to create complex [Rule Chains](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) to process data from your devices and match your application specific use cases.
+
+[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+
+
+
+[**Read more about Rule Engine ➜**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+
+
+
+## 📦 Real-Time IoT Dashboards
+
+ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that speeds up time-to-market with powerful built-in solution templates. It enables data collection and analysis from any devices, saving resources on routine tasks and letting you focus on your solution’s unique aspects. See more our Use Cases [here](https://thingsboard.io/iot-use-cases/).
+
+[**Smart energy**](https://thingsboard.io/use-cases/smart-energy/)
+
+[](https://thingsboard.io/use-cases/smart-energy/)
+
+[**SCADA swimming pool**](https://thingsboard.io/use-cases/scada/)
+
+[](https://thingsboard.io/use-cases/scada/)
+
+[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/)
+
+[](https://thingsboard.io/use-cases/fleet-tracking/)
+
+[**Smart farming**](https://thingsboard.io/use-cases/smart-farming/)
+
+[](https://thingsboard.io/use-cases/smart-farming/)
-ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management.
-
-
-
-
-## Documentation
-
-ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs).
-
-## IoT use cases
-
-[**Smart energy**](https://thingsboard.io/smart-energy/)
-[](https://thingsboard.io/smart-energy/)
-
-[**SCADA Swimming pool**](https://thingsboard.io/use-cases/scada/)
-[](https://thingsboard.io/use-cases/scada/)
-
-[**Fleet tracking**](https://thingsboard.io/fleet-tracking/)
-[](https://thingsboard.io/fleet-tracking/)
-
-[**Smart farming**](https://thingsboard.io/smart-farming/)
-[](https://thingsboard.io/smart-farming/)
+[**Smart metering**](https://thingsboard.io/smart-metering/)
-[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
-[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+[](https://thingsboard.io/smart-metering/)
-[**Smart metering**](https://thingsboard.io/smart-metering/)
-[](https://thingsboard.io/smart-metering/)
+
-## Getting Started
+[**Check more of our use cases ➜**](https://thingsboard.io/iot-use-cases/)
-Collect and Visualize your IoT data in minutes by following this [guide](https://thingsboard.io/docs/getting-started-guides/helloworld/).
+
-## Support
+## 🫶 Support
- - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard)
+To get support, please visit our [GitHub issues page](https://github.com/thingsboard/thingsboard/issues)
-## Licenses
+## 📄 Licenses
-This project is released under [Apache 2.0 License](./LICENSE).
+This project is released under [Apache 2.0 License](./LICENSE)
diff --git a/application/pom.xml b/application/pom.xml
index 9c683cbd25..ab26655052 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -1,6 +1,6 @@
+
+
+
diff --git a/application/src/main/resources/templates/2fa.verification.code.ftl b/application/src/main/resources/templates/2fa.verification.code.ftl
index b8e957431a..7442f3d897 100644
--- a/application/src/main/resources/templates/2fa.verification.code.ftl
+++ b/application/src/main/resources/templates/2fa.verification.code.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/account.activated.ftl b/application/src/main/resources/templates/account.activated.ftl
index 3a9974dcc5..49501e353c 100644
--- a/application/src/main/resources/templates/account.activated.ftl
+++ b/application/src/main/resources/templates/account.activated.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/account.lockout.ftl b/application/src/main/resources/templates/account.lockout.ftl
index 19515d95d5..a26254d5f1 100644
--- a/application/src/main/resources/templates/account.lockout.ftl
+++ b/application/src/main/resources/templates/account.lockout.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/activation.ftl b/application/src/main/resources/templates/activation.ftl
index 57cccec6ea..ac0a675e1e 100644
--- a/application/src/main/resources/templates/activation.ftl
+++ b/application/src/main/resources/templates/activation.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/password.was.reset.ftl b/application/src/main/resources/templates/password.was.reset.ftl
index 3de0d2eedd..f12ba1901a 100644
--- a/application/src/main/resources/templates/password.was.reset.ftl
+++ b/application/src/main/resources/templates/password.was.reset.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/reset.password.ftl b/application/src/main/resources/templates/reset.password.ftl
index 942800766a..e6e5a59021 100644
--- a/application/src/main/resources/templates/reset.password.ftl
+++ b/application/src/main/resources/templates/reset.password.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/state.disabled.ftl b/application/src/main/resources/templates/state.disabled.ftl
index 0f5ce52dca..9f5a69e142 100644
--- a/application/src/main/resources/templates/state.disabled.ftl
+++ b/application/src/main/resources/templates/state.disabled.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/state.enabled.ftl b/application/src/main/resources/templates/state.enabled.ftl
index e1baaa12b1..b6f2bab894 100644
--- a/application/src/main/resources/templates/state.enabled.ftl
+++ b/application/src/main/resources/templates/state.enabled.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/state.warning.ftl b/application/src/main/resources/templates/state.warning.ftl
index bfb85c3a0a..2b757d7d62 100644
--- a/application/src/main/resources/templates/state.warning.ftl
+++ b/application/src/main/resources/templates/state.warning.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/templates/test.ftl b/application/src/main/resources/templates/test.ftl
index d921439d6b..a022e2b451 100644
--- a/application/src/main/resources/templates/test.ftl
+++ b/application/src/main/resources/templates/test.ftl
@@ -1,6 +1,6 @@
<#--
- Copyright © 2016-2025 The Thingsboard Authors
+ 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.
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index cda7f5782a..db8792131b 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -1,5 +1,5 @@
#
-# Copyright © 2016-2025 The Thingsboard Authors
+# 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.
@@ -100,6 +100,9 @@ server:
rate_limits:
# Limit that prohibits resetting the password for the user too often. The value of the rate limit. By default, no more than 5 requests per hour
reset_password_per_user: "${RESET_PASSWORD_PER_USER_RATE_LIMIT_CONFIGURATION:5:3600}"
+ rule_engine:
+ # Default timeout for waiting response of REST API request to Rule Engine in milliseconds
+ response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}"
# Application info parameters
app:
@@ -148,6 +151,12 @@ security:
tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded
# Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator
user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}"
+ # API key parameters
+ api_key:
+ # Prefix for the auto-generated API key. For example, tb_Ood4dQMxWvMH-76z3E_Cv0mZaBWT0Clk3hRSO0P_jNQ
+ value_prefix: "${SECURITY_API_KEY_VALUE_PREFIX:tb_}"
+ # Length of the auto-generated API key. Max is 255
+ value_bytes_size: "${SECURITY_API_KEY_VALUE_PREFIX:64}"
# Enable/disable case-sensitive username login
user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}"
claim:
@@ -208,7 +217,7 @@ ui:
# Help parameters
help:
# Base URL for UI help assets
- base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.2.1 }"
+ base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}"
# Database telemetry parameters
database:
@@ -427,6 +436,9 @@ sql:
enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records
ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days
checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
+ api_keys:
+ enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records
+ checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
relations:
max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible
pool_size: "${SQL_RELATIONS_POOL_SIZE:4}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls
@@ -504,14 +516,14 @@ actors:
response_timeout_ms: "${ACTORS_RPC_RESPONSE_TIMEOUT_MS:30000}"
# Close transport session if RPC delivery timed out. If enabled, RPC will be reverted to the queued state.
# Note:
- # - For MQTT transport:
- # - QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered.
- # - QoS level 1: This feature applies, as an acknowledgment is expected.
- # - QoS level 2: Unsupported.
- # - For CoAP transport:
- # - Confirmable requests: This feature applies, as delivery confirmation is expected.
- # - Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected.
- # - For HTTP and SNPM transports: RPC is considered delivered immediately, and there is no logic to await acknowledgment.
+ # - For MQTT transport:
+ #
- QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered.
+ # - QoS level 1: This feature applies, as an acknowledgment is expected.
+ # - QoS level 2: Unsupported.
+ # - For CoAP transport:
+ #
- Confirmable requests: This feature applies, as delivery confirmation is expected.
+ # - Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected.
+ # - For HTTP and SNPM transports: RPC is considered delivered immediately, and there is no logic to await acknowledgment.
close_session_on_rpc_delivery_timeout: "${ACTORS_RPC_CLOSE_SESSION_ON_RPC_DELIVERY_TIMEOUT:false}"
statistics:
# Enable/disable actor statistics
@@ -662,6 +674,9 @@ cache:
aiModel:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL
maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled
+ apiKeys:
+ timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL
+ maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:
@@ -682,6 +697,9 @@ cache:
tbResourceData:
timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL
maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled
+ userAuthDetails:
+ timeToLiveInMinutes: "${CACHE_SPECS_USER_AUTH_DETAILS_TTL:120}" # User auth details cache TTL
+ maxSize: "${CACHE_SPECS_USER_AUTH_DETAILS_MAX_SIZE:200000}" # 0 means the cache is disabled
# Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required.
@@ -715,18 +733,20 @@ redis:
# if set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
sentinel:
- # name of the master node
+ # Name of the master node
master: "${REDIS_MASTER:}"
- # comma-separated list of "host:port" pairs of sentinels
+ # Comma-separated list of "host:port" pairs of sentinels
sentinels: "${REDIS_SENTINELS:}"
- # password to authenticate with sentinel
+ # Password to authenticate with sentinel
password: "${REDIS_SENTINEL_PASSWORD:}"
- # if set false will be used pool config build from values of the pool config section
+ # If set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
# db index
db: "${REDIS_DB:0}"
# db password
password: "${REDIS_PASSWORD:}"
+ # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth
+ username: "${REDIS_USERNAME:}"
# ssl config
ssl:
# Enable/disable secure connection
@@ -1021,10 +1041,10 @@ transport:
activity:
# This property specifies the strategy for reporting activity events within each reporting period.
# The accepted values are 'FIRST', 'LAST', 'FIRST_AND_LAST' and 'ALL'.
- # - 'FIRST': Only the first activity event in each reporting period is reported.
- # - 'LAST': Only the last activity event in the reporting period is reported.
- # - 'FIRST_AND_LAST': Both the first and last activity events in the reporting period are reported.
- # - 'ALL': All activity events in the reporting period are reported.
+ # - 'FIRST': Only the first activity event in each reporting period is reported.
+ # - 'LAST': Only the last activity event in the reporting period is reported.
+ # - 'FIRST_AND_LAST': Both the first and last activity events in the reporting period are reported.
+ # - 'ALL': All activity events in the reporting period are reported.
reporting_strategy: "${TB_TRANSPORT_ACTIVITY_REPORTING_STRATEGY:LAST}"
json:
# Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON
@@ -1142,16 +1162,16 @@ transport:
dtls:
# RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000
retransmission_timeout: "${LWM2M_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}"
- # CoAP DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
- # Default: off
+ # LWM2M DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
+ # Default: off.
# Control usage of DTLS connection ID length (CID).
- # - 'off' to deactivate it.
- # - 'on' to activate Connection ID support (same as CID 0 or more 0).
- # - A positive value defines generated CID size in bytes.
- # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
- # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
- # - A value that are > 4: MultiNodeConnectionIdGenerator is used
- connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}"
+ # - 'off' to deactivate it.
+ # - 'on' to activate Connection ID support (same as CID 0 or more 0).
+ # - A positive value defines generated CID size in bytes.
+ # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
+ # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
+ # - A value that are > 4: MultiNodeConnectionIdGenerator is used
+ connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}"
server:
# LwM2M Server ID
id: "${LWM2M_SERVER_ID:123}"
@@ -1196,7 +1216,7 @@ transport:
# Enable/disable Bootstrap Server
enabled: "${LWM2M_ENABLED_BS:true}"
# Default value in LwM2M client after start in mode Bootstrap for the object : name "LWM2M Security" field: "Short Server ID" (deviceProfile: Bootstrap.BOOTSTRAP SERVER.Short ID)
- id: "${LWM2M_SERVER_ID_BS:111}"
+ id: "${LWM2M_SERVER_ID_BS:0}"
# LwM2M bootstrap server bind address. Bind to all interfaces by default
bind_address: "${LWM2M_BS_BIND_ADDRESS:0.0.0.0}"
# LwM2M bootstrap server bind port
@@ -1333,15 +1353,15 @@ coap:
# CoAP DTLS bind port
bind_port: "${COAP_DTLS_BIND_PORT:5684}"
# CoAP DTLS connection ID length. RFC 9146, Connection Identifier for DTLS 1.2
- # Default: off
+ # Default: off.
# Control usage of DTLS connection ID length (CID).
- # - 'off' to deactivate it.
- # - 'on' to activate Connection ID support (same as CID 0 or more 0).
- # - A positive value defines generated CID size in bytes.
- # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
- # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
- # - A value that are > 4: MultiNodeConnectionIdGenerator is used
- connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}"
+ # - 'off' to deactivate it.
+ # - 'on' to activate Connection ID support (same as CID 0 or more 0).
+ # - A positive value defines generated CID size in bytes.
+ # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
+ # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
+ # - A value that are > 4: MultiNodeConnectionIdGenerator is used
+ connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}"
# Specify the MTU (Maximum Transmission Unit).
# Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU.
# Default = 1024
@@ -1357,13 +1377,13 @@ coap:
# In order to negotiate smaller maximum fragment lengths,
# clients MAY include an extension of type "max_fragment_length" in the (extended) client hello.
# The "extension_data" field of this extension SHALL contain:
- # enum {
+ # enum {
# 2^9(1) == 512,
# 2^10(2) == 1024,
# 2^11(3) == 2048,
# 2^12(4) == 4096,
# (255)
- # } MaxFragmentLength;
+ # } MaxFragmentLength;
# TLS already requires clients and servers to support fragmentation of handshake messages.
max_fragment_length: "${COAP_DTLS_MAX_FRAGMENT_LENGTH:1024}"
# Server DTLS credentials
@@ -1449,7 +1469,7 @@ device:
pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}"
gateway:
# The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch
- image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}"
+ image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}"
# Edges parameters
edges:
@@ -1488,6 +1508,11 @@ edges:
no_read_records_sleep: "${EDGES_NO_READ_RECORDS_SLEEP:1000}"
# Number of milliseconds to wait before resending failed batch of edge events to edge
sleep_between_batches: "${EDGES_SLEEP_BETWEEN_BATCHES:60000}"
+ # Time (in milliseconds) to subtract from the start timestamp when fetching edge events.
+ # This compensates for possible misordering between `created_time` (used for partitioning)
+ # and `seqId` (used for sorting). Without this, events with smaller seqId but larger created_time
+ # might be skipped, especially across partition boundaries.
+ misordering_compensation_millis: "${EDGES_MISORDERING_COMPENSATION_MILLIS:60000}"
# Max number of high priority edge events per edge session. No persistence - stored in memory
max_high_priority_queue_size_per_session: "${EDGES_MAX_HIGH_PRIORITY_QUEUE_SIZE_PER_SESSION:10000}"
# Number of threads that are used to check DB for edge events
@@ -1542,6 +1567,8 @@ swagger:
version: "${SWAGGER_VERSION:}"
# The group name (definition) on the API doc UI page.
group_name: "${SWAGGER_GROUP_NAME:thingsboard}"
+ # Control the initial display state of API operations and tags (none or list)
+ doc_expansion: "${SWAGGER_DOC_EXPANSION:list}"
# Queue configuration parameters
queue:
diff --git a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java
index 9243e295bc..5695d27162 100644
--- a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java
index bf055ee146..759b7ffa2d 100644
--- a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java
index 49bf3c8670..441d0adfe5 100644
--- a/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java
index 24102de06b..5c91903fc8 100644
--- a/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java
index 80a6b8ad91..d0839e5718 100644
--- a/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java
index d8fac2b936..cc2266b9cc 100644
--- a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java
+++ b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java
index df0bf34929..8253e3021f 100644
--- a/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java
+++ b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
new file mode 100644
index 0000000000..cf94940e07
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
@@ -0,0 +1,1016 @@
+/**
+ * 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.cf;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.rule.engine.action.TbAlarmResult;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.alarm.Alarm;
+import org.thingsboard.server.common.data.alarm.AlarmInfo;
+import org.thingsboard.server.common.data.alarm.AlarmSeverity;
+import org.thingsboard.server.common.data.alarm.AlarmStatus;
+import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
+import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
+import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition;
+import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition;
+import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate.NumericOperation;
+import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate;
+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.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.CurrentOwnerDynamicSourceConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.debug.DebugSettings;
+import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent;
+import org.thingsboard.server.common.data.event.EventType;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.EventId;
+import org.thingsboard.server.common.data.query.EntityKeyValueType;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.event.EventDao;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.testcontainers.shaded.org.awaitility.Awaitility.await;
+
+@Slf4j
+@DaoSqlTest
+public class AlarmRulesTest extends AbstractControllerTest {
+
+ @MockitoSpyBean
+ private ActorSystemContext actorSystemContext;
+
+ @Autowired
+ private EventDao eventDao;
+
+ private Device device;
+ private DeviceId deviceId;
+ private EntityId originatorId;
+ private EventId latestEventId;
+
+ @Before
+ public void beforeEach() throws Exception {
+ loginSysAdmin();
+
+ updateDefaultTenantProfileConfig(tenantProfileConfig -> {
+ tenantProfileConfig.setCfReevaluationCheckInterval(1);
+ tenantProfileConfig.setAlarmsReevaluationInterval(1);
+ });
+
+ loginTenantAdmin();
+ device = createDevice("Device A", "aaa");
+ deviceId = device.getId();
+ originatorId = deviceId;
+ }
+
+ @Test
+ public void testCreateAlarm_severityUpdate_clear() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", null, null),
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null)
+ );
+
+ Condition clearRule = new Condition("return temperature <= 25;", null, null);
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, clearRule);
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, 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 -> {
+ 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 -> {
+ 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 -> {
+ assertThat(alarmResult.isCleared()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_simpleConditionExpression() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression();
+ AlarmConditionFilter filter = new AlarmConditionFilter();
+ filter.setArgument("temperature");
+ filter.setValueType(EntityKeyValueType.NUMERIC);
+ NumericFilterPredicate predicate = new NumericFilterPredicate();
+ predicate.setOperation(NumericOperation.GREATER_OR_EQUAL);
+ AlarmConditionValue thresholdValue = new AlarmConditionValue<>();
+ thresholdValue.setStaticValue(100.0);
+ predicate.setValue(thresholdValue);
+ filter.setPredicates(List.of(predicate));
+ simpleExpression.setFilters(List.of(filter));
+ simpleExpression.setOperation(ComplexOperation.AND);
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"temperature\":100}");
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_eventBeforeDefaultTs() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}"));
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_repeatingCondition() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ int eventsCountMajor = 5;
+ int eventsCountCritical = 10;
+ Map createRules = Map.of(
+ AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null),
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(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();
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionRepeats()).isEqualTo(5);
+ });
+
+ for (int i = 0; i < 4; i++) {
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ Thread.sleep(10);
+ }
+ checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> {
+ assertThat(alarmResult.isUpdated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
+ });
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isSeverityUpdated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionRepeats()).isEqualTo(10);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_dynamicRepeatingCondition() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+
+ Argument eventsCountArgument = new Argument();
+ eventsCountArgument.setRefEntityKey(new ReferencedEntityKey("eventsCount", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ eventsCountArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument,
+ "eventsCount", eventsCountArgument
+ );
+
+ int eventsCount = 5;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null,
+ new AlarmConditionValue<>(null, "eventsCount"), null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(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 -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionRepeats()).isEqualTo(eventsCount);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_durationCondition() throws Exception {
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "powerConsumption", argument
+ );
+
+ long createDurationMs = 5000L;
+ long clearDurationMs = 3000L;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, createDurationMs)
+ );
+ Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs);
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds",
+ arguments, createRules, clearRule);
+ postTelemetry(deviceId, "{\"powerConsumption\":3500}");
+ Thread.sleep(createDurationMs - 2000);
+ assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000);
+ });
+
+ postTelemetry(deviceId, "{\"powerConsumption\":2000}");
+ Thread.sleep(clearDurationMs - 2000);
+ assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCleared()).isTrue();
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
+ assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_dynamicDurationCondition() throws Exception {
+ Argument powerConsumptionArgument = new Argument();
+ powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null));
+ powerConsumptionArgument.setDefaultValue("0");
+
+ Argument durationArgument = new Argument();
+ durationArgument.setRefEntityKey(new ReferencedEntityKey("duration", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ durationArgument.setDefaultValue("-1");
+ Map arguments = Map.of(
+ "powerConsumption", powerConsumptionArgument,
+ "duration", durationArgument
+ );
+
+ long createDurationMs = 2000L;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null,
+ new AlarmConditionValue(null, "duration"), null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds",
+ arguments, createRules, null);
+ postTelemetry(deviceId, "{\"powerConsumption\":3500}");
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}");
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_durationCondition_defaultValue() {
+ Argument powerConsumptionArgument = new Argument();
+ powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null));
+ powerConsumptionArgument.setDefaultValue("3500");
+ Map arguments = Map.of(
+ "powerConsumption", powerConsumptionArgument
+ );
+
+ long createDurationMs = 2000L;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null,
+ new AlarmConditionValue(2000L, null), null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds",
+ arguments, createRules, null);
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_currentOwnerArgument() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+
+ Argument temperatureThresholdArgument = new Argument();
+ temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
+ temperatureThresholdArgument.setDefaultValue("1000");
+
+ Map arguments = Map.of(
+ "temperature", temperatureArgument,
+ "temperatureThreshold", temperatureThresholdArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null)
+ );
+
+ device.setCustomerId(customerId);
+ device = doPost("/api/device", device, Device.class);
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+ postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}");
+
+ postTelemetry(deviceId, "{\"temperature\":51}");
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAndClearAlarm_customerAlarmRule_simpleExpression() throws Exception {
+ Argument locationArgument = new Argument();
+ locationArgument.setRefEntityKey(new ReferencedEntityKey("location", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ locationArgument.setDefaultValue("unknown");
+ originatorId = customerId;
+
+ Argument locationFilterArgument = new Argument();
+ locationFilterArgument.setRefEntityKey(new ReferencedEntityKey("locationFilter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ locationFilterArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
+ locationFilterArgument.setDefaultValue("None");
+
+ Map arguments = Map.of(
+ "location", locationArgument,
+ "locationFilter", locationFilterArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.INDETERMINATE, new Condition(createSimpleExpression(
+ "location", StringOperation.CONTAINS, new AlarmConditionValue<>(null, "locationFilter")
+ ), null, null)
+ );
+ Condition clearRule = new Condition(createSimpleExpression(
+ "location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter")
+ ), null, null);
+
+ CalculatedField calculatedField = createAlarmCf(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 -> {
+ 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 -> {
+ assertThat(alarmResult.isCleared()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_dynamicSchedule() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Argument scheduleArgument = new Argument();
+ scheduleArgument.setRefEntityKey(new ReferencedEntityKey("schedule", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ scheduleArgument.setDefaultValue("None");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument,
+ "schedule", scheduleArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null, null,
+ new AlarmConditionValue<>(null, "schedule"))
+ );
+
+ CalculatedField calculatedField = createAlarmCf(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}]}
+ """;
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}");
+ postTelemetry(deviceId, "{\"temperature\":50}");
+
+ Thread.sleep(1000);
+ assertThat(getLatestAlarmResult(calculatedField.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()
+ .filter(event -> event.getResult() != null)
+ .findFirst().orElse(null);
+ assertThat(debugEvent).isNotNull();
+ TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class);
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_noDataPredicate() 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 majorNoDataDuration = 3L;
+ long criticalNoDataDuration = 10L;
+
+ SimpleAlarmConditionExpression majorExpression = new SimpleAlarmConditionExpression();
+ AlarmConditionFilter majorFilter = new AlarmConditionFilter();
+ majorFilter.setArgument("temperature");
+ majorFilter.setValueType(EntityKeyValueType.NUMERIC);
+ majorFilter.setPredicates(List.of(
+ new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)),
+ new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(majorNoDataDuration, null))
+ ));
+ majorExpression.setFilters(List.of(majorFilter));
+
+ SimpleAlarmConditionExpression criticalExpression = new SimpleAlarmConditionExpression();
+ AlarmConditionFilter criticalFilter = new AlarmConditionFilter();
+ criticalFilter.setArgument("temperature");
+ criticalFilter.setValueType(EntityKeyValueType.NUMERIC);
+ criticalFilter.setPredicates(List.of(
+ new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)),
+ new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(criticalNoDataDuration, null))
+ ));
+ criticalExpression.setFilters(List.of(criticalFilter));
+
+ Map createRules = Map.of(
+ AlarmSeverity.MAJOR, new Condition(majorExpression, null, null),
+ AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isSeverityUpdated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testChangeAlarmType() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, 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 -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testChangeRuleExpression() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ Thread.sleep(1000);
+ assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+
+ AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration();
+ ((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression())
+ .setExpression("return temperature >= 50;");
+ calculatedField = saveCalculatedField(calculatedField);
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testChangeRequiredEventsCountForRepeatingCondition() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ int eventsCountMajor = 5;
+ int eventsCountCritical = 10;
+ Map createRules = Map.of(
+ AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null),
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+ for (int i = 0; i < eventsCountMajor; i++) {
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ Thread.sleep(10);
+ }
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionRepeats()).isEqualTo(5);
+ });
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, 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();
+ ((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition())
+ .setCount(new AlarmConditionValue<>(6, null));
+ calculatedField = saveCalculatedField(calculatedField);
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isSeverityUpdated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ assertThat(alarmResult.getConditionRepeats()).isEqualTo(6);
+ });
+ }
+
+ @Test
+ public void testChangeConditionArgumentSource() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+
+ Argument temperatureThresholdArgument = new Argument();
+ temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
+ temperatureThresholdArgument.setDefaultValue("100");
+ loginSysAdmin();
+ postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":100}");
+ loginTenantAdmin();
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}");
+
+ Map arguments = Map.of(
+ "temperature", temperatureArgument,
+ "temperatureThreshold", temperatureThresholdArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(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();
+
+ ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold")
+ .setRefDynamicSourceConfiguration(null);
+ // using threshold 50 on device level
+ calculatedField = saveCalculatedField(calculatedField);
+
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testAlarmDetails() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Argument humidityArgument = new Argument();
+ humidityArgument.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ humidityArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument,
+ "humidity", humidityArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null)
+ );
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm",
+ arguments, createRules, null, configuration -> {
+ configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails(
+ "temperature is ${temperature}, humidity is ${humidity}"
+ );
+ });
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}");
+
+ checkAlarmResult(calculatedField, 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(
+ "UPDATED temperature is ${temperature}, humidity is ${humidity}"
+ );
+ calculatedField = saveCalculatedField(calculatedField);
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isFalse();
+ assertThat(alarmResult.isUpdated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getDetails().get("data").asText())
+ .isEqualTo("UPDATED temperature is 50, humidity is 50");
+ });
+ }
+
+ @Test
+ public void testCreateAlarm_scheduleStarted() throws Exception {
+ Argument parkingSpotOccupiedArgument = new Argument();
+ parkingSpotOccupiedArgument.setRefEntityKey(new ReferencedEntityKey("parkingSpotOccupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ parkingSpotOccupiedArgument.setDefaultValue("false");
+ Map arguments = Map.of(
+ "parkingSpotOccupied", parkingSpotOccupiedArgument
+ );
+
+ SpecificTimeSchedule schedule = new SpecificTimeSchedule();
+ schedule.setTimezone(ZoneId.systemDefault().getId());
+ schedule.setDaysOfWeek(Set.of(1, 2, 3, 4, 5, 6, 7));
+ long startsOn = Duration.between(LocalDate.now().atStartOfDay(), LocalDateTime.now())
+ .plus(15, ChronoUnit.SECONDS).toMillis();
+ schedule.setStartsOn(startsOn);
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return parkingSpotOccupied == true;", null, null, null,
+ new AlarmConditionValue<>(schedule, null))
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm",
+ arguments, createRules, null);
+
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}");
+
+ Thread.sleep(10000);
+ assertThat(getLatestAlarmResult(calculatedField.getId())).isNull();
+
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
+ CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream()
+ .filter(event -> event.getResult() != null)
+ .findFirst().orElse(null);
+ assertThat(debugEvent).isNotNull();
+ TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class);
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ @Test
+ public void testManualClearAlarm() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
+ );
+
+ CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ }).getAlarm();
+
+ doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class);
+ Thread.sleep(1000);
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ checkAlarmResult(calculatedField, alarmResult -> {
+ assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId());
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
+ // TODO: MSA tests
+
+ private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) {
+ return checkAlarmResult(calculatedField, null, assertion);
+ }
+
+ private TbAlarmResult checkAlarmResult(CalculatedField calculatedField,
+ Predicate waitFor,
+ Consumer assertion) {
+ TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> getLatestAlarmResult(calculatedField.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());
+ return alarmResult;
+ }
+
+ private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) {
+ List debugEvents = getDebugEvents(calculatedFieldId, 1);
+ if (debugEvents.isEmpty()) {
+ return null;
+ }
+ CalculatedFieldDebugEvent debugEvent = debugEvents.get(0);
+ if (debugEvent.getError() != null) {
+ throw new RuntimeException(debugEvent.getError());
+ }
+ if (debugEvent.getId().equals(latestEventId)) {
+ return null;
+ }
+ latestEventId = debugEvent.getId();
+ return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class);
+ }
+
+ private CalculatedField createAlarmCf(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);
+ AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration();
+ configuration.setArguments(arguments);
+ configuration.setCreateRules(createRules);
+ configuration.setClearRule(clearRule);
+ calculatedField.setConfiguration(configuration);
+ calculatedField.setDebugSettings(DebugSettings.all());
+ if (modifier.length > 0) {
+ modifier[0].accept(configuration);
+ }
+ CalculatedField savedCalculatedField = saveCalculatedField(calculatedField);
+
+ CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> getDebugEvents(savedCalculatedField.getId(), 1),
+ events -> !events.isEmpty()).get(0);
+ latestEventId = debugEvent.getId();
+ return savedCalculatedField;
+ }
+
+ private AlarmRule toAlarmRule(Condition condition) {
+ AlarmRule rule = new AlarmRule();
+ AlarmConditionExpression expression;
+ if (condition.getTbelExpression() != null) {
+ TbelAlarmConditionExpression tbelExpression = new TbelAlarmConditionExpression();
+ tbelExpression.setExpression(condition.getTbelExpression());
+ expression = tbelExpression;
+ } else {
+ expression = condition.getSimpleExpression();
+ }
+ if (condition.getEventsCount() != null) {
+ RepeatingAlarmCondition alarmCondition = new RepeatingAlarmCondition();
+ alarmCondition.setExpression(expression);
+ alarmCondition.setCount(condition.getEventsCount());
+ rule.setCondition(alarmCondition);
+ } else if (condition.getDuration() != null) {
+ DurationAlarmCondition alarmCondition = new DurationAlarmCondition();
+ alarmCondition.setExpression(expression);
+ alarmCondition.setUnit(TimeUnit.MILLISECONDS);
+ alarmCondition.setValue(condition.getDuration());
+ rule.setCondition(alarmCondition);
+ } else {
+ SimpleAlarmCondition alarmCondition = new SimpleAlarmCondition();
+ alarmCondition.setExpression(expression);
+ rule.setCondition(alarmCondition);
+ }
+ if (condition.getSchedule() != null) {
+ rule.getCondition().setSchedule(condition.getSchedule());
+ }
+ return rule;
+ }
+
+ private SimpleAlarmConditionExpression createSimpleExpression(String argument, StringOperation stringOperation, AlarmConditionValue conditionValue) {
+ SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression();
+ AlarmConditionFilter filter = new AlarmConditionFilter();
+ filter.setArgument(argument);
+ filter.setValueType(EntityKeyValueType.STRING);
+ StringFilterPredicate predicate = new StringFilterPredicate();
+ predicate.setOperation(stringOperation);
+ predicate.setValue(conditionValue);
+ filter.setPredicates(List.of(predicate));
+ simpleExpression.setFilters(List.of(filter));
+ return simpleExpression;
+ }
+
+ private List getDebugEvents(CalculatedFieldId calculatedFieldId, int limit) {
+ return eventDao.findLatestEvents(tenantId.getId(), calculatedFieldId.getId(), EventType.DEBUG_CALCULATED_FIELD, limit).stream()
+ .map(e -> (CalculatedFieldDebugEvent) e).toList();
+ }
+
+ @Getter
+ @AllArgsConstructor
+ private static final class Condition {
+
+ private final String tbelExpression;
+ private final SimpleAlarmConditionExpression simpleExpression;
+ private AlarmConditionValue eventsCount;
+ private AlarmConditionValue duration;
+ private AlarmConditionValue schedule;
+
+ private Condition(String tbelExpression, Integer eventsCount, Long durationMs) {
+ this.tbelExpression = tbelExpression;
+ this.simpleExpression = null;
+ if (eventsCount != null) {
+ this.eventsCount = new AlarmConditionValue<>(eventsCount, null);
+ }
+ if (durationMs != null) {
+ this.duration = new AlarmConditionValue<>(durationMs, null);
+ }
+ }
+
+ private Condition(SimpleAlarmConditionExpression simpleExpression, Integer eventsCount, Long durationMs) {
+ this.tbelExpression = null;
+ this.simpleExpression = simpleExpression;
+ if (eventsCount != null) {
+ this.eventsCount = new AlarmConditionValue<>(eventsCount, null);
+ }
+ if (durationMs != null) {
+ this.duration = new AlarmConditionValue<>(durationMs, null);
+ }
+ }
+
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java
new file mode 100644
index 0000000000..52180a888d
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java
@@ -0,0 +1,198 @@
+/**
+ * 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.cf;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.Test;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+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.CurrentOwnerDynamicSourceConfiguration;
+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.AssetProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DaoSqlTest
+public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest {
+
+ public static final int TIMEOUT = 60;
+ public static final int POLL_INTERVAL = 1;
+
+ @Test
+ public void testCreateCFWithCurrentOwner() throws Exception {
+ loginTenantAdmin();
+
+ postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
+
+ Device testDevice = createDevice("Test device", "1234567890");
+
+ doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk());
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class);
+
+ await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
+ assertThat(fahrenheitTemp).isNotNull();
+ assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105");
+ });
+
+ postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":10}");
+
+ await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
+ assertThat(fahrenheitTemp).isNotNull();
+ assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("110");
+ });
+ }
+
+ @Test
+ public void testChangeOwner() throws Exception {
+ loginSysAdmin();
+
+ postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}");
+
+ loginTenantAdmin();
+
+ postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
+ Device testDevice = createDevice("Test device", "1234567890");
+ doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk());
+
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class);
+
+ await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
+ assertThat(fahrenheitTemp).isNotNull();
+ assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105");
+ });
+
+ doDelete("/api/customer/device/" + testDevice.getId().getId()).andExpect(status().isOk());
+
+ await().alias("change owner -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
+ assertThat(fahrenheitTemp).isNotNull();
+ assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("150");
+ });
+ }
+
+ @Test
+ public void testCreateCFWithCurrentOwnerWhenEntityIsProfile() throws Exception {
+ loginSysAdmin();
+
+ postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}");
+
+ loginTenantAdmin();
+
+ postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
+
+ AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class);
+
+ Asset asset1 = createAsset("Test asset 1", assetProfile.getId());
+ doPost("/api/customer/" + customerId.getId() + "/asset/" + asset1.getId().getId()).andExpect(status().isOk());
+
+ Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); // owner - TENANT
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(assetProfile.getId()), CalculatedField.class);
+
+ await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ // result of asset 1
+ ObjectNode result1 = getLatestTelemetry(asset1.getId(), "result");
+ assertThat(result1).isNotNull();
+ assertThat(result1.get("result").get(0).get("value").asText()).isEqualTo("105");
+
+ // result of asset 2
+ ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result");
+ assertThat(result2).isNotNull();
+ assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("150");
+ });
+
+ doPost("/api/customer/" + customerId.getId() + "/asset/" + asset2.getId().getId()).andExpect(status().isOk());
+
+ await().alias("change asset2 owner -> recalculate state for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ // result of asset 2
+ ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result");
+ assertThat(result2).isNotNull();
+ assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("105");
+ });
+ }
+
+ private CalculatedField buildCalculatedField(EntityId entityId) {
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setEntityId(entityId);
+ calculatedField.setType(CalculatedFieldType.SIMPLE);
+ calculatedField.setName("Test Calculated Field");
+
+ SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
+
+ Argument argument = new Argument();
+ ReferencedEntityKey refEntityKey = new ReferencedEntityKey("attrKey", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE);
+ argument.setRefEntityKey(refEntityKey);
+ argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
+
+ config.setArguments(Map.of("a", argument));
+
+ config.setExpression("a + 100");
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setName("result");
+ output.setDecimalsByDefault(0);
+
+ config.setOutput(output);
+
+ calculatedField.setConfiguration(config);
+ return calculatedField;
+ }
+
+ private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
+ return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
+ }
+
+ private Asset createAsset(String name, AssetProfileId assetProfileId) {
+ Asset asset = new Asset();
+ asset.setName(name);
+ asset.setAssetProfileId(assetProfileId);
+ return doPost("/api/asset", asset, Asset.class);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
index c83e8c5ee3..129d81b400 100644
--- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
+++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -15,37 +15,54 @@
*/
package org.thingsboard.server.cf;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
-import org.junit.jupiter.api.BeforeEach;
-import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
-import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.EntityInfo;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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.Output;
-import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.cf.configuration.AttributesOutput;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.controller.CalculatedFieldControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest {
@@ -53,16 +70,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
public static final int TIMEOUT = 60;
public static final int POLL_INTERVAL = 1;
- @BeforeEach
- void setUp() throws Exception {
- loginTenantAdmin();
- }
-
@Test
public void testSimpleCalculatedFieldWhenAllTelemetryPresent() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}"));
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":25}");
+ postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"deviceTemperature\":40}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -80,13 +92,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/5) + 32");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("fahrenheitTemp");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
calculatedField.setConfiguration(config);
- calculatedField.setVersion(1L);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@@ -98,7 +108,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":30}");
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -108,10 +118,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0");
});
- Output savedOutput = savedCalculatedField.getConfiguration().getOutput();
- savedOutput.setType(OutputType.ATTRIBUTES);
- savedOutput.setScope(AttributeScope.SERVER_SCOPE);
- savedOutput.setName("temperatureF");
+ AttributesOutput newOutput = new AttributesOutput();
+ newOutput.setScope(AttributeScope.SERVER_SCOPE);
+ newOutput.setName("temperatureF");
+ config.setOutput(newOutput);
+ savedCalculatedField.setConfiguration(config);
+
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS)
@@ -119,10 +131,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
.untilAsserted(() -> {
ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF");
assertThat(temperatureF).isNotNull();
+ assertThat(temperatureF.get(0)).isNotNull();
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0");
});
- Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T");
+ Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
@@ -134,7 +147,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0");
});
- savedCalculatedField.getConfiguration().setExpression("1.8 * T + 32");
+ ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("1.8 * T + 32");
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS)
@@ -165,9 +178,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/5) + 32");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("fahrenheitTemp");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
calculatedField.setConfiguration(config);
@@ -183,7 +195,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue();
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":30}");
await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -214,9 +226,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/5) + 32");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("fahrenheitTemp");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
calculatedField.setConfiguration(config);
@@ -232,7 +243,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":30}");
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -246,15 +257,15 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
@Test
public void testSimpleCalculatedFieldWhenEntityIdIsProfile() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":40}"));
+ postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":40}");
AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class);
Asset asset1 = createAsset("Test asset 1", assetProfile.getId());
- doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":11}"));
+ postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":11}");
Asset asset2 = createAsset("Test asset 2", assetProfile.getId());
- doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":12}"));
+ postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":12}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(assetProfile.getId());
@@ -278,9 +289,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setExpression("x + y");
- Output output = new Output();
+ AttributesOutput output = new AttributesOutput();
output.setName("z");
- output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
config.setOutput(output);
@@ -304,7 +314,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("52.0");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":25}"));
+ postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":25}");
await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -320,7 +330,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0");
});
- doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":15}"));
+ postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":15}");
await().alias("update asset 1 telemetry -> recalculate state only for asset 1").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -336,7 +346,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0");
});
- doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":5}"));
+ postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":5}");
await().alias("update asset 2 telemetry -> recalculate state only for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -353,7 +363,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
Asset asset3 = createAsset("Test asset 3", assetProfile.getId());
- doPost("/api/plugins/telemetry/ASSET/" + asset3.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":13}"));
+ postAttributes(asset3.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":13}");
Asset finalAsset3 = asset3;
await().alias("add new entity to profile -> calculate state for new entity").atMost(TIMEOUT, TimeUnit.SECONDS)
@@ -365,7 +375,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z3.get(0).get("value").asText()).isEqualTo("38.0");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":20}"));
+ postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":20}");
await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -391,7 +401,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
asset3.setAssetProfileId(newAssetProfile.getId());
asset3 = doPost("/api/asset", asset3, Asset.class);
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":15}"));
+ postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":15}");
Asset updatedAsset3 = asset3;
await().alias("update device telemetry -> recalculate state for asset 1 and asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
@@ -417,7 +427,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
@Test
public void testSimpleCalculatedFieldWhenExpressionIsInvalid() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":25}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -435,9 +445,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/0) + 32");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("fahrenheitTemp");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
calculatedField.setConfiguration(config);
@@ -453,7 +462,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue();
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}"));
+ postTelemetry(testDevice.getId(), "{\"temperature\":30}");
await().alias("update telemetry -> ctx is not initialized -> no calculation perform").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -468,7 +477,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
public void testSimpleCalculatedFieldWhenUseLatestTsIsTrue() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
long ts = System.currentTimeMillis() - 300000L;
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts));
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -485,9 +494,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/5) + 32");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("fahrenheitTemp");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
config.setUseLatestTs(true);
@@ -512,10 +520,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
long ts = System.currentTimeMillis();
long tsA = ts - 300000L;
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA));
long tsB = ts - 300L;
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB));
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -535,9 +543,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("a", argument1, "b", argument2));
config.setExpression("a + b");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("c");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
config.setUseLatestTs(true);
@@ -556,7 +563,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
long tsABeforeTsB = tsB - 300L;
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB));
await().alias("update telemetry with ts less than latest -> save result with latest ts").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -573,7 +580,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
Device testDevice = createDevice("Test device", "1234567890");
long ts = System.currentTimeMillis() - 300000L;
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts));
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -590,9 +597,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("T", argument));
config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};");
- Output output = new Output();
- output.setType(OutputType.TIME_SERIES);
- config.setOutput(output);
+ config.setOutput(new TimeSeriesOutput());
calculatedField.setConfiguration(config);
@@ -636,9 +641,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("a", argument1, "b", argument2, "c", argument3));
config.setExpression("a + b + c");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("d");
- output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
config.setOutput(output);
@@ -656,7 +660,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(d.get("d").get(0).get("value").asText()).isEqualTo("600");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}"));
+ postTelemetry(testDevice.getId(), "{\"a\":10}");
await().alias("update telemetry -> save result with ts of 'a' argument").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -668,8 +672,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("510");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"b\":20}"));
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"c\":30}"));
+ postTelemetry(testDevice.getId(), "{\"b\":20}");
+ postTelemetry(testDevice.getId(), "{\"c\":30}");
await().alias("update telemetry -> save result with latest ts of updated arguments").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -711,9 +715,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("m", argument));
config.setExpression("m + 1");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("m1");
- output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
config.setOutput(output);
@@ -721,7 +724,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}"));
+ postTelemetry(testDevice.getId(), "{\"m\":1}");
await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -735,7 +738,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
calculatedField.setConfiguration(config);
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}"));
+ postTelemetry(testDevice.getId(), "{\"m\":2}");
await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -746,10 +749,483 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
+ @Test
+ public void testGeofencingCalculatedField_withZonesCreatedOnDevice() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("GF Test Device", "sn-geo-2");
+
+ // Allowed zone polygon (square)
+ String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+ // Restricted zone polygon (square)
+ String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
+
+ postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"allowedZone\":" + allowedPolygon + "}");
+ postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}");
+
+ // Initial device coordinates (inside Allowed, outside Restricted)
+ postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
+
+ // --- Build CF: GEOFENCING ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getDeviceProfileId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+
+ // Coordinates: TS_LATEST on the device
+ EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ cfg.setEntityCoordinates(entityCoordinates);
+
+ // Zone groups: ATTRIBUTE on the device
+ ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
+
+ // Output to server attributes
+ AttributesOutput out = new AttributesOutput();
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert initial evaluation ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent");
+ // --- no transition events as no transitions happened yet
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+
+ // --- delete attributes reported in previous evaluation
+ doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesStatus,restrictedZonesStatus", String.class);
+
+ // --- Update restrictedZone by 'restrictedZone' attribute update
+ postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}");
+
+ // --- Assert no transition ---
+ // --- Assert attributes updated with the same values for restrictedZones ---
+ // --- Assert attributes updated with the new values for allowedZones ---
+ await().alias("evaluation after version bump of geo argument")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus",
+ "restrictedZonesEvent", "restrictedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+ }
+
+ @Test
+ public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("GF Device", "sn-geo-1");
+
+ // Allowed zone polygon (square)
+ String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+ // Restricted zone polygon (square)
+ String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
+
+ Asset allowedZoneAsset = createAsset("Allowed Zone", null);
+ postAttributes(allowedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygon + "}");
+
+ Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
+ postAttributes(restrictedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + restrictedPolygon + "}");
+
+ // Relations from device to zones
+ createEntityRelation(device.getId(), allowedZoneAsset.getId(), "AllowedZone");
+ createEntityRelation(device.getId(), restrictedZoneAsset.getId(), "RestrictedZone");
+
+ // Initial device coordinates (inside Allowed, outside Restricted)
+ postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
+
+ // --- Build CF: GEOFENCING ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+
+ // Coordinates: TS_LATEST on the device
+ EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ cfg.setEntityCoordinates(entityCoordinates);
+
+ // Zone groups: ATTRIBUTE on specific assets (one zone per group)
+ ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
+ allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
+
+ ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone")));
+ restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
+
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
+
+ // Output to server attributes
+ AttributesOutput out = new AttributesOutput();
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert initial evaluation ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+
+ // --- Move the device into Restricted zone (and outside Allowed) ---
+ postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
+
+ // --- Assert transition (LEFT / ENTERED) ---
+ await().alias("transition evaluation after movement")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus",
+ "restrictedZonesEvent", "restrictedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(4);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
+ .containsEntry("restrictedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "OUTSIDE")
+ .containsEntry("restrictedZonesStatus", "INSIDE");
+ });
+ }
+
+ @Test
+ public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception {
+ // --- Update min allowed scheduled update intervals for CFs ---
+ loginSysAdmin();
+ EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class);
+ assertThat(tenantProfileEntityInfo).isNotNull();
+ TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class);
+ assertThat(foundTenantProfile).isNotNull();
+ assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull();
+ int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10;
+ foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF);
+ TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class);
+ assertThat(savedTenantProfile).isNotNull();
+ assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF);
+ loginTenantAdmin();
+
+ // --- Arrange entities ---
+ Device device = createDevice("GF Device dyn", "sn-geo-dyn-1");
+
+ // Allowed Zone A: covers initial point (ENTERED)
+ String allowedPolygonA = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+
+ Asset allowedZoneA = createAsset("Allowed Zone A", null);
+ postAttributes(allowedZoneA.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonA + "}");
+
+ // Relation from device to Allowed Zone A
+ createEntityRelation(device.getId(), allowedZoneA.getId(), "AllowedZone");
+
+ // Initial device coordinates: INSIDE Zone A
+ postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
+
+ // --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF (dynamic refresh)");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
+
+ var allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
+ allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup));
+
+ // Server attributes output
+ AttributesOutput out = new AttributesOutput();
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ // Enable scheduled refresh with a 6-second interval
+ cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF);
+ cfg.setScheduledUpdateEnabled(true);
+
+ cf.setConfiguration(cfg);
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
+ assertThat(savedCalculatedField).isNotNull();
+ CalculatedFieldConfiguration configuration = savedCalculatedField.getConfiguration();
+ assertThat(configuration).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
+ var geofencingConfiguration = (GeofencingCalculatedFieldConfiguration) configuration;
+ assertThat(geofencingConfiguration.isScheduledUpdateEnabled()).isTrue();
+
+ // --- Assert initial evaluation ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(1);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesStatus", "INSIDE");
+ });
+
+ // --- Move device OUTSIDE Zone A (expect LEFT) ---
+ postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
+
+ await().alias("outside zone A (LEFT)")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
+ .containsEntry("allowedZonesStatus", "OUTSIDE");
+ });
+
+ // --- Create Allowed Zone B covering the CURRENT location ---
+ String allowedPolygonB = "[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]";
+
+ Asset allowedZoneB = createAsset("Allowed Zone B", null);
+ postAttributes(allowedZoneB.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonB + "}");
+
+ // Add a new relation
+ createEntityRelation(device.getId(), allowedZoneB.getId(), "AllowedZone");
+
+ awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF);
+
+ // --- Same coordinates as before, but now we expect INSIDE group status since a new zone is registered ---
+ postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
+
+ // --- Assert dynamic refresh picks up a new relation and flips status back to INSIDE on the next telemetry update ---
+ await().alias("dynamic refresh rebinds allowedZones")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(1, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "LEFT") // attribute from previous eval with outdated ts.
+ .containsEntry("allowedZonesStatus", "INSIDE");
+ });
+ }
+
+ @Test
+ public void testPropagationCalculatedField_withExpression() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("Propagation Device With Expression", "sn-prop-1");
+ Asset asset1 = createAsset("Propagated Asset 1", null);
+ Asset asset2 = createAsset("Propagated Asset 2", null);
+
+ // Create relations FROM assets TO device
+ createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
+ createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
+
+ // Telemetry on device
+ postTelemetry(device.getId(), "{\"temperature\":12.5, \"humidity\":85}");
+
+ // --- Build CF: PROPAGATION with expression ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.PROPAGATION);
+ cf.setName("Propagation CF (expr)");
+ cf.setConfigurationVersion(1);
+
+ PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
+ cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE));
+ cfg.setApplyExpressionToResolvedArguments(true);
+
+ Argument arg1 = new Argument();
+ arg1.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+
+ Argument arg2 = new Argument();
+ arg2.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null));
+
+ cfg.setArguments(Map.of("t", arg1, "h", arg2));
+ cfg.setExpression("return { testResult: (t + h) / 2};");
+
+ AttributesOutput output = new AttributesOutput();
+ output.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(output);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert propagated calculation (expression applied) ---
+ await().alias("propagation expr mode evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
+ ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
+ assertThat(attrs1).isNotNull();
+ assertThat(attrs2).isNotNull();
+ assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(48.75);
+ assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(48.75);
+ });
+
+ String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
+ asset1.getId().getId(), EntityType.ASSET,
+ EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
+ );
+ doDelete(deleteUrl).andExpect(status().isOk());
+ doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk());
+
+ postTelemetry(device.getId(), "{\"temperature\":25}");
+
+ // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) ---
+ await().alias("propagation expr mode evaluation after temperature update")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
+ ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
+ assertThat(attrs1).isNullOrEmpty();
+ assertThat(attrs2).isNotNull();
+ assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(55);
+ });
+ }
+
+ @Test
+ public void testPropagationCalculatedField_withoutExpression() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("Propagation Device Without Expression", "sn-prop-2");
+ Asset asset1 = createAsset("Propagated Asset 1", null);
+ Asset asset2 = createAsset("Propagated Asset 2", null);
+
+ // Create relations FROM assets TO device
+ createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
+ createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
+
+ // Telemetry on device
+ long ts = System.currentTimeMillis() - 300000L;
+ postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5, \"humidity\":85}}", ts));
+
+ // --- Build CF: PROPAGATION without expression ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.PROPAGATION);
+ cf.setName("Propagation CF (args-only)");
+ cf.setConfigurationVersion(1);
+
+ PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
+ cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE));
+ cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode
+
+ Argument arg1 = new Argument();
+ arg1.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ Argument arg2 = new Argument();
+ arg2.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null));
+
+ cfg.setArguments(Map.of("temperatureComputed", arg1, "humidityComputed", arg2));
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setStrategy(new TimeSeriesImmediateOutputStrategy(0, true, true, true, true));
+ cfg.setOutput(output);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert propagated calculation (arguments-only mode) ---
+ await().alias("propagation args-only evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed,humidityComputed");
+ ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed,humidityComputed");
+ assertThat(telemetry1).isNotNull();
+ assertThat(telemetry2).isNotNull();
+ assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
+ assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
+ assertThat(telemetry1.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
+ assertThat(telemetry1.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85);
+ assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
+ assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
+ assertThat(telemetry2.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
+ assertThat(telemetry2.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85);
+ });
+
+ String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
+ asset1.getId().getId(), EntityType.ASSET,
+ EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
+ );
+ doDelete(deleteUrl).andExpect(status().isOk());
+ doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed,humidityComputed&deleteAllDataForKeys=true").andExpect(status().isOk());
+
+ // Update telemetry on the device
+ long newTs = ts + 300000L;
+ postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs));
+
+ // --- Assert propagated calculation (arguments-only mode after update) ---
+ await().alias("propagation args-only evaluation after temperature update")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed,humidityComputed");
+ ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed,humidityComputed");
+ assertThat(telemetry1).isNotNull();
+ assertThat(telemetry2).isNotNull();
+ assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance);
+ assertThat(telemetry1.get("humidityComputed").get(0).get("value")).isEqualTo(NullNode.instance);
+
+ assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
+ assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25);
+ // TS for humidity is not updated -> expected
+ assertThat(telemetry2.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
+ assertThat(telemetry2.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85);
+ });
+
+ Asset asset3 = createAsset("Propagated Asset 3", null);
+ createEntityRelation(asset3.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
+
+ // --- Assert propagated calculation (arguments-only mode after update) ---
+ await().alias("propagation args-only to new entity after relation creation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode telemetry = getLatestTelemetry(asset3.getId(), "temperatureComputed,humidityComputed");
+ assertThat(telemetry).isNotNull();
+ assertThat(telemetry.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
+ assertThat(telemetry.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25);
+ assertThat(telemetry.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
+ assertThat(telemetry.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85);
+ });
+
+ }
+
@Test
public void testCalculatedFieldWhenTheSameTelemetryKeysUsed() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":5}"));
+ postTelemetry(testDevice.getId(), "{\"a\":5}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@@ -767,9 +1243,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
config.setArguments(Map.of("a", argumentA, "b", argumentB));
config.setExpression("a + b");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("c");
- output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
config.setOutput(output);
@@ -785,7 +1260,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("10");
});
- doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}"));
+ postTelemetry(testDevice.getId(), "{\"a\":10}");
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@@ -796,6 +1271,125 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
+ @Test
+ public void testCalculatedFieldWhenBatchOfTelemetrySent() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ long now = System.currentTimeMillis();
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":5, \"b\":10}}", now - TimeUnit.MINUTES.toMillis(3)));
+ postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":20}}", now - TimeUnit.MINUTES.toMillis(1)));
+
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setEntityId(testDevice.getId());
+ calculatedField.setType(CalculatedFieldType.SCRIPT);
+ calculatedField.setName("Script CF");
+ calculatedField.setDebugSettings(DebugSettings.all());
+
+ ScriptCalculatedFieldConfiguration config = new ScriptCalculatedFieldConfiguration();
+
+ ReferencedEntityKey refEntityKeyA = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null);
+ Argument argumentA = new Argument();
+ argumentA.setRefEntityKey(refEntityKeyA);
+ Argument argumentB = new Argument();
+ ReferencedEntityKey refEntityKeyB = new ReferencedEntityKey("b", ArgumentType.TS_ROLLING, null);
+ argumentB.setTimeWindow(TimeUnit.MINUTES.toMillis(10));
+ argumentB.setLimit(1000);
+ argumentB.setRefEntityKey(refEntityKeyB);
+ config.setArguments(Map.of("a", argumentA, "b", argumentB));
+ config.setExpression("""
+ return {
+ "latestA": a,
+ "avgB": b.avg
+ };
+ """);
+
+ config.setOutput(new TimeSeriesOutput());
+
+ calculatedField.setConfiguration(config);
+
+ doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(testDevice.getId(), "latestA", "avgB");
+ assertThat(result).isNotNull();
+ assertThat(result.get("latestA").get(0).get("value").asText()).isEqualTo("5");
+ assertThat(result.get("avgB").get(0).get("value").asText()).isEqualTo("15.0");
+ });
+
+ postTelemetry(testDevice.getId(), String.format("""
+ [{
+ "ts": %s,
+ "values": {
+ "a": 6,
+ "b": 100
+ }
+ }, {
+ "ts": %s,
+ "values": {
+ "a": 7,
+ "b": 200
+ }
+ }, {
+ "ts": %s,
+ "values": {
+ "a": 8,
+ "b": 300
+ }
+ }]""", now - TimeUnit.MINUTES.toMillis(2), now, now - TimeUnit.MINUTES.toMillis(5)));
+
+ await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(testDevice.getId(), "latestA", "avgB");
+ assertThat(result).isNotNull();
+ assertThat(result.get("latestA").get(0).get("value").asText()).isEqualTo("7");
+ assertThat(result.get("avgB").get(0).get("value").asText()).isEqualTo("126.0");
+ });
+ }
+
+ @Test
+ public void testSimpleCalculatedFieldWhenSkipRuleEngineOutputProcessing() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+
+ postTelemetry(testDevice.getId(), "{\"temperature\":24.5}");
+
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setEntityId(testDevice.getId());
+ calculatedField.setType(CalculatedFieldType.SIMPLE);
+ calculatedField.setName("C to F");
+ calculatedField.setDebugSettings(DebugSettings.all());
+
+ SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
+
+ Argument argument = new Argument();
+ ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null);
+ argument.setRefEntityKey(refEntityKey);
+ config.setArguments(Map.of("T", argument));
+ config.setExpression("(T * 9/5) + 32");
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setName("fahrenheitTemp");
+ output.setDecimalsByDefault(1);
+ output.setStrategy(new TimeSeriesImmediateOutputStrategy(1000L, true, true, true, true));
+
+ config.setOutput(output);
+
+ config.setUseLatestTs(true);
+
+ calculatedField.setConfiguration(config);
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp");
+ assertThat(fahrenheitTemp).isNotNull();
+ assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("76.1");
+ });
+ }
+
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
}
@@ -811,4 +1405,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
return doPost("/api/asset", asset, Asset.class);
}
+ private static Map kv(ArrayNode attrs) {
+ Map m = new HashMap<>();
+ for (JsonNode n : attrs) {
+ m.put(n.get("key").asText(), n.get("value").asText());
+ }
+ return m;
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
new file mode 100644
index 0000000000..0872e21dae
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
@@ -0,0 +1,372 @@
+/**
+ * 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.cf;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.test.annotation.DirtiesContext;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.Tenant;
+import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+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.Output;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
+import org.thingsboard.server.common.data.debug.DebugSettings;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.security.Authority;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL;
+
+@DaoSqlTest
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest {
+
+ private final String TZ = "Europe/Kyiv";
+
+ private Tenant savedTenant;
+
+ @Before
+ public void beforeEach() throws Exception {
+ loginSysAdmin();
+
+ updateDefaultTenantProfileConfig(tenantProfileConfig -> {
+ tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1);
+ tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1);
+ tenantProfileConfig.setCfReevaluationCheckInterval(1);
+ });
+
+ 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("tenant@thingsboard.org");
+ tenantAdmin.setFirstName("John");
+ tenantAdmin.setLastName("Doe");
+
+ createUserAndLogin(tenantAdmin, "testPassword");
+ }
+
+ @After
+ public void afterTest() throws Exception {
+ loginSysAdmin();
+
+ deleteTenant(savedTenant.getId());
+ }
+
+ @Test
+ public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception {
+ Device device = createDevice("Device", "1234567890111");
+
+ CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
+ createConsumptionCF(device.getId(), customInterval, null);
+
+ long interval = customInterval.getCurrentIntervalDurationMillis();
+
+ await().alias("create CF and no telemetry during interval -> save metric with default value")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999");
+ assertThat(result.get("avgConsumption").get(0).get("value").isNull()).isTrue();
+ });
+ }
+
+ @Test
+ public void testCreateCfWithoutWatermark_checkAggregation() throws Exception {
+ Device device = createDevice("Device", "1234567890111");
+
+ CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
+ createConsumptionCF(device.getId(), customInterval, null);
+
+ long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
+
+ long tsBeforeInterval = currentIntervalStartTs - 1000;
+ long tsInInterval_1 = currentIntervalStartTs + 1000;
+ long tsInInterval_2 = currentIntervalStartTs + 500;
+ long tsInInterval_3 = currentIntervalStartTs + 200;
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
+
+ long interval = customInterval.getCurrentIntervalDurationMillis();
+
+ await().alias("create CF -> perform aggregation after interval end")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133");
+ });
+
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1));
+
+ await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133");
+ });
+ }
+
+ @Test
+ public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception {
+ Device device = createDevice("Device", "1234567890111");
+
+ CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
+ Watermark watermark = new Watermark(10);
+ createConsumptionCF(device.getId(), customInterval, watermark);
+
+ long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
+
+ long tsBeforeInterval = currentIntervalStartTs - 1000L;
+ long tsInInterval_1 = currentIntervalStartTs + 1000L;
+ long tsInInterval_2 = currentIntervalStartTs + 500L;
+ long tsInInterval_3 = currentIntervalStartTs + 200L;
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
+
+ long interval = customInterval.getCurrentIntervalDurationMillis();
+
+ await().alias("create CF -> perform aggregation after interval end")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133");
+ });
+
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1));
+
+ await().alias("update telemetry during watermark -> perform aggregation")
+ .atMost(2 * 10, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600");
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("200");
+ });
+ }
+
+ @Test
+ public void testSendFutureTelemetry_checkAggregation() throws Exception {
+ Device device = createDevice("Device", "1234567890111");
+
+ CustomInterval customInterval = new CustomInterval(TZ, 0L, 2L);
+ createConsumptionCF(device.getId(), customInterval, null);
+
+ long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
+
+ long tsBeforeInterval = currentIntervalStartTs - 1000;
+ long tsInInterval_1 = currentIntervalStartTs + 1000;
+ long tsInInterval_2 = currentIntervalStartTs + 500;
+ long tsInInterval_3 = currentIntervalStartTs + 200;
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
+
+ long interval = customInterval.getCurrentIntervalDurationMillis();
+
+ await().alias("create CF -> perform aggregation after interval end")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133");
+ });
+
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L));
+
+ await().alias("update telemetry that belongs to future interval -> check aggregation ")
+ .atMost(3 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500");
+ assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L);
+ assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500");
+ assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L);
+ });
+ }
+
+ private CalculatedField createConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) {
+ Map arguments = new HashMap<>();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
+ arguments.put("en", argument);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric consumption = new AggMetric();
+ consumption.setFunction(AggFunction.SUM);
+ consumption.setInput(new AggKeyInput("en"));
+ consumption.setDefaultValue(9999.0);
+ aggMetrics.put("consumption", consumption);
+
+ AggMetric avgEnergyConsumption = new AggMetric();
+ avgEnergyConsumption.setFunction(AggFunction.AVG);
+ avgEnergyConsumption.setInput(new AggKeyInput("en"));
+ aggMetrics.put("avgConsumption", avgEnergyConsumption);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("Consumption", entityId,
+ aggInterval,
+ watermark,
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ @Test
+ public void testCreateCfWith2Arguments_checkAggregation() throws Exception {
+ Device device = createDevice("Device", "1234567890111");
+
+ CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
+ createCFWith2Args(device.getId(), customInterval, null);
+
+ long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
+
+ long tsBeforeInterval = currentIntervalStartTs - 1000;
+ long tsInInterval_1 = currentIntervalStartTs + 1000;
+ long tsInInterval_2 = currentIntervalStartTs + 500;
+ long tsInInterval_3 = currentIntervalStartTs + 200;
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120, \"temperature\":43}}", tsBeforeInterval));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100, \"temperature\":39}}", tsInInterval_1));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180, \"temperature\":27}}", tsInInterval_2));
+ postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120, \"temperature\":50}}", tsInInterval_3));
+
+ long interval = customInterval.getCurrentIntervalDurationMillis();
+
+ await().alias("create CF -> perform aggregation after interval end")
+ .atMost(2 * interval, TimeUnit.MILLISECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgTemperature");
+ assertThat(result).isNotNull();
+ assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
+ assertThat(result.get("avgTemperature").get(0).get("value").asText()).isEqualTo("39");
+ });
+ }
+
+ private CalculatedField createCFWith2Args(EntityId entityId, AggInterval aggInterval, Watermark watermark) {
+ Map arguments = new HashMap<>();
+ Argument energy = new Argument();
+ energy.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
+ arguments.put("en", energy);
+
+ Argument temperature = new Argument();
+ temperature.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ arguments.put("temp", temperature);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric consumption = new AggMetric();
+ consumption.setFunction(AggFunction.SUM);
+ consumption.setInput(new AggKeyInput("en"));
+ consumption.setDefaultValue(9999.0);
+ aggMetrics.put("consumption", consumption);
+
+ AggMetric avgTemperature = new AggMetric();
+ avgTemperature.setFunction(AggFunction.AVG);
+ avgTemperature.setInput(new AggKeyInput("temp"));
+ aggMetrics.put("avgTemperature", avgTemperature);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("CF with 2 args", entityId,
+ aggInterval,
+ watermark,
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ private CalculatedField createAggCf(String name,
+ EntityId entityId,
+ AggInterval aggInterval,
+ Watermark watermark,
+ Map inputs,
+ Map metrics,
+ Output output) {
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setName(name);
+ calculatedField.setEntityId(entityId);
+ calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION);
+
+ EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration();
+
+ configuration.setArguments(inputs);
+ configuration.setMetrics(metrics);
+ configuration.setInterval(aggInterval);
+ if (watermark != null) {
+ configuration.setWatermark(watermark);
+ }
+ configuration.setOutput(output);
+
+ calculatedField.setConfiguration(configuration);
+ calculatedField.setDebugSettings(DebugSettings.all());
+ return saveCalculatedField(calculatedField);
+ }
+
+ private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
+ return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java
new file mode 100644
index 0000000000..4c708bce56
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java
@@ -0,0 +1,978 @@
+/**
+ * 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.cf;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.test.annotation.DirtiesContext;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.Tenant;
+import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+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.AttributesOutput;
+import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.debug.DebugSettings;
+import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration;
+import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration;
+import org.thingsboard.server.common.data.device.data.DeviceData;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
+import org.thingsboard.server.common.data.relation.RelationTypeGroup;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.common.data.security.Authority;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL;
+
+@DaoSqlTest
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest {
+
+ private Tenant savedTenant;
+
+ private DeviceProfile deviceProfile;
+ private Device device1;
+ private String accessToken1 = "1234567890111";
+ private Device device2;
+ private String accessToken2 = "1234567890222";
+
+ private AssetProfile assetProfile;
+ private Asset asset;
+
+ private final long deduplicationInterval = 5;
+
+ @Before
+ public void beforeEach() throws Exception {
+ loginSysAdmin();
+
+ updateDefaultTenantProfileConfig(tenantProfileConfig -> {
+ tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1);
+ tenantProfileConfig.setMinAllowedScheduledUpdateIntervalInSecForCF(1);
+ });
+
+ 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("tenant@thingsboard.org");
+ tenantAdmin.setFirstName("John");
+ tenantAdmin.setLastName("Doe");
+
+ createUserAndLogin(tenantAdmin, "testPassword");
+
+ deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class);
+ device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1);
+ device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2);
+
+ postTelemetry(device1.getId(), "{\"occupied\":true}");
+ postTelemetry(device2.getId(), "{\"occupied\":false}");
+
+ assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class);
+ asset = createAsset("Asset", assetProfile.getId());
+
+ createEntityRelation(asset.getId(), device1.getId(), "Contains");
+ createEntityRelation(asset.getId(), device2.getId(), "Contains");
+ }
+
+ @After
+ public void afterTest() throws Exception {
+ loginSysAdmin();
+
+ deleteTenant(savedTenant.getId());
+ }
+
+ @Test
+ public void testCreateCfOnProfile_checkInitialAggregation() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ createOccupancyCF(assetProfile.getId());
+
+ await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+
+ postTelemetry(device3.getId(), "{\"occupied\":true}");
+
+ await().alias("update telemetry and perform aggregation")
+ .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testAddEntityToProfile_checkAggregation() throws Exception {
+ createOccupancyCF(assetProfile.getId());
+
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+ postTelemetry(device3.getId(), "{\"occupied\":true}");
+ postTelemetry(device4.getId(), "{\"occupied\":true}");
+
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+
+ await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces");
+ assertThat(occupancy).isNotNull();
+ assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue();
+ assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue();
+ assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue();
+ });
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ await().alias("create relations and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "2",
+ "totalSpaces", "2"
+ ));
+ });
+
+ postTelemetry(device3.getId(), "{\"occupied\":false}");
+
+ await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testChangeEntityProfile_checkAggregation() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ createOccupancyCF(assetProfile.getId());
+
+ await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+
+ AssetProfile newAssetProfile = createAssetProfile("New Asset Profile");
+ asset2.setAssetProfileId(newAssetProfile.getId());
+ doPost("/api/asset", asset2, Asset.class);
+
+ postTelemetry(device3.getId(), "{\"occupied\":true}");
+
+ await().alias("change profile and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ createOccupancyCF(asset2.getId());
+
+ await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception {
+ createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ postTelemetry(device1.getId(), "{\"occupied\":false}");
+
+ await().alias("update telemetry and perform aggregation")
+ .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testCreateCfAndRelationToRuleChain_checkAggregation() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ postTelemetry(device3.getId(), "{\"occupied\":true}");
+
+ RuleChain ruleChain = new RuleChain();
+ ruleChain.setName("RuleChain");
+ ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
+ postTelemetry(ruleChain.getId(), "{\"occupied\":true}");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), ruleChain.getId(), "Contains");
+
+ createOccupancyCF(asset2.getId());
+
+ await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "1",
+ "totalSpaces", "1"
+ ));
+ });
+
+ postTelemetry(ruleChain.getId(), "{\"occupied\":true}");
+
+ await().alias("update telemetry on rule chain and no aggregation performed").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "1",
+ "totalSpaces", "1"
+ ));
+ });
+ }
+
+ @Test
+ public void testDeleteCf_checkNoAggregation() throws Exception {
+ CalculatedField cf = createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ doDelete("/api/calculatedField/" + cf.getId().getId().toString())
+ .andExpect(status().isOk());
+
+ postTelemetry(device1.getId(), "{\"occupied\":false}");
+
+ await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception {
+ createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ postTelemetry(device1.getId(), "{\"occupied\":false}");
+
+ await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(this::checkInitialCalculationValues);
+
+ postTelemetry(device2.getId(), "{\"occupied\":false}");
+
+ await().alias("create CF and perform initial calculation")
+ .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ long currentTime = System.currentTimeMillis();
+ long firstTs = currentTime - 10;
+ long secondTs = currentTime - 10;
+ long thirdTs = currentTime - 5;
+ postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}");
+ postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}");
+ postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}");
+
+ createOccupancyCF(asset2.getId());
+
+ await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "2",
+ "totalSpaces", "2"
+ ));
+ });
+
+ doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class);
+ doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class);
+
+ await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testDeleteAttr_checkAggregationWithDefault() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}");
+ postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}");
+
+ createOccupancyCFWithAttr(asset2.getId());
+
+ await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "2",
+ "totalSpaces", "2"
+ ));
+ });
+
+ doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class);
+ doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class);
+
+ await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testCreateRelation_checkAggregation() throws Exception {
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ Device device4 = createDevice("Device 4", "1234567890444");
+
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ createEntityRelation(asset2.getId(), device4.getId(), "Contains");
+
+ createOccupancyCF(assetProfile.getId());
+
+ await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+
+ Device device5 = createDevice("Device 5", "1234567890555");
+ createEntityRelation(asset2.getId(), device5.getId(), "Contains");
+
+ await().alias("create relation and perform aggregation on asset 2")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "1",
+ "totalSpaces", "2"
+ ));
+
+ verifyTelemetry(asset2.getId(), Map.of(
+ "freeSpaces", "3",
+ "occupiedSpaces", "0",
+ "totalSpaces", "3"
+ ));
+ });
+ }
+
+ @Test
+ public void testDeleteRelation_checkAggregation() throws Exception {
+ createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON));
+
+ await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "0",
+ "totalSpaces", "1"
+ ));
+ });
+ }
+
+ @Test
+ public void testDeleteEntityByRelation_checkAggregation() throws Exception {
+ createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ doDelete("/api/device/" + device1.getId()).andExpect(status().isOk());
+
+ await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "1",
+ "occupiedSpaces", "0",
+ "totalSpaces", "1"
+ ));
+ });
+ }
+
+ @Test
+ public void testUpdateRelationPath_checkAggregation() throws Exception {
+ CalculatedField cf = createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ Device device3 = createDevice("Device 3", "1234567890333");
+ createEntityRelation(asset.getId(), device3.getId(), "Has");
+ postTelemetry(device3.getId(), "{\"occupied\":true}");
+
+ var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has"));
+ saveCalculatedField(cf);
+
+ await().alias("update relation path and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "0",
+ "occupiedSpaces", "1",
+ "totalSpaces", "1"
+ ));
+ });
+ }
+
+ @Test
+ public void testUpdateArguments_checkAggregation() throws Exception {
+ CalculatedField cf = createOccupancyCF(asset.getId());
+ checkInitialCalculation();
+
+ postTelemetry(device1.getId(), "{\"occupiedStatus\":false}");
+ postTelemetry(device2.getId(), "{\"occupiedStatus\":false}");
+
+ var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("false");
+ configuration.setArguments(Map.of("oc", argument));
+ saveCalculatedField(cf);
+
+ await().alias("update arguments and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of(
+ "freeSpaces", "2",
+ "occupiedSpaces", "0",
+ "totalSpaces", "2"
+ ));
+ });
+ }
+
+ @Test
+ public void testUpdateMetrics_checkAggregation() throws Exception {
+ postTelemetry(device1.getId(), "{\"temperature\":24.2}");
+ postTelemetry(device2.getId(), "{\"temperature\":19.6}");
+ CalculatedField cf = createAvgTemperatureCF(asset.getId());
+
+ await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
+ });
+
+ var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ AggMetric aggMetric = new AggMetric();
+ aggMetric.setInput(new AggKeyInput("temp"));
+ aggMetric.setFilter("return temp < 100;");
+ aggMetric.setFunction(AggFunction.MAX);
+ configuration.setMetrics(Map.of("maxTemperature", aggMetric));
+ saveCalculatedField(cf);
+
+ await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24"));
+ });
+
+ postTelemetry(device1.getId(), "{\"temperature\":101.3}");
+ postTelemetry(device2.getId(), "{\"temperature\":25.8}");
+
+ await().alias("update telemetry and perform aggregation")
+ .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26"));
+ });
+ }
+
+ @Test
+ public void testUpdateOutput_checkAggregation() throws Exception {
+ postTelemetry(device1.getId(), "{\"temperature\":24.2}");
+ postTelemetry(device2.getId(), "{\"temperature\":19.6}");
+ CalculatedField cf = createAvgTemperatureCF(asset.getId());
+
+ await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
+ });
+
+ var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ AttributesOutput output = new AttributesOutput();
+ output.setScope(AttributeScope.SERVER_SCOPE);
+ configuration.setOutput(output);
+ saveCalculatedField(cf);
+
+ await().alias("update output and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature");
+ assertThat(avgTemperature).isNotNull();
+ assertThat(avgTemperature.get(0)).isNotNull();
+ assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2");
+ });
+ }
+
+ @Test
+ public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception {
+ postTelemetry(device1.getId(), "{\"temperature\":24.2}");
+ postTelemetry(device2.getId(), "{\"temperature\":19.6}");
+ CalculatedField cf = createAvgTemperatureCF(asset.getId());
+
+ await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
+ });
+
+ var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval);
+ saveCalculatedField(cf);
+
+ await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
+ });
+
+ postTelemetry(device2.getId(), "{\"temperature\":32.1}");
+
+ await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval + 10, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28"));
+ });
+ }
+
+
+ @Test
+ public void testTheSameRelationTypeAndKeyButDifferentCfs_checkAggregation() throws Exception {
+ postTelemetry(device1.getId(), "{\"temperature\":24.2}");
+ postTelemetry(device2.getId(), "{\"temperature\":19.6}");
+ CalculatedField cf = createAvgTemperatureCF(asset.getId());
+
+ Asset asset2 = createAsset("Asset 2", assetProfile.getId());
+ Device device3 = createDevice("Device 3", "1234567890333");
+ createEntityRelation(asset2.getId(), device3.getId(), "Contains");
+ postTelemetry(device3.getId(), "{\"temperature\":10.2}");
+
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setName("Average temperature");
+ calculatedField.setEntityId(asset2.getId());
+ calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION);
+
+ var config = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
+ AggMetric avgMetric = new AggMetric();
+ avgMetric.setFunction(AggFunction.AVG);
+ avgMetric.setInput(new AggKeyInput("temp"));
+ config.setMetrics(Map.of("avgTemperature_2", avgMetric));
+
+ calculatedField.setConfiguration(config);
+ calculatedField.setDebugSettings(DebugSettings.all());
+ saveCalculatedField(calculatedField);
+
+ await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode avgTemperature = getLatestTelemetry(asset.getId(), "avgTemperature", "avgTemperature_2");
+ assertThat(avgTemperature).isNotNull();
+ assertThat(avgTemperature.get("avgTemperature").get(0).get("value").asText()).isEqualTo("24");
+ assertThat(avgTemperature.get("avgTemperature_2").get(0).get("value").isNull()).isTrue();
+ });
+
+ postTelemetry(device1.getId(), "{\"temperature\":26.2}");
+
+ await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode avgTemperature = getLatestTelemetry(asset.getId(), "avgTemperature", "avgTemperature_2");
+ assertThat(avgTemperature).isNotNull();
+ assertThat(avgTemperature.get("avgTemperature").get(0).get("value").asText()).isEqualTo("26");
+ assertThat(avgTemperature.get("avgTemperature_2").get(0).get("value").isNull()).isTrue();
+ });
+ }
+
+ @Test
+ public void testUpdateMaxRelatedEntitiesPerArgument_checkAggregation() throws Exception {
+ loginSysAdmin();
+
+ updateDefaultTenantProfileConfig(tenantProfileConfig -> {
+ tenantProfileConfig.setMaxRelatedEntitiesToReturnPerCfArgument(1);
+ });
+
+ login("tenant@thingsboard.org", "testPassword");
+
+ createCountCF(asset.getId());
+
+ await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode numberOfDevices = getLatestTelemetry(asset.getId(), "numberOfDevices");
+ assertThat(numberOfDevices).isNotNull();
+ assertThat(numberOfDevices.get("numberOfDevices").get(0).get("value").asText()).isEqualTo("1");
+ });
+
+ loginSysAdmin();
+
+ updateDefaultTenantProfileConfig(tenantProfileConfig -> {
+ tenantProfileConfig.setMaxRelatedEntitiesToReturnPerCfArgument(10);
+ });
+
+ login("tenant@thingsboard.org", "testPassword");
+
+ await().alias("update max related entities per argument and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode numberOfDevices = getLatestTelemetry(asset.getId(), "numberOfDevices");
+ assertThat(numberOfDevices).isNotNull();
+ assertThat(numberOfDevices.get("numberOfDevices").get(0).get("value").asText()).isEqualTo("2");
+ });
+ }
+
+ private void checkInitialCalculation() {
+ await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(this::checkInitialCalculationValues);
+ }
+
+ private void checkInitialCalculationValues() throws Exception {
+ ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces");
+ assertThat(occupancy).isNotNull();
+ assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1");
+ assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1");
+ assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2");
+ }
+
+ private CalculatedField createAvgTemperatureCF(EntityId entityId) {
+ Map arguments = new HashMap<>();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("20");
+ arguments.put("temp", argument);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric avgMetric = new AggMetric();
+ avgMetric.setFunction(AggFunction.AVG);
+ avgMetric.setFilter("return temp >= 20;");
+ avgMetric.setInput(new AggKeyInput("temp"));
+ aggMetrics.put("avgTemperature", avgMetric);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("Average temperature", entityId,
+ new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ private CalculatedField createOccupancyCF(EntityId entityId) {
+ Map arguments = new HashMap<>();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("false");
+ arguments.put("oc", argument);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric freeSpaces = new AggMetric();
+ freeSpaces.setFunction(AggFunction.COUNT);
+ freeSpaces.setFilter("return oc == false;");
+ freeSpaces.setInput(new AggKeyInput("oc"));
+ aggMetrics.put("freeSpaces", freeSpaces);
+
+ AggMetric occupiedSpaces = new AggMetric();
+ occupiedSpaces.setFunction(AggFunction.COUNT);
+ occupiedSpaces.setFilter("return oc == true;");
+ occupiedSpaces.setInput(new AggKeyInput("oc"));
+ aggMetrics.put("occupiedSpaces", occupiedSpaces);
+
+ AggMetric totalSpaces = new AggMetric();
+ totalSpaces.setFunction(AggFunction.COUNT);
+ totalSpaces.setInput(new AggFunctionInput("return 1;"));
+ aggMetrics.put("totalSpaces", totalSpaces);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("Occupied spaces", entityId,
+ new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ private CalculatedField createOccupancyCFWithAttr(EntityId entityId) {
+ Map arguments = new HashMap<>();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ argument.setDefaultValue("false");
+ arguments.put("oc", argument);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric freeSpaces = new AggMetric();
+ freeSpaces.setFunction(AggFunction.COUNT);
+ freeSpaces.setFilter("return oc == false;");
+ freeSpaces.setInput(new AggKeyInput("oc"));
+ aggMetrics.put("freeSpaces", freeSpaces);
+
+ AggMetric occupiedSpaces = new AggMetric();
+ occupiedSpaces.setFunction(AggFunction.COUNT);
+ occupiedSpaces.setFilter("return oc == true;");
+ occupiedSpaces.setInput(new AggKeyInput("oc"));
+ aggMetrics.put("occupiedSpaces", occupiedSpaces);
+
+ AggMetric totalSpaces = new AggMetric();
+ totalSpaces.setFunction(AggFunction.COUNT);
+ totalSpaces.setInput(new AggFunctionInput("return 1;"));
+ aggMetrics.put("totalSpaces", totalSpaces);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("Occupied spaces", entityId,
+ new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ private CalculatedField createCountCF(EntityId entityId) {
+ Map arguments = new HashMap<>();
+ Argument argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey("active", ArgumentType.TS_LATEST, null));
+ argument.setDefaultValue("true");
+ arguments.put("active", argument);
+
+ Map aggMetrics = new HashMap<>();
+
+ AggMetric avgMetric = new AggMetric();
+ avgMetric.setFunction(AggFunction.COUNT);
+ avgMetric.setInput(new AggKeyInput("active"));
+ aggMetrics.put("numberOfDevices", avgMetric);
+
+ TimeSeriesOutput output = new TimeSeriesOutput();
+ output.setDecimalsByDefault(0);
+
+ return createAggCf("Number of devices", entityId,
+ new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
+ arguments,
+ aggMetrics,
+ output);
+ }
+
+ private CalculatedField createAggCf(String name,
+ EntityId entityId,
+ RelationPathLevel relation,
+ Map inputs,
+ Map metrics,
+ Output output) {
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setName(name);
+ calculatedField.setEntityId(entityId);
+ calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION);
+
+ RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration();
+ configuration.setRelation(relation);
+ configuration.setArguments(inputs);
+ configuration.setDeduplicationIntervalInSec(deduplicationInterval);
+ configuration.setScheduledUpdateInterval(10);
+ configuration.setMetrics(metrics);
+ configuration.setOutput(output);
+
+ calculatedField.setConfiguration(configuration);
+ calculatedField.setDebugSettings(DebugSettings.all());
+ return saveCalculatedField(calculatedField);
+ }
+
+ private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) {
+ Device device = new Device();
+ device.setName(name);
+ device.setDeviceProfileId(deviceProfileId);
+ DeviceData deviceData = new DeviceData();
+ deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration());
+ deviceData.setConfiguration(new DefaultDeviceConfiguration());
+ device.setDeviceData(deviceData);
+ return doPost("/api/device?accessToken=" + accessToken, device, Device.class);
+ }
+
+ private Asset createAsset(String name, AssetProfileId assetProfileId) {
+ Asset asset = new Asset();
+ asset.setName(name);
+ asset.setAssetProfileId(assetProfileId);
+ return doPost("/api/asset", asset, Asset.class);
+ }
+
+ private void verifyTelemetry(EntityId entityId, Map expectedResults) throws Exception {
+ ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0]));
+ assertThat(result).isNotNull();
+ expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value));
+ }
+
+ private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
+ return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
+ }
+
+ private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception {
+ return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
index 32d7ba56ac..c25dafa5e6 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java
index ea5fd0291b..9642884ea2 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java
index d4eec0d648..fcbd60eb4d 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -212,8 +212,7 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest {
testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType);
ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass());
ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass());
- ArgumentMatcher matcherCustomerId = customerId == null ?
- argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId);
+ ArgumentMatcher matcherCustomerId = customerId == null ? argument -> true : actualCustomerId -> actualCustomerId.equals(customerId);
ArgumentMatcher matcherUserId = userId == null ?
argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId);
testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime,
@@ -638,7 +637,7 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest {
return fieldName + " length must be equal or less than 255";
}
- protected String msgErrorNoFound(String entityClassName, String entityIdStr) {
+ protected static String msgErrorNoFound(String entityClassName, String entityIdStr) {
return entityClassName + " with id [" + entityIdStr + "] is not found";
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java
index cf9d2feb23..0cf114fa35 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -15,19 +15,13 @@
*/
package org.thingsboard.server.controller;
-import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.EventInfo;
-import org.thingsboard.server.common.data.event.EventType;
-import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
-import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
-import org.thingsboard.server.common.data.page.PageData;
-import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.dao.rule.RuleChainService;
@@ -61,18 +55,6 @@ public abstract class AbstractRuleEngineControllerTest extends AbstractControlle
return doGet("/api/ruleChain/metadata/" + ruleChainId.getId().toString(), RuleChainMetaData.class);
}
- protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception {
- return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE.getOldName(), limit);
- }
-
- protected PageData getEvents(TenantId tenantId, EntityId entityId, String eventType, int limit) throws Exception {
- TimePageLink pageLink = new TimePageLink(limit);
- return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&",
- new TypeReference>() {
- }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId());
- }
-
-
protected JsonNode getMetadata(EventInfo outEvent) {
String metaDataStr = outEvent.getBody().get("metadata").asText();
try {
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 54b2bbf6cf..457f723644 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -68,17 +68,24 @@ import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.actors.DefaultTbActorSystem;
import org.thingsboard.server.actors.TbActorId;
import org.thingsboard.server.actors.TbActorMailbox;
+import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
import org.thingsboard.server.actors.TbEntityActorId;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor;
import org.thingsboard.server.actors.device.DeviceActor;
import org.thingsboard.server.actors.device.DeviceActorMessageProcessor;
import org.thingsboard.server.actors.device.SessionInfo;
import org.thingsboard.server.actors.device.ToDeviceRpcRequestMetadata;
import org.thingsboard.server.actors.service.DefaultActorService;
+import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileType;
import org.thingsboard.server.common.data.DeviceTransportType;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.EventInfo;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TbResourceInfo;
@@ -86,6 +93,9 @@ 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.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldInfo;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration;
import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration;
import org.thingsboard.server.common.data.device.data.DeviceData;
@@ -98,7 +108,9 @@ import org.thingsboard.server.common.data.device.profile.MqttTopics;
import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration;
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
import org.thingsboard.server.common.data.edge.Edge;
+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.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
@@ -138,6 +150,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
+import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.session.FeatureType;
@@ -150,6 +163,8 @@ import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.service.cf.CfRocksDb;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
@@ -188,6 +203,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE;
+import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
+import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
@Slf4j
public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
@@ -198,13 +215,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String TEST_DIFFERENT_TENANT_NAME = "TEST DIFFERENT TENANT";
protected static final String SYS_ADMIN_EMAIL = "sysadmin@thingsboard.org";
- private static final String SYS_ADMIN_PASSWORD = "sysadmin";
+ protected static final String SYS_ADMIN_PASSWORD = "sysadmin";
protected static final String TENANT_ADMIN_EMAIL = "testtenant@thingsboard.org";
protected static final String TENANT_ADMIN_PASSWORD = "tenant";
protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org";
- private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
+ protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer";
@@ -233,6 +250,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected String mobileToken;
protected String username;
+ protected String apiKey;
+
protected TenantId tenantId;
protected TenantProfileId tenantProfileId;
protected UserId tenantAdminUserId;
@@ -600,8 +619,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
Assert.assertNotNull(tokenInfo);
Assert.assertTrue(tokenInfo.has("token"));
Assert.assertTrue(tokenInfo.has("refreshToken"));
- String token = tokenInfo.get("token").asText();
- String refreshToken = tokenInfo.get("refreshToken").asText();
+ validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username);
+ }
+
+ protected void validateAndSetJwtToken(JwtPair jwtPair, String username) {
+ Assert.assertNotNull(jwtPair);
+ String token = jwtPair.getToken();
+ String refreshToken = jwtPair.getRefreshToken();
validateJwtToken(token, username);
validateJwtToken(refreshToken, username);
this.token = token;
@@ -630,13 +654,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void setJwtToken(MockHttpServletRequestBuilder request) {
if (this.token != null) {
- request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token);
+ request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, BEARER_HEADER_PREFIX + this.token);
}
if (this.mobileToken != null) {
request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken);
}
}
+ protected void resetApiKey() {
+ this.apiKey = null;
+ }
+
+ protected void setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ }
+
+ protected void setApiKey(MockHttpServletRequestBuilder request) {
+ if (this.apiKey != null) {
+ request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, API_KEY_HEADER_PREFIX + this.apiKey);
+ }
+ }
+
protected DeviceProfile createDeviceProfile(String name) {
return createDeviceProfile(name, null);
}
@@ -672,9 +710,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
}
protected Device createDevice(String name, String accessToken) throws Exception {
+ return createDevice(name, "default", null, accessToken);
+ }
+
+ protected Device createDevice(String name, String type, String label, String accessToken) throws Exception {
Device device = new Device();
device.setName(name);
- device.setType("default");
+ device.setType(type);
+ device.setLabel(label);
DeviceData deviceData = new DeviceData();
deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration());
deviceData.setConfiguration(new DefaultDeviceConfiguration());
@@ -730,6 +773,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return doPost("/api/device-with-credentials", request, Device.class);
}
+ protected ResultActions doGetAsync(String urlTemplate, MultiValueMap params) throws Exception {
+ MockHttpServletRequestBuilder getRequest = get(urlTemplate).params(params);
+ setJwtToken(getRequest);
+ return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn()));
+ }
+
protected ResultActions doGet(String urlTemplate, Object... urlVariables) throws Exception {
MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables);
setJwtToken(getRequest);
@@ -743,6 +792,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(getRequest);
}
+ protected ResultActions doGetWithApiKey(String urlTemplate, Object... urlVariables) throws Exception {
+ MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables);
+ setApiKey(getRequest);
+ return mockMvc.perform(getRequest);
+ }
+
protected T doGet(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
}
@@ -766,6 +821,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn()));
}
+ protected T doGetWithApiKey(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception {
+ return readResponse(doGetWithApiKey(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
+ }
+
protected T doGetTyped(String urlTemplate, TypeReference responseType, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType);
}
@@ -845,6 +904,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
}
}
+ protected R doPostWithApiKey(String urlTemplate, T content, Class responseClass, String... params) {
+ try {
+ return readResponse(doPostWithApiKey(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
protected R doPostWithResponse(String urlTemplate, T content, Class responseClass, String... params) throws Exception {
return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
}
@@ -912,6 +979,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(postRequest);
}
+ protected ResultActions doPostWithApiKey(String urlTemplate, T content, String... params) throws Exception {
+ MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
+ setApiKey(postRequest);
+ String json = json(content);
+ postRequest.contentType(contentType).content(json);
+ return mockMvc.perform(postRequest);
+ }
+
protected ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setJwtToken(postRequest);
@@ -929,6 +1004,22 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(deleteRequest);
}
+ protected ResultActions doDeleteAsync(String urlTemplate, MultiValueMap params) throws Exception {
+ MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate)
+ .params(params);
+ setJwtToken(deleteRequest);
+ MvcResult result = mockMvc.perform(deleteRequest).andReturn();
+ result.getAsyncResult(DEFAULT_TIMEOUT);
+ return mockMvc.perform(asyncDispatch(result));
+ }
+
+ protected ResultActions doDeleteWithApiKey(String urlTemplate, String... params) throws Exception {
+ MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate);
+ setApiKey(deleteRequest);
+ populateParams(deleteRequest, params);
+ return mockMvc.perform(deleteRequest);
+ }
+
protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params);
setJwtToken(deleteRequest);
@@ -1052,7 +1143,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void createEntityRelation(EntityId entityIdFrom, EntityId entityIdTo, String typeRelation) throws Exception {
EntityRelation relation = new EntityRelation(entityIdFrom, entityIdTo, typeRelation);
- doPost("/api/relation", relation);
+ doPost("/api/relation", relation).andExpect(status().isOk());
+ }
+
+ protected void deleteEntityRelation(EntityRelation entityRelation) throws Exception {
+ String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
+ entityRelation.getFrom().getId(),
+ entityRelation.getFrom().getEntityType(),
+ entityRelation.getType(),
+ entityRelation.getTo().getId(),
+ entityRelation.getTo().getEntityType());
+ doDelete(url);
}
protected List findRelationsByTo(EntityId entityId) throws Exception {
@@ -1103,6 +1204,18 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
});
}
+ protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) {
+ CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId);
+ Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states");
+ Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> {
+ CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
+ boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() <
+ System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval);
+ log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady);
+ return isReady;
+ });
+ }
+
protected static String getMapName(FeatureType featureType) {
switch (featureType) {
case ATTRIBUTES:
@@ -1124,6 +1237,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
}
+ protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) {
+ DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system");
+ ConcurrentMap actors = (ConcurrentMap) ReflectionTestUtils.getField(actorSystem, "actors");
+ Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId)));
+ TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId));
+ CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor");
+ return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
+ }
+
protected void updateDefaultTenantProfileConfig(Consumer updater) throws ThingsboardException {
updateDefaultTenantProfile(tenantProfile -> {
TenantProfileData profileData = tenantProfile.getProfileData();
@@ -1278,7 +1401,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected List findJobs(List types, List entities) throws Exception {
return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) +
- "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&",
+ "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&",
new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData();
}
@@ -1290,4 +1413,52 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
doPost("/api/job/" + jobId + "/reprocess").andExpect(status().isOk());
}
+ protected void postTelemetry(EntityId entityId, String payload) throws Exception {
+ doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
+ "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
+ }
+
+ protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception {
+ doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
+ "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
+ }
+
+ protected CalculatedField saveCalculatedField(CalculatedField calculatedField) {
+ return doPost("/api/calculatedField", calculatedField, CalculatedField.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);
+ }
+
+ protected PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception {
+ return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&",
+ new TypeReference>() {}, pageLink);
+ }
+
+ protected List getCalculatedFields(CalculatedFieldType type,
+ EntityType entityType,
+ List entities,
+ List names) throws Exception {
+ return doGetTypedWithPageLink("/api/calculatedFields?" +
+ (type != null ? "types=" + type + "&" : "") +
+ (entityType != null ? "entityType=" + entityType + "&" : "") +
+ (entities != null ? "entities=" + String.join(",",
+ entities.stream().map(UUID::toString).toList()) + "&" : "") +
+ (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""),
+ new TypeReference>() {}, new PageLink(10)).getData();
+ }
+
+ protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception {
+ return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE, limit);
+ }
+
+ protected PageData getEvents(TenantId tenantId, EntityId entityId, EventType eventType, int limit) throws Exception {
+ TimePageLink pageLink = new TimePageLink(limit);
+ return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&",
+ new TypeReference>() {
+ }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId());
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
index 245993b51e..21a8b3b2e9 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
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 53648af441..099ef05752 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
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 0dcc037b98..17a30cac35 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -48,6 +48,7 @@ import java.util.List;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.COMMENT_DELETED;
@Slf4j
@ContextConfiguration(classes = {AlarmCommentControllerTest.Config.class})
@@ -207,8 +208,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM)
- .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment",
- CUSTOMER_USER_EMAIL)))
+ .comment(JacksonUtil.newObjectNode()
+ .put("text", String.format(COMMENT_DELETED.getText(), CUSTOMER_USER_EMAIL))
+ .put("subtype", COMMENT_DELETED.name())
+ .put("userName", CUSTOMER_USER_EMAIL))
.build();
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
}
@@ -226,8 +229,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM)
- .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment",
- TENANT_ADMIN_EMAIL)))
+ .comment(JacksonUtil.newObjectNode()
+ .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL))
+ .put("subtype", COMMENT_DELETED.name())
+ .put("userName", TENANT_ADMIN_EMAIL))
.build();
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java
index 8a49d34d20..81e2ff0311 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java
new file mode 100644
index 0000000000..9c7f7a898d
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.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.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.common.data.pat.ApiKey;
+import org.thingsboard.server.common.data.pat.ApiKeyInfo;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.UUID;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DaoSqlTest
+public class ApiKeyControllerTest extends AbstractControllerTest {
+
+ @Before
+ public void setUp() throws Exception {
+ loginTenantAdmin();
+ }
+
+ @Test
+ public void testSaveApiKey() throws Exception {
+ ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true);
+
+ doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
+
+ PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertEquals(1, pageData.getData().size());
+
+ ApiKeyInfo savedApiKey = pageData.getData().get(0);
+ Assert.assertNotNull(savedApiKey);
+ Assert.assertEquals(apiKeyInfo.getDescription(), savedApiKey.getDescription());
+ Assert.assertEquals(apiKeyInfo.isEnabled(), savedApiKey.isEnabled());
+ Assert.assertEquals(tenantId, savedApiKey.getTenantId());
+ Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId());
+
+ doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
+ }
+
+ @Test
+ public void tesFindUserApiKeys() throws Exception {
+ PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertTrue(pageData.getData().isEmpty());
+
+ ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
+ int expectedSize = 10;
+ for (int i = 0; i < expectedSize; i++) {
+ doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
+ }
+
+ PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertEquals(expectedSize, pageData2.getData().size());
+
+ pageData2.getData().forEach(apiKey -> {
+ try {
+ doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testUpdateApiKeyDescription() throws Exception {
+ ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
+ doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
+
+ PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertEquals(1, pageData.getData().size());
+
+ ApiKeyInfo savedApiKey = pageData.getData().get(0);
+
+ String newDescription = "Updated API Key Description";
+
+ ApiKeyInfo updatedApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/description", newDescription, ApiKeyInfo.class);
+ Assert.assertNotNull(updatedApiKeyInfo);
+ Assert.assertEquals(newDescription, updatedApiKeyInfo.getDescription());
+
+ doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
+ }
+
+ @Test
+ public void testEnableApiKey() throws Exception {
+ ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
+ doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
+
+ PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertEquals(1, pageData.getData().size());
+
+ ApiKeyInfo savedApiKey = pageData.getData().get(0);
+
+ ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class);
+ Assert.assertNotNull(disabledApiKeyInfo);
+ Assert.assertFalse(disabledApiKeyInfo.isEnabled());
+
+ ApiKeyInfo enabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/true", Boolean.TRUE, ApiKeyInfo.class);
+ Assert.assertNotNull(enabledApiKeyInfo);
+ Assert.assertTrue(enabledApiKeyInfo.isEnabled());
+
+ doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
+ }
+
+ @Test
+ public void testDeleteApiKey() throws Exception {
+ doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound());
+
+ ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", false);
+ doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
+
+ PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
+ Assert.assertEquals(1, pageData.getData().size());
+ ApiKeyInfo savedApiKey = pageData.getData().get(0);
+
+ doDelete("/api/apiKey/" + savedApiKey.getId().getId()).andExpect(status().isOk());
+ }
+
+ private ApiKeyInfo constructApiKeyInfo(String description, boolean enabled) {
+ ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
+ apiKeyInfo.setDescription(description);
+ apiKeyInfo.setEnabled(enabled);
+ apiKeyInfo.setUserId(tenantAdminUserId);
+ return apiKeyInfo;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
index 90b5cbeef2..98b06ebbd1 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -50,7 +50,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.asset.AssetDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.service.DaoSqlTest;
@@ -1080,6 +1080,30 @@ public class AssetControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId);
}
+ @Test
+ public void testSaveAssetWithUniquifyStrategy() throws Exception {
+ Asset asset = new Asset();
+ asset.setName("My unique asset");
+ asset.setType("default");
+ doPost("/api/asset", asset, Asset.class);
+
+ doPost("/api/asset", asset).andExpect(status().isBadRequest());
+
+ doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest());
+
+ Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class);
+ assertThat(secondAsset.getName()).startsWith("My unique asset_");
+
+ Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class);
+ assertThat(thirdAsset.getName()).startsWith("My unique asset-");
+
+ Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
+ assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1");
+
+ Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
+ assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2");
+ }
+
private Asset createAsset(String name) {
Asset asset = new Asset();
asset.setName(name);
diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java
index 2691352d01..490973f66c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -42,13 +42,17 @@ 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.asset.AssetProfileDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static org.hamcrest.Matchers.containsString;
@@ -195,6 +199,38 @@ public class AssetProfileControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedAssetProfile.getName(), foundAssetProfileInfo.getName());
}
+ @Test
+ public void testFindAssetProfileInfoByIds() throws Exception {
+ List assetProfiles = new ArrayList<>();
+ for (int i = 0; i < 20; i++) {
+ AssetProfile assetProfile = this.createAssetProfile("Asset Profile" + i);
+ assetProfile.setTenantId(savedTenant.getId());
+ assetProfiles.add(doPost("/api/assetProfile", assetProfile, AssetProfileInfo.class));
+ }
+
+ List expected = assetProfiles.subList(5, 15);
+
+ String idsParam = expected.stream()
+ .map(ap -> ap.getId().getId().toString())
+ .collect(Collectors.joining(","));
+ AssetProfileInfo[] foundAssetProfileInfos = doGet("/api/assetProfileInfos?assetProfileIds=" + idsParam, AssetProfileInfo[].class);
+
+ Assert.assertNotNull(foundAssetProfileInfos);
+ Assert.assertEquals(expected.size(), foundAssetProfileInfos.length);
+
+ Map infoById = Arrays.stream(foundAssetProfileInfos)
+ .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity()));
+
+ for (AssetProfileInfo assetProfileInfo : expected) {
+ UUID id = assetProfileInfo.getId().getId();
+ AssetProfileInfo info = infoById.get(id);
+ Assert.assertNotNull("AssetProfileInfo not found for id " + id, info);
+
+ Assert.assertEquals(assetProfileInfo.getId(), info.getId());
+ Assert.assertEquals(assetProfileInfo.getName(), info.getName());
+ }
+ }
+
@Test
public void whenGetAssetProfileInfoById_thenPermissionsAreChecked() throws Exception {
AssetProfile assetProfile = createAssetProfile("Asset profile 1");
diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
index c5c90f55b3..a76da5f161 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java
index e9f0036415..c594beae98 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
index 9e707f426e..9126c42f2f 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java
index b0a3518e60..0d79882a94 100644
--- a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -24,8 +24,8 @@ import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.DataConstants;
@@ -96,15 +96,15 @@ public class BaseQueueControllerTest extends AbstractControllerTest {
private RuleEngineStatisticsService ruleEngineStatisticsService;
@Autowired
private StatsFactory statsFactory;
- @SpyBean
- private TimeseriesDao timeseriesDao;
@Autowired
private QueueStatsService queueStatsService;
- @SpyBean
+ @MockitoSpyBean
+ private TimeseriesDao timeseriesDao;
+ @MockitoSpyBean
private PartitionService partitionService;
- @SpyBean
+ @MockitoSpyBean
private TimeseriesService timeseriesService;
- @SpyBean
+ @MockitoSpyBean
private ActorSystemContext actorSystemContext;
@Test
diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
index af43b34558..d9ec187e39 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -19,25 +19,48 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
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.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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.CalculatedFieldConfiguration;
-import org.thingsboard.server.common.data.cf.configuration.Output;
-import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
-import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval;
+import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
+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.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
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.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldControllerTest extends AbstractControllerTest {
@@ -73,7 +96,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testSaveCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- CalculatedField calculatedField = getCalculatedField(testDevice.getId());
+ CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@@ -84,7 +107,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
- assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
@@ -98,10 +121,104 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
+ @Test
+ public void testSaveGeofencingCalculatedField() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING);
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ assertThat(savedCalculatedField).isNotNull();
+ assertThat(savedCalculatedField.getId()).isNotNull();
+ assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
+ assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
+ assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
+ assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
+ assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
+
+ savedCalculatedField.setName("Test CF");
+
+ CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
+
+ assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
+ assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
+
+ doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testSavePropagationCalculatedField() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION);
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ assertThat(savedCalculatedField).isNotNull();
+ assertThat(savedCalculatedField.getId()).isNotNull();
+ assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
+ assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
+ assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
+ assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
+ assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
+
+ savedCalculatedField.setName("Test CF");
+
+ CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
+
+ assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
+ assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
+
+ doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testSaveEntityAggregationCalculatedField() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION);
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ assertThat(savedCalculatedField).isNotNull();
+ assertThat(savedCalculatedField.getId()).isNotNull();
+ assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
+ assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
+ assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
+ assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
+ assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getEntityAggregationCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
+
+ savedCalculatedField.setName("Test CF");
+
+ CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
+
+ assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
+ assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
+
+ doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null));
+
+ doPost("/api/calculatedField", calculatedField)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("arguments must not be empty")));
+ }
+
@Test
public void testGetCalculatedFieldById() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- CalculatedField calculatedField = getCalculatedField(testDevice.getId());
+ CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class);
@@ -113,10 +230,74 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
+ @Test
+ public void testGetEntityCalculatedFields() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
+ calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ assertThat(getEntityCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData())
+ .singleElement().isEqualTo(calculatedField);
+ assertThat(getEntityCalculatedFields(testDevice.getId(), CalculatedFieldType.SIMPLE, new PageLink(10)).getData())
+ .singleElement().isEqualTo(calculatedField);
+ }
+
+ @Test
+ public void testGetCalculatedFieldsByFilter() throws Exception {
+ Device device = createDevice("Device A", "1234567890");
+ CalculatedFieldInfo deviceCalculatedField = new CalculatedFieldInfo(
+ doPost("/api/calculatedField", getSimpleCalculatedField(device.getId()), CalculatedField.class),
+ "Device A"
+ );
+
+ DeviceProfile deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class);
+ CalculatedFieldInfo profileCalculatedField = new CalculatedFieldInfo(
+ doPost("/api/calculatedField", getSimpleCalculatedField(deviceProfile.getId()), CalculatedField.class),
+ "Profile A"
+ );
+
+ List allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
+ null, null, null);
+ assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField);
+ allCalculatedFields = getCalculatedFields(null, null, null, null);
+ assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField);
+
+ List profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
+ EntityType.DEVICE_PROFILE, null, null);
+ assertThat(profileLevelCalculatedFields).containsOnly(profileCalculatedField);
+
+ List specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
+ EntityType.DEVICE, List.of(device.getUuidId()), null);
+ assertThat(specificDeviceCalculatedFields).containsOnly(deviceCalculatedField);
+
+ List byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
+ null, null, List.of(deviceCalculatedField.getName()));
+ assertThat(byNameCalculatedFields).containsOnly(deviceCalculatedField);
+
+ byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null,
+ List.of(deviceCalculatedField.getName(), profileCalculatedField.getName()));
+ assertThat(byNameCalculatedFields).contains(deviceCalculatedField, profileCalculatedField);
+
+ PageData names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, 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(deviceCalculatedField.getName(), profileCalculatedField.getName());
+
+ names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0,
+ null, new SortOrder("", SortOrder.Direction.DESC)));
+ assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder());
+
+ names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0,
+ device.getId().toString(), new SortOrder("", SortOrder.Direction.DESC)));
+ assertThat(names.getTotalElements()).isEqualTo(1);
+ assertThat(names.getData()).containsOnly(deviceCalculatedField.getName());
+ }
+
@Test
public void testDeleteCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
- CalculatedField calculatedField = getCalculatedField(testDevice.getId());
+ CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@@ -127,18 +308,92 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound());
}
- private CalculatedField getCalculatedField(DeviceId deviceId) {
+ private CalculatedField getSimpleCalculatedField(EntityId entityId) {
+ return getCalculatedField(entityId, CalculatedFieldType.SIMPLE);
+ }
+
+ private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType) {
+ return getCalculatedField(entityId, cfType, null);
+ }
+
+ private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) {
CalculatedField calculatedField = new CalculatedField();
- calculatedField.setEntityId(deviceId);
- calculatedField.setType(CalculatedFieldType.SIMPLE);
- calculatedField.setName("Test Calculated Field");
+ calculatedField.setEntityId(entityId);
+ calculatedField.setType(cfType);
+ calculatedField.setName("Test Calculated Field for " + entityId);
calculatedField.setConfigurationVersion(1);
- calculatedField.setConfiguration(getCalculatedFieldConfig());
+ if (customConfiguration != null) {
+ calculatedField.setConfiguration(customConfiguration);
+ } else switch (cfType) {
+ case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig());
+ case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig());
+ case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig());
+ case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig());
+ }
calculatedField.setVersion(1L);
return calculatedField;
}
- private CalculatedFieldConfiguration getCalculatedFieldConfig() {
+ private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() {
+ var config = new GeofencingCalculatedFieldConfiguration();
+
+ var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea")));
+
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
+
+ config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude"));
+ config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration));
+ config.setScheduledUpdateEnabled(false);
+ config.setOutput(new TimeSeriesOutput());
+
+ return config;
+ }
+
+ private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() {
+ Argument arg = new Argument();
+ arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ return getPropagationCalculatedFieldConfig(Map.of("t", arg));
+ }
+
+ private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map arguments) {
+ var config = new PropagationCalculatedFieldConfiguration();
+
+ config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE));
+
+ config.setApplyExpressionToResolvedArguments(false);
+ config.setExpression(null);
+ config.setOutput(new TimeSeriesOutput());
+
+ Argument arg = new Argument();
+ arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ config.setArguments(arguments);
+
+ return config;
+ }
+
+ private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() {
+ var config = new EntityAggregationCalculatedFieldConfiguration();
+
+ Argument energyArgument = new Argument();
+ energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
+ config.setArguments(Map.of("en", energyArgument));
+
+ AggMetric metric = new AggMetric();
+ metric.setInput(new AggKeyInput("en"));
+ metric.setDefaultValue(9999.0);
+ config.setMetrics(Map.of("consumption", metric));
+
+ config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1)));
+ config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15)));
+
+ config.setOutput(new TimeSeriesOutput());
+
+ return config;
+ }
+
+ private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument argument = new Argument();
@@ -150,9 +405,8 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
config.setExpression("T - (100 - H) / 5");
- Output output = new Output();
+ TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("output");
- output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
diff --git a/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java
index c29bbe6375..bfa796d8f3 100644
--- a/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
index f312e49f71..dd3ab07ba0 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
@@ -43,12 +44,17 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.customer.CustomerDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@@ -260,6 +266,40 @@ public class CustomerControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
+ @Test
+ public void testFindCustomersByIds() throws Exception {
+ List savedCustomers = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Customer customer = new Customer();
+ customer.setTitle("My customer " + i);
+ savedCustomers.add(doPost("/api/customer", customer, Customer.class));
+ }
+
+ String idsParam = savedCustomers.stream()
+ .map(c -> c.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ Customer[] foundCustomers = doGet("/api/customers?customerIds=" + idsParam, Customer[].class);
+
+ Assert.assertNotNull(foundCustomers);
+ Assert.assertEquals(savedCustomers.size(), foundCustomers.length);
+
+ Map foundById = Arrays.stream(foundCustomers)
+ .collect(Collectors.toMap(c -> c.getId().getId(), Function.identity()));
+
+ for (Customer savedCustomer : savedCustomers) {
+ UUID id = savedCustomer.getId().getId();
+ Customer foundCustomer = foundById.get(id);
+ Assert.assertNotNull("Customer not found for id " + id, foundCustomer);
+ Assert.assertEquals(savedCustomer, foundCustomer);
+ }
+
+ for (Customer savedCustomer : savedCustomers) {
+ doDelete("/api/customer/" + savedCustomer.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+ }
+
@Test
public void testDeleteCustomer() throws Exception {
Customer customer = new Customer();
@@ -462,6 +502,27 @@ public class CustomerControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId);
}
+ @Test
+ public void testSaveCustomerWithUniquifyStrategy() throws Exception {
+ Customer customer = new Customer();
+ customer.setTitle("My unique customer");
+ Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
+
+ doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest());
+
+ Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class);
+ assertThat(secondCustomer.getName()).startsWith("My unique customer_");
+
+ Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class);
+ assertThat(thirdCustomer.getName()).startsWith("My unique customer-");
+
+ Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
+ assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1");
+
+ Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
+ assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2");
+ }
+
private Customer createCustomer(String title) {
Customer customer = new Customer();
customer.setTitle(title);
diff --git a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java
index a73bc3befb..27fcabbd2c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -51,13 +51,16 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.dashboard.DashboardDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -155,6 +158,42 @@ public class DashboardControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedDashboard, foundDashboard);
}
+ @Test
+ public void testFindDashboardInfosByIds() throws Exception {
+ List dashboards = new ArrayList<>();
+ for (int i = 0; i < 20; i++) {
+ Dashboard dashboard = new Dashboard();
+ dashboard.setTitle("My dashboard " + i);
+ dashboards.add(doPost("/api/dashboard", dashboard, Dashboard.class));
+ }
+
+ List expected = dashboards.subList(5, 15);
+
+ String idsParam = expected.stream()
+ .map(d -> d.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ DashboardInfo[] result = doGet(
+ "/api/dashboards?dashboardIds=" + idsParam,
+ DashboardInfo[].class
+ );
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(expected.size(), result.length);
+
+ Map infoById = Arrays.stream(result)
+ .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity()));
+
+ for (Dashboard dashboard : expected) {
+ UUID id = dashboard.getId().getId();
+ DashboardInfo info = infoById.get(id);
+ Assert.assertNotNull("DashboardInfo not found for id " + id, info);
+
+ Assert.assertEquals(dashboard.getId(), info.getId());
+ Assert.assertEquals(dashboard.getTitle(), info.getTitle());
+ }
+ }
+
@Test
public void testDeleteDashboard() throws Exception {
Dashboard dashboard = new Dashboard();
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
index fdf54d0109..9a80ad2e25 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
private DeviceProfileId mqttDeviceProfileId;
private DeviceProfileId coapDeviceProfileId;
- @Value("${device.connectivity.gateway.image_version:3.7-stable}")
+ @Value("${device.connectivity.gateway.image_version:3.8-stable}")
private String gatewayImageVersion;
@Before
@@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
"\n" +
" # Environment variables\n" +
" environment:\n" +
- " - host=host.docker.internal\n" +
- " - port=1883\n" +
- " - accessToken=" + credentials.getCredentialsId() + "\n" +
+ " - TB_GW_HOST=host.docker.internal\n" +
+ " - TB_GW_PORT=1883\n" +
+ " - TB_GW_SECURITY_TYPE=accessToken\n" +
+ " - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" +
"\n" +
" # Volumes bind\n" +
" volumes:\n" +
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
index 4551e530d2..7332ee26c6 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
+import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceInfo;
@@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceCredentialsId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
@@ -70,15 +72,19 @@ import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumn
import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest;
import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult;
import org.thingsboard.server.dao.device.DeviceDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.service.gateway_device.GatewayNotificationsService;
import org.thingsboard.server.service.state.DeviceStateService;
+import org.thingsboard.server.utils.CsvUtils;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -1629,6 +1635,57 @@ public class DeviceControllerTest extends AbstractControllerTest {
Assert.assertEquals(newAttributeValue, actualAttribute.get("value"));
}
+ @Test
+ public void testBulkImportDeviceWithJsonAttr() throws Exception {
+ String deviceName = "some_device";
+ String deviceType = "some_type";
+ String deviceAttr = "{\"threshold\":45}";
+
+ List> content = new LinkedList<>();
+ content.add(Arrays.asList("NAME", "TYPE", "ATTR"));
+ content.add(Arrays.asList(deviceName, deviceType, deviceAttr));
+
+ byte[] bytes = CsvUtils.generateCsv(content);
+ BulkImportRequest request = new BulkImportRequest();
+ request.setFile(new String(bytes, StandardCharsets.UTF_8));
+ BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping();
+ BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping();
+ name.setType(BulkImportColumnType.NAME);
+ BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping();
+ type.setType(BulkImportColumnType.TYPE);
+ BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping();
+ attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE);
+ attr.setKey("attr");
+ List columns = new ArrayList<>();
+ columns.add(name);
+ columns.add(type);
+ columns.add(attr);
+
+ mapping.setColumns(columns);
+ mapping.setDelimiter(',');
+ mapping.setUpdate(true);
+ mapping.setHeader(true);
+ request.setMapping(mapping);
+
+ BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {});
+
+ Assert.assertEquals(1, deviceBulkImportResult.getCreated().get());
+ Assert.assertEquals(0, deviceBulkImportResult.getErrors().get());
+ Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get());
+ Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty());
+
+ Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class);
+
+ Assert.assertNotNull(savedDevice);
+ Assert.assertEquals(deviceName, savedDevice.getName());
+ Assert.assertEquals(deviceType, savedDevice.getType());
+
+ Optional retrieved = await().atMost(5, TimeUnit.SECONDS)
+ .until(() -> attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(), Optional::isPresent);
+ assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr);
+ assertThat(retrieved.get().getStrValue()).isNotPresent();
+ }
+
@Test
public void testSaveDeviceWithOutdatedVersion() throws Exception {
Device device = createDevice("Device v1.0");
@@ -1651,6 +1708,30 @@ public class DeviceControllerTest extends AbstractControllerTest {
assertThat(device.getVersion()).isEqualTo(3);
}
+ @Test
+ public void testSaveDeviceWithUniquifyStrategy() throws Exception {
+ Device device = new Device();
+ device.setName("My unique device");
+ device.setType("default");
+ Device savedDevice = doPost("/api/device", device, Device.class);
+
+ doPost("/api/device", device).andExpect(status().isBadRequest());
+
+ doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest());
+
+ Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class);
+ assertThat(secondDevice.getName()).startsWith("My unique device_");
+
+ Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class);
+ assertThat(thirdDevice.getName()).startsWith("My unique device-");
+
+ Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
+ assertThat(fourthDevice.getName()).isEqualTo("My unique device_1");
+
+ Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
+ assertThat(fifthDevice.getName()).isEqualTo("My unique device_2");
+ }
+
private Device createDevice(String name) {
Device device = new Device();
device.setName(name);
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 320e3815f6..3966bbb97a 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -52,13 +52,17 @@ 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.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -214,6 +218,42 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedDeviceProfile.getType(), foundDeviceProfileInfo.getType());
}
+ @Test
+ public void testFindDeviceProfileInfosByIds() throws Exception {
+ List deviceProfiles = new ArrayList<>();
+ for (int i = 0; i < 20; i++) {
+ DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile " + i);
+ deviceProfiles.add(saveDeviceProfile(deviceProfile));
+ }
+
+ List expected = deviceProfiles.subList(5, 15);
+
+ String idsParam = expected.stream()
+ .map(dp -> dp.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ DeviceProfileInfo[] result = doGet(
+ "/api/deviceProfileInfos?deviceProfileIds=" + idsParam,
+ DeviceProfileInfo[].class
+ );
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(expected.size(), result.length);
+
+ Map infoById = Arrays.stream(result)
+ .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity()));
+
+ for (DeviceProfile dp : expected) {
+ UUID id = dp.getId().getId();
+ DeviceProfileInfo info = infoById.get(id);
+ Assert.assertNotNull("DeviceProfileInfo not found for id " + id, info);
+
+ Assert.assertEquals(dp.getId(), info.getId());
+ Assert.assertEquals(dp.getName(), info.getName());
+ Assert.assertEquals(dp.getType(), info.getType());
+ }
+ }
+
@Test
public void whenGetDeviceProfileInfoById_thenPermissionsAreChecked() throws Exception {
DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null);
diff --git a/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java
index 215a2490df..84b352b4c7 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java
index f1d3ef1d52..43f08f42f9 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -72,7 +72,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.model.JwtSettings;
import org.thingsboard.server.dao.edge.EdgeDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edge.imitator.EdgeImitator;
diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java
index 38ca393fc3..ba389d3bd6 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java
index 91be3f4744..fbd7d73421 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -22,6 +22,7 @@ import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
@@ -41,6 +42,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.query.KeyFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.edqs.util.EdqsRocksDb;
import java.util.ArrayList;
import java.util.Collections;
@@ -62,8 +64,11 @@ public class EdqsControllerTest extends AbstractControllerTest {
@Autowired
private JdbcTemplate jdbcTemplate;
+ @MockitoBean
+ private EdqsRocksDb edqsRocksDb;
+
@Before
- public void beforeEdqsControllerTest() throws Exception {
+ public void before() throws Exception {
loginTenantAdmin();
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java
index 655ab417e6..f15ab09ab2 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -19,7 +19,6 @@ import org.assertj.core.api.ThrowingConsumer;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.edqs.EdqsState;
import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode;
@@ -33,7 +32,6 @@ import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.msg.edqs.EdqsService;
import org.thingsboard.server.dao.service.DaoSqlTest;
-import org.thingsboard.server.edqs.util.EdqsRocksDb;
import org.thingsboard.server.queue.discovery.DiscoveryService;
import java.util.concurrent.TimeUnit;
@@ -59,9 +57,6 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest {
@Autowired
private DiscoveryService discoveryService;
- @MockBean // so that we don't do backup for tests
- private EdqsRocksDb edqsRocksDb;
-
@Before
public void before() {
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsService.getState().isApiEnabled());
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 93d67ccd4b..e1173ef9a7 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -17,15 +17,18 @@ package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.After;
import org.junit.Assert;
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.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DataConstants;
@@ -33,6 +36,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
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.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmSeverity;
@@ -62,6 +66,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.query.KeyFilter;
import org.thingsboard.server.common.data.query.NumericFilterPredicate;
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.TsValue;
import org.thingsboard.server.common.data.queue.QueueStats;
@@ -69,14 +74,18 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.edqs.util.EdqsRocksDb;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -101,6 +110,9 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired
private QueueStatsService queueStatsService;
+ @MockitoBean
+ private EdqsRocksDb edqsRocksDb;
+
@Before
public void beforeTest() throws Exception {
loginSysAdmin();
@@ -210,6 +222,64 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
countByQueryAndCheck(countQuery, 97);
}
+ @Test
+ public void testEDQForSysAdmin() throws Exception {
+ loginSysAdmin();
+
+ ObjectNode tenantAttr = JacksonUtil.newObjectNode();
+ tenantAttr.put("attr", "tenantAttrValue");
+ doPost("/api/plugins/telemetry/TENANT/" + tenantId + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantAttr);
+
+ loginTenantAdmin();
+ Device tenantDevice = new Device();
+ tenantDevice.setName("device " + StringUtils.randomAlphanumeric(10));
+ tenantDevice.setType("default");
+ tenantDevice = doPost("/api/device", tenantDevice, Device.class);
+
+ loginSysAdmin();
+ TenantProfile tenantProfile = doPost("/api/tenantProfile", createTenantProfile("Test tenant profile"), TenantProfile.class);
+
+ ObjectNode tenantProfileAttr = JacksonUtil.newObjectNode();
+ tenantProfileAttr.put("attr", "tenantProfileAttrValue");
+ doPost("/api/plugins/telemetry/TENANT_PROFILE/" + tenantProfile.getId() + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantProfileAttr);
+
+ // check tenant telemetry is accessible for sysadmin
+ SingleEntityFilter filter = new SingleEntityFilter();
+ filter.setSingleEntity(AliasEntityId.fromEntityId(tenantId));
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC);
+ EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
+ List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ List latestValues = List.of(new EntityKey(EntityKeyType.ATTRIBUTE, "attr"));
+ EntityDataQuery dataQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null);
+
+ PageData loadedTenants = findByQueryAndCheck(dataQuery, 1);
+ String retrievedTenantAttr = loadedTenants.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue();
+ assertThat(retrievedTenantAttr).isEqualTo("tenantAttrValue");
+
+ // check tenant profile telemetry is accessible for sysadmin
+ filter.setSingleEntity(AliasEntityId.fromEntityId(tenantProfile.getId()));
+
+ PageData loadedTenantProfiles = findByQueryAndCheck(dataQuery, 1);
+ String retrievedProfileAttr = loadedTenantProfiles.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue();
+ assertThat(retrievedProfileAttr).isEqualTo("tenantProfileAttrValue");
+
+ // check other tenant entities are prohibited
+ filter.setSingleEntity(AliasEntityId.fromEntityId(tenantDevice.getId()));
+ findByQueryAndCheck(dataQuery, 0);
+ }
+
+ private TenantProfile createTenantProfile(String name) {
+ TenantProfile tenantProfile = new TenantProfile();
+ tenantProfile.setName(name);
+ tenantProfile.setDescription(name + " Test");
+ TenantProfileData tenantProfileData = new TenantProfileData();
+ tenantProfileData.setConfiguration(new DefaultTenantProfileConfiguration());
+ tenantProfile.setProfileData(tenantProfileData);
+ tenantProfile.setDefault(false);
+ tenantProfile.setIsolatedTbRuleEngine(false);
+ return tenantProfile;
+ }
+
@Test
public void testTenantCountAlarmsByQuery() throws Exception {
loginTenantAdmin();
@@ -431,19 +501,22 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
List alarmFields = new ArrayList<>();
alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
+ alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "originatorDisplayName"));
EntityTypeFilter assetTypeFilter = new EntityTypeFilter();
assetTypeFilter.setEntityType(EntityType.ASSET);
- AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields);
+ AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields);
PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10);
- List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList();
+ List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList();
assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes);
+ List retrievedAlarmDisplayName = alarmPageData.getData().stream().map(AlarmData::getOriginatorDisplayName).toList();
+ assertThat(retrievedAlarmDisplayName).containsExactlyInAnyOrderElementsOf(assets.stream().map(Asset::getLabel).toList());
KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1");
List keyFilters = Collections.singletonList(nameFilter);
- AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields);
- PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() {
+ AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields);
+ PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() {
});
Assert.assertEquals(1, filteredAssetAlamData.getTotalElements());
}
@@ -505,16 +578,16 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
EntityTypeFilter assetTypeFilter = new EntityTypeFilter();
assetTypeFilter.setEntityType(EntityType.ASSET);
- AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList());
+ AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList());
- PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10);
+ PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10);
List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList();
assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes);
KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1");
List keyFilters = Collections.singletonList(nameFilter);
- AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList());
- PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() {
+ AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList());
+ PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() {
});
Assert.assertEquals(1, filteredAssetAlamData.getTotalElements());
}
@@ -570,7 +643,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
EntityTypeFilter deviceTypeFilter = new EntityTypeFilter();
deviceTypeFilter.setEntityType(EntityType.DEVICE);
- AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields);
+ AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields);
PageData alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10);
List retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList();
@@ -1068,6 +1141,119 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
countByQueryAndCheck(customerEntitiesQuery, 0);
}
+ @Test
+ public void testFindDevicesByDisplayName() throws Exception {
+ loginTenantAdmin();
+ int numOfDevices = 3;
+
+ for (int i = 0; i < numOfDevices; i++) {
+ Device device = new Device();
+ String name = "Device" + i;
+ device.setName(name);
+ device.setLabel("Device Label " + i);
+ device.setType("testFindDevicesByDisplayName");
+
+ Device savedDevice = doPost("/api/device?accessToken=" + name, device, Device.class);
+ }
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("testFindDevicesByDisplayName"));
+ filter.setDeviceNameFilter("");
+
+ KeyFilter displayNameFilter = getEntityFieldEqualFilter("displayName", "Device Label " + 0);
+
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC
+ );
+ EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
+ List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"));
+
+ // all devices with ownerName = TEST TENANT
+ EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), Collections.emptyList());
+ checkEntitiesByQuery(query, numOfDevices, (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals("Device Label " + i, displayName);
+ });
+
+ // all devices with ownerName = TEST TENANT
+ EntityDataQuery displayNameFilterQuery = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(displayNameFilter));
+ checkEntitiesByQuery(displayNameFilterQuery, 1, (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals("Device Label " + i, displayName);
+ });
+ }
+
+ @Test
+ public void testFindUsersByDisplayName() throws Exception {
+ loginTenantAdmin();
+
+ User userA = new User();
+ userA.setAuthority(Authority.TENANT_ADMIN);
+ userA.setFirstName("John");
+ userA.setLastName("Doe");
+ userA.setEmail("john.doe@tb.org");
+ userA = doPost("/api/user", userA, User.class);
+ var aId = userA.getId();
+
+ User userB = new User();
+ userB.setAuthority(Authority.TENANT_ADMIN);
+ userB.setFirstName("John");
+ userB.setEmail("john@tb.org");
+ userB = doPost("/api/user", userB, User.class);
+ var bId = userB.getId();
+
+ User userC = new User();
+ userC.setAuthority(Authority.TENANT_ADMIN);
+ userC.setLastName("Doe");
+ userC.setEmail("doe@tb.org");
+ userC = doPost("/api/user", userC, User.class);
+ var cId = userC.getId();
+
+ User userD = new User();
+ userD.setAuthority(Authority.TENANT_ADMIN);
+ userD.setEmail("noname@tb.org");
+ userD = doPost("/api/user", userD, User.class);
+ var dId = userD.getId();
+
+ EntityTypeFilter filter = new EntityTypeFilter();
+ filter.setEntityType(EntityType.USER);
+
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC
+ );
+ EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
+ List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"));
+
+ EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John Doe")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(aId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("John Doe", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(bId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("John", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "Doe")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(cId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Doe", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "noname@tb.org")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(dId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("noname@tb.org", displayName);
+ });
+ }
+
@Test
public void testFindDevicesByOwnerNameAndOwnerType() throws Exception {
loginTenantAdmin();
@@ -1105,19 +1291,30 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
// all devices with ownerName = TEST TENANT
EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter));
- checkEntitiesByQuery(query, numOfDevices, TEST_TENANT_NAME, "TENANT");
+ BiConsumer checkFunction = (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String ownerName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerName", new TsValue(0, "Invalid")).getValue();
+ String ownerType = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerType", new TsValue(0, "Invalid")).getValue();
+ String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue();
+
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals(TEST_TENANT_NAME, ownerName);
+ Assert.assertEquals("TENANT", ownerType);
+ Assert.assertEquals("1" + i, alarmActiveTime);
+ };
+ checkEntitiesByQuery(query, numOfDevices, checkFunction);
// all devices with wrong ownerName
EntityDataQuery wrongTenantNameQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter));
- checkEntitiesByQuery(wrongTenantNameQuery, 0, null, null);
+ checkEntitiesByQuery(wrongTenantNameQuery, 0, null);
// all devices with owner type = TENANT
EntityDataQuery tenantEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter));
- checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, TEST_TENANT_NAME, "TENANT");
+ checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, checkFunction);
// all devices with owner type = CUSTOMER
EntityDataQuery customerEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter));
- checkEntitiesByQuery(customerEntitiesQuery, 0, null, null);
+ checkEntitiesByQuery(customerEntitiesQuery, 0, null);
}
@Test
@@ -1163,6 +1360,28 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
findByQueryAndCheck(query, 0);
}
+ private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception {
+ await()
+ .alias("data by query")
+ .atMost(30, TimeUnit.SECONDS)
+ .until(() -> {
+ var data = findByQuery(query);
+ var loadedEntities = new ArrayList<>(data.getData());
+ return loadedEntities.size() == expectedNumOfDevices;
+ });
+ if (expectedNumOfDevices == 0) {
+ return;
+ }
+ var data = findByQuery(query);
+ var loadedEntities = new ArrayList<>(data.getData());
+
+ Assert.assertEquals(expectedNumOfDevices, loadedEntities.size());
+
+ for (int i = 0; i < expectedNumOfDevices; i++) {
+ checkFunction.accept(i, loadedEntities.get(i));
+ }
+ }
+
private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception {
await()
.alias("data by query")
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java
index 977877f8b9..b0c76a16a8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -17,19 +17,15 @@ package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
-import lombok.extern.slf4j.Slf4j;
-import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
-import org.thingsboard.server.common.data.EntityView;
-import org.thingsboard.server.common.data.Tenant;
-import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.relation.EntityRelation;
@@ -39,8 +35,7 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
-import org.thingsboard.server.common.data.security.Authority;
-import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.relation.RelationDao;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.Collections;
@@ -48,57 +43,32 @@ import java.util.List;
import java.util.UUID;
import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-@Slf4j
@DaoSqlTest
public class EntityRelationControllerTest extends AbstractControllerTest {
public static final String BASE_DEVICE_NAME = "Test dummy device";
@Autowired
- RelationService relationService;
+ private RelationDao relationDao;
- private IdComparator idComparator;
- private Tenant savedTenant;
- private User tenantAdmin;
private Device mainDevice;
@Before
public void beforeTest() throws Exception {
- loginSysAdmin();
- idComparator = new IdComparator<>();
+ loginTenantAdmin();
- Tenant tenant = new Tenant();
- tenant.setTitle("Test tenant");
-
- savedTenant = saveTenant(tenant);
- Assert.assertNotNull(savedTenant);
-
- tenantAdmin = new User();
- tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
- tenantAdmin.setTenantId(savedTenant.getId());
- tenantAdmin.setEmail("tenant2@thingsboard.org");
- tenantAdmin.setFirstName("Joe");
- tenantAdmin.setLastName("Downs");
- tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1");
-
- Device device = new Device();
+ var device = new Device();
device.setName("Main test device");
device.setType("default");
mainDevice = doPost("/api/device", device, Device.class);
}
- @After
- public void afterTest() throws Exception {
- loginSysAdmin();
-
- deleteTenant(savedTenant.getId());
- }
-
@Test
public void testSaveAndFindRelation() throws Exception {
- Device device = buildSimpleDevice("Test device 1");
+ Device device = createDevice("Test device 1");
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
Mockito.reset(tbClusterService, auditLogService);
@@ -116,57 +86,117 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation);
testNotifyEntityAllOneTimeRelation(foundRelation,
- savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
+ tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.RELATION_ADD_OR_UPDATE, foundRelation);
}
@Test
- public void testSaveWithDeviceFromNotCreated() throws Exception {
- Device device = new Device();
- device.setName("Test device 2");
- device.setType("default");
- EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS");
-
- Mockito.reset(tbClusterService, auditLogService);
+ public void testSaveRelationFromValidation() throws Exception {
+ // GIVEN
+ var relation = new EntityRelation();
+ relation.setFrom(null);
+ relation.setTo(mainDevice.getId());
+ relation.setType("Contains");
+
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ doPost(endpoint, relation)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: from must not be null")));
+ }
+ }
- doPost("/api/relation", relation)
- .andExpect(status().isBadRequest())
- .andExpect(statusReason(containsString("Parameter entityId can't be empty!")));
+ @Test
+ public void testSaveRelationToValidation() throws Exception {
+ // GIVEN
+ var relation = new EntityRelation();
+ relation.setFrom(mainDevice.getId());
+ relation.setTo(null);
+ relation.setType("Contains");
+
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ doPost(endpoint, relation)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: to must not be null")));
+ }
+ }
- testNotifyEntityNever(mainDevice.getId(), null);
+ @Test
+ public void testSaveRelationRelationTypeValidation() throws Exception {
+ // GIVEN
+ var device = createDevice("Test device");
+
+ EntityRelation relationTypeNull = createFromRelation(mainDevice, device, null);
+ EntityRelation relationTypeEmpty = createFromRelation(mainDevice, device, "");
+ EntityRelation relationTypeBlank = createFromRelation(mainDevice, device, " ");
+ EntityRelation relationTypeContainsNullChar = createFromRelation(mainDevice, device, "null char \u0000");
+ EntityRelation relationTypeTooLong = createFromRelation(mainDevice, device, "a".repeat(256));
+
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ doPost(endpoint, relationTypeNull)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: type must not be blank")));
+
+ doPost(endpoint, relationTypeEmpty)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: type must not be blank")));
+
+ doPost(endpoint, relationTypeBlank)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: type must not be blank")));
+
+ doPost(endpoint, relationTypeContainsNullChar)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: type should not contain 0x00 symbol")));
+
+ doPost(endpoint, relationTypeTooLong)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(is("Validation error: type length must be equal or less than 255")));
+ }
}
@Test
- public void testSaveWithDeviceToNotCreated() throws Exception {
- Device device = new Device();
- device.setName("Test device 2");
- device.setType("default");
- EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
+ public void testSaveRelationFromNonexistentEntity() throws Exception {
+ // GIVEN
+ var nonexistentDevice = new Device();
+ nonexistentDevice.setId(new DeviceId(UUID.randomUUID()));
+ nonexistentDevice.setName("Nonexistent device");
+ nonexistentDevice.setType("default");
+ EntityRelation relation = createFromRelation(nonexistentDevice, mainDevice, "CONTAINS");
Mockito.reset(tbClusterService, auditLogService);
- doPost("/api/relation", relation)
- .andExpect(status().isBadRequest())
- .andExpect(statusReason(containsString("Parameter entityId can't be empty!")));
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ doPost(endpoint, relation)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(is(msgErrorNoFound("Device", nonexistentDevice.getId().toString()))));
- testNotifyEntityNever(mainDevice.getId(), null);
+ testNotifyEntityNever(mainDevice.getId(), null);
+ }
}
@Test
- public void testSaveWithDeviceToMissing() throws Exception {
- Device device = new Device();
- device.setName("Test device 2");
- device.setType("default");
- device.setId(new DeviceId(UUID.randomUUID()));
- EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
+ public void testSaveRelationToNonexistentEntity() throws Exception {
+ // GIVEN
+ var nonexistentDevice = new Device();
+ nonexistentDevice.setId(new DeviceId(UUID.randomUUID()));
+ nonexistentDevice.setName("Nonexistent device");
+ nonexistentDevice.setType("default");
+ EntityRelation relation = createFromRelation(mainDevice, nonexistentDevice, "CONTAINS");
Mockito.reset(tbClusterService, auditLogService);
- doPost("/api/relation", relation)
- .andExpect(status().isNotFound())
- .andExpect(statusReason(containsString(msgErrorNoFound("Device", device.getId().getId().toString()))));
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ doPost(endpoint, relation)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(is(msgErrorNoFound("Device", nonexistentDevice.getId().toString()))));
- testNotifyEntityNever(mainDevice.getId(), null);
+ testNotifyEntityNever(mainDevice.getId(), null);
+ }
}
@Test
@@ -178,7 +208,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME);
EntityRelation relationTest = createFromRelation(mainDevice, mainDevice, "TEST_NOTIFY_ENTITY");
- testNotifyEntityAllManyRelation(relationTest, savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
+ testNotifyEntityAllManyRelation(relationTest, tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.RELATION_ADD_OR_UPDATE, numOfDevices);
String url = String.format("/api/relations?fromId=%s&fromType=%s",
@@ -204,7 +234,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
final int numOfDevices = 30;
createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME);
- Device device = buildSimpleDevice("Unique dummy test device ");
+ Device device = createDevice("Unique dummy test device ");
String relationType = "TEST";
EntityRelation relation = createFromRelation(mainDevice, device, relationType);
@@ -221,7 +251,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
final int numOfDevices = 30;
createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME);
- Device device = buildSimpleDevice("Unique dummy test device ");
+ Device device = createDevice("Unique dummy test device ");
String relationType = "TEST";
EntityRelation relation = createFromRelation(mainDevice, device, relationType);
@@ -240,7 +270,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
final int numOfDevices = 30;
createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME);
- Device device = buildSimpleDevice("Unique dummy test device ");
+ Device device = createDevice("Unique dummy test device ");
String relationType = "TEST";
EntityRelation relation = createFromRelation(device, mainDevice, relationType);
@@ -252,13 +282,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
assertFoundList(url, 1);
}
-
@Test
public void testSaveAndFindRelationsByToWithRelationTypeOther() throws Exception {
final int numOfDevices = 30;
createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME);
- Device device = buildSimpleDevice("Unique dummy test device ");
+ Device device = createDevice("Unique dummy test device ");
String relationType = "TEST";
EntityRelation relation = createFromRelation(device, mainDevice, relationType);
@@ -280,9 +309,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
mainDevice.getUuidId(), EntityType.DEVICE
);
- List relationsInfos =
- JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {
- });
+ List relationsInfos = JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {});
Assert.assertNotNull("Relations is not found!", relationsInfos);
Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!",
@@ -299,57 +326,88 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
mainDevice.getUuidId(), EntityType.DEVICE
);
- List relationsInfos =
- JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {
- });
+ List relationsInfos = JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {});
Assert.assertNotNull("Relations is not found!", relationsInfos);
- Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!",
- numOfDevices, relationsInfos.size());
+ Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", numOfDevices, relationsInfos.size());
assertRelationsInfosByTo(relationsInfos);
}
@Test
public void testDeleteRelation() throws Exception {
- Device device = buildSimpleDevice("Test device 1");
+ // GIVEN
+ Device device = createDevice("Test device 1");
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
relation = doPost("/api/v2/relation", relation, EntityRelation.class);
- String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
- mainDevice.getUuidId(), EntityType.DEVICE,
- "CONTAINS", device.getUuidId(), EntityType.DEVICE
- );
-
- EntityRelation foundRelation = doGet(url, EntityRelation.class);
-
- Assert.assertNotNull("Relation is not found!", foundRelation);
- Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation);
-
Mockito.reset(tbClusterService, auditLogService);
+ // WHEN
String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
mainDevice.getUuidId(), EntityType.DEVICE,
"CONTAINS", device.getUuidId(), EntityType.DEVICE
);
var deletedRelation = doDelete(deleteUrl, EntityRelation.class);
+ // THEN
testNotifyEntityAllOneTimeRelation(deletedRelation,
- savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
+ tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.RELATION_DELETED, deletedRelation);
- doGet(url).andExpect(status().is4xxClientError());
+ getRelation(relation)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(is(msgErrorNotFound)));
+ }
+
+ @Test
+ public void testDeleteRelationWithTypeEmptyOrBlank() throws Exception {
+ // GIVEN
+ Device device = createDevice("Test device");
+
+ // WHEN-THEN
+ for (String endpoint : List.of("/api/relation", "/api/v2/relation")) {
+ // saving relation with empty type
+ EntityRelation emptyRelation = createFromRelation(mainDevice, device, "");
+ relationDao.saveRelation(tenantId, emptyRelation);
+
+ // saving relation with blank type
+ EntityRelation blankRelation = createFromRelation(mainDevice, device, " ");
+ relationDao.saveRelation(tenantId, blankRelation);
+
+ // deleting relation with empty type
+ String deleteEmptyUrl = String.format(endpoint + "?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
+ mainDevice.getUuidId(), EntityType.DEVICE,
+ emptyRelation.getType(), device.getUuidId(), EntityType.DEVICE
+ );
+ doDelete(deleteEmptyUrl).andExpect(status().isOk());
+
+ getRelation(emptyRelation)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(is(msgErrorNotFound)));
+
+ // deleting relation with blank type
+ String deleteBlankUrl = String.format(endpoint + "?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
+ mainDevice.getUuidId(), EntityType.DEVICE,
+ blankRelation.getType(), device.getUuidId(), EntityType.DEVICE
+ );
+ doDelete(deleteBlankUrl).andExpect(status().isOk());
+
+ getRelation(blankRelation)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(is(msgErrorNotFound)));
+ }
}
@Test
public void testDeleteRelationWithOtherFromDeviceError() throws Exception {
- Device device = buildSimpleDevice("Test device 1");
+ Device device = createDevice("Test device 1");
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
doPost("/api/relation", relation).andExpect(status().isOk());
- Device device2 = buildSimpleDevice("Test device 2");
+ Device device2 = createDevice("Test device 2");
String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
device2.getUuidId(), EntityType.DEVICE,
"CONTAINS", device.getUuidId(), EntityType.DEVICE
@@ -366,12 +424,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
@Test
public void testDeleteRelationWithOtherToDeviceError() throws Exception {
- Device device = buildSimpleDevice("Test device 1");
+ Device device = createDevice("Test device 1");
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
doPost("/api/relation", relation).andExpect(status().isOk());
- Device device2 = buildSimpleDevice("Test device 2");
+ Device device2 = createDevice("Test device 2");
String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
mainDevice.getUuidId(), EntityType.DEVICE,
"CONTAINS", device2.getUuidId(), EntityType.DEVICE
@@ -411,7 +469,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
doDelete(url).andExpect(status().isOk());
testNotifyEntityOneTimeMsgToEdgeServiceNever(null, mainDevice.getId(), mainDevice.getId(),
- savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
+ tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.RELATIONS_DELETED);
Assert.assertTrue(
@@ -442,8 +500,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
List relations = readResponse(
doPost("/api/relations", query).andExpect(status().isOk()),
- new TypeReference>() {
- }
+ new TypeReference<>() {}
);
assertFoundRelations(relations, numOfDevices);
@@ -467,8 +524,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
List relations = readResponse(
doPost("/api/relations", query).andExpect(status().isOk()),
- new TypeReference<>() {
- }
+ new TypeReference<>() {}
);
assertFoundRelations(relations, numOfDevices);
@@ -526,11 +582,11 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
@Test
public void testCreateRelationFromTenantToDevice() throws Exception {
- EntityRelation relation = new EntityRelation(tenantAdmin.getTenantId(), mainDevice.getId(), "CONTAINS");
+ EntityRelation relation = new EntityRelation(tenantId, mainDevice.getId(), "CONTAINS");
relation = doPost("/api/v2/relation", relation, EntityRelation.class);
String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
- tenantAdmin.getTenantId(), EntityType.TENANT,
+ tenantId, EntityType.TENANT,
"CONTAINS", mainDevice.getUuidId(), EntityType.DEVICE
);
@@ -542,12 +598,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
@Test
public void testCreateRelationFromDeviceToTenant() throws Exception {
- EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantAdmin.getTenantId(), "CONTAINS");
+ EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantId, "CONTAINS");
relation = doPost("/api/v2/relation", relation, EntityRelation.class);
String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
mainDevice.getUuidId(), EntityType.DEVICE,
- "CONTAINS", tenantAdmin.getTenantId(), EntityType.TENANT
+ "CONTAINS", tenantId, EntityType.TENANT
);
EntityRelation foundRelation = doGet(url, EntityRelation.class);
@@ -558,7 +614,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
@Test
public void testSaveAndFindRelationDifferentTenant() throws Exception {
- Device device = buildSimpleDevice("Test device 1");
+ Device device = createDevice("Test device 1");
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
doPost("/api/relation", relation).andExpect(status().isOk());
@@ -577,12 +633,20 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
deleteDifferentTenant();
}
- private Device buildSimpleDevice(String name) throws Exception {
- Device device = new Device();
+ private Device createDevice(String name) {
+ var device = new Device();
device.setName(name);
device.setType("default");
- device = doPost("/api/device", device, Device.class);
- return device;
+ return doPost("/api/device", device, Device.class);
+ }
+
+ private ResultActions getRelation(EntityRelation relation) throws Exception {
+ return doGet("/api/relation?" +
+ "fromId=" + relation.getFrom().getId() +
+ "&fromType=" + relation.getFrom().getEntityType() +
+ "&relationType=" + relation.getType() +
+ "&toId=" + relation.getTo().getId() +
+ "&toType=" + relation.getTo().getEntityType());
}
private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) {
@@ -591,7 +655,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
private void createDevicesByFrom(int numOfDevices, String baseName) throws Exception {
for (int i = 0; i < numOfDevices; i++) {
- Device device = buildSimpleDevice(baseName + i);
+ Device device = createDevice(baseName + i);
EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS");
doPost("/api/relation", relation).andExpect(status().isOk());
@@ -600,7 +664,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
private void createDevicesByTo(int numOfDevices, String baseName) throws Exception {
for (int i = 0; i < numOfDevices; i++) {
- Device device = buildSimpleDevice(baseName + i);
+ Device device = createDevice(baseName + i);
EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS");
doPost("/api/relation", relation).andExpect(status().isOk());
}
@@ -633,4 +697,5 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
Assert.assertEquals("Wrong relationType!", "CONTAINS", info.getType());
}
}
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
index 10b49dfe1d..6d095dc95f 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -63,16 +63,19 @@ import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.dao.entityview.EntityViewDao;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static java.util.concurrent.TimeUnit.HOURS;
@@ -148,6 +151,36 @@ public class EntityViewControllerTest extends AbstractControllerTest {
assertEquals(savedView, foundView);
}
+ @Test
+ public void testFindEntityViewByIds() throws Exception {
+ List assetProfiles = new ArrayList<>();
+ for (int i = 0; i < 20; i++) {
+ assetProfiles.add(getNewSavedEntityView("Test entity view " + i));
+ }
+
+ List expected = assetProfiles.subList(5, 15);
+
+ String idsParam = expected.stream()
+ .map(ap -> ap.getId().getId().toString())
+ .collect(Collectors.joining(","));
+ EntityView[] foundEntityViews = doGet("/api/entityViews?entityViewIds=" + idsParam, EntityView[].class);
+
+ Assert.assertNotNull(foundEntityViews);
+ Assert.assertEquals(expected.size(), foundEntityViews.length);
+
+ Map infoById = Arrays.stream(foundEntityViews)
+ .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity()));
+
+ for (EntityView entityView : expected) {
+ UUID id = entityView.getId().getId();
+ EntityView view = infoById.get(id);
+ Assert.assertNotNull("Entity view not found for id " + id, view);
+
+ Assert.assertEquals(entityView.getId(), view.getId());
+ Assert.assertEquals(entityView.getName(), view.getName());
+ }
+ }
+
@Test
public void testSaveEntityView() throws Exception {
String name = "Test entity view";
@@ -853,4 +886,29 @@ public class EntityViewControllerTest extends AbstractControllerTest {
EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId();
testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId);
}
+
+ @Test
+ public void testSaveEntityViewWithUniquifyStrategy() throws Exception {
+ EntityView view = new EntityView();
+ view.setEntityId(testDevice.getId());
+ view.setTenantId(tenantId);
+ view.setType("default");
+ view.setName("My unique view");
+
+ EntityView savedView = doPost("/api/entityView", view, EntityView.class);
+
+ doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest());
+
+ EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class);
+ assertThat(secondView.getName()).startsWith("My unique view_");
+
+ EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class);
+ assertThat(thirdView.getName()).startsWith("My unique view-");
+
+ EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
+ assertThat(fourthView.getName()).isEqualTo("My unique view_1");
+
+ EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
+ assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2");
+ }
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
index bd0ca9def9..ee2aefc81a 100644
--- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java
index 86ba070bba..0b8767e65d 100644
--- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -189,7 +189,7 @@ public class ImageControllerTest extends AbstractControllerTest {
assertThat(newImageInfo.getTitle()).isEqualTo(newTitle);
assertThat(newImageInfo.getDescriptor(ImageDescriptor.class)).isEqualTo(imageDescriptor);
assertThat(newImageInfo.getResourceKey()).isEqualTo(imageInfo.getResourceKey());
- assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(newImageInfo.getPublicResourceKey());
+ assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(imageInfo.getPublicResourceKey());
}
@Test
diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java
index 83ccb122d3..2156d07e57 100644
--- a/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java
index d643058ef4..72b925bcf8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java
index a3c0dbb571..ca514f7690 100644
--- a/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java
index 3fc839996c..5425c34740 100644
--- a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -37,7 +37,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.nio.ByteBuffer;
diff --git a/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java
index 1a17f79ac3..797b1a0d5c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java
index 9cd75e9a3c..072e83cc47 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java
index 38d73b2966..7a32a4785c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
index a6a022347f..d53e69d721 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -47,12 +47,17 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@@ -231,6 +236,44 @@ public class RuleChainControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedRuleChain, foundRuleChain);
}
+ @Test
+ public void testFindRuleChainsByIds() throws Exception {
+ List ruleChains = new ArrayList<>();
+ for (int i = 0; i < 20; i++) {
+ RuleChain ruleChain = new RuleChain();
+ ruleChain.setName("RuleChain " + i);
+ ruleChains.add(doPost("/api/ruleChain", ruleChain, RuleChain.class));
+ }
+
+ List expected = ruleChains.subList(5, 15);
+
+ String idsParam = expected.stream()
+ .map(rc -> rc.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ RuleChain[] result = doGet(
+ "/api/ruleChains?ruleChainIds=" + idsParam,
+ RuleChain[].class
+ );
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(expected.size(), result.length);
+
+ Map rcById = Arrays.stream(result)
+ .collect(Collectors.toMap(rc -> rc.getId().getId(), Function.identity()));
+
+ for (RuleChain rc : expected) {
+ UUID id = rc.getId().getId();
+ RuleChain found = rcById.get(id);
+ Assert.assertNotNull("RuleChain not found for id " + id, found);
+
+ Assert.assertEquals(rc.getId(), found.getId());
+ Assert.assertEquals(rc.getName(), found.getName());
+ Assert.assertEquals(rc.getTenantId(), found.getTenantId());
+ }
+ }
+
+
@Test
public void testDeleteRuleChain() throws Exception {
RuleChain ruleChain = new RuleChain();
diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java
index ac79b180bb..81c74de038 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
index d88aaa2756..7fb316dd7e 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -25,11 +25,12 @@ import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockPart;
import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard;
-import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
@@ -47,15 +48,14 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.widget.WidgetType;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
-import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.Base64;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@@ -64,7 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DaoSqlTest
public class TbResourceControllerTest extends AbstractControllerTest {
- private IdComparator idComparator = new IdComparator<>();
+ private final IdComparator idComparator = new IdComparator<>();
private static final String DEFAULT_FILE_NAME = "test.jks";
private static final String DEFAULT_FILE_NAME_2 = "test2.jks";
@@ -126,13 +126,10 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey());
Assert.assertArrayEquals(resource.getData(), download(savedResource.getId()));
- TbResource foundResource = doGet("/api/resource/" + savedResource.getId().getId().toString(), TbResource.class);
- foundResource.setTitle("My new resource");
- foundResource.setData(null);
-
- savedResource = save(foundResource);
-
- Assert.assertEquals(foundResource.getTitle(), savedResource.getTitle());
+ String resourceTitle = "My new resource";
+ savedResource.setTitle(resourceTitle);
+ savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class);
+ assertThat(savedResource.getTitle()).isEqualTo(resourceTitle);
testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(),
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
@@ -501,8 +498,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
ActionType.ADDED, cntEntity, cntEntity, cntEntity);
- Collections.sort(resources, idComparator);
- Collections.sort(loadedResources, idComparator);
+ resources.sort(idComparator);
+ loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources);
}
@@ -549,8 +546,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED,
jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity);
- Collections.sort(resources, idComparator);
- Collections.sort(loadedResources, idComparator);
+ resources.sort(idComparator);
+ loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources);
}
@@ -581,8 +578,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
}
} while (pageData.hasNext());
- Collections.sort(resources, idComparator);
- Collections.sort(loadedResources, idComparator);
+ resources.sort(idComparator);
+ loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources);
@@ -654,8 +651,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
}
} while (pageData.hasNext());
- Collections.sort(jksResources, idComparator);
- Collections.sort(loadedResources, idComparator);
+ jksResources.sort(idComparator);
+ loadedResources.sort(idComparator);
Assert.assertEquals(jksResources, loadedResources);
@@ -736,8 +733,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
}
} while (pageData.hasNext());
- Collections.sort(expectedResources, idComparator);
- Collections.sort(loadedResources, idComparator);
+ expectedResources.sort(idComparator);
+ loadedResources.sort(idComparator);
Assert.assertEquals(expectedResources, loadedResources);
@@ -770,7 +767,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
MockHttpServletResponse response = resultActions.andReturn().getResponse();
String eTag = response.getHeader("ETag");
Assert.assertNotNull(eTag);
- Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA);
+ Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray()));
//download with if-none-match header
HttpHeaders headers = new HttpHeaders();
@@ -814,7 +811,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
MockHttpServletResponse response = resultActions.andReturn().getResponse();
String eTag = response.getHeader("ETag");
Assert.assertNotNull(eTag);
- Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA);
+ Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray()));
//download with if-none-match header
HttpHeaders headers = new HttpHeaders();
@@ -859,10 +856,10 @@ public class TbResourceControllerTest extends AbstractControllerTest {
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("can't be updated")));
- foundResource.setData(null);
- foundResource.setTitle("Updated resource");
- savedResource = doPost("/api/resource", foundResource, TbResource.class);
- assertThat(savedResource.getTitle()).isEqualTo("Updated resource");
+ String resourceTitle = "Updated resource";
+ savedResource.setTitle(resourceTitle);
+ savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class);
+ assertThat(savedResource.getTitle()).isEqualTo(resourceTitle);
assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName());
assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag());
assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA);
@@ -923,8 +920,20 @@ public class TbResourceControllerTest extends AbstractControllerTest {
}
private TbResourceInfo save(TbResource tbResource) throws Exception {
- return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() {
- });
+ byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null;
+ List parts = new ArrayList<>();
+ parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes()));
+ if (tbResource.getTitle() != null) {
+ parts.add(new MockPart("title", tbResource.getTitle().getBytes()));
+ }
+ if (tbResource.getDescriptor() != null) {
+ parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes()));
+ }
+ if (tbResource.getResourceSubType() != null) {
+ parts.add(new MockPart("resourceSubType", tbResource.getResourceSubType().name().getBytes()));
+ }
+
+ return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts);
}
private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception {
@@ -949,7 +958,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
for (String model : models) {
String fileName = model + ".xml";
- byte[] bytes = IOUtils.toByteArray(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName));
+ byte[] bytes = IOUtils.toByteArray(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)));
TbResource resource = new TbResource();
resource.setResourceType(ResourceType.LWM2M_MODEL);
diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
index 1c5b1dd154..26705ddacb 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java
index ad3c6d5312..dd51cdd440 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
@@ -110,6 +113,13 @@ public class TelemetryControllerTest extends AbstractControllerTest {
var monthResult = result.get("t").get(0);
Assert.assertEquals(22L, monthResult.get("value").asLong());
Assert.assertEquals(middleOfTheInterval, monthResult.get("ts").asLong());
+
+ // get all latest (without keys parameter)
+ ObjectNode allLatest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() +
+ "/values/timeseries?startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}",
+ ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv");
+ Assert.assertNotNull(allLatest);
+ Assert.assertNotNull(allLatest.get("t"));
}
@Test
@@ -232,6 +242,63 @@ public class TelemetryControllerTest extends AbstractControllerTest {
doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody2, String.class, status().isBadRequest());
}
+ @Test
+ public void testDeleteTelemetryByKeyWithComma() throws Exception {
+ loginTenantAdmin();
+ Device device = createDevice();
+
+ String tsKey = "key1,key2";
+ String testBody = JacksonUtil.newObjectNode()
+ .put(tsKey, "value")
+ .toString();
+ doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk());
+
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ params.add("key", tsKey);
+ params.add("deleteAllDataForKeys", "true");
+
+ ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class);
+ assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value");
+
+ doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params);
+
+ ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class);
+ Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull());
+ }
+
+ @Test
+ public void testDeleteTelemetryByKeysWithComma() throws Exception {
+ loginTenantAdmin();
+ Device device = createDevice();
+
+ String keyWithComma = "key1,key2";
+ String testBody = JacksonUtil.newObjectNode()
+ .put(keyWithComma, "value")
+ .toString();
+ doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk());
+
+ String key = "key3";
+ String testBody2 = JacksonUtil.newObjectNode()
+ .put(key, "value")
+ .toString();
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ params.add("key", keyWithComma);
+ params.add("key", key);
+ params.add("deleteAllDataForKeys", "true");
+
+ doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody2, String.class, status().isOk());
+
+ ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class);
+ assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value");
+ assertThat(tsData.get("key3").get(0).get("value").asText()).isEqualTo("value");
+
+ doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params);
+
+ ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class);
+ Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull());
+ Assert.assertTrue(tsDataAfterDeletion.get("key3").get(0).get("value").isNull());
+ }
+
private Device createDevice() throws Exception {
String testToken = "TEST_TOKEN";
diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
index ba45be8e28..fb86ea7b43 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -27,8 +27,8 @@ import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentMatcher;
import org.mockito.Mockito;
-import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.actors.ActorSystemContext;
@@ -68,6 +68,7 @@ import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.QueueKey;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@@ -77,6 +78,7 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
+import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -109,11 +111,11 @@ public class TenantControllerTest extends AbstractControllerTest {
ListeningExecutorService executor;
- @SpyBean
+ @MockitoSpyBean
private PartitionService partitionService;
- @SpyBean
+ @MockitoSpyBean
private ActorSystemContext actorContext;
- @SpyBean
+ @MockitoSpyBean
private TbQueueAdmin queueAdmin;
@Before
@@ -187,6 +189,51 @@ public class TenantControllerTest extends AbstractControllerTest {
deleteTenant(savedTenant.getId());
}
+ @Test
+ public void testFindTenantsByIds() throws Exception {
+ loginSysAdmin();
+
+ List savedTenants = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ Tenant tenant = new Tenant();
+ tenant.setTitle("My tenant " + i);
+ savedTenants.add(saveTenant(tenant));
+ }
+
+ String idsParam = savedTenants.stream()
+ .map(t -> t.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ Tenant[] foundTenants = doGet("/api/tenants?tenantIds=" + idsParam, Tenant[].class);
+
+ Assert.assertNotNull(foundTenants);
+ Assert.assertEquals(savedTenants.size(), foundTenants.length);
+
+ Map foundById = Arrays.stream(foundTenants)
+ .collect(Collectors.toMap(t -> t.getId().getId(), Function.identity()));
+
+ for (Tenant savedTenant : savedTenants) {
+ UUID id = savedTenant.getId().getId();
+ Tenant foundTenant = foundById.get(id);
+ Assert.assertNotNull("Tenant not found for id " + id, foundTenant);
+ Assert.assertEquals(savedTenant, foundTenant);
+ }
+
+ loginTenantAdmin();
+ Tenant[] foundTenantsByTenant = doGet("/api/tenants?tenantIds=" + idsParam, Tenant[].class);
+ assertThat(foundTenantsByTenant).isEmpty();
+
+ Tenant[] foundCurrentTenant = doGet("/api/tenants?tenantIds=" + tenantId, Tenant[].class);
+ assertThat(foundCurrentTenant).hasSize(1);
+ assertThat(foundCurrentTenant[0].getTenantId()).isEqualTo(tenantId);
+
+ loginSysAdmin();
+ for (Tenant savedTenant : savedTenants) {
+ deleteTenant(savedTenant.getId());
+ }
+ }
+
+
@Test
public void testFindTenantInfoById() throws Exception {
loginSysAdmin();
@@ -243,15 +290,37 @@ public class TenantControllerTest extends AbstractControllerTest {
.andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr))));
}
+ @Test
+ public void testDeleteTenantByTenantAdmin() throws Exception {
+ loginSysAdmin();
+ Tenant tenant = new Tenant();
+ tenant.setTitle("My tenant");
+ Tenant savedTenant = saveTenant(tenant);
+
+ //login as tenant admin
+ User tenantAdminUser = new User();
+ tenantAdminUser.setAuthority(Authority.TENANT_ADMIN);
+ tenantAdminUser.setTenantId(savedTenant.getId());
+ tenantAdminUser.setEmail("tenantToDelete@thingsboard.io");
+
+ createUserAndLogin(tenantAdminUser, TENANT_ADMIN_PASSWORD);
+
+ String tenantIdStr = savedTenant.getId().getId().toString();
+ deleteTenant(savedTenant.getId());
+ loginSysAdmin();
+ doGet("/api/tenant/" + tenantIdStr)
+ .andExpect(status().isNotFound())
+ .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr))));
+ }
+
@Test
public void testFindTenants() throws Exception {
loginSysAdmin();
- List tenants = new ArrayList<>();
PageLink pageLink = new PageLink(17);
PageData pageData = doGetTypedWithPageLink("/api/tenants?", PAGE_DATA_TENANT_TYPE_REF, pageLink);
Assert.assertFalse(pageData.hasNext());
Assert.assertEquals(1, pageData.getData().size());
- tenants.addAll(pageData.getData());
+ List tenants = new ArrayList<>(pageData.getData());
Mockito.reset(tbClusterService);
@@ -377,12 +446,11 @@ public class TenantControllerTest extends AbstractControllerTest {
@Test
public void testFindTenantInfos() throws Exception {
loginSysAdmin();
- List tenants = new ArrayList<>();
PageLink pageLink = new PageLink(17);
PageData pageData = doGetTypedWithPageLink("/api/tenantInfos?", PAGE_DATA_TENANT_INFO_TYPE_REF, pageLink);
Assert.assertFalse(pageData.hasNext());
Assert.assertEquals(1, pageData.getData().size());
- tenants.addAll(pageData.getData());
+ List tenants = new ArrayList<>(pageData.getData());
List> createFutures = new ArrayList<>(56);
for (int i = 0; i < 56; i++) {
diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java
index 8f83f2c186..42a27eb6d8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -42,8 +42,12 @@ import org.thingsboard.server.queue.TbQueueCallback;
import java.lang.reflect.Field;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static org.hamcrest.Matchers.containsString;
@@ -120,6 +124,43 @@ public class TenantProfileControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedTenantProfile.getName(), foundTenantProfileInfo.getName());
}
+ @Test
+ public void testFindTenantProfilesByIds() throws Exception {
+ loginSysAdmin();
+ List tenantProfiles = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ TenantProfile tenantProfile = this.createTenantProfile("Tenant Profile " + i);
+ tenantProfiles.add(doPost("/api/tenantProfile", tenantProfile, TenantProfile.class));
+ }
+
+ List expected = tenantProfiles.subList(2, 7);
+
+ String idsParam = expected.stream()
+ .map(tp -> tp.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ TenantProfile[] result = doGet(
+ "/api/tenantProfiles?ids=" + idsParam,
+ TenantProfile[].class
+ );
+
+ Assert.assertNotNull(result);
+ Assert.assertEquals(expected.size(), result.length);
+
+ Map tpById = Arrays.stream(result)
+ .collect(Collectors.toMap(tp -> tp.getId().getId(), Function.identity()));
+
+ for (TenantProfile tp : expected) {
+ UUID id = tp.getId().getId();
+ TenantProfile found = tpById.get(id);
+ Assert.assertNotNull("TenantProfile not found for id " + id, found);
+
+ Assert.assertEquals(tp.getId(), found.getId());
+ Assert.assertEquals(tp.getName(), found.getName());
+ }
+ }
+
+
@Test
public void testFindDefaultTenantProfileInfo() throws Exception {
loginSysAdmin();
diff --git a/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java
index 3612c4a584..04b58a084c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
index 3611409cb7..c8b5bf06c9 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter;
+import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig;
@@ -48,6 +50,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaP
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -85,7 +88,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaConfigManager.deletePlatformTwoFaSettings(tenantId);
}
-
@Test
public void testSavePlatformTwoFaSettings() throws Exception {
loginSysAdmin();
@@ -102,15 +104,32 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
twoFaSettings.setTotalAllowedTimeForVerification(3600);
+ twoFaSettings.setEnforceTwoFa(true);
+ twoFaSettings.setEnforcedUsersFilter(new AllUsersFilter());
- doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
+ saveTwoFaSettings(twoFaSettings);
- PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class);
+ PlatformTwoFaSettings savedTwoFaSettings = findTwoFaSettings();
assertThat(savedTwoFaSettings.getProviders()).hasSize(2);
assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig);
}
+ @Test
+ public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception {
+ loginSysAdmin();
+
+ PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
+ twoFaSettings.setProviders(List.of());
+ twoFaSettings.setMinVerificationCodeSendPeriod(5);
+ twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
+ twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
+ twoFaSettings.setTotalAllowedTimeForVerification(3600);
+ twoFaSettings.setEnforceTwoFa(true);
+
+ doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest());
+ }
+
@Test
public void testSavePlatformTwoFaSettings_validationError() throws Exception {
loginSysAdmin();
@@ -157,17 +176,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorResponse).containsIgnoringCase("verificationCodeLifetime is required");
}
- private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
- PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
- twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
- twoFaSettings.setMinVerificationCodeSendPeriod(5);
- twoFaSettings.setTotalAllowedTimeForVerification(100);
-
- return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
- .andExpect(status().isBadRequest()));
- }
-
-
@Test
public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception {
configureSmsTwoFaProvider("${code}");
@@ -268,24 +276,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorMessage).containsIgnoringCase("verification code is incorrect");
}
- private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
- TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
- .andExpect(status().isOk()), TwoFaAccountConfig.class);
- assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
-
- assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
- UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
- assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
- assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
- assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
- assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
- assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
- assertDoesNotThrow(() -> Base32.decode(secretKey));
- });
- });
- return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
- }
-
@Test
public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception {
testVerifyAndSaveTotpTwoFaAccountConfig();
@@ -419,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig);
}
+ @Test
+ public void testIsTwoFaEnabled() throws Exception {
+ configureSmsTwoFaProvider("${code}");
+ SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
+ accountConfig.setPhoneNumber("+38050505050");
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
+
+ assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue();
+ }
+
+ @Test
+ public void testDeleteTwoFaAccountConfig() throws Exception {
+ configureSmsTwoFaProvider("${code}");
+ loginTenantAdmin();
+ SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
+ accountConfig.setPhoneNumber("+38050505050");
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
+
+ AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
+ TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
+ assertThat(savedAccountConfig).isEqualTo(accountConfig);
+
+ PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get();
+ twoFaSettings.setEnforceTwoFa(true);
+ TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
+ enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
+ twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
+ twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
+
+ String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS")
+ .andExpect(status().isBadRequest()));
+ assertThat(errorMessage).isEqualTo("At least one 2FA provider is required");
+
+ twoFaSettings.setEnforceTwoFa(false);
+ twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
+
+ doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
+
+ assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
+ .doesNotContainKey(TwoFaProviderType.SMS);
+ }
+
+ private PlatformTwoFaSettings findTwoFaSettings() throws Exception {
+ return doGet("/api/2fa/settings", PlatformTwoFaSettings.class);
+ }
+
+ private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception {
+ doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
+ }
+
private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception {
TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
totpTwoFaProviderConfig.setIssuerName("tb");
@@ -441,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList()));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
- doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
+ saveTwoFaSettings(twoFaSettings);
}
- @Test
- public void testIsTwoFaEnabled() throws Exception {
- configureSmsTwoFaProvider("${code}");
- SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
- accountConfig.setPhoneNumber("+38050505050");
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
+ private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
+ TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
+ .andExpect(status().isOk()), TwoFaAccountConfig.class);
+ assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
- assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue();
+ assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
+ UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
+ assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
+ assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
+ assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
+ assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
+ assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
+ assertDoesNotThrow(() -> Base32.decode(secretKey));
+ });
+ });
+ return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
}
- @Test
- public void testDeleteTwoFaAccountConfig() throws Exception {
- configureSmsTwoFaProvider("${code}");
- SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
- accountConfig.setPhoneNumber("+38050505050");
-
- loginTenantAdmin();
-
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
-
- AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
- TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
- assertThat(savedAccountConfig).isEqualTo(accountConfig);
-
- doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
+ private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
+ PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
+ twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
+ twoFaSettings.setMinVerificationCodeSendPeriod(5);
+ twoFaSettings.setTotalAllowedTimeForVerification(100);
- assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
- .doesNotContainKey(TwoFaProviderType.SMS);
+ return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
+ .andExpect(status().isBadRequest()));
}
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
index 10d06150dc..ca81b97a48 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
@@ -33,6 +34,8 @@ import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter;
+import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.common.data.page.TimePageLink;
@@ -58,6 +61,7 @@ import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -116,7 +120,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_totp() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@@ -136,7 +140,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_sms() throws Exception {
configureSmsTwoFa();
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=SMS")
.andExpect(status().isOk());
@@ -160,7 +164,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setTotalAllowedTimeForVerification(65);
});
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("expiration of the pre-verification token")
.atLeast(Duration.ofSeconds(30).plusMillis(500))
@@ -177,7 +181,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
});
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
Stream.generate(() -> StringUtils.randomNumeric(6))
.limit(9)
@@ -206,7 +210,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMinVerificationCodeSendPeriod(10);
});
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@@ -230,7 +234,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:10");
});
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (int i = 0; i < 3; i++) {
String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@@ -258,7 +262,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testCheckVerificationCode_invalidVerificationCode() throws Exception {
configureTotpTwoFa();
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) {
String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode)
@@ -273,7 +277,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
smsTwoFaProviderConfig.setVerificationCodeLifetime(10);
});
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class);
doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk());
@@ -296,7 +300,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_logLoginAction() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
- logInWithPreVerificationToken(username, password);
+ logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("async audit log saving").during(1, TimeUnit.SECONDS);
doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@@ -334,7 +338,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException {
configureTotpTwoFa();
- twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP);
+ twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP);
assertDoesNotThrow(() -> {
login(username, password);
@@ -368,17 +372,17 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP);
totpTwoFaAccountConfig.setUseByDefault(true);
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig);
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig);
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38012312322");
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig);
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig);
EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig();
emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail());
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig);
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig);
- logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678");
+ logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN);
Map providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference>() {}).stream()
.collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v));
@@ -395,13 +399,79 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse();
}
- private void logInWithPreVerificationToken(String username, String password) throws Exception {
+ @Test
+ public void testEnforceTwoFa() throws Exception {
+ TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
+ totpTwoFaProviderConfig.setIssuerName("tb");
+
+ PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
+ twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList()));
+ twoFaSettings.setMinVerificationCodeSendPeriod(5);
+ twoFaSettings.setTotalAllowedTimeForVerification(100);
+ twoFaSettings.setEnforceTwoFa(true);
+ TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
+ enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
+ twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
+ twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
+
+ logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN);
+
+ TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class);
+
+ String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build()
+ .getQueryParams().getFirst("secret");
+ String verificationCode = new Totp(secret).now();
+ readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class);
+
+ JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class);
+ assertThat(tokenPair.getToken()).isNotEmpty();
+ assertThat(tokenPair.getRefreshToken()).isNotEmpty();
+ validateAndSetJwtToken(tokenPair, username);
+
+ doGet("/api/user/" + user.getId()).andExpect(status().isOk());
+
+ // verifying enforced users filter
+ createDifferentTenant();
+ doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk());
+ }
+
+ @Test
+ public void testEnforceTwoFa_sysadmin() throws Exception {
+ TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
+ totpTwoFaProviderConfig.setIssuerName("tb");
+
+ PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
+ twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList()));
+ twoFaSettings.setMinVerificationCodeSendPeriod(5);
+ twoFaSettings.setTotalAllowedTimeForVerification(100);
+ twoFaSettings.setEnforceTwoFa(true);
+ AllUsersFilter enforcedUsersFilter = new AllUsersFilter();
+ twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
+ twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
+
+ logInWithMfaToken(SYS_ADMIN_EMAIL, SYS_ADMIN_PASSWORD, Authority.MFA_CONFIGURATION_TOKEN);
+
+ TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class);
+ String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build()
+ .getQueryParams().getFirst("secret");
+ String verificationCode = new Totp(secret).now();
+ readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class);
+
+ JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class);
+ assertThat(tokenPair.getToken()).isNotEmpty();
+ assertThat(tokenPair.getRefreshToken()).isNotEmpty();
+ validateAndSetJwtToken(tokenPair, SYS_ADMIN_EMAIL);
+
+ doGet("/api/user/" + user.getId()).andExpect(status().isOk());
+ }
+
+ private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception {
LoginRequest loginRequest = new LoginRequest(username, password);
JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class);
assertThat(response.getToken()).isNotNull();
assertThat(response.getRefreshToken()).isNull();
- assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN);
+ assertThat(response.getScope()).isEqualTo(expectedScope);
this.token = response.getToken();
}
@@ -418,7 +488,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP);
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig);
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig);
return totpTwoFaAccountConfig;
}
@@ -436,7 +506,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38050505050");
- twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig);
+ twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig);
return smsTwoFaAccountConfig;
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
index 28d43699fd..957b3721e0 100644
--- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -50,13 +50,17 @@ import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.settings.StarredDashboardInfo;
import org.thingsboard.server.common.data.settings.UserDashboardsInfo;
import org.thingsboard.server.dao.device.DeviceService;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserDao;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -263,6 +267,37 @@ public class UserControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedUser, foundUser);
}
+ @Test
+ public void testFindUsersByIds() throws Exception {
+ loginTenantAdmin();
+ List savedUsers = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ User user = createTenantAdminUser();
+ savedUsers.add(doPost("/api/user", user, User.class));
+ }
+
+ String idsParam = savedUsers.stream()
+ .map(u -> u.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ User[] foundUsers = doGet("/api/users?userIds=" + idsParam, User[].class);
+
+ Assert.assertNotNull(foundUsers);
+ Assert.assertEquals(savedUsers.size(), foundUsers.length);
+
+ Map foundById = Arrays.stream(foundUsers)
+ .collect(Collectors.toMap(u -> u.getId().getId(), Function.identity()));
+
+ for (User savedUser : savedUsers) {
+ User foundUser = foundById.get(savedUser.getId().getId());
+ Assert.assertNotNull("User not found for id " + savedUser.getId().getId(), foundUser);
+
+ foundUser.setAdditionalInfo(savedUser.getAdditionalInfo());
+ Assert.assertEquals(savedUser, foundUser);
+ }
+ }
+
+
@Test
public void testSaveUserWithSameEmail() throws Exception {
loginSysAdmin();
diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
index 6dacf50592..87ba0ec3e8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java
index e418db9857..a7a0349ebe 100644
--- a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java
index 3440f26ea8..917470eea1 100644
--- a/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -29,12 +29,17 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
-import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -150,6 +155,37 @@ public class WidgetsBundleControllerTest extends AbstractControllerTest {
Assert.assertEquals(savedWidgetsBundle, foundWidgetsBundle);
}
+ @Test
+ public void testFindWidgetsBundlesByIds() throws Exception {
+ List savedWidgetsBundles = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ WidgetsBundle widgetsBundle = new WidgetsBundle();
+ widgetsBundle.setTitle("My widgets bundle " + i);
+ savedWidgetsBundles.add(doPost("/api/widgetsBundle", widgetsBundle, WidgetsBundle.class));
+ }
+
+ String idsParam = savedWidgetsBundles.stream()
+ .map(wb -> wb.getId().getId().toString())
+ .collect(Collectors.joining(","));
+
+ WidgetsBundle[] foundWidgetsBundles =
+ doGet("/api/widgetsBundles?widgetsBundleIds=" + idsParam, WidgetsBundle[].class);
+
+ Assert.assertNotNull(foundWidgetsBundles);
+ Assert.assertEquals(savedWidgetsBundles.size(), foundWidgetsBundles.length);
+
+ Map foundById = Arrays.stream(foundWidgetsBundles)
+ .collect(Collectors.toMap(wb -> wb.getId().getId(), Function.identity()));
+
+ for (WidgetsBundle savedWidgetsBundle : savedWidgetsBundles) {
+ UUID id = savedWidgetsBundle.getId().getId();
+ WidgetsBundle foundWidgetsBundle = foundById.get(id);
+ Assert.assertNotNull("WidgetsBundle not found for id " + id, foundWidgetsBundle);
+ Assert.assertEquals(savedWidgetsBundle, foundWidgetsBundle);
+ }
+ }
+
+
@Test
public void testDeleteWidgetsBundle() throws Exception {
WidgetsBundle widgetsBundle = new WidgetsBundle();
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 b50adfca63..053cb6808f 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
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java
index 84d879c993..3629d7ff49 100644
--- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java
+++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java
@@ -1,5 +1,5 @@
/**
- * Copyright © 2016-2025 The Thingsboard Authors
+ * 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.
@@ -22,6 +22,7 @@ import com.google.protobuf.AbstractMessage;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageLite;
import lombok.extern.slf4j.Slf4j;
+import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -102,6 +103,7 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.UUID;
@@ -738,4 +740,27 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest {
return rpc;
}
+ protected void verifyEdgeDisconnected() {
+ verifyEdgeActiveFlag(false);
+ }
+
+ protected void verifyEdgeConnected() {
+ verifyEdgeActiveFlag(true);
+ }
+
+ private void verifyEdgeActiveFlag(boolean value) {
+ Awaitility.await()
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> {
+ List