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.junit.jupiter.api.Assertions.*;
16 import static org.mockito.ArgumentMatchers.*;
17 import static org.mockito.Mockito.*;
19 import java.nio.ByteBuffer;
20 import java.nio.charset.StandardCharsets;
21 import java.util.Collections;
22 import java.util.List;
23 import java.util.concurrent.AbstractExecutorService;
24 import java.util.concurrent.Callable;
25 import java.util.concurrent.Delayed;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ScheduledExecutorService;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31 import java.util.function.Consumer;
33 import org.eclipse.jdt.annotation.NonNull;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.api.Request;
37 import org.eclipse.jetty.client.api.Response;
38 import org.eclipse.jetty.client.api.Response.CompleteListener;
39 import org.eclipse.jetty.client.api.Result;
40 import org.eclipse.jetty.client.util.BufferingResponseListener;
41 import org.eclipse.jetty.http.HttpMethod;
42 import org.junit.jupiter.api.AfterEach;
43 import org.junit.jupiter.api.BeforeEach;
44 import org.junit.jupiter.api.Test;
45 import org.junit.jupiter.api.extension.ExtendWith;
46 import org.mockito.ArgumentCaptor;
47 import org.mockito.Mock;
48 import org.mockito.junit.jupiter.MockitoExtension;
49 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
50 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
51 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
52 import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult;
53 import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
54 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
55 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
57 import com.google.gson.JsonObject;
58 import com.google.gson.JsonSyntaxException;
61 * Unit tests for {@link LongPolling}.
63 * @author David Pace - Initial contribution
67 @ExtendWith(MockitoExtension.class)
68 class LongPollingTest {
71 * A dummy implementation of {@link ScheduledFuture}.
73 * This is required because we can not return <code>null</code> in the executor service test implementation (see
76 * @author David Pace - Initial contribution
78 * @param <T> The result type returned by this Future
80 private static class NullScheduledFuture<T> implements ScheduledFuture<T> {
83 public long getDelay(@Nullable TimeUnit unit) {
88 public int compareTo(@Nullable Delayed o) {
93 public boolean cancel(boolean mayInterruptIfRunning) {
98 public boolean isCancelled() {
103 public boolean isDone() {
108 public T get() throws InterruptedException, ExecutionException {
113 public T get(long timeout, @Nullable TimeUnit unit)
114 throws InterruptedException, ExecutionException, TimeoutException {
120 * Executor service implementation that runs all runnables in the same thread in order to enable deterministic
123 * @author David Pace - Initial contribution
126 private static class SameThreadExecutorService extends AbstractExecutorService implements ScheduledExecutorService {
128 private volatile boolean terminated;
131 public void shutdown() {
135 @NonNullByDefault({})
137 public List<Runnable> shutdownNow() {
138 return Collections.emptyList();
142 public boolean isShutdown() {
147 public boolean isTerminated() {
152 public boolean awaitTermination(long timeout, @Nullable TimeUnit unit) throws InterruptedException {
158 public void execute(@Nullable Runnable command) {
159 if (command != null) {
160 // execute in the same thread in unit tests
166 public ScheduledFuture<?> schedule(@Nullable Runnable command, long delay, @Nullable TimeUnit unit) {
167 // not used in this tests
168 return new NullScheduledFuture<Object>();
172 public <V> ScheduledFuture<V> schedule(@Nullable Callable<V> callable, long delay, @Nullable TimeUnit unit) {
173 return new NullScheduledFuture<V>();
177 public ScheduledFuture<?> scheduleAtFixedRate(@Nullable Runnable command, long initialDelay, long period,
178 @Nullable TimeUnit unit) {
179 if (command != null) {
182 return new NullScheduledFuture<Object>();
186 public ScheduledFuture<?> scheduleWithFixedDelay(@Nullable Runnable command, long initialDelay, long delay,
187 @Nullable TimeUnit unit) {
188 if (command != null) {
191 return new NullScheduledFuture<Object>();
195 private @NonNullByDefault({}) LongPolling fixture;
197 private @NonNullByDefault({}) BoschHttpClient httpClient;
199 private @Mock @NonNullByDefault({}) Consumer<@NonNull LongPollResult> longPollHandler;
201 private @Mock @NonNullByDefault({}) Consumer<@NonNull Throwable> failureHandler;
205 fixture = new LongPolling(new SameThreadExecutorService(), longPollHandler, failureHandler);
206 httpClient = mock(BoschHttpClient.class);
210 void start() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
211 // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
212 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
214 Request subscribeRequest = mock(Request.class);
215 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
216 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
217 SubscribeResult subscribeResult = new SubscribeResult();
218 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
220 Request longPollRequest = mock(Request.class);
221 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
222 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
224 fixture.start(httpClient);
226 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
227 verify(longPollRequest).send(completeListener.capture());
229 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
231 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";
232 Response response = mock(Response.class);
233 bufferingResponseListener.onContent(response,
234 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
236 Result result = mock(Result.class);
237 bufferingResponseListener.onComplete(result);
239 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
240 verify(longPollHandler).accept(longPollResultCaptor.capture());
241 LongPollResult longPollResult = longPollResultCaptor.getValue();
242 assertEquals(1, longPollResult.result.size());
243 assertEquals(longPollResult.result.get(0).getClass(), DeviceServiceData.class);
244 DeviceServiceData longPollResultItem = (DeviceServiceData) longPollResult.result.get(0);
245 assertEquals("hdm:HomeMaticIP:3014F711A0001916D859A8A9", longPollResultItem.deviceId);
246 assertEquals("/devices/hdm:HomeMaticIP:3014F711A0001916D859A8A9/services/PowerSwitch", longPollResultItem.path);
247 assertEquals("PowerSwitch", longPollResultItem.id);
248 JsonObject stateObject = (JsonObject) longPollResultItem.state;
249 assertNotNull(stateObject);
250 assertEquals("ON", stateObject.get("switchState").getAsString());
254 void startLongPollingReceiveScenario()
255 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
256 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
258 Request subscribeRequest = mock(Request.class);
259 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
260 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
261 SubscribeResult subscribeResult = new SubscribeResult();
262 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
264 Request longPollRequest = mock(Request.class);
265 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
266 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
268 fixture.start(httpClient);
270 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
271 verify(longPollRequest).send(completeListener.capture());
273 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
275 String longPollResultJSON = "{\"result\":[{\"@type\": \"scenarioTriggered\",\"name\": \"My scenario\",\"id\": \"509bd737-eed0-40b7-8caa-e8686a714399\",\"lastTimeTriggered\": \"1693758693032\"}],\"jsonrpc\":\"2.0\"}\n";
276 Response response = mock(Response.class);
277 bufferingResponseListener.onContent(response,
278 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
280 Result result = mock(Result.class);
281 bufferingResponseListener.onComplete(result);
283 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
284 verify(longPollHandler).accept(longPollResultCaptor.capture());
285 LongPollResult longPollResult = longPollResultCaptor.getValue();
286 assertEquals(1, longPollResult.result.size());
287 assertEquals(longPollResult.result.get(0).getClass(), Scenario.class);
288 Scenario longPollResultItem = (Scenario) longPollResult.result.get(0);
289 assertEquals("509bd737-eed0-40b7-8caa-e8686a714399", longPollResultItem.id);
290 assertEquals("My scenario", longPollResultItem.name);
291 assertEquals("1693758693032", longPollResultItem.lastTimeTriggered);
295 void startLongPollingReceiveUserDefinedState()
296 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
297 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
299 Request subscribeRequest = mock(Request.class);
300 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
301 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
302 SubscribeResult subscribeResult = new SubscribeResult();
303 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
305 Request longPollRequest = mock(Request.class);
306 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
307 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
309 fixture.start(httpClient);
311 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
312 verify(longPollRequest).send(completeListener.capture());
314 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
316 String longPollResultJSON = "{\"result\":[{\"deleted\":false,\"@type\":\"userDefinedState\",\"name\":\"My User state\",\"id\":\"23d34fa6-382a-444d-8aae-89c706e22155\",\"state\":true}],\"jsonrpc\":\"2.0\"}\n";
317 Response response = mock(Response.class);
318 bufferingResponseListener.onContent(response,
319 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
321 Result result = mock(Result.class);
322 bufferingResponseListener.onComplete(result);
324 ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
325 verify(longPollHandler).accept(longPollResultCaptor.capture());
326 LongPollResult longPollResult = longPollResultCaptor.getValue();
327 assertEquals(1, longPollResult.result.size());
328 assertEquals(longPollResult.result.get(0).getClass(), UserDefinedState.class);
329 UserDefinedState longPollResultItem = (UserDefinedState) longPollResult.result.get(0);
330 assertEquals("23d34fa6-382a-444d-8aae-89c706e22155", longPollResultItem.getId());
331 assertEquals("My User state", longPollResultItem.getName());
332 assertTrue(longPollResultItem.isState());
336 void startSubscriptionFailure()
337 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
338 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any()))
339 .thenThrow(new ExecutionException("Subscription failed.", null));
341 LongPollingFailedException e = assertThrows(LongPollingFailedException.class, () -> fixture.start(httpClient));
342 assertTrue(e.getMessage().contains("Subscription failed."));
346 void startLongPollFailure() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
347 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
349 Request request = mock(Request.class);
350 when(httpClient.createRequest(anyString(), same(HttpMethod.POST), any(JsonRpcRequest.class)))
351 .thenReturn(request);
352 SubscribeResult subscribeResult = new SubscribeResult();
353 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
355 Request longPollRequest = mock(Request.class);
356 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
357 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
359 fixture.start(httpClient);
361 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
362 verify(longPollRequest).send(completeListener.capture());
364 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
366 Result result = mock(Result.class);
367 ExecutionException exception = new ExecutionException("test exception", null);
368 when(result.getFailure()).thenReturn(exception);
369 bufferingResponseListener.onComplete(result);
371 ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
372 verify(failureHandler).accept(throwableCaptor.capture());
373 Throwable t = throwableCaptor.getValue();
374 assertEquals("Unexpected exception during long polling request", t.getMessage());
375 assertSame(exception, t.getCause());
379 void startSubscriptionInvalid()
380 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
381 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
383 Request subscribeRequest = mock(Request.class);
384 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
385 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
386 SubscribeResult subscribeResult = new SubscribeResult();
387 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
389 Request longPollRequest = mock(Request.class);
390 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
391 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
393 fixture.start(httpClient);
395 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
396 verify(longPollRequest).send(completeListener.capture());
398 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
400 String longPollResultJSON = "{\"jsonrpc\":\"2.0\",\"error\": {\"code\":-32001,\"message\":\"No subscription with id: e8fei62b0-0\"}}\n";
401 Response response = mock(Response.class);
402 bufferingResponseListener.onContent(response,
403 ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
405 Result result = mock(Result.class);
406 bufferingResponseListener.onComplete(result);
410 * Tests a case in which the Smart Home Controller returns a HTML error response that is not parsable as JSON.
412 * See <a href="https://github.com/openhab/openhab-addons/issues/15912">Issue 15912</a>
415 void startLongPollingInvalidLongPollResponse()
416 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
417 when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
419 Request subscribeRequest = mock(Request.class);
420 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
421 argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
422 SubscribeResult subscribeResult = new SubscribeResult();
423 when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
425 Request longPollRequest = mock(Request.class);
426 when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
427 argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
429 fixture.start(httpClient);
431 ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
432 verify(longPollRequest).send(completeListener.capture());
434 BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
436 String longPollResultContent = "<HTML><HEAD><TITLE>400</TITLE></HEAD><BODY><H1>400 Unsupported HTTP Protocol Version: /remote/json-rpcHTTP/1.1</H1></BODY></HTML>";
437 Response response = mock(Response.class);
438 bufferingResponseListener.onContent(response,
439 ByteBuffer.wrap(longPollResultContent.getBytes(StandardCharsets.UTF_8)));
441 Result result = mock(Result.class);
442 bufferingResponseListener.onComplete(result);
444 ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
445 verify(failureHandler).accept(throwableCaptor.capture());
446 Throwable t = throwableCaptor.getValue();
448 "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>'",
450 assertTrue(t.getCause() instanceof JsonSyntaxException);