|
|
|
@ -15,6 +15,8 @@ |
|
|
|
*/ |
|
|
|
package org.thingsboard.rule.engine.action; |
|
|
|
|
|
|
|
import com.google.common.base.Stopwatch; |
|
|
|
import com.google.common.base.Ticker; |
|
|
|
import org.junit.jupiter.api.BeforeEach; |
|
|
|
import org.junit.jupiter.api.Test; |
|
|
|
import org.junit.jupiter.api.extension.ExtendWith; |
|
|
|
@ -26,29 +28,35 @@ import org.mockito.ArgumentCaptor; |
|
|
|
import org.mockito.Captor; |
|
|
|
import org.mockito.Mock; |
|
|
|
import org.mockito.junit.jupiter.MockitoExtension; |
|
|
|
import org.springframework.test.util.ReflectionTestUtils; |
|
|
|
import org.thingsboard.common.util.JacksonUtil; |
|
|
|
import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager; |
|
|
|
import org.thingsboard.rule.engine.api.TbContext; |
|
|
|
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|
|
|
import org.thingsboard.rule.engine.api.TbNodeException; |
|
|
|
import org.thingsboard.server.common.data.Device; |
|
|
|
import org.thingsboard.server.common.data.EntityType; |
|
|
|
import org.thingsboard.server.common.data.id.DeviceId; |
|
|
|
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|
|
|
import org.thingsboard.server.common.data.id.EntityId; |
|
|
|
import org.thingsboard.server.common.data.id.RuleNodeId; |
|
|
|
import org.thingsboard.server.common.data.id.TenantId; |
|
|
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
|
|
|
import org.thingsboard.server.common.msg.TbMsg; |
|
|
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|
|
|
import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; |
|
|
|
import org.thingsboard.server.common.msg.queue.ServiceType; |
|
|
|
import org.thingsboard.server.common.msg.queue.TbCallback; |
|
|
|
|
|
|
|
import java.time.Duration; |
|
|
|
import java.util.UUID; |
|
|
|
import java.util.concurrent.ConcurrentHashMap; |
|
|
|
import java.util.concurrent.ConcurrentMap; |
|
|
|
import java.util.stream.Stream; |
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat; |
|
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|
|
|
import static org.assertj.core.api.Assertions.fail; |
|
|
|
import static org.mockito.ArgumentMatchers.eq; |
|
|
|
import static org.mockito.ArgumentMatchers.isNull; |
|
|
|
import static org.mockito.BDDMockito.given; |
|
|
|
import static org.mockito.BDDMockito.then; |
|
|
|
|
|
|
|
@ -62,28 +70,33 @@ public class TbDeviceStateNodeTest { |
|
|
|
@Captor |
|
|
|
private static ArgumentCaptor<TbCallback> callbackCaptor; |
|
|
|
private TbDeviceStateNode node; |
|
|
|
private RuleNodeId nodeId; |
|
|
|
private TbDeviceStateNodeConfiguration config; |
|
|
|
|
|
|
|
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.randomUUID()); |
|
|
|
private static final DeviceId DEVICE_ID = new DeviceId(UUID.randomUUID()); |
|
|
|
private static final long METADATA_TS = System.currentTimeMillis(); |
|
|
|
private TbMsg cleanupMsg; |
|
|
|
private TbMsg msg; |
|
|
|
private long nowNanos; |
|
|
|
private final Ticker controlledTicker = new Ticker() { |
|
|
|
@Override |
|
|
|
public long read() { |
|
|
|
return nowNanos; |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
@BeforeEach |
|
|
|
public void setup() { |
|
|
|
var device = new Device(); |
|
|
|
device.setTenantId(TENANT_ID); |
|
|
|
device.setId(DEVICE_ID); |
|
|
|
device.setName("My humidity sensor"); |
|
|
|
device.setType("Humidity sensor"); |
|
|
|
device.setDeviceProfileId(new DeviceProfileId(UUID.randomUUID())); |
|
|
|
var metaData = new TbMsgMetaData(); |
|
|
|
metaData.putValue("deviceName", device.getName()); |
|
|
|
metaData.putValue("deviceType", device.getType()); |
|
|
|
metaData.putValue("deviceName", "My humidity sensor"); |
|
|
|
metaData.putValue("deviceType", "Humidity sensor"); |
|
|
|
metaData.putValue("ts", String.valueOf(METADATA_TS)); |
|
|
|
var data = JacksonUtil.newObjectNode(); |
|
|
|
data.put("humidity", 58.3); |
|
|
|
msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, device.getId(), metaData, JacksonUtil.toString(data)); |
|
|
|
msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, JacksonUtil.toString(data)); |
|
|
|
nodeId = new RuleNodeId(UUID.randomUUID()); |
|
|
|
cleanupMsg = TbMsg.newMsg(null, TbMsgType.DEVICE_STATE_STALE_ENTRIES_CLEANUP_MSG, nodeId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING); |
|
|
|
} |
|
|
|
|
|
|
|
@BeforeEach |
|
|
|
@ -99,17 +112,121 @@ public class TbDeviceStateNodeTest { |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenNullEventInConfig_whenInit_thenThrowsUnrecoverableTbNodeException() { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(null); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
|
|
|
|
// WHEN-THEN
|
|
|
|
assertThatThrownBy(() -> node.init(ctxMock, nodeConfig)) |
|
|
|
// GIVEN-WHEN-THEN
|
|
|
|
assertThatThrownBy(() -> initNode(null)) |
|
|
|
.isInstanceOf(TbNodeException.class) |
|
|
|
.hasMessage("Event cannot be null!") |
|
|
|
.matches(e -> ((TbNodeException) e).isUnrecoverable()); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenValidConfig_whenInit_thenSchedulesCleanupMsg() { |
|
|
|
// GIVEN
|
|
|
|
given(ctxMock.getSelfId()).willReturn(nodeId); |
|
|
|
given(ctxMock.newMsg(isNull(), eq(TbMsgType.DEVICE_STATE_STALE_ENTRIES_CLEANUP_MSG), eq(nodeId), eq(TbMsgMetaData.EMPTY), eq(TbMsg.EMPTY_STRING))).willReturn(cleanupMsg); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
try { |
|
|
|
initNode(TbMsgType.ACTIVITY_EVENT); |
|
|
|
} catch (Exception e) { |
|
|
|
fail("Node failed to initialize."); |
|
|
|
} |
|
|
|
|
|
|
|
// THEN
|
|
|
|
verifyCleanupMsgSent(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenCleanupMsg_whenOnMsg_thenCleansStaleEntries() { |
|
|
|
// GIVEN
|
|
|
|
given(ctxMock.getSelfId()).willReturn(nodeId); |
|
|
|
given(ctxMock.newMsg(isNull(), eq(TbMsgType.DEVICE_STATE_STALE_ENTRIES_CLEANUP_MSG), eq(nodeId), eq(TbMsgMetaData.EMPTY), eq(TbMsg.EMPTY_STRING))).willReturn(cleanupMsg); |
|
|
|
|
|
|
|
ConcurrentMap<DeviceId, Duration> lastActivityEventTimestamps = new ConcurrentHashMap<>(); |
|
|
|
ReflectionTestUtils.setField(node, "lastActivityEventTimestamps", lastActivityEventTimestamps); |
|
|
|
|
|
|
|
Stopwatch stopwatch = Stopwatch.createStarted(controlledTicker); |
|
|
|
ReflectionTestUtils.setField(node, "stopwatch", stopwatch); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
Duration expirationTime = Duration.ofDays(1L); |
|
|
|
|
|
|
|
DeviceId staleId = DEVICE_ID; |
|
|
|
Duration staleTs = Duration.ofHours(4L); |
|
|
|
lastActivityEventTimestamps.put(staleId, staleTs); |
|
|
|
|
|
|
|
DeviceId goodId = new DeviceId(UUID.randomUUID()); |
|
|
|
Duration goodTs = staleTs.plus(expirationTime); |
|
|
|
lastActivityEventTimestamps.put(goodId, goodTs); |
|
|
|
|
|
|
|
nowNanos = staleTs.toNanos() + expirationTime.toNanos() + 1; |
|
|
|
node.onMsg(ctxMock, cleanupMsg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
assertThat(lastActivityEventTimestamps) |
|
|
|
.containsKey(goodId) |
|
|
|
.doesNotContainKey(staleId) |
|
|
|
.size().isOne(); |
|
|
|
|
|
|
|
verifyCleanupMsgSent(); |
|
|
|
then(ctxMock).should().ack(cleanupMsg); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenMsgArrivedTooFast_whenOnMsg_thenRateLimitsThisMsg() { |
|
|
|
// GIVEN
|
|
|
|
ConcurrentMap<DeviceId, Duration> lastActivityEventTimestamps = new ConcurrentHashMap<>(); |
|
|
|
ReflectionTestUtils.setField(node, "lastActivityEventTimestamps", lastActivityEventTimestamps); |
|
|
|
|
|
|
|
Stopwatch stopwatch = Stopwatch.createStarted(controlledTicker); |
|
|
|
ReflectionTestUtils.setField(node, "stopwatch", stopwatch); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
Duration firstEventTs = Duration.ofMillis(1000L); |
|
|
|
lastActivityEventTimestamps.put(DEVICE_ID, firstEventTs); |
|
|
|
|
|
|
|
Duration tooFastEventTs = firstEventTs.plus(Duration.ofMillis(999L)); |
|
|
|
nowNanos = tooFastEventTs.toNanos(); |
|
|
|
node.onMsg(ctxMock, msg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
Duration actualEventTs = lastActivityEventTimestamps.get(DEVICE_ID); |
|
|
|
assertThat(actualEventTs).isEqualTo(firstEventTs); |
|
|
|
|
|
|
|
then(ctxMock).should().tellSuccess(msg); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
then(deviceStateManagerMock).shouldHaveNoInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenHasNonLocalDevices_whenOnPartitionChange_thenRemovesEntriesForNonLocalDevices() { |
|
|
|
// GIVEN
|
|
|
|
ConcurrentMap<DeviceId, Duration> lastActivityEventTimestamps = new ConcurrentHashMap<>(); |
|
|
|
ReflectionTestUtils.setField(node, "lastActivityEventTimestamps", lastActivityEventTimestamps); |
|
|
|
|
|
|
|
lastActivityEventTimestamps.put(DEVICE_ID, Duration.ofHours(24L)); |
|
|
|
given(ctxMock.isLocalEntity(eq(DEVICE_ID))).willReturn(true); |
|
|
|
|
|
|
|
DeviceId nonLocalDeviceId1 = new DeviceId(UUID.randomUUID()); |
|
|
|
lastActivityEventTimestamps.put(nonLocalDeviceId1, Duration.ofHours(30L)); |
|
|
|
given(ctxMock.isLocalEntity(eq(nonLocalDeviceId1))).willReturn(false); |
|
|
|
|
|
|
|
DeviceId nonLocalDeviceId2 = new DeviceId(UUID.randomUUID()); |
|
|
|
lastActivityEventTimestamps.put(nonLocalDeviceId2, Duration.ofHours(32L)); |
|
|
|
given(ctxMock.isLocalEntity(eq(nonLocalDeviceId2))).willReturn(false); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onPartitionChangeMsg(ctxMock, new PartitionChangeMsg(ServiceType.TB_RULE_ENGINE)); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
assertThat(lastActivityEventTimestamps) |
|
|
|
.containsKey(DEVICE_ID) |
|
|
|
.doesNotContainKey(nonLocalDeviceId1) |
|
|
|
.doesNotContainKey(nonLocalDeviceId2) |
|
|
|
.size().isOne(); |
|
|
|
} |
|
|
|
|
|
|
|
@ParameterizedTest |
|
|
|
@EnumSource( |
|
|
|
value = TbMsgType.class, |
|
|
|
@ -117,12 +234,8 @@ public class TbDeviceStateNodeTest { |
|
|
|
mode = EnumSource.Mode.EXCLUDE |
|
|
|
) |
|
|
|
public void givenUnsupportedEventInConfig_whenInit_thenThrowsUnrecoverableTbNodeException(TbMsgType unsupportedEvent) { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(unsupportedEvent); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
|
|
|
|
// WHEN-THEN
|
|
|
|
assertThatThrownBy(() -> node.init(ctxMock, nodeConfig)) |
|
|
|
// GIVEN-WHEN-THEN
|
|
|
|
assertThatThrownBy(() -> initNode(unsupportedEvent)) |
|
|
|
.isInstanceOf(TbNodeException.class) |
|
|
|
.hasMessage("Unsupported event: " + unsupportedEvent) |
|
|
|
.matches(e -> ((TbNodeException) e).isUnrecoverable()); |
|
|
|
@ -156,17 +269,19 @@ public class TbDeviceStateNodeTest { |
|
|
|
|
|
|
|
@ParameterizedTest |
|
|
|
@MethodSource |
|
|
|
public void givenInactivityEventAndDeviceOriginator_whenOnMsg_thenOnDeviceInactivityIsCalledWithCorrectCallback(TbMsgType supportedEventType, Runnable actionVerification) { |
|
|
|
public void givenSupportedEventAndDeviceOriginator_whenOnMsg_thenCorrectEventIsSentWithCorrectCallback(TbMsgType supportedEventType, Runnable actionVerification) { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(supportedEventType); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
given(ctxMock.getSelfId()).willReturn(nodeId); |
|
|
|
given(ctxMock.newMsg(isNull(), eq(TbMsgType.DEVICE_STATE_STALE_ENTRIES_CLEANUP_MSG), eq(nodeId), eq(TbMsgMetaData.EMPTY), eq(TbMsg.EMPTY_STRING))).willReturn(cleanupMsg); |
|
|
|
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|
|
|
given(ctxMock.getDeviceStateManager()).willReturn(deviceStateManagerMock); |
|
|
|
|
|
|
|
try { |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
initNode(supportedEventType); |
|
|
|
} catch (TbNodeException e) { |
|
|
|
fail("Node failed to initialize!", e); |
|
|
|
} |
|
|
|
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|
|
|
given(ctxMock.getDeviceStateManager()).willReturn(deviceStateManagerMock); |
|
|
|
verifyCleanupMsgSent(); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, msg); |
|
|
|
@ -188,7 +303,7 @@ public class TbDeviceStateNodeTest { |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
private static Stream<Arguments> givenInactivityEventAndDeviceOriginator_whenOnMsg_thenOnDeviceInactivityIsCalledWithCorrectCallback() { |
|
|
|
private static Stream<Arguments> givenSupportedEventAndDeviceOriginator_whenOnMsg_thenCorrectEventIsSentWithCorrectCallback() { |
|
|
|
return Stream.of( |
|
|
|
Arguments.of(TbMsgType.CONNECT_EVENT, (Runnable) () -> then(deviceStateManagerMock).should().onDeviceConnect(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), |
|
|
|
Arguments.of(TbMsgType.ACTIVITY_EVENT, (Runnable) () -> then(deviceStateManagerMock).should().onDeviceActivity(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), |
|
|
|
@ -197,4 +312,16 @@ public class TbDeviceStateNodeTest { |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
private void initNode(TbMsgType event) throws TbNodeException { |
|
|
|
config.setEvent(event); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
} |
|
|
|
|
|
|
|
private void verifyCleanupMsgSent() { |
|
|
|
then(ctxMock).should().getSelfId(); |
|
|
|
then(ctxMock).should().newMsg(isNull(), eq(TbMsgType.DEVICE_STATE_STALE_ENTRIES_CLEANUP_MSG), eq(nodeId), eq(TbMsgMetaData.EMPTY), eq(TbMsg.EMPTY_STRING)); |
|
|
|
then(ctxMock).should().tellSelf(eq(cleanupMsg), eq(Duration.ofHours(1L).toMillis())); |
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|