diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java new file mode 100644 index 0000000000..cae91cd91a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -0,0 +1,658 @@ +/** + * Copyright © 2016-2025 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.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.Test; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AiModelId; +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.dao.service.DaoSqlTest; +import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelControllerTest extends AbstractControllerTest { + + @SpyBean + private EntitiesVersionControlService versionControlService; + + @SpyBean + private TbLogEntityActionService logEntityActionService; + + /* --- Save API tests --- */ + + @Test + public void saveAiModel_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenCreatingValidModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + var savedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(savedModel.getId()).isNotNull(); + assertThat(savedModel.getUuidId()).isNotNull().isNotEqualTo(EntityId.NULL_UUID); + assertThat(savedModel.getId().getEntityType()).isEqualTo(EntityType.AI_MODEL); + + assertThat(savedModel.getCreatedTime()).isPositive(); + assertThat(savedModel.getVersion()).isEqualTo(1); + + assertThat(savedModel.getTenantId()).isEqualTo(tenantId); + assertThat(savedModel.getName()).isEqualTo("Test model"); + assertThat(savedModel.getConfiguration()).isEqualTo(model.getConfiguration()); + + assertThat(savedModel.getExternalId()).isNull(); + + // verify auto-commit + then(versionControlService).should().autoCommit( + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(savedModel.getId()) + ); + + // verify a rule engine message was sent, and an audit log was created + then(logEntityActionService).should().logEntityAction( + eq(tenantId), + eq(savedModel.getId()), + eq(savedModel), + eq(ActionType.ADDED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())) + ); + } + + @Test + public void saveAiModel_whenUpdatingExistingModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + var newModelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .modelId("o4-mini") + .temperature(0.2) + .topP(0.4) + .frequencyPenalty(0.2) + .presencePenalty(0.5) + .maxOutputTokens(2000) + .timeoutSeconds(20) + .maxRetries(0) + .build(); + + model.setName("Test model updated"); + model.setConfiguration(newModelConfig); + + // WHEN + var updatedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(updatedModel.getId()).isEqualTo(model.getId()); + + assertThat(updatedModel.getCreatedTime()).isEqualTo(model.getCreatedTime()); + assertThat(updatedModel.getVersion()).isEqualTo(2); + + assertThat(updatedModel.getTenantId()).isEqualTo(tenantId); + assertThat(updatedModel.getName()).isEqualTo("Test model updated"); + assertThat(updatedModel.getConfiguration()).isEqualTo(newModelConfig); + + assertThat(updatedModel.getExternalId()).isNull(); + + // verify auto-commit + then(versionControlService).should(times(2)).autoCommit( + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(updatedModel.getId()) + ); + + // verify a rule engine message was sent, and an audit log was created + then(logEntityActionService).should().logEntityAction( + eq(tenantId), eq(updatedModel.getId()), eq(updatedModel), eq(ActionType.UPDATED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())) + ); + } + + /* --- Get by ID API tests --- */ + + @Test + public void getAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenGettingExistingModelAsTenantAdmin_shouldReturnModel() throws Exception { + // GIVEN + loginTenantAdmin(); + + var saved = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + AiModel actual = doGet("/api/ai/model/" + saved.getId(), AiModel.class); + + // THEN + assertThat(actual).isEqualTo(saved); + } + + @Test + public void getAiModelById_whenGettingNonexistentModelAsTenantAdmin_shouldReturnNotFound() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + nonexistentModelId); + + // THEN + result.andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + nonexistentModelId + "] is not found"))); + } + + /* --- Get paged API tests --- */ + + @Test + public void getAiModels_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_testPagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + var model3 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 3"), AiModel.class); + var model4 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 4"), AiModel.class); + var model5 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 5"), AiModel.class); + + // WHEN + PageData result = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(2, 1)); + + // THEN + assertThat(result.getData()).containsExactly(model3, model4); + assertThat(result.getTotalPages()).isEqualTo(3); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.hasNext()).isTrue(); + } + + @Test + public void getAiModels_testTextSearch() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("o3-pro") + .build()) + .build(), AiModel.class); + var model2 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-flash") + .build()) + .build(), AiModel.class); + var model3 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 3") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 10; + int page = 0; + SortOrder sortOrder = null; + + PageData result1 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "google ai", sortOrder)); + + PageData result2 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "pro", sortOrder)); + + PageData result3 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "test", sortOrder)); + + PageData result4 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "anthropic", sortOrder)); + + // THEN + + // should find google models + assertThat(result1.getData()).containsExactly(model2, model3); + assertThat(result1.getTotalPages()).isEqualTo(1); + assertThat(result1.getTotalElements()).isEqualTo(2); + assertThat(result1.hasNext()).isFalse(); + + // should find "o3-pro" and "gemini-2.5-pro" models + assertThat(result2.getData()).containsExactly(model1, model3); + assertThat(result2.getTotalPages()).isEqualTo(1); + assertThat(result2.getTotalElements()).isEqualTo(2); + assertThat(result2.hasNext()).isFalse(); + + // should find all models (all contain "Test" in their names) + assertThat(result3.getData()).containsExactly(model1, model2, model3); + assertThat(result3.getTotalPages()).isEqualTo(1); + assertThat(result3.getTotalElements()).isEqualTo(3); + assertThat(result3.hasNext()).isFalse(); + + // should find no models (nothing matches "anthropic") + assertThat(result4.getData()).isEmpty(); + assertThat(result4.getTotalPages()).isEqualTo(0); + assertThat(result4.getTotalElements()).isEqualTo(0); + assertThat(result4.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByCreatedTime() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(model1, model2); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(model2, model1); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByName() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByProvider() throws Exception { + // GIVEN + loginTenantAdmin(); + + var anthropicModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-0") + .build()) + .build(), AiModel.class); + var geminiModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(anthropicModel, geminiModel); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(geminiModel, anthropicModel); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByModelId() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("model-a") + .build()) + .build(), AiModel.class); + + var modelB = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("model-b") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByIdTieBreaker() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Both models are from OpenAI and sorting will be done on provider + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + + // in both cases result should be the same since in case of ties (both models have OpenAI as provider, sorting by ID ascending is used) + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelA, modelB); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + /* --- Delete API tests --- */ + + @Test + public void deleteAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenDeletingExistingModelAsTenantAdmin_shouldSucceedAndReturnTrue() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + model.getId(), Boolean.class); + + // THEN + assertThat(deleted).isTrue(); + + // verify a rule engine message was sent, and an audit log was created + then(logEntityActionService).should().logEntityAction( + eq(tenantId), + eq(model.getId()), + eq(model), + eq(ActionType.DELETED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), + eq(model.getId().toString()) + ); + + // verify model cannot be found anymore + doGet("/api/ai/model/" + model.getId()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + model.getId() + "] is not found"))); + } + + @Test + public void deleteAiModelById_whenDeletingNonexistentModelAsTenantAdmin_shouldSucceedAndReturnFalse() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + nonexistentModelId, Boolean.class); + + // THEN + assertThat(deleted).isFalse(); + + // verify a rule engine message was not sent, and an audit log was not created + then(logEntityActionService).should(never()).logEntityAction( + eq(tenantId), + eq(nonexistentModelId), + any(AiModel.class), + eq(ActionType.DELETED), + argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), + eq(nonexistentModelId.toString()) + ); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + + return AiModel.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index d9e6a1753e..0a2b41a91f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.ai.model; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; @@ -74,7 +73,6 @@ public interface AiModelConfig { }) AiProviderConfig providerConfig(); - @JsonProperty("modelType") AiModelType modelType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 2284d8b485..8df5cc0075 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.ai.model.chat; +import com.fasterxml.jackson.annotation.JsonProperty; import dev.langchain4j.model.chat.ChatModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.AiModelType; @@ -28,6 +29,7 @@ public sealed interface AiChatModelConfig> extend ChatModel configure(Langchain4jChatModelConfigurer configurer); @Override + @JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY) default AiModelType modelType() { return AiModelType.CHAT; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 1f4e4645dd..954a84f519 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +@Builder public record AmazonBedrockChatModelConfig( - AiModelType modelType, @NotNull @Valid AmazonBedrockProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 69af0f8c2b..6477e4f106 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +@Builder public record AnthropicChatModelConfig( - AiModelType modelType, @NotNull @Valid AnthropicProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index afe5f6c563..797c744a3e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +@Builder public record AzureOpenAiChatModelConfig( - AiModelType modelType, @NotNull @Valid AzureOpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index ecadb7d453..1d2f3aad40 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +@Builder public record GitHubModelsChatModelConfig( - AiModelType modelType, @NotNull @Valid GitHubModelsProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 1922dea6a1..d659deaa5c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +@Builder public record GoogleAiGeminiChatModelConfig( - AiModelType modelType, @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index e8691c0926..a0b7870a6c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +@Builder public record GoogleVertexAiGeminiChatModelConfig( - AiModelType modelType, @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index ea2409ba35..d978b36dc2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +@Builder public record MistralAiChatModelConfig( - AiModelType modelType, @NotNull @Valid MistralAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 95f6f1cdc3..7c03a670a7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -22,13 +22,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; import lombok.With; -import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +@Builder public record OpenAiChatModelConfig( - AiModelType modelType, @NotNull @Valid OpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index ef083ebccb..60fb1df6aa 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import java.io.Serializable; @@ -64,6 +65,7 @@ public final class TbMsgMetaData implements Serializable { return new TbMsgMetaData(data); } + @JsonIgnore public boolean isEmpty() { return data == null || data.isEmpty(); } diff --git a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java index 2c214460f6..001513b008 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java @@ -26,11 +26,13 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; import java.util.Base64; import java.util.Iterator; @Slf4j public final class AzureIotHubUtil { + private static final String BASE_DIR_PATH = System.getProperty("user.dir"); private static final String APP_DIR = "application"; private static final String SRC_DIR = "src"; @@ -52,41 +54,37 @@ public final class AzureIotHubUtil { } } - private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; - private static final long ONE_SECOND_IN_MILLISECONDS = 1000; + private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; // one year private static final String SAS_TOKEN_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s"; private static final String USERNAME_FORMAT = "%s/%s/?api-version=2018-06-30"; - private AzureIotHubUtil() { - } + private AzureIotHubUtil() {} public static String buildUsername(String host, String deviceId) { return String.format(USERNAME_FORMAT, host, deviceId); } - public static String buildSasToken(String host, String sasKey) { + public static String buildSasToken(String host, String sasKey, Clock clock) { try { - final String targetUri = URLEncoder.encode(host.toLowerCase(), "UTF-8"); - final long expiryTime = buildExpiresOn(); + final String targetUri = URLEncoder.encode(host.toLowerCase(), StandardCharsets.UTF_8); + final long expiryTime = buildExpiresOn(clock); String toSign = targetUri + "\n" + expiryTime; byte[] keyBytes = Base64.getDecoder().decode(sasKey.getBytes(StandardCharsets.UTF_8)); SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] rawHmac = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)); - String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), "UTF-8"); + String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), StandardCharsets.UTF_8); return String.format(SAS_TOKEN_FORMAT, targetUri, signature, expiryTime); } catch (Exception e) { - throw new RuntimeException("Failed to build SAS token!!!", e); + throw new RuntimeException("Failed to build SAS token!", e); } } - private static long buildExpiresOn() { - long expiresOnDate = System.currentTimeMillis(); - expiresOnDate += SAS_TOKEN_VALID_SECS * ONE_SECOND_IN_MILLISECONDS; - return expiresOnDate / ONE_SECOND_IN_MILLISECONDS; + private static long buildExpiresOn(Clock clock) { + return clock.instant().plusSeconds(SAS_TOKEN_VALID_SECS).getEpochSecond(); } public static String getDefaultCaCert() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java index 0a746f15ab..cbe681fb58 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java @@ -42,8 +42,8 @@ interface AiModelRepository extends JpaRepository, Exportab WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL OR model.name ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%') """, countQuery = """ SELECT COUNT(*) @@ -51,7 +51,7 @@ interface AiModelRepository extends JpaRepository, Exportab WHERE model.tenant_id = :tenantId AND (:textSearch IS NULL OR model.name ILIKE '%' || :textSearch || '%' - OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') """, nativeQuery = true diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java index e0c145fe03..882c86555b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java @@ -89,7 +89,7 @@ class JpaAiModelDao extends JpaAbstractDao implements Ai sort = JpaSort.unsafe( Sort.Direction.fromString(sortOrder.getDirection().name()), AiModelEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty()) - ); + ).and(Sort.by(Sort.Direction.ASC, "id")); } return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); } diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index a44303107c..1c2c0c5519 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -111,6 +111,9 @@ cache.specs.mobileSecretKey.maxSize=10000 cache.specs.trendzSettings.timeToLiveInMinutes=1440 cache.specs.trendzSettings.maxSize=10000 +cache.specs.aiModel.timeToLiveInMinutes=1440 +cache.specs.aiModel.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 @@ -158,4 +161,4 @@ queue.core.poll-interval=5 queue.core.partitions=2 queue.rule-engine.poll-interval=5 -spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 740ce16a04..f36e9cb17a 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -52,9 +52,20 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie @RuleNode( type = ComponentType.EXTERNAL, - name = "AI", - nodeDescription = "Interact with AI", - nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message", + name = "AI request", + nodeDescription = "Sends a request to an AI model using system and user prompts. Supports JSON mode.", + nodeDetails = """ + Interact with large language models (LLMs) by sending dynamic requests from your rule chain. + You can select a specific AI model and define its behavior using a system prompt (optional context or role) and a user prompt (the main task). + Both prompts can be populated with data and metadata from the incoming message using patterns. + For example, the $[*] and ${*} patterns allow you to access the all message body and all metadata, respectively. +

+ After sending the request, the node waits for a response within a configured timeout. + You can specify the desired response format as Text, JSON, or provide a specific JSON Schema to structure the output. + The AI-generated content is forwarded as the body of the outgoing message; the originator, message type, and metadata from the incoming message remain unchanged. +

+ Output connections: Success, Failure. + """, configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", ruleChainTypes = RuleChainType.CORE @@ -87,7 +98,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); timeoutSeconds = config.getTimeoutSeconds(); - modelId = config.getAiModelId(); + modelId = config.getModelId(); + super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so Optional model = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId); if (model.isEmpty()) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java index eba3d5fef9..7dec428dad 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -32,7 +32,7 @@ import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseForm public class TbAiNodeConfiguration implements NodeConfiguration { @NotNull - private AiModelId aiModelId; + private AiModelId modelId; @Pattern(regexp = ".*\\S.*", message = "must not be blank") @Length(min = 1, max = 10000) @@ -50,6 +50,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { boolean hasChanges = false; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java index 433d5d4673..c8c1553fa5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java @@ -34,6 +34,9 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.credentials.CertPemCredentials; import org.thingsboard.rule.engine.mqtt.TbMqttNodeConfiguration; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -77,7 +80,10 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyPrepareMqttClientConfigMethodWithAzureIotHubSasCredentials() throws Exception { - AzureIotHubSasCredentials credentials = new AzureIotHubSasCredentials(); + var fixedClock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); + azureIotHubNode.setClock(fixedClock); + + var credentials = new AzureIotHubSasCredentials(); credentials.setSasKey("testSasKey"); credentials.setCaCert("test-ca-cert.pem"); azureIotHubNodeConfig.setCredentials(credentials); @@ -89,7 +95,7 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { azureIotHubNode.prepareMqttClientConfig(mqttClientConfig); assertThat(mqttClientConfig.getUsername()).isEqualTo(AzureIotHubUtil.buildUsername(azureIotHubNodeConfig.getHost(), mqttClientConfig.getClientId())); - assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey())); + assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey(), fixedClock)); } @Test