@ -15,6 +15,7 @@
* /
* /
package org.thingsboard.server.cf ;
package org.thingsboard.server.cf ;
import com.fasterxml.jackson.databind.JsonNode ;
import com.fasterxml.jackson.databind.node.ObjectNode ;
import com.fasterxml.jackson.databind.node.ObjectNode ;
import org.junit.After ;
import org.junit.After ;
import org.junit.Before ;
import org.junit.Before ;
@ -106,7 +107,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
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 ( ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . isNull ( ) ) . isTrue ( ) ;
} ) ;
} ) ;
}
}
@ -137,8 +138,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "400" ) ;
assertNumericValue ( result , "consumption" , 400 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "133" ) ;
assertNumericValue ( result , "avgConsumption" , 133 ) ;
} ) ;
} ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":500}}" , tsInInterval_1 ) ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":500}}" , tsInInterval_1 ) ) ;
@ -149,46 +150,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "400" ) ;
assertNumericValue ( result , "consumption" , 400 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "133" ) ;
assertNumericValue ( result , "avgConsumption" , 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 ) ;
} ) ;
} ) ;
}
}
@ -219,8 +182,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "400" ) ;
assertNumericValue ( result , "consumption" , 400 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "133" ) ;
assertNumericValue ( result , "avgConsumption" , 133 ) ;
} ) ;
} ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":300}}" , tsInInterval_1 ) ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":300}}" , tsInInterval_1 ) ) ;
@ -231,8 +194,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "600" ) ;
assertNumericValue ( result , "consumption" , 600 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "200" ) ;
assertNumericValue ( result , "avgConsumption" , 200 ) ;
} ) ;
} ) ;
}
}
@ -269,8 +232,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "400" ) ;
assertNumericValue ( result , "consumption" , 400 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "133" ) ;
assertNumericValue ( result , "avgConsumption" , 133 ) ;
} ) ;
} ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":500}}" , currentIntervalStartTs + 4500L ) ) ;
postTelemetry ( device . getId ( ) , String . format ( "{\"ts\": \"%s\", \"values\": {\"energy\":500}}" , currentIntervalStartTs + 4500L ) ) ;
@ -281,9 +244,9 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgConsumption" ) ;
assertThat ( result ) . isNotNull ( ) ;
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 ( "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 ) ;
assertThat ( result . get ( "avgConsumption" ) . get ( 0 ) . get ( "ts" ) . asLong ( ) ) . isEqualTo ( currentIntervalStartTs + 4000L ) ;
} ) ;
} ) ;
}
}
@ -344,8 +307,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
. untilAsserted ( ( ) - > {
. untilAsserted ( ( ) - > {
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgTemperature" ) ;
ObjectNode result = getLatestTelemetry ( device . getId ( ) , "consumption" , "avgTemperature" ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result ) . isNotNull ( ) ;
assertThat ( result . get ( "consumption" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "400" ) ;
assertNumericValue ( result , "consumption" , 400 ) ;
assertThat ( result . get ( "avgTemperature" ) . get ( 0 ) . get ( "value" ) . asText ( ) ) . isEqualTo ( "39" ) ;
assertNumericValue ( result , "avgTemperature" , 39 ) ;
} ) ;
} ) ;
}
}
@ -410,14 +373,20 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
return saveCalculatedField ( calculatedField ) ;
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).
// 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.
// 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 ) ;
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 ) ;
}
}
}