Added integration test with dynamic arguments refresh logic
This commit is contained in:
parent
ed70a1e690
commit
a4ac5e3a7f
@ -24,6 +24,8 @@ import org.thingsboard.common.util.JacksonUtil;
|
|||||||
import org.thingsboard.server.common.data.AttributeScope;
|
import org.thingsboard.server.common.data.AttributeScope;
|
||||||
import org.thingsboard.server.common.data.DataConstants;
|
import org.thingsboard.server.common.data.DataConstants;
|
||||||
import org.thingsboard.server.common.data.Device;
|
import org.thingsboard.server.common.data.Device;
|
||||||
|
import org.thingsboard.server.common.data.EntityInfo;
|
||||||
|
import org.thingsboard.server.common.data.TenantProfile;
|
||||||
import org.thingsboard.server.common.data.asset.Asset;
|
import org.thingsboard.server.common.data.asset.Asset;
|
||||||
import org.thingsboard.server.common.data.asset.AssetProfile;
|
import org.thingsboard.server.common.data.asset.AssetProfile;
|
||||||
import org.thingsboard.server.common.data.cf.CalculatedField;
|
import org.thingsboard.server.common.data.cf.CalculatedField;
|
||||||
@ -618,26 +620,28 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGeofencingCalculatedField_SingleZonePerGroup() throws Exception {
|
public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception {
|
||||||
// --- Arrange entities ---
|
// --- Arrange entities ---
|
||||||
Device device = createDevice("GF Device", "sn-geo-1");
|
Device device = createDevice("GF Device", "sn-geo-1");
|
||||||
|
|
||||||
// Allowed zone polygon (square)
|
// Allowed zone polygon (square)
|
||||||
String allowedPolygon = """
|
String allowedPolygon = """
|
||||||
{"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"}
|
{"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"}
|
||||||
""";
|
""";
|
||||||
// Restricted zone polygon (square)
|
// Restricted zone polygon (square)
|
||||||
String restrictedPolygon = """
|
String restrictedPolygon = """
|
||||||
{"type":"POLYGON","polygonsDefinition":"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"}
|
{"type":"POLYGON","polygonsDefinition":"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
Asset allowedZoneAsset = createAsset("Allowed Zone", null);
|
Asset allowedZoneAsset = createAsset("Allowed Zone", null);
|
||||||
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
||||||
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());;
|
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());
|
||||||
|
;
|
||||||
|
|
||||||
Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
|
Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
|
||||||
doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
||||||
JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());;
|
JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
|
||||||
|
;
|
||||||
|
|
||||||
// Relations from device to zones
|
// Relations from device to zones
|
||||||
EntityRelation deviceToAllowedZoneRelation = new EntityRelation();
|
EntityRelation deviceToAllowedZoneRelation = new EntityRelation();
|
||||||
@ -748,6 +752,153 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception {
|
||||||
|
// --- Update min allowed scheduled update intervals for CFs ---
|
||||||
|
loginSysAdmin();
|
||||||
|
EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class);
|
||||||
|
assertThat(tenantProfileEntityInfo).isNotNull();
|
||||||
|
TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class);
|
||||||
|
assertThat(foundTenantProfile).isNotNull();
|
||||||
|
assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull();
|
||||||
|
foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10);
|
||||||
|
TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class);
|
||||||
|
assertThat(savedTenantProfile).isNotNull();
|
||||||
|
assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10);
|
||||||
|
loginTenantAdmin();
|
||||||
|
|
||||||
|
// --- Arrange entities ---
|
||||||
|
Device device = createDevice("GF Device dyn", "sn-geo-dyn-1");
|
||||||
|
|
||||||
|
// Allowed Zone A: covers initial point (ENTERED)
|
||||||
|
String allowedPolygonA = """
|
||||||
|
{"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Asset allowedZoneA = createAsset("Allowed Zone A", null);
|
||||||
|
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
||||||
|
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
// Relation from device to Allowed Zone A
|
||||||
|
EntityRelation relAllowedA = new EntityRelation();
|
||||||
|
relAllowedA.setFrom(device.getId());
|
||||||
|
relAllowedA.setTo(allowedZoneA.getId());
|
||||||
|
relAllowedA.setType("AllowedZone");
|
||||||
|
doPost("/api/relation", relAllowedA).andExpect(status().isOk());
|
||||||
|
|
||||||
|
// Initial device coordinates: INSIDE Zone A
|
||||||
|
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
|
||||||
|
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
// --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh ---
|
||||||
|
CalculatedField cf = new CalculatedField();
|
||||||
|
cf.setEntityId(device.getId());
|
||||||
|
cf.setType(CalculatedFieldType.GEOFENCING);
|
||||||
|
cf.setName("Geofencing CF (dynamic refresh)");
|
||||||
|
cf.setDebugSettings(DebugSettings.off());
|
||||||
|
|
||||||
|
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
|
||||||
|
|
||||||
|
// Coordinates (TS_LATEST)
|
||||||
|
Argument lat = new Argument();
|
||||||
|
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
|
||||||
|
Argument lon = new Argument();
|
||||||
|
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
|
||||||
|
|
||||||
|
// Dynamic group 'allowedZones' resolved by relations (FROM device -> assets of type AllowedZone)
|
||||||
|
Argument allowedZones = new Argument();
|
||||||
|
var dyn = new RelationQueryDynamicSourceConfiguration();
|
||||||
|
dyn.setDirection(EntitySearchDirection.FROM);
|
||||||
|
dyn.setRelationType("AllowedZone");
|
||||||
|
dyn.setMaxLevel(1);
|
||||||
|
dyn.setFetchLastLevelOnly(true);
|
||||||
|
allowedZones.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
|
||||||
|
allowedZones.setRefDynamicSourceConfiguration(dyn);
|
||||||
|
|
||||||
|
cfg.setArguments(Map.of(
|
||||||
|
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
|
||||||
|
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
|
||||||
|
"allowedZones", allowedZones
|
||||||
|
));
|
||||||
|
|
||||||
|
// Report all events for the group
|
||||||
|
List<GeofencingEvent> reportEvents = Arrays.stream(GeofencingEvent.values()).toList();
|
||||||
|
GeofencingZoneGroupConfiguration allowedCfg = new GeofencingZoneGroupConfiguration("allowedZone", reportEvents);
|
||||||
|
cfg.setZoneGroupConfigurations(Map.of("allowedZones", allowedCfg));
|
||||||
|
|
||||||
|
// Server attributes output
|
||||||
|
Output out = new Output();
|
||||||
|
out.setType(OutputType.ATTRIBUTES);
|
||||||
|
out.setScope(AttributeScope.SERVER_SCOPE);
|
||||||
|
cfg.setOutput(out);
|
||||||
|
|
||||||
|
// Enable scheduled refresh with a 6-second interval
|
||||||
|
cfg.setScheduledUpdateIntervalSec(6);
|
||||||
|
|
||||||
|
cf.setConfiguration(cfg);
|
||||||
|
CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
|
||||||
|
assertThat(savedCalculatedField).isNotNull();
|
||||||
|
assertThat(savedCalculatedField.getConfiguration().isScheduledUpdateEnabled()).isTrue();
|
||||||
|
|
||||||
|
// --- Assert initial evaluation (ENTERED) ---
|
||||||
|
await().alias("initial geofencing evaluation")
|
||||||
|
.atMost(TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent");
|
||||||
|
assertThat(attrs).isNotNull().isNotEmpty().hasSize(1);
|
||||||
|
Map<String, String> m = kv(attrs);
|
||||||
|
assertThat(m).containsEntry("allowedZoneEvent", "ENTERED");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Move device OUTSIDE Zone A (expect LEFT) ---
|
||||||
|
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
|
||||||
|
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
await().alias("outside zone A (LEFT)")
|
||||||
|
.atMost(TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent");
|
||||||
|
assertThat(attrs).isNotNull().isNotEmpty().hasSize(1);
|
||||||
|
Map<String, String> m = kv(attrs);
|
||||||
|
assertThat(m).containsEntry("allowedZoneEvent", "LEFT");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create Allowed Zone B covering the CURRENT location ---
|
||||||
|
String allowedPolygonB = """
|
||||||
|
{"type":"POLYGON","polygonsDefinition":"[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Asset allowedZoneB = createAsset("Allowed Zone B", null);
|
||||||
|
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
|
||||||
|
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
// Add a new relation
|
||||||
|
EntityRelation relAllowedB = new EntityRelation();
|
||||||
|
relAllowedB.setFrom(device.getId());
|
||||||
|
relAllowedB.setTo(allowedZoneB.getId());
|
||||||
|
relAllowedB.setType("AllowedZone");
|
||||||
|
doPost("/api/relation", relAllowedB).andExpect(status().isOk());
|
||||||
|
|
||||||
|
awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId());
|
||||||
|
|
||||||
|
// --- Same coordinates as before, but now we expect ENTERED since a new zone is registered ---
|
||||||
|
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
|
||||||
|
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
// --- Assert dynamic refresh picks up new relation and flips event back to ENTERED on the next telemetry update ---
|
||||||
|
await().alias("dynamic refresh rebinds allowedZones")
|
||||||
|
.atMost(TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(1, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent");
|
||||||
|
assertThat(attrs).isNotNull().isNotEmpty().hasSize(1);
|
||||||
|
Map<String, String> m = kv(attrs);
|
||||||
|
assertThat(m).containsEntry("allowedZoneEvent", "ENTERED");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode getLatestTelemetry(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?keys=" + String.join(",", keys), ObjectNode.class);
|
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,10 @@ import org.thingsboard.rule.engine.api.MailService;
|
|||||||
import org.thingsboard.server.actors.DefaultTbActorSystem;
|
import org.thingsboard.server.actors.DefaultTbActorSystem;
|
||||||
import org.thingsboard.server.actors.TbActorId;
|
import org.thingsboard.server.actors.TbActorId;
|
||||||
import org.thingsboard.server.actors.TbActorMailbox;
|
import org.thingsboard.server.actors.TbActorMailbox;
|
||||||
|
import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
|
||||||
import org.thingsboard.server.actors.TbEntityActorId;
|
import org.thingsboard.server.actors.TbEntityActorId;
|
||||||
|
import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor;
|
||||||
|
import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor;
|
||||||
import org.thingsboard.server.actors.device.DeviceActor;
|
import org.thingsboard.server.actors.device.DeviceActor;
|
||||||
import org.thingsboard.server.actors.device.DeviceActorMessageProcessor;
|
import org.thingsboard.server.actors.device.DeviceActorMessageProcessor;
|
||||||
import org.thingsboard.server.actors.device.SessionInfo;
|
import org.thingsboard.server.actors.device.SessionInfo;
|
||||||
@ -99,6 +102,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo
|
|||||||
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
|
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
|
||||||
import org.thingsboard.server.common.data.edge.Edge;
|
import org.thingsboard.server.common.data.edge.Edge;
|
||||||
import org.thingsboard.server.common.data.exception.ThingsboardException;
|
import org.thingsboard.server.common.data.exception.ThingsboardException;
|
||||||
|
import org.thingsboard.server.common.data.id.CalculatedFieldId;
|
||||||
import org.thingsboard.server.common.data.id.CustomerId;
|
import org.thingsboard.server.common.data.id.CustomerId;
|
||||||
import org.thingsboard.server.common.data.id.DeviceId;
|
import org.thingsboard.server.common.data.id.DeviceId;
|
||||||
import org.thingsboard.server.common.data.id.EntityId;
|
import org.thingsboard.server.common.data.id.EntityId;
|
||||||
@ -150,6 +154,7 @@ import org.thingsboard.server.dao.tenant.TenantProfileService;
|
|||||||
import org.thingsboard.server.dao.timeseries.TimeseriesService;
|
import org.thingsboard.server.dao.timeseries.TimeseriesService;
|
||||||
import org.thingsboard.server.queue.memory.InMemoryStorage;
|
import org.thingsboard.server.queue.memory.InMemoryStorage;
|
||||||
import org.thingsboard.server.service.cf.CfRocksDb;
|
import org.thingsboard.server.service.cf.CfRocksDb;
|
||||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
|
||||||
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
|
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
|
||||||
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
|
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
|
||||||
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
|
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
|
||||||
@ -1099,6 +1104,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) {
|
||||||
|
CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId);
|
||||||
|
Map<CalculatedFieldId, CalculatedFieldState> statesMap = (Map<CalculatedFieldId, CalculatedFieldState>) ReflectionTestUtils.getField(processor, "states");
|
||||||
|
Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> {
|
||||||
|
CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
|
||||||
|
boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty();
|
||||||
|
log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty);
|
||||||
|
return stateDirty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected static String getMapName(FeatureType featureType) {
|
protected static String getMapName(FeatureType featureType) {
|
||||||
switch (featureType) {
|
switch (featureType) {
|
||||||
case ATTRIBUTES:
|
case ATTRIBUTES:
|
||||||
@ -1120,6 +1136,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
|
|||||||
return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
|
return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) {
|
||||||
|
DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system");
|
||||||
|
ConcurrentMap<TbActorId, TbActorMailbox> actors = (ConcurrentMap<TbActorId, TbActorMailbox>) ReflectionTestUtils.getField(actorSystem, "actors");
|
||||||
|
Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId)));
|
||||||
|
TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId));
|
||||||
|
CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor");
|
||||||
|
return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
|
||||||
|
}
|
||||||
|
|
||||||
protected void updateDefaultTenantProfileConfig(Consumer<DefaultTenantProfileConfiguration> updater) throws ThingsboardException {
|
protected void updateDefaultTenantProfileConfig(Consumer<DefaultTenantProfileConfiguration> updater) throws ThingsboardException {
|
||||||
updateDefaultTenantProfile(tenantProfile -> {
|
updateDefaultTenantProfile(tenantProfile -> {
|
||||||
TenantProfileData profileData = tenantProfile.getProfileData();
|
TenantProfileData profileData = tenantProfile.getProfileData();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user