AI rule node: add tests for audit logs and rule engine lifecycle messages

This commit is contained in:
Dmytro Skarzhynets 2025-07-14 21:05:04 +03:00
parent 7bb5347fbe
commit 8dd5a4e7cd
No known key found for this signature in database
GPG Key ID: 2B51652F224037DF
2 changed files with 294 additions and 69 deletions

View File

@ -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<AiModel> 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) {

View File

@ -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();
}
}