]> git.basschouten.com Git - openhab-addons.git/blob
6ae484982520b473a79c12662468d9df4e8d448b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.boschshc.internal.devices.bridge;
14
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;
30
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;
44
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;
71
72 import com.google.gson.JsonObject;
73 import com.google.gson.JsonSyntaxException;
74
75 /**
76  * Unit tests for {@link LongPolling}.
77  *
78  * @author David Pace - Initial contribution
79  *
80  */
81 @NonNullByDefault
82 @ExtendWith(MockitoExtension.class)
83 class LongPollingTest {
84
85     /**
86      * A dummy implementation of {@link ScheduledFuture}.
87      * <p>
88      * This is required because we can not return <code>null</code> in the executor service test implementation (see
89      * below).
90      *
91      * @author David Pace - Initial contribution
92      *
93      * @param <T> The result type returned by this Future
94      */
95     private static class NullScheduledFuture<T> implements ScheduledFuture<T> {
96
97         @Override
98         public long getDelay(@Nullable TimeUnit unit) {
99             return 0;
100         }
101
102         @Override
103         public int compareTo(@Nullable Delayed o) {
104             return 0;
105         }
106
107         @Override
108         public boolean cancel(boolean mayInterruptIfRunning) {
109             return false;
110         }
111
112         @Override
113         public boolean isCancelled() {
114             return false;
115         }
116
117         @Override
118         public boolean isDone() {
119             return false;
120         }
121
122         @Override
123         public T get() throws InterruptedException, ExecutionException {
124             return null;
125         }
126
127         @Override
128         public T get(long timeout, @Nullable TimeUnit unit)
129                 throws InterruptedException, ExecutionException, TimeoutException {
130             return null;
131         }
132     }
133
134     /**
135      * Executor service implementation that runs all runnables in the same thread in order to enable deterministic
136      * testing.
137      *
138      * @author David Pace - Initial contribution
139      *
140      */
141     private static class SameThreadExecutorService extends AbstractExecutorService implements ScheduledExecutorService {
142
143         private volatile boolean terminated;
144
145         @Override
146         public void shutdown() {
147             terminated = true;
148         }
149
150         @NonNullByDefault({})
151         @Override
152         public List<Runnable> shutdownNow() {
153             return Collections.emptyList();
154         }
155
156         @Override
157         public boolean isShutdown() {
158             return terminated;
159         }
160
161         @Override
162         public boolean isTerminated() {
163             return terminated;
164         }
165
166         @Override
167         public boolean awaitTermination(long timeout, @Nullable TimeUnit unit) throws InterruptedException {
168             shutdown();
169             return terminated;
170         }
171
172         @Override
173         public void execute(@Nullable Runnable command) {
174             if (command != null) {
175                 // execute in the same thread in unit tests
176                 command.run();
177             }
178         }
179
180         @Override
181         public ScheduledFuture<?> schedule(@Nullable Runnable command, long delay, @Nullable TimeUnit unit) {
182             // not used in this tests
183             return new NullScheduledFuture<>();
184         }
185
186         @Override
187         public <V> ScheduledFuture<V> schedule(@Nullable Callable<V> callable, long delay, @Nullable TimeUnit unit) {
188             return new NullScheduledFuture<>();
189         }
190
191         @Override
192         public ScheduledFuture<?> scheduleAtFixedRate(@Nullable Runnable command, long initialDelay, long period,
193                 @Nullable TimeUnit unit) {
194             if (command != null) {
195                 command.run();
196             }
197             return new NullScheduledFuture<>();
198         }
199
200         @Override
201         public ScheduledFuture<?> scheduleWithFixedDelay(@Nullable Runnable command, long initialDelay, long delay,
202                 @Nullable TimeUnit unit) {
203             if (command != null) {
204                 command.run();
205             }
206             return new NullScheduledFuture<>();
207         }
208     }
209
210     private @NonNullByDefault({}) LongPolling fixture;
211
212     private @NonNullByDefault({}) BoschHttpClient httpClient;
213
214     private @Mock @NonNullByDefault({}) Consumer<@NonNull LongPollResult> longPollHandler;
215
216     private @Mock @NonNullByDefault({}) Consumer<@NonNull Throwable> failureHandler;
217
218     @BeforeEach
219     void beforeEach() {
220         fixture = new LongPolling(new SameThreadExecutorService(), longPollHandler, failureHandler);
221         httpClient = mock(BoschHttpClient.class);
222     }
223
224     @Test
225     void start() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
226         // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
227         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
228
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);
234
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);
238
239         fixture.start(httpClient);
240
241         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
242         verify(longPollRequest).send(completeListener.capture());
243
244         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
245
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)));
250
251         Result result = mock(Result.class);
252         bufferingResponseListener.onComplete(result);
253
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());
266     }
267
268     @Test
269     void startLongPollingReceiveScenario()
270             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
271         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
272
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);
278
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);
282
283         fixture.start(httpClient);
284
285         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
286         verify(longPollRequest).send(completeListener.capture());
287
288         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
289
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)));
294
295         Result result = mock(Result.class);
296         bufferingResponseListener.onComplete(result);
297
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);
307     }
308
309     @Test
310     void startLongPollingReceiveUserDefinedState()
311             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
312         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
313
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);
319
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);
323
324         fixture.start(httpClient);
325
326         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
327         verify(longPollRequest).send(completeListener.capture());
328
329         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
330
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)));
335
336         Result result = mock(Result.class);
337         bufferingResponseListener.onComplete(result);
338
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());
348     }
349
350     @ParameterizedTest
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);
355
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));
359     }
360
361     @ParameterizedTest
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();
366
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);
372
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);
376
377         fixture.start(httpClient);
378
379         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
380         verify(longPollRequest).send(completeListener.capture());
381
382         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
383
384         Result result = mock(Result.class);
385         when(result.getFailure()).thenReturn(exception);
386         bufferingResponseListener.onComplete(result);
387
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());
393     }
394
395     @Test
396     void startSubscriptionInvalid()
397             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
398         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
399
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);
405
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);
409
410         fixture.start(httpClient);
411
412         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
413         verify(longPollRequest).send(completeListener.capture());
414
415         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
416
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)));
421
422         Result result = mock(Result.class);
423         bufferingResponseListener.onComplete(result);
424     }
425
426     /**
427      * Tests a case in which the Smart Home Controller returns a HTML error response that is not parsable as JSON.
428      * <p>
429      * See <a href="https://github.com/openhab/openhab-addons/issues/15912">Issue 15912</a>
430      */
431     @Test
432     void startLongPollingInvalidLongPollResponse()
433             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
434         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
435
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);
441
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);
445
446         fixture.start(httpClient);
447
448         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
449         verify(longPollRequest).send(completeListener.capture());
450
451         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
452
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)));
457
458         Result result = mock(Result.class);
459         bufferingResponseListener.onComplete(result);
460
461         ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
462         verify(failureHandler).accept(throwableCaptor.capture());
463         Throwable t = throwableCaptor.getValue();
464         assertEquals(
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>'",
466                 t.getMessage());
467         assertTrue(t.getCause() instanceof JsonSyntaxException);
468     }
469
470     @AfterEach
471     void afterEach() {
472         fixture.stop();
473     }
474 }