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 3de0cc6c31..9e598db69a 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 @@ -135,7 +135,7 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState { Coordinates entityCoordinates, GeofencingCalculatedFieldConfiguration configuration) { - var geofencingZoneGroupConfigurations = configuration.getGeofencingZoneGroupConfigurations(); + var geofencingZoneGroupConfigurations = configuration.getZoneGroupConfigurations(); Map zoneEventMap = new HashMap<>(); ObjectNode resultNode = JacksonUtil.newObjectNode(); @@ -184,7 +184,7 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState { Coordinates entityCoordinates, GeofencingCalculatedFieldConfiguration configuration) { - var geofencingZoneGroupConfigurations = configuration.getGeofencingZoneGroupConfigurations(); + var geofencingZoneGroupConfigurations = configuration.getZoneGroupConfigurations(); ObjectNode resultNode = JacksonUtil.newObjectNode(); getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 1980ea26cd..8a8d5e389e 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -32,7 +32,7 @@ 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.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent; -import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ZoneGroupConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -701,10 +701,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes // Zone group reporting config List reportEvents = Arrays.stream(GeofencingEvent.values()).toList(); - GeofencingZoneGroupConfiguration allowedCfg = new GeofencingZoneGroupConfiguration("allowedZone", reportEvents); - GeofencingZoneGroupConfiguration restrictedCfg = new GeofencingZoneGroupConfiguration("restrictedZone", reportEvents); + ZoneGroupConfiguration allowedCfg = new ZoneGroupConfiguration("allowedZone", reportEvents); + ZoneGroupConfiguration restrictedCfg = new ZoneGroupConfiguration("restrictedZone", reportEvents); - cfg.setGeofencingZoneGroupConfigurations(Map.of( + cfg.setZoneGroupConfigurations(Map.of( "allowedZones", allowedCfg, "restrictedZones", restrictedCfg )); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 218742539c..0d7a2971d0 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent; -import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ZoneGroupConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -337,9 +337,9 @@ public class GeofencingCalculatedFieldStateTest { config.setArguments(Map.of("latitude", argument1, "longitude", argument2, "allowedZones", argument3, "restrictedZones", argument4)); List reportEvents = Arrays.stream(GeofencingEvent.values()).toList(); - GeofencingZoneGroupConfiguration allowedZoneGroupConfiguration = new GeofencingZoneGroupConfiguration("allowedZone", reportEvents); - GeofencingZoneGroupConfiguration restrictedZoneGroupConfiguration = new GeofencingZoneGroupConfiguration("restrictedZone", reportEvents); - config.setGeofencingZoneGroupConfigurations(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); + ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("allowedZone", reportEvents); + ZoneGroupConfiguration restrictedZoneGroupConfiguration = new ZoneGroupConfiguration("restrictedZone", reportEvents); + config.setZoneGroupConfigurations(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); config.setCreateRelationsWithMatchedZones(true); config.setZoneRelationType("CurrentZone"); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java index fe3c2eff16..e31e62deef 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java @@ -30,14 +30,14 @@ import static org.assertj.core.api.Assertions.assertThat; public class GeofencingZoneStateTest { private final AssetId ZONE_ID = new AssetId(UUID.fromString("628730fd-d625-417f-9c6d-ae9fe4addbdb")); - private final String POLYGON = """ - {"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"} - """; private GeofencingZoneState state; @BeforeEach void setUp() { + String POLYGON = """ + {"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"} + """; state = new GeofencingZoneState(ZONE_ID, new BaseAttributeKvEntry(new JsonDataEntry("zone", POLYGON), 100L, 1L)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java index 39780f09a6..b115f3e334 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java @@ -44,7 +44,7 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC private boolean createRelationsWithMatchedZones; private String zoneRelationType; private EntitySearchDirection zoneRelationDirection; - private Map geofencingZoneGroupConfigurations; + private Map zoneGroupConfigurations; @Override public CalculatedFieldType getType() { @@ -60,13 +60,9 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC @Override public void validate() { if (arguments == null) { - throw new IllegalArgumentException("Geofencing calculated field arguments are empty!"); - } - if (arguments.size() < 3) { - throw new IllegalArgumentException("Geofencing calculated field must contain at least 3 arguments!"); + throw new IllegalArgumentException("Geofencing calculated field arguments must be specified!"); } validateCoordinateArguments(); - Map zoneGroupsArguments = getZoneGroupArguments(); if (zoneGroupsArguments.isEmpty()) { throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!"); @@ -81,32 +77,30 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC return; } if (StringUtils.isBlank(zoneRelationType)) { - throw new IllegalArgumentException("Zone relation type must be specified when to maintain relations with matched zones!"); + throw new IllegalArgumentException("Zone relation type must be specified to create relations with matched zones!"); } if (zoneRelationDirection == null) { - throw new IllegalArgumentException("Zone relation direction must be specified to maintain relations with matched zones!"); + throw new IllegalArgumentException("Zone relation direction must be specified to create relations with matched zones!"); } } private void validateZoneGroupConfigurations(Map zoneGroupsArguments) { - if (geofencingZoneGroupConfigurations == null) { - throw new IllegalArgumentException("Geofencing calculated field zone group configurations are empty!"); + if (zoneGroupConfigurations == null || zoneGroupConfigurations.isEmpty()) { + throw new IllegalArgumentException("Zone groups configuration should be specified!"); } Set usedPrefixes = new HashSet<>(); - geofencingZoneGroupConfigurations.forEach((zoneGroupName, config) -> { - Argument zoneGroupArgument = zoneGroupsArguments.get(zoneGroupName); - if (zoneGroupArgument == null) { - throw new IllegalArgumentException("Geofencing calculated field zone group configuration is not configured for zone group: " + zoneGroupName); - } + + zoneGroupsArguments.forEach((zoneGroupName, zoneGroupArgument) -> { + ZoneGroupConfiguration config = zoneGroupConfigurations.get(zoneGroupName); if (config == null) { - throw new IllegalArgumentException("Zone group configuration is not configured for zone group: " + zoneGroupName); + throw new IllegalArgumentException("Zone group configuration is not configured for '" + zoneGroupName + "' argument!"); } if (CollectionsUtil.isEmpty(config.getReportEvents())) { - throw new IllegalArgumentException("Zone group configuration report events must be specified for zone group: " + zoneGroupName); + throw new IllegalArgumentException("Zone group configuration report events must be specified for '" + zoneGroupName + "' argument!"); } String prefix = config.getReportTelemetryPrefix(); if (StringUtils.isBlank(prefix)) { - throw new IllegalArgumentException("Report telemetry prefix should be specified for zone group: " + zoneGroupName); + throw new IllegalArgumentException("Report telemetry prefix should be specified for '" + zoneGroupName + "' argument!"); } if (!usedPrefixes.add(prefix)) { throw new IllegalArgumentException("Duplicate report telemetry prefix found: '" + prefix + "'. Must be unique!"); @@ -118,26 +112,23 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC for (String coordinateKey : coordinateKeys) { Argument argument = arguments.get(coordinateKey); if (argument == null) { - throw new IllegalArgumentException("Missing required coordinates argument: " + coordinateKey); + throw new IllegalArgumentException("Missing required coordinates argument: " + coordinateKey + "!"); } ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, coordinateKey); if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) { - throw new IllegalArgumentException("Argument '" + coordinateKey + "' must be of type TS_LATEST."); + throw new IllegalArgumentException("Argument '" + coordinateKey + "' must be of type TS_LATEST!"); } if (argument.hasDynamicSource()) { - throw new IllegalArgumentException("Dynamic source is not allowed for argument: '" + coordinateKey + "'."); + throw new IllegalArgumentException("Dynamic source is not allowed for '" + coordinateKey + "' argument!"); } } } private void validateZoneGroupAruguments(Map zoneGroupsArguments) { zoneGroupsArguments.forEach((argumentKey, argument) -> { - if (argument == null) { - throw new IllegalArgumentException("Zone group argument is not configured: " + argumentKey); - } ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, argumentKey); if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) { - throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE."); + throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE!"); } if (argument.hasDynamicSource()) { argument.getRefDynamicSourceConfiguration().validate(); @@ -148,6 +139,7 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC private Map getZoneGroupArguments() { return arguments.entrySet() .stream() + .filter(entry -> entry.getValue() != null) .filter(entry -> !coordinateKeys.contains(entry.getKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ZoneGroupConfiguration.java similarity index 94% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ZoneGroupConfiguration.java index c82151fc64..cc5eb70eea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ZoneGroupConfiguration.java @@ -20,7 +20,7 @@ import lombok.Data; import java.util.List; @Data -public class GeofencingZoneGroupConfiguration { +public class ZoneGroupConfiguration { private final String reportTelemetryPrefix; private final List reportEvents; diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfigurationTest.java new file mode 100644 index 0000000000..f4b4a66300 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfigurationTest.java @@ -0,0 +1,472 @@ +/** + * 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.common.data.cf.configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY; +import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; + +@ExtendWith(MockitoExtension.class) +public class GeofencingCalculatedFieldConfigurationTest { + + @Test + void typeShouldBeGeofencing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.GEOFENCING); + } + + @Test + void validateShouldThrowWhenArgumentsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(null); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Geofencing calculated field arguments must be specified!"); + } + + @Test + void validateShouldThrowWhenLatitudeArgIsMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, null); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + cfg.setArguments(arguments); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing required coordinates argument: " + ENTITY_ID_LATITUDE_ARGUMENT_KEY + "!"); + } + + @Test + void validateShouldThrowWhenLongitudeArgIsMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, null); + cfg.setArguments(arguments); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing required coordinates argument: " + ENTITY_ID_LONGITUDE_ARGUMENT_KEY + "!"); + } + + @Test + void validateShouldThrowWhenLatitudeReferenceKeyIsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(null), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)) + ); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing or invalid reference entity key for argument: " + ENTITY_ID_LATITUDE_ARGUMENT_KEY); + } + + @Test + void validateShouldThrowWhenLongitudeReferenceKeyIsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(null)) + ); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing or invalid reference entity key for argument: " + ENTITY_ID_LONGITUDE_ARGUMENT_KEY); + } + + @Test + void validateShouldThrowWhenLatitudeReferenceKeyTypeIsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(new ReferencedEntityKey("latitude", null, null)), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)) + ); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing or invalid reference entity key for argument: " + ENTITY_ID_LATITUDE_ARGUMENT_KEY); + } + + @Test + void validateShouldThrowWhenReferenceKeyTypeIsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(new ReferencedEntityKey("longitude", null, null))) + ); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing or invalid reference entity key for argument: " + ENTITY_ID_LONGITUDE_ARGUMENT_KEY); + } + + @Test + void validateShouldThrowWhenLatitudeArgHasWrongArgumentType() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.ATTRIBUTE), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST) + )); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument '" + ENTITY_ID_LATITUDE_ARGUMENT_KEY + "' must be of type TS_LATEST!"); + } + + @Test + void validateShouldThrowWhenLongitudeArgHasWrongArgumentType() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.ATTRIBUTE) + )); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument '" + ENTITY_ID_LONGITUDE_ARGUMENT_KEY + "' must be of type TS_LATEST!"); + } + + @Test + void validateShouldThrowWhenLatitudeArgHasDynamicSource() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + + Argument latitudeArg = toArgument("latitude", ArgumentType.TS_LATEST); + var refDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); + latitudeArg.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + + Argument longitudeArg = toArgument("longitude", ArgumentType.TS_LATEST); + + cfg.setArguments(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArg, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArg)); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Dynamic source is not allowed for '" + ENTITY_ID_LATITUDE_ARGUMENT_KEY + "' argument!"); + } + + @Test + void validateShouldThrowWhenLongitudeArgHasDynamicSource() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + + Argument latitudeArg = toArgument("latitude", ArgumentType.TS_LATEST); + Argument longitudeArg = toArgument("longitude", ArgumentType.TS_LATEST); + var refDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); + longitudeArg.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + + cfg.setArguments(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArg, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArg)); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Dynamic source is not allowed for '" + ENTITY_ID_LONGITUDE_ARGUMENT_KEY + "' argument!"); + } + + @Test + void validateShouldThrowWhenGeofencingArgumentsMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST) + )); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!"); + } + + @Test + void validateShouldThrowWhenZoneGroupArgumentIsNull() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + arguments.put("someZones", null); + + cfg.setArguments(arguments); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!"); + } + + @Test + void validateShouldThrowWhenZoneGroupArgumentHasInvalidArgumentType() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + arguments.put("allowedZones", toArgument("allowedZone", ArgumentType.TS_LATEST)); + + cfg.setArguments(arguments); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument 'allowedZones' must be of type ATTRIBUTE!"); + } + + @Test + void validateShouldCallDynamicSourceConfigValidationWhenZoneGroupArgumentHasDynamicSourceConfiguration() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("allowedZone", Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + + cfg.validate(); + + verify(refDynamicSourceConfigurationMock).validate(); + } + + @Test + void validateShouldThrowWhenZoneGroupConfigurationIsMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(null); + cfg.setCreateRelationsWithMatchedZones(false); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone groups configuration should be specified!"); + } + + @Test + void validateShouldThrowWhenReportTelemetryPrefixDuplicate() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + Argument restrictedZonesArg = toArgument("restrictedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + arguments.put("restrictedZones", restrictedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("theSamePrefixTest", Arrays.asList(GeofencingEvent.values())); + ZoneGroupConfiguration restrictedZoneConfiguration = new ZoneGroupConfiguration("theSamePrefixTest", Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration, "restrictedZones", restrictedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(false); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Duplicate report telemetry prefix found: 'theSamePrefixTest'. Must be unique!"); + } + + @Test + void validateShouldThrowWhenZoneGroupArgumentConfigurationIsMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("allowedZone", Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("someOtherZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(false); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone group configuration is not configured for 'allowedZones' argument!"); + } + + @Test + void validateShouldThrowWhenZoneGroupConfigurationReportEventsAreNotSpecified() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("allowedZone", null); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(false); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone group configuration report events must be specified for 'allowedZones' argument!"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void validateShouldThrowWhenZoneGroupConfigurationTelemetryPrefixIsBlankOrNull(String reportTelemetryPrefix) { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration(reportTelemetryPrefix, Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(false); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Report telemetry prefix should be specified for 'allowedZones' argument!"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void validateShouldThrowWhenHasBlankOrNullZoneRelationType(String zoneRelationType) { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("allowedZone", Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(true); + cfg.setZoneRelationType(zoneRelationType); + cfg.setZoneRelationDirection(EntitySearchDirection.TO); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone relation type must be specified to create relations with matched zones!"); + } + + @Test + void validateShouldThrowWhenNoZoneRelationDirectionSpecified() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + var arguments = new HashMap(); + arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)); + arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)); + Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE); + var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class); + allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock); + arguments.put("allowedZones", allowedZonesArg); + + ZoneGroupConfiguration allowedZoneConfiguration = new ZoneGroupConfiguration("allowedZone", Arrays.asList(GeofencingEvent.values())); + Map zoneGroupConfigurations = Map.of("allowedZones", allowedZoneConfiguration); + + cfg.setArguments(arguments); + cfg.setZoneGroupConfigurations(zoneGroupConfigurations); + cfg.setCreateRelationsWithMatchedZones(true); + cfg.setZoneRelationType("SomeRelationType"); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Zone relation direction must be specified to create relations with matched zones!"); + } + + @Test + void scheduledUpdateDisabledWhenIntervalIsZero() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setScheduledUpdateIntervalSec(0); + assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); + } + + @Test + void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButArgumentsAreEmpty() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of()); + cfg.setScheduledUpdateIntervalSec(60); + assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); + } + + @Test + void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButDynamicArgumentsAreMissing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setArguments(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST))); + cfg.setScheduledUpdateIntervalSec(60); + assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); + } + + @Test + void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + Argument someDynamicArgument = toArgument("someDynamicArgument", ArgumentType.ATTRIBUTE); + someDynamicArgument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + cfg.setArguments(Map.of("someDynamicArugument", someDynamicArgument)); + cfg.setScheduledUpdateIntervalSec(60); + assertThat(cfg.isScheduledUpdateEnabled()).isTrue(); + } + + + private Argument toArgument(String key, ArgumentType type) { + var referencedEntityKey = new ReferencedEntityKey(key, type, null); + return toArgument(referencedEntityKey); + } + + private Argument toArgument(ReferencedEntityKey referencedEntityKey) { + Argument argument = new Argument(); + argument.setRefEntityKey(referencedEntityKey); + return argument; + } + +}