2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschshc.internal.devices.bridge;
15 import static org.hamcrest.CoreMatchers.containsString;
16 import static org.hamcrest.CoreMatchers.instanceOf;
17 import static org.hamcrest.MatcherAssert.assertThat;
18 import static org.junit.jupiter.api.Assertions.assertEquals;
19 import static org.junit.jupiter.api.Assertions.assertNotNull;
20 import static org.junit.jupiter.api.Assertions.assertSame;
21 import static org.junit.jupiter.api.Assertions.assertThrows;
22 import static org.junit.jupiter.api.Assertions.assertTrue;
23 import static org.mockito.ArgumentMatchers.any;
24 import static org.mockito.ArgumentMatchers.anyString;
25 import static org.mockito.ArgumentMatchers.argThat;
26 import static org.mockito.ArgumentMatchers.same;
27 import static org.mockito.Mockito.mock;
28 import static org.mockito.Mockito.verify;
29 import static org.mockito.Mockito.when;
31 import java.nio.ByteBuffer;
32 import java.nio.charset.StandardCharsets;
33 import java.util.Collections;
34 import java.util.List;
35 import java.util.concurrent.AbstractExecutorService;
36 import java.util.concurrent.Callable;
37 import java.util.concurrent.Delayed;
38 import java.util.concurrent.ExecutionException;
39 import java.util.concurrent.ScheduledExecutorService;
40 import java.util.concurrent.ScheduledFuture;
41 import java.util.concurrent.TimeUnit;
42 import java.util.concurrent.TimeoutException;
43 import java.util.function.Consumer;
45 import org.eclipse.jdt.annotation.NonNull;
46 import org.eclipse.jdt.annotation.NonNullByDefault;
47 import org.eclipse.jdt.annotation.Nullable;
48 import org.eclipse.jetty.client.api.Request;
49 import org.eclipse.jetty.client.api.Response;
50 import org.eclipse.jetty.client.api.Response.CompleteListener;
51 import org.eclipse.jetty.client.api.Result;
52 import org.eclipse.jetty.client.util.BufferingResponseListener;
53 import org.eclipse.jetty.http.HttpMethod;
54 import org.junit.jupiter.api.AfterEach;
55 import org.junit.jupiter.api.BeforeEach;
56 import org.junit.jupiter.api.Test;
57 import org.junit.jupiter.api.extension.ExtendWith;
58 import org.junit.jupiter.params.ParameterizedTest;
59 import org.junit.jupiter.params.provider.MethodSource;
60 import org.mockito.ArgumentCaptor;
61 import org.mockito.Mock;
62 import org.mockito.junit.jupiter.MockitoExtension;
63 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
64 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
65 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
66 import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult;
67 import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
68 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
69 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
70 import org.openhab.binding.boschshc.internal.tests.common.CommonTestUtils;
72 import com.google.gson.JsonObject;
73 import com.google.gson.JsonSyntaxException;
76 * Unit tests for {@link LongPolling}.
78 * @author David Pace - Initial contribution
82 @ExtendWith(MockitoExtension.class)
83 class LongPollingTest {
86 * A dummy implementation of {@link ScheduledFuture}.
88 * This is required because we can not return <code>null</code> in the executor service test implementation (see
91 * @author David Pace - Initial contribution
93 * @param <T> The result type returned by this Future
95 private static class NullScheduledFuture<T> implements ScheduledFuture<T> {
98 public long getDelay(@Nullable TimeUnit unit) {
103 public int compareTo(@Nullable Delayed o) {
108 public boolean cancel(boolean mayInterruptIfRunning) {
113 public boolean isCancelled() {
118 public boolean isDone() {
123 public T get() throws InterruptedException, ExecutionException {
128 public T get(long timeout, @Nullable TimeUnit unit)
129 throws InterruptedException, ExecutionException, TimeoutException {
135 * Executor service implementation that runs all runnables in the same thread in order to enable deterministic
138 * @author David Pace - Initial contribution
141 private static class SameThreadExecutorService extends AbstractExecutorService implements ScheduledExecutorService {
143 private volatile boolean terminated;
146 public void shutdown() {
150 @NonNullByDefault({})
152 public List<Runnable> shutdownNow() {
153 return Collections.emptyList();
157 public boolean isShutdown() {
162 public boolean isTerminated() {
167 public boolean awaitTermination(long timeout, @Nullable TimeUnit unit) throws InterruptedException {
173 public void execute(@Nullable Runnable command) {
174 if (command != null) {
175 // execute in the same thread in unit tests
181 public ScheduledFuture<?> schedule(@Nullable Runnable command, long delay, @Nullable TimeUnit unit) {
182 // not used in this tests
183 return new NullScheduledFuture<>();
187 public <V> ScheduledFuture<V> schedule(@Nullable Callable<V> callable, long delay, @Nullable TimeUnit unit) {
188 return new NullScheduledFuture<>();
192 public ScheduledFuture<?> scheduleAtFixedRate(@Nullable Runnable command, long initialDelay, long period,
193 @Nullable TimeUnit unit) {
194 if (command != null) {
197 return new NullScheduledFuture<>();
201 public ScheduledFuture<?> scheduleWithFixedDelay(@Nullable Runnable command, long initialDelay, long delay,
202 @Nullable TimeUnit unit) {
203 if (command != null) {
206 return new NullScheduledFuture<>();
210 private @NonNullByDefault({}) LongPolling fixture;
212 private @NonNullByDefault({}) BoschHttpClient httpClient;
214 private @Mock @NonNullByDefault({}) Consumer<@NonNull LongPollResult> longPollHandler;
216 private @Mock @NonNullByDefault({}) Consumer<@NonNull Throwable> failureHandler;
220 fixture = new LongPolling(new SameThreadExecutorService(), longPollHandler, failureHandler);
221 httpClient = mock(BoschHttpClient.class);
225 void start() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
226 // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
227 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
229 Request subscribeRequest = mock(Request.class);
230 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
231 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
232 SubscribeResult subscribeResult = new SubscribeResult();
233 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
235 Request longPollRequest = mock(Request.class);
236 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
237 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
239 fixture.start(httpClient);
241 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
242 verify(longPollRequest).send(completeListener.capture());
244 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
246 String longPollResultJSON = "{\"result\":[{\"path\":\"/devices/hdm:HomeMaticIP:3014F711A0001916D859A8A9/services/PowerSwitch\",\"@type\":\"DeviceServiceData\",\"id\":\"PowerSwitch\",\"state\":{\"@type\":\"powerSwitchState\",\"switchState\":\"ON\"},\"deviceId\":\"hdm:HomeMaticIP:3014F711A0001916D859A8A9\"}],\"jsonrpc\":\"2.0\"}\n";
247 Response response = mock(Response.class);
248 bufferingResponseListener.onContent(response,
249 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
251 Result result = mock(Result.class);
252 bufferingResponseListener.onComplete(result);
254 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
255 verify(longPollHandler).accept(longPollResultCaptor.capture());
256 LongPollResult longPollResult = longPollResultCaptor.getValue();
257 assertEquals(1, longPollResult.result.size());
258 assertEquals(longPollResult.result.get(0).getClass(), DeviceServiceData.class);
259 DeviceServiceData longPollResultItem = (DeviceServiceData) longPollResult.result.get(0);
260 assertEquals("hdm:HomeMaticIP:3014F711A0001916D859A8A9", longPollResultItem.deviceId);
261 assertEquals("/devices/hdm:HomeMaticIP:3014F711A0001916D859A8A9/services/PowerSwitch", longPollResultItem.path);
262 assertEquals("PowerSwitch", longPollResultItem.id);
263 JsonObject stateObject = (JsonObject) longPollResultItem.state;
264 assertNotNull(stateObject);
265 assertEquals("ON", stateObject.get("switchState").getAsString());
269 void startLongPollingReceiveScenario()
270 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
271 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
273 Request subscribeRequest = mock(Request.class);
274 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
275 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
276 SubscribeResult subscribeResult = new SubscribeResult();
277 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
279 Request longPollRequest = mock(Request.class);
280 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
281 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
283 fixture.start(httpClient);
285 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
286 verify(longPollRequest).send(completeListener.capture());
288 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
290 String longPollResultJSON = "{\"result\":[{\"@type\": \"scenarioTriggered\",\"name\": \"My scenario\",\"id\": \"509bd737-eed0-40b7-8caa-e8686a714399\",\"lastTimeTriggered\": \"1693758693032\"}],\"jsonrpc\":\"2.0\"}\n";
291 Response response = mock(Response.class);
292 bufferingResponseListener.onContent(response,
293 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
295 Result result = mock(Result.class);
296 bufferingResponseListener.onComplete(result);
298 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
299 verify(longPollHandler).accept(longPollResultCaptor.capture());
300 LongPollResult longPollResult = longPollResultCaptor.getValue();
301 assertEquals(1, longPollResult.result.size());
302 assertEquals(longPollResult.result.get(0).getClass(), Scenario.class);
303 Scenario longPollResultItem = (Scenario) longPollResult.result.get(0);
304 assertEquals("509bd737-eed0-40b7-8caa-e8686a714399", longPollResultItem.id);
305 assertEquals("My scenario", longPollResultItem.name);
306 assertEquals("1693758693032", longPollResultItem.lastTimeTriggered);
310 void startLongPollingReceiveUserDefinedState()
311 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
312 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
314 Request subscribeRequest = mock(Request.class);
315 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
316 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
317 SubscribeResult subscribeResult = new SubscribeResult();
318 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
320 Request longPollRequest = mock(Request.class);
321 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
322 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
324 fixture.start(httpClient);
326 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
327 verify(longPollRequest).send(completeListener.capture());
329 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
331 String longPollResultJSON = "{\"result\":[{\"deleted\":false,\"@type\":\"userDefinedState\",\"name\":\"My User state\",\"id\":\"23d34fa6-382a-444d-8aae-89c706e22155\",\"state\":true}],\"jsonrpc\":\"2.0\"}\n";
332 Response response = mock(Response.class);
333 bufferingResponseListener.onContent(response,
334 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
336 Result result = mock(Result.class);
337 bufferingResponseListener.onComplete(result);
339 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
340 verify(longPollHandler).accept(longPollResultCaptor.capture());
341 LongPollResult longPollResult = longPollResultCaptor.getValue();
342 assertEquals(1, longPollResult.result.size());
343 assertEquals(longPollResult.result.get(0).getClass(), UserDefinedState.class);
344 UserDefinedState longPollResultItem = (UserDefinedState) longPollResult.result.get(0);
345 assertEquals("23d34fa6-382a-444d-8aae-89c706e22155", longPollResultItem.getId());
346 assertEquals("My User state", longPollResultItem.getName());
347 assertTrue(longPollResultItem.isState());
351 @MethodSource("org.openhab.binding.boschshc.internal.tests.common.CommonTestUtils#getBoschShcAndExecutionAndTimeoutAndInterruptedExceptionArguments()")
352 void startSubscriptionFailureHandleExceptions(Exception exception)
353 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
354 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenThrow(exception);
356 LongPollingFailedException e = assertThrows(LongPollingFailedException.class, () -> fixture.start(httpClient));
357 assertThat(e.getCause(), instanceOf(exception.getClass()));
358 assertThat(e.getMessage(), containsString(CommonTestUtils.TEST_EXCEPTION_MESSAGE));
362 @MethodSource("org.openhab.binding.boschshc.internal.tests.common.CommonTestUtils#getExceutionExceptionAndRuntimeExceptionArguments()")
363 void startLongPollFailure(Exception exception)
364 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
365 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
367 Request request = mock(Request.class);
368 when(httpClient.createRequest(anyString(), same(HttpMethod.POST), any(JsonRpcRequest.class)))
369 .thenReturn(request);
370 SubscribeResult subscribeResult = new SubscribeResult();
371 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
373 Request longPollRequest = mock(Request.class);
374 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
375 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
377 fixture.start(httpClient);
379 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
380 verify(longPollRequest).send(completeListener.capture());
382 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
384 Result result = mock(Result.class);
385 when(result.getFailure()).thenReturn(exception);
386 bufferingResponseListener.onComplete(result);
388 ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
389 verify(failureHandler).accept(throwableCaptor.capture());
390 Throwable t = throwableCaptor.getValue();
391 assertEquals("Unexpected exception during long polling request", t.getMessage());
392 assertSame(exception, t.getCause());
396 void startSubscriptionInvalid()
397 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
398 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
400 Request subscribeRequest = mock(Request.class);
401 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
402 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
403 SubscribeResult subscribeResult = new SubscribeResult();
404 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
406 Request longPollRequest = mock(Request.class);
407 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
408 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
410 fixture.start(httpClient);
412 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
413 verify(longPollRequest).send(completeListener.capture());
415 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
417 String longPollResultJSON = "{\"jsonrpc\":\"2.0\",\"error\": {\"code\":-32001,\"message\":\"No subscription with id: e8fei62b0-0\"}}\n";
418 Response response = mock(Response.class);
419 bufferingResponseListener.onContent(response,
420 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
422 Result result = mock(Result.class);
423 bufferingResponseListener.onComplete(result);
427 * Tests a case in which the Smart Home Controller returns a HTML error response that is not parsable as JSON.
429 * See <a href="https://github.com/openhab/openhab-addons/issues/15912">Issue 15912</a>
432 void startLongPollingInvalidLongPollResponse()
433 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
434 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
436 Request subscribeRequest = mock(Request.class);
437 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
438 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
439 SubscribeResult subscribeResult = new SubscribeResult();
440 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
442 Request longPollRequest = mock(Request.class);
443 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
444 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
446 fixture.start(httpClient);
448 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
449 verify(longPollRequest).send(completeListener.capture());
451 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
453 String longPollResultContent = "<HTML><HEAD><TITLE>400</TITLE></HEAD><BODY><H1>400 Unsupported HTTP Protocol Version: /remote/json-rpcHTTP/1.1</H1></BODY></HTML>";
454 Response response = mock(Response.class);
455 bufferingResponseListener.onContent(response,
456 ByteBuffer.wrap(longPollResultContent.getBytes(StandardCharsets.UTF_8)));
458 Result result = mock(Result.class);
459 bufferingResponseListener.onComplete(result);
461 ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
462 verify(failureHandler).accept(throwableCaptor.capture());
463 Throwable t = throwableCaptor.getValue();
465 "Could not deserialize long poll response: '<HTML><HEAD><TITLE>400</TITLE></HEAD><BODY><H1>400 Unsupported HTTP Protocol Version: /remote/json-rpcHTTP/1.1</H1></BODY></HTML>'",
467 assertTrue(t.getCause() instanceof JsonSyntaxException);