From 8538b7cc022feb0029dc8b35c0adeec5aa5a3b8f Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 8 Jul 2026 17:48:12 +0300 Subject: [PATCH 1/4] Fix ENTITY_AGGREGATION CF storing numeric results as strings EntityAggregationCalculatedFieldState.toResult() serialized every metric result via ObjectNode.put(name, JacksonUtil.toString(value)), which produces a JSON *string* node even for numeric aggregation results (SUM/AVG/COUNT/...). When persisted (in particular with transport.json.type_cast_enabled=false, or for non-parsable values) the result lands in ts_kv.str_v, so server-side AVG/SUM aggregation returns no data in widgets/queries while COUNT/MIN/MAX still return. Serialize with JacksonUtil.valueToTree(...) so numeric results are emitted as numeric JSON nodes (-> dbl_v/long_v), mirroring RelatedEntitiesAggregationCalculatedFieldState. Genuine string MIN/MAX results over string telemetry are preserved as string nodes. The instanceof Number guard is retained (it only governs rounding). --- .../single/EntityAggregationCalculatedFieldState.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index c945868576..f50bc602ce 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -293,7 +293,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt Object resultValue = argumentEntry.getValue() instanceof Number number ? NumberUtils.roundResult(number.doubleValue(), precision) : argumentEntry.getValue(); - metricsNode.put(metricName, JacksonUtil.toString(resultValue)); + metricsNode.set(metricName, JacksonUtil.valueToTree(resultValue)); } } if (!metricsNode.isEmpty()) { From 80d1631bb3d0194a4fdee7692341e0d2fe111140 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 13:35:31 +0300 Subject: [PATCH 2/4] Add regression tests for ENTITY_AGGREGATION CF numeric serialization Cover the fix that makes EntityAggregationCalculatedFieldState.toResult() emit numeric aggregation results as numeric JSON nodes (dbl_v/long_v) instead of JSON strings (str_v), which had broken server-side AVG/SUM aggregation. - New unit test EntityAggregationCalculatedFieldStateTest: asserts toResult() serializes a numeric result as a numeric node and a genuine string result (lexical MIN/MAX over string telemetry) as a string node. Assertions check the node type (isNumber/isTextual) rather than asText(), since asText() coerces both node kinds identically - which is why the bug went unnoticed. - EntityAggregationCalculatedFieldTest: add a strict-types read helper (useStrictDataTypes=true) and a test asserting the stored SUM/AVG telemetry are numeric JSON nodes end-to-end. Existing asText()-based assertions are left untouched (they read via the non-strict endpoint, which stringifies every value and therefore cannot observe the storage type). --- .../EntityAggregationCalculatedFieldTest.java | 44 +++++ ...tyAggregationCalculatedFieldStateTest.java | 179 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.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 c23c59d137..a51ac508be 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -154,6 +154,44 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest }); } + @Test + public void testAggregationResult_isStoredAsNumericTelemetry() throws Exception { + // Regression: ENTITY_AGGREGATION must store numeric results as numbers (ts_kv.dbl_v/long_v), + // not as JSON strings (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. + // The existing .asText()-based tests cannot catch this (asText coerces both types), so this + // test reads with useStrictDataTypes=true and asserts the value node type. + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); + createConsumptionCF(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + 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(); + + await().alias("create CF -> aggregation result stored as numeric telemetry") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetryStrict(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption")).isNotNull(); + assertThat(result.get("avgConsumption")).isNotNull(); + // SUM and AVG results must be numeric JSON nodes, not strings. + assertThat(result.get("consumption").get(0).get("value").isNumber()).isTrue(); + assertThat(result.get("avgConsumption").get(0).get("value").isNumber()).isTrue(); + // Values are still correct (SUM=400, AVG=133). + assertThat(result.get("consumption").get(0).get("value").asInt()).isEqualTo(400); + assertThat(result.get("avgConsumption").get(0).get("value").asInt()).isEqualTo(133); + }); + } + @Test public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { Device device = createDevice("Device", "1234567890111"); @@ -376,4 +414,10 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } + // useStrictDataTypes=true so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). + // Without it the endpoint returns every value via getValueAsString(), masking the string-vs-number distinction. + private ObjectNode getLatestTelemetryStrict(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?useStrictDataTypes=true&keys=" + String.join(",", keys), ObjectNode.class); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..22bfcbe276 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2026 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.cf.ctx.state.aggregation.single; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.BeforeEach; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +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.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +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.CustomInterval; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class EntityAggregationCalculatedFieldStateTest { + + private static final long INTERVAL_START_TS = 1_000L; + private static final long INTERVAL_END_TS = 2_000L; + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("80ee80ef-019f-46b1-80ba-22f3ef1b094c")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("fc83d188-9cf5-4919-a774-d5c56bba2d27")); + + private EntityAggregationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Mock + private TenantProfile tenantProfile; + @Mock + private TbTenantProfileCache tenantProfileCache; + @InjectMocks + private ActorSystemContext systemContext; + + @BeforeEach + void setUp() { + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); + ctx.init(); + state = new EntityAggregationCalculatedFieldState(DEVICE_ID); + state.setCtx(ctx, null); + state.init(false); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION); + } + + // A numeric aggregation result (SUM/AVG/COUNT/..., numeric MIN/MAX) must be serialized as a numeric + // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, spec §7) must stay a string + // node. The node type is asserted explicitly, so asText() is only used to verify the value once the type + // is already pinned - it is not relied on to distinguish the types (that blindness is what hid the bug). + @ParameterizedTest(name = "{0} (precision {2}) -> numeric={3}") + @MethodSource("toResultSerializationCases") + void toResultSerializesResultWithTypePreservingNode(String metricName, BasicKvEntry kvEntry, Integer precision, + boolean expectNumeric, String expectedText) { + JsonNode value = toResultValue(metricName, kvEntry, precision); + + assertThat(value.isNumber()).isEqualTo(expectNumeric); + assertThat(value.isTextual()).isEqualTo(!expectNumeric); + assertThat(value.asText()).isEqualTo(expectedText); + } + + private static Stream toResultSerializationCases() { + return Stream.of( + // SUM: Number result, precision 0 -> whole-number (long) node + arguments("consumption", new DoubleDataEntry("consumption", 400.0), 0, true, "400"), + // AVG: Number result, precision 2 -> half-up rounded double node + arguments("avgConsumption", new DoubleDataEntry("avgConsumption", 133.335), 2, true, "133.34"), + // MIN/MAX over string telemetry: lexical String result -> preserved as string node (spec §7) + arguments("maxCode", new StringDataEntry("maxCode", "9"), 0, false, "9") + ); + } + + private JsonNode toResultValue(String metricName, BasicKvEntry kvEntry, Integer precision) { + AggIntervalEntry interval = new AggIntervalEntry(INTERVAL_START_TS, INTERVAL_END_TS); + ArgumentEntry argumentEntry = new SingleValueArgumentEntry(INTERVAL_START_TS, kvEntry, SingleValueArgumentEntry.DEFAULT_VERSION); + Map> results = new HashMap<>(); + results.put(interval, Map.of(metricName, argumentEntry)); + + ArrayNode result = state.toResult(results, precision); + + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).get("ts").asLong()).isEqualTo(INTERVAL_START_TS); + return result.get(0).get("values").get(metricName); + } + + private CalculatedField getCalculatedField() { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION); + calculatedField.setName("Test Entity Aggregation CF"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getConfiguration()); + calculatedField.setVersion(1L); + return calculatedField; + } + + private EntityAggregationCalculatedFieldConfiguration getConfiguration() { + EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration(); + + Argument energy = new Argument(); + energy.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + configuration.setArguments(Map.of("en", energy)); + + Map metrics = new HashMap<>(); + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + metrics.put("consumption", consumption); + + AggMetric avgConsumption = new AggMetric(); + avgConsumption.setFunction(AggFunction.AVG); + avgConsumption.setInput(new AggKeyInput("en")); + metrics.put("avgConsumption", avgConsumption); + configuration.setMetrics(metrics); + + configuration.setInterval(new CustomInterval("UTC", 0L, 5L)); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + configuration.setOutput(output); + + return configuration; + } + +} From bf6f0f1ec5e6e0c38b0e9ac05a6faebb4a540ac1 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 14:53:42 +0300 Subject: [PATCH 3/4] Refine ENTITY_AGGREGATION CF numeric regression coverage in integration test Address review feedback on the integration test: - Read latest telemetry with useStrictDataTypes=true always, so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). - Assert the aggregation result is a numeric node across all existing scenarios via a shared assertNumericValue helper, instead of a separate standalone test. isNumber() is the actual regression guard; asLong()/asText() would coerce a str_v string and miss it, so the type is asserted explicitly and the value is compared numerically. - Drop the redundant standalone test that duplicated an existing scenario. --- .../EntityAggregationCalculatedFieldTest.java | 85 ++++++------------- 1 file changed, 27 insertions(+), 58 deletions(-) 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 a51ac508be..71b89bff40 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.cf; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.After; import org.junit.Before; @@ -106,7 +107,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999"); + assertNumericValue(result, "consumption", 9999); assertThat(result.get("avgConsumption").get(0).get("value").isNull()).isTrue(); }); } @@ -137,8 +138,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1)); @@ -149,46 +150,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); - }); - } - - @Test - public void testAggregationResult_isStoredAsNumericTelemetry() throws Exception { - // Regression: ENTITY_AGGREGATION must store numeric results as numbers (ts_kv.dbl_v/long_v), - // not as JSON strings (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. - // The existing .asText()-based tests cannot catch this (asText coerces both types), so this - // test reads with useStrictDataTypes=true and asserts the value node type. - Device device = createDevice("Device", "1234567890111"); - - CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); - createConsumptionCF(device.getId(), customInterval, null); - - long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); - long tsInInterval_1 = currentIntervalStartTs + 1000; - long tsInInterval_2 = currentIntervalStartTs + 500; - long tsInInterval_3 = currentIntervalStartTs + 200; - 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(); - - await().alias("create CF -> aggregation result stored as numeric telemetry") - .atMost(2 * interval, TimeUnit.MILLISECONDS) - .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) - .untilAsserted(() -> { - ObjectNode result = getLatestTelemetryStrict(device.getId(), "consumption", "avgConsumption"); - assertThat(result).isNotNull(); - assertThat(result.get("consumption")).isNotNull(); - assertThat(result.get("avgConsumption")).isNotNull(); - // SUM and AVG results must be numeric JSON nodes, not strings. - assertThat(result.get("consumption").get(0).get("value").isNumber()).isTrue(); - assertThat(result.get("avgConsumption").get(0).get("value").isNumber()).isTrue(); - // Values are still correct (SUM=400, AVG=133). - assertThat(result.get("consumption").get(0).get("value").asInt()).isEqualTo(400); - assertThat(result.get("avgConsumption").get(0).get("value").asInt()).isEqualTo(133); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); } @@ -219,8 +182,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1)); @@ -231,8 +194,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("200"); + assertNumericValue(result, "consumption", 600); + assertNumericValue(result, "avgConsumption", 200); }); } @@ -269,8 +232,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L)); @@ -281,9 +244,9 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500"); + assertNumericValue(result, "consumption", 500); assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500"); + assertNumericValue(result, "avgConsumption", 500); assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); }); } @@ -344,8 +307,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgTemperature"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgTemperature").get(0).get("value").asText()).isEqualTo("39"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgTemperature", 39); }); } @@ -410,14 +373,20 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest return saveCalculatedField(calculatedField); } - private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { - return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); - } - // useStrictDataTypes=true so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). // Without it the endpoint returns every value via getValueAsString(), masking the string-vs-number distinction. - private ObjectNode getLatestTelemetryStrict(EntityId entityId, String... keys) throws Exception { + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?useStrictDataTypes=true&keys=" + String.join(",", keys), ObjectNode.class); } + // Regression guard: a numeric aggregation result must be stored as a numeric JSON node (ts_kv.dbl_v/long_v), + // not a JSON string (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. A value-only check would + // not catch this: asLong()/asText() coerce a string node like "400" to the same value/text, so the node type + // is asserted explicitly; the numeric comparison then verifies the aggregated value. + private static void assertNumericValue(ObjectNode result, String key, long expectedValue) { + JsonNode value = result.get(key).get(0).get("value"); + assertThat(value.isNumber()).as(key + " should be stored as a numeric node").isTrue(); + assertThat(value.asLong()).isEqualTo(expectedValue); + } + } From da3f2e933a81af2551746134228b41724ca70b36 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 15:53:36 +0300 Subject: [PATCH 4/4] Address review comments on EntityAggregationCalculatedFieldStateTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the dangling "spec §7" reference from the test comments; inline the actual rule (a lexical MIN/MAX result over string telemetry stays a string). - Use a zero-padded code ("0009") for the string-result case - a clearer example of a genuine string that must not be coerced to a number (would lose padding). - Define the maxCode metric in the test CF configuration alongside consumption and avgConsumption, so all parameterized metric names are configured. --- ...tityAggregationCalculatedFieldStateTest.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java index 22bfcbe276..8f7cda872e 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java @@ -98,9 +98,10 @@ public class EntityAggregationCalculatedFieldStateTest { } // A numeric aggregation result (SUM/AVG/COUNT/..., numeric MIN/MAX) must be serialized as a numeric - // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, spec §7) must stay a string - // node. The node type is asserted explicitly, so asText() is only used to verify the value once the type - // is already pinned - it is not relied on to distinguish the types (that blindness is what hid the bug). + // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, e.g. a zero-padded code) + // must stay a string node. The node type is asserted explicitly, so asText() is only used to verify the + // value once the type is already pinned - it is not relied on to distinguish the types (that blindness + // is what hid the bug). @ParameterizedTest(name = "{0} (precision {2}) -> numeric={3}") @MethodSource("toResultSerializationCases") void toResultSerializesResultWithTypePreservingNode(String metricName, BasicKvEntry kvEntry, Integer precision, @@ -118,8 +119,9 @@ public class EntityAggregationCalculatedFieldStateTest { arguments("consumption", new DoubleDataEntry("consumption", 400.0), 0, true, "400"), // AVG: Number result, precision 2 -> half-up rounded double node arguments("avgConsumption", new DoubleDataEntry("avgConsumption", 133.335), 2, true, "133.34"), - // MIN/MAX over string telemetry: lexical String result -> preserved as string node (spec §7) - arguments("maxCode", new StringDataEntry("maxCode", "9"), 0, false, "9") + // MAX over string telemetry: a zero-padded code is a genuine String result and must stay a + // string node - as a number it would lose its padding ("0009" -> 9). + arguments("maxCode", new StringDataEntry("maxCode", "0009"), 0, false, "0009") ); } @@ -165,6 +167,11 @@ public class EntityAggregationCalculatedFieldStateTest { avgConsumption.setFunction(AggFunction.AVG); avgConsumption.setInput(new AggKeyInput("en")); metrics.put("avgConsumption", avgConsumption); + + AggMetric maxCode = new AggMetric(); + maxCode.setFunction(AggFunction.MAX); + maxCode.setInput(new AggKeyInput("en")); + metrics.put("maxCode", maxCode); configuration.setMetrics(metrics); configuration.setInterval(new CustomInterval("UTC", 0L, 5L));