diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index d85d025290..c5b077c128 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -44,7 +44,6 @@ import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -279,17 +278,15 @@ public class CalculatedFieldController extends BaseController { } private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { - if (calculatedFieldConfig instanceof ArgumentsBasedCalculatedFieldConfiguration config) { - List referencedEntityIds = config.getReferencedEntities(); - for (EntityId referencedEntityId : referencedEntityIds) { - EntityType entityType = referencedEntityId.getEntityType(); - switch (entityType) { - case TENANT -> { - return; - } - case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + List referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); + for (EntityId referencedEntityId : referencedEntityIds) { + EntityType entityType = referencedEntityId.getEntityType(); + switch (entityType) { + case TENANT -> { + return; } + case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); + default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java index fc5e3106ea..e44829b3a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.geo.Coordinates; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -39,12 +40,13 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -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.GeofencingPresenceStatus.INSIDE; import static org.thingsboard.server.common.data.cf.configuration.GeofencingPresenceStatus.OUTSIDE; +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; @Data +@Slf4j @EqualsAndHashCode(callSuper = true) public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { @@ -116,18 +118,8 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue(); Coordinates entityCoordinates = new Coordinates(latitude, longitude); - var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); - // TODO: refactor - return calculate(entityId, ctx, entityCoordinates, configuration); - } - - private ListenableFuture calculate( - EntityId entityId, - CalculatedFieldCtx ctx, - Coordinates entityCoordinates, - GeofencingCalculatedFieldConfiguration configuration) { - - Map zoneGroups = configuration + var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + Map zoneGroups = geofencingCfg .getZoneGroups() .stream() .collect(Collectors.toMap(ZoneGroupConfiguration::getName, Function.identity())); @@ -136,41 +128,40 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { List> relationFutures = new ArrayList<>(); getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { - ZoneGroupConfiguration zoneGroupConfiguration = zoneGroups.get(argumentKey); - if (zoneGroupConfiguration.isCreateRelationsWithMatchedZones()) { - List zoneResults = new ArrayList<>(); - - argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { - GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); - zoneResults.add(eval); - + ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey); + if (zoneGroupCfg == null) { + log.error("[{}][{}] Zone group config is missing for the {}", entityId, ctx.getCalculatedField().getId(), argumentKey); + return; + } + boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones(); + List zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size()); + argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { + GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); + zoneResults.add(eval); + if (createRelationsWithMatchedZones) { GeofencingTransitionEvent transitionEvent = eval.transition(); if (transitionEvent == null) { return; } - EntityRelation relation = toRelation(zoneId, entityId, zoneGroupConfiguration); + EntityRelation relation = switch (zoneGroupCfg.getDirection()) { + case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); + case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); + }; ListenableFuture f = switch (transitionEvent) { case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); }; relationFutures.add(f); - }); - updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode); - } else { - List zoneResults = argumentEntry.getZoneStates().values().stream() - .map(zs -> zs.evaluate(entityCoordinates)) - .toList(); - updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode); - } + } + }); + updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode); }); var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode); if (relationFutures.isEmpty()) { return Futures.immediateFuture(result); } - return Futures.whenAllComplete(relationFutures) - .call(() -> new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode), - MoreExecutors.directExecutor()); + return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor()); } private Map getGeofencingArguments() { @@ -194,12 +185,6 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } } - private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { - if (aggregationResult.transition() != null) { - resultNode.put(eventKey, aggregationResult.transition().name()); - } - } - private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); boolean prevInside = zoneResults.stream() @@ -213,11 +198,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE); } - private EntityRelation toRelation(EntityId zoneId, EntityId entityId, ZoneGroupConfiguration configuration) { - return switch (configuration.getDirection()) { - case TO -> new EntityRelation(zoneId, entityId, configuration.getRelationType()); - case FROM -> new EntityRelation(entityId, zoneId, configuration.getRelationType()); - }; + private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { + if (aggregationResult.transition() != null) { + resultNode.put(eventKey, aggregationResult.transition().name()); + } } } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java index cfe5a4c4e2..f3c1ae3263 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java @@ -15,7 +15,119 @@ */ package org.thingsboard.server.common.data.cf.configuration.geofencing; -// TODO: add tests +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; + public class ZoneGroupConfigurationTest { + @ParameterizedTest + @ValueSource(strings = " ") + @NullAndEmptySource + void validateShouldThrowWhenNameIsNullEmptyOrBlank(String name) { + var zoneGroupConfiguration = new ZoneGroupConfiguration(name, "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone group name must be specified!"); + } + + @ParameterizedTest + @ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY}) + void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) { + var zoneGroupConfiguration = new ZoneGroupConfiguration(name, "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!"); + } + + @ParameterizedTest + @ValueSource(strings = " ") + @NullAndEmptySource + void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!"); + } + + @Test + void validateShouldThrowWhenReportStrategyIsNull() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", null, false); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!"); + } + + @ParameterizedTest + @ValueSource(strings = " ") + @NullAndEmptySource + void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(relationType); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!"); + } + + @Test + void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setDirection(null); + assertThatThrownBy(zoneGroupConfiguration::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!"); + } + + @Test + void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatCode(zoneGroupConfiguration::validate).doesNotThrowAnyException(); + } + + @Test + void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setDirection(EntitySearchDirection.TO); + assertThatCode(zoneGroupConfiguration::validate).doesNotThrowAnyException(); + } + + @Test + void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue(); + } + + @Test + void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() { + var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); + assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull(); + assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse(); + } + + + @Test + void validateToArgumentsMethodCallWithoutRefEntityId() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + Argument zoneGroupArgument = zoneGroupConfiguration.toArgument(); + assertThat(zoneGroupArgument).isNotNull(); + assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + } + }