From 8dd5a4e7cd172310072c8cd4325706d4977f4888 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 14 Jul 2025 21:05:04 +0300 Subject: [PATCH] AI rule node: add tests for audit logs and rule engine lifecycle messages --- .../controller/AiModelControllerTest.java | 95 ++----- .../ai/DefaultTbAiModelServiceTest.java | 268 ++++++++++++++++++ 2 files changed, 294 insertions(+), 69 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java 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 cae91cd91a..ae2972b0cc 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -18,7 +18,6 @@ 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; @@ -28,38 +27,21 @@ 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 @@ -106,29 +88,12 @@ public class AiModelControllerTest extends AbstractControllerTest { 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 @@ -160,26 +125,12 @@ public class AiModelControllerTest extends AbstractControllerTest { // 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 --- */ @@ -284,6 +235,32 @@ public class AiModelControllerTest extends AbstractControllerTest { assertThat(result.hasNext()).isTrue(); } + @Test + public void getAiModels_testSearchAndSortAppliedBeforePagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Create 5 models: 3 with "Alpha" in name, 2 with "Beta" in name + var alpha1 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 1"), AiModel.class); + var beta1 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 1"), AiModel.class); + var alpha2 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 2"), AiModel.class); + var beta2 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 2"), AiModel.class); + var alpha3 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 3"), AiModel.class); + + // WHEN + // Search for "Alpha", sort by name DESC, get the first page with size 2 + PageData result = doGetTypedWithPageLink("/api/ai/model?", + new TypeReference<>() {}, + new PageLink(2, 0, "Alpha", SortOrder.of("name", SortOrder.Direction.DESC))); + + // THEN + // Should find only 3 "Alpha" models, sort them DESC (3, 2, 1), then return first 2 + assertThat(result.getData()).containsExactly(alpha3, alpha2); + assertThat(result.getTotalPages()).isEqualTo(2); // One more "Alpha" model on the next page + assertThat(result.getTotalElements()).isEqualTo(3); // Only 3 models match "Alpha", not 5 + assertThat(result.hasNext()).isTrue(); // One more "Alpha" model on the next page + } + @Test public void getAiModels_testTextSearch() throws Exception { // GIVEN @@ -595,16 +572,6 @@ public class AiModelControllerTest extends AbstractControllerTest { // 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()) @@ -623,16 +590,6 @@ public class AiModelControllerTest extends AbstractControllerTest { // 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) { diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java new file mode 100644 index 0000000000..2321446b44 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -0,0 +1,268 @@ +/** + * 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.service.entitiy.ai; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +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.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class DefaultTbAiModelServiceTest { + + @Mock + EntitiesVersionControlService vcServiceMock; + + @Mock + AiModelService aiModelServiceMock; + + @Mock + TbLogEntityActionService logEntityActionServiceMock; + + @Spy + @InjectMocks + DefaultTbAiModelService service; + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + User user; + + @BeforeEach + void setUp() { + user = new User(); + user.setTenantId(tenantId); + + service = new DefaultTbAiModelService(aiModelServiceMock); + ReflectionTestUtils.setField(service, "vcService", vcServiceMock); + ReflectionTestUtils.setField(service, "logEntityActionService", logEntityActionServiceMock); + } + + @Test + void save_whenCreatingNewModel_shouldAutoCommitAndLogAddedActionAndUseTenantIdFromUser() { + // GIVEN + var modelToSave = AiModel.builder() + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var savedModel = new AiModel(modelToSave); + savedModel.setId(new AiModelId(UUID.randomUUID())); + savedModel.setTenantId(user.getTenantId()); + savedModel.setVersion(1L); + savedModel.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.save(modelToSave)).willReturn(savedModel); + + // WHEN + AiModel result = service.save(modelToSave, user); + + // THEN + assertThat(result).isEqualTo(savedModel); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should().autoCommit(user, savedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, savedModel.getId(), savedModel, ActionType.ADDED, user); + } + + @Test + void save_whenUpdatingExistingModel_shouldAutoCommitAndLogUpdatedAction() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var updatedModel = new AiModel(modelToUpdate); + updatedModel.setVersion(2L); + updatedModel.setName("Updated model"); + + given(aiModelServiceMock.save(modelToUpdate)).willReturn(updatedModel); + + // WHEN + AiModel result = service.save(modelToUpdate, user); + + // THEN + assertThat(result).isEqualTo(updatedModel); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should().autoCommit(user, updatedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, updatedModel.getId(), updatedModel, ActionType.UPDATED, user); + } + + @Test + void save_whenCreatingNewModelThrowsException_shouldUseEmptyIdAndLogError() { + // GIVEN + var modelToSave = AiModel.builder() + .tenantId(tenantId) + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToSave)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToSave, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, new AiModelId(EntityId.NULL_UUID), modelToSave, ActionType.ADDED, user, exception); + } + + @Test + void save_whenUpdatingExistingModelThrowsException_shouldUseExistingModelIdAndLogError() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToUpdate)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToUpdate, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToUpdate.getId(), modelToUpdate, ActionType.UPDATED, user, exception); + } + + @Test + void delete_whenDeleteSuccessful_shouldLogDeletedAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(true); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isTrue(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteReturnsFalse_shouldNotLogAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(false); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isFalse(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should(never()).logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteThrowsException_shouldLogError() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to delete"); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.delete(modelToDelete, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to delete"); + + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, exception, modelToDelete.getId().toString()); + } + + private static AiModelConfig constructValidOpenAiModelConfig() { + return 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(); + } + +}