From 40e0d4a92f04b6cd3e3be92ae278e79a42da6c92 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 14 Nov 2025 16:02:45 +0200 Subject: [PATCH] added validation for config and tests --- .../EntityAggregationCalculatedFieldTest.java | 84 ++++++++---- .../CalculatedFieldControllerTest.java | 57 ++++++++ ...gregationCalculatedFieldConfiguration.java | 32 +++++ .../single/interval/AggInterval.java | 2 + .../single/interval/BaseAggInterval.java | 24 +++- ...ationCalculatedFieldConfigurationTest.java | 128 ++++++++++++++++++ .../single/interval/AggIntervalTest.java | 29 ++++ 7 files changed, 327 insertions(+), 29 deletions(-) create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index 8e35b01cb5..e479c4959e 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -93,41 +93,71 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest } @Test - public void testCreateCf_checkAggregation() throws Exception { + public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception { Device device = createDevice("Device", "1234567890111"); - CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 30L); + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + long intervalEndTs = customInterval.getCurrentIntervalEndTs(); + + CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null); + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF and no telemetry during interval -> save metric with default value") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999"); + }); + } + + @Test + public void testCreateCfWithoutWatermark_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs(); - long tsBeforeInterval = currentIntervalStartTs - 1000L; - long tsInInterval_1 = currentIntervalStartTs + 1000L; - long tsInInterval_2 = currentIntervalStartTs + 500L; - long tsInInterval_3 = currentIntervalStartTs + 200L; + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); long interval = customInterval.getCurrentIntervalDurationMillis(); - Watermark watermark = new Watermark(60); - CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, watermark); + CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1)); - await().alias("create CF and perform aggregation after interval end") + await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ") .atMost(2 * interval, TimeUnit.MILLISECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode result = getLatestTelemetry(device.getId(), "consumptionPerMin"); + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumptionPerMin").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); }); } @Test - public void testCreateCf_checkAggregationDuringWatermark() throws Exception { + public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { Device device = createDevice("Device", "1234567890111"); - CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 30L); + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs(); @@ -141,27 +171,27 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); long interval = customInterval.getCurrentIntervalDurationMillis(); - Watermark watermark = new Watermark(60); + Watermark watermark = new Watermark(10); CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, watermark); - await().alias("create CF and perform aggregation after interval end") + await().alias("create CF -> perform aggregation after interval end") .atMost(2 * interval, TimeUnit.MILLISECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode result = getLatestTelemetry(device.getId(), "consumptionPerMin"); + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumptionPerMin").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1)); - await().alias("create CF and perform aggregation after interval end") + await().alias("update telemetry during watermark -> perform aggregation") .atMost(2 * 10, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode result = getLatestTelemetry(device.getId(), "consumptionPerMin"); + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumptionPerMin").get(0).get("value").asText()).isEqualTo("600"); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600"); }); } @@ -169,15 +199,15 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest Map arguments = new HashMap<>(); Argument argument = new Argument(); argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); - argument.setLimit(100); arguments.put("en", argument); Map aggMetrics = new HashMap<>(); - AggMetric consumptionPerMin = new AggMetric(); - consumptionPerMin.setFunction(AggFunction.SUM); - consumptionPerMin.setInput(new AggKeyInput("en")); - aggMetrics.put("consumptionPerMin", consumptionPerMin); + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + consumption.setDefaultValue(9999L); + aggMetrics.put("consumption", consumption); Output output = new Output(); output.setType(OutputType.TIME_SERIES); @@ -208,7 +238,9 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest configuration.setArguments(inputs); configuration.setMetrics(metrics); configuration.setInterval(aggInterval); - configuration.setWatermark(watermark); + if (watermark != null) { + configuration.setWatermark(watermark); + } configuration.setOutput(output); calculatedField.setConfiguration(configuration); diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 4ebace6ae7..61fc7a9e48 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -32,6 +32,11 @@ import org.thingsboard.server.common.data.cf.configuration.PropagationCalculated import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -45,6 +50,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -166,6 +172,34 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testSaveEntityAggregationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getEntityAggregationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + @Test public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); @@ -237,6 +271,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig()); case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig()); case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig()); + case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig()); } calculatedField.setVersion(1L); return calculatedField; @@ -287,6 +322,28 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { return config; } + private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() { + var config = new EntityAggregationCalculatedFieldConfiguration(); + + Argument energyArgument = new Argument(); + energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("en", energyArgument)); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("en")); + metric.setDefaultValue(9999L); + config.setMetrics(Map.of("consumption", metric)); + + config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1))); + config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15))); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + return config; + } + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java index eb97538701..f6095d41a7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java @@ -24,6 +24,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.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; @@ -53,6 +54,12 @@ public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsB @Override public void validate() { + validateArguments(); + validateMetrics(); + validateInterval(); + } + + private void validateArguments() { if (arguments.containsKey("ctx")) { throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); } @@ -61,4 +68,29 @@ public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsB } } + private void validateMetrics() { + if (metrics == null || metrics.isEmpty()) { + throw new IllegalArgumentException("Metrics map cannot be empty."); + } + + for (AggMetric metric : metrics.values()) { + if (metric.getInput() instanceof AggKeyInput aggKeyInput) { + if (!arguments.containsKey(aggKeyInput.getKey())) { + throw new IllegalArgumentException( + "Metric references unknown argument: '" + aggKeyInput.getKey() + "'." + ); + } + } else { + throw new IllegalArgumentException("Metric key can only refer to argument."); + } + } + } + + private void validateInterval() { + if (interval == null) { + throw new IllegalArgumentException("Interval must be defined."); + } + interval.validate(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java index 6bff7b4398..3d38ebc1f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java @@ -62,4 +62,6 @@ public interface AggInterval { ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart); + void validate(); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java index 53f7117bfc..e6400230b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java @@ -23,6 +23,7 @@ import lombok.NoArgsConstructor; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.concurrent.TimeUnit; @Data @JsonInclude(JsonInclude.Include.NON_NULL) @@ -39,7 +40,7 @@ public abstract class BaseAggInterval implements AggInterval { return ZoneId.of(tz); } - protected long getOffset() { + protected long getOffsetSafe() { return offsetSec != null ? offsetSec : 0L; } @@ -57,7 +58,7 @@ public abstract class BaseAggInterval implements AggInterval { @Override public long getDateTimeIntervalStartTs(ZonedDateTime dateTime) { - long offset = getOffset(); + long offset = getOffsetSafe(); ZonedDateTime shiftedNow = dateTime.minusSeconds(offset); ZonedDateTime alignedStart = getAlignedBoundary(shiftedNow, false); ZonedDateTime actualStart = alignedStart.plusSeconds(offset); @@ -73,7 +74,7 @@ public abstract class BaseAggInterval implements AggInterval { @Override public long getDateTimeIntervalEndTs(ZonedDateTime dateTime) { - long offset = getOffset(); + long offset = getOffsetSafe(); ZonedDateTime shiftedNow = dateTime.minusSeconds(offset); ZonedDateTime alignedEnd = getAlignedBoundary(shiftedNow, true); ZonedDateTime actualEnd = alignedEnd.plusSeconds(offset); @@ -82,4 +83,21 @@ public abstract class BaseAggInterval implements AggInterval { protected abstract ZonedDateTime getAlignedBoundary(ZonedDateTime reference, boolean next); + @Override + public void validate() { + try { + getZoneId(); + } catch (Exception ex) { + throw new IllegalArgumentException("Invalid timezone in interval: " + ex.getMessage()); + } + if (offsetSec != null) { + if (offsetSec < 0) { + throw new IllegalArgumentException("Offset cannot be negative."); + } + if (TimeUnit.SECONDS.toMillis(offsetSec) >= getCurrentIntervalDurationMillis()) { + throw new IllegalArgumentException("Offset must be greater than interval duration."); + } + } + } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java new file mode 100644 index 0000000000..3884b5a214 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java @@ -0,0 +1,128 @@ +/** + * 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.aggregation.single; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +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.Output; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class EntityAggregationCalculatedFieldConfigurationTest { + + @Test + void typeShouldBeEntityAggregation() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION); + } + + @ParameterizedTest + @ValueSource(strings = {"ATTRIBUTE", "TS_ROLLING"}) + void validateShouldThrowWhenNotTsLatestArgumentUsed(String argumentType) { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("k", validArgument(ArgumentType.valueOf(argumentType)))); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Calculated field with type: '" + cfg.getType() + "' support only TS_LATEST arguments."); + } + + @Test + void validateShouldThrowWhenMetricMapIsEmpty() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + cfg.setMetrics(Map.of()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metrics map cannot be empty."); + } + + @Test + void validateShouldThrowWhenMetricInputIsNotAggKeyInput() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggFunctionInput()); // cannot be function + cfg.setMetrics(Map.of("m", metric)); + + cfg.setInterval(new HourInterval("Europe/Kiev", null)); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metric key can only refer to argument."); + } + + @Test + void validateShouldThrowWhenMetricReferencesUnknownArgument() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("unknown")); + cfg.setMetrics(Map.of("m", metric)); + + cfg.setInterval(new HourInterval("Europe/Kiev", null)); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metric references unknown argument: 'unknown'."); + } + + @Test + void validateShouldThrowWhenIntervalIsNull() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + cfg.setMetrics(Map.of("m", validMetric())); + cfg.setInterval(null); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Interval must be defined."); + } + + private Argument validArgument(ArgumentType type) { + Argument a = new Argument(); + a.setRefEntityKey(new ReferencedEntityKey("key", type, null)); + return a; + } + + private AggMetric validMetric() { + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("k")); + return metric; + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java index 38fda87879..b439c44fef 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -29,11 +30,39 @@ import java.util.function.LongFunction; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class AggIntervalTest { private static final String TZ = "Europe/Kiev"; + @Test + void validateShouldThrowWhenInvalidTimZone() { + AggInterval interval = new HourInterval("TimeZone", null); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid timezone in interval: "); + } + + @Test + void validateShouldThrowWhenOffsetIsNegative() { + AggInterval interval = new CustomInterval(TZ, -100L, TimeUnit.HOURS.toSeconds(2)); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Offset cannot be negative."); + } + + @Test + void validateShouldThrowWhenOffsetGreaterThanIntervalDuration() { + AggInterval interval = new CustomInterval(TZ, TimeUnit.HOURS.toSeconds(2), TimeUnit.HOURS.toSeconds(2)); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Offset must be greater than interval duration."); + } + @ParameterizedTest @MethodSource("intervals") void testGetStartAndEndWithoutOffset(LongFunction intervalCreator) {