]> git.basschouten.com Git - openhab-addons.git/blob
7b4ea728883a16b42114b95464ab396193dc9c48
[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.junit.jupiter.api.Assertions.*;
16 import static org.mockito.ArgumentMatchers.*;
17 import static org.mockito.Mockito.*;
18
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;
32
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;
56
57 import com.google.gson.JsonObject;
58 import com.google.gson.JsonSyntaxException;
59
60 /**
61  * Unit tests for {@link LongPolling}.
62  *
63  * @author David Pace - Initial contribution
64  *
65  */
66 @NonNullByDefault
67 @ExtendWith(MockitoExtension.class)
68 class LongPollingTest {
69
70     /**
71      * A dummy implementation of {@link ScheduledFuture}.
72      * <p>
73      * This is required because we can not return <code>null</code> in the executor service test implementation (see
74      * below).
75      *
76      * @author David Pace - Initial contribution
77      *
78      * @param <T> The result type returned by this Future
79      */
80     private static class NullScheduledFuture<T> implements ScheduledFuture<T> {
81
82         @Override
83         public long getDelay(@Nullable TimeUnit unit) {
84             return 0;
85         }
86
87         @Override
88         public int compareTo(@Nullable Delayed o) {
89             return 0;
90         }
91
92         @Override
93         public boolean cancel(boolean mayInterruptIfRunning) {
94             return false;
95         }
96
97         @Override
98         public boolean isCancelled() {
99             return false;
100         }
101
102         @Override
103         public boolean isDone() {
104             return false;
105         }
106
107         @Override
108         public T get() throws InterruptedException, ExecutionException {
109             return null;
110         }
111
112         @Override
113         public T get(long timeout, @Nullable TimeUnit unit)
114                 throws InterruptedException, ExecutionException, TimeoutException {
115             return null;
116         }
117     }
118
119     /**
120      * Executor service implementation that runs all runnables in the same thread in order to enable deterministic
121      * testing.
122      *
123      * @author David Pace - Initial contribution
124      *
125      */
126     private static class SameThreadExecutorService extends AbstractExecutorService implements ScheduledExecutorService {
127
128         private volatile boolean terminated;
129
130         @Override
131         public void shutdown() {
132             terminated = true;
133         }
134
135         @NonNullByDefault({})
136         @Override
137         public List<Runnable> shutdownNow() {
138             return Collections.emptyList();
139         }
140
141         @Override
142         public boolean isShutdown() {
143             return terminated;
144         }
145
146         @Override
147         public boolean isTerminated() {
148             return terminated;
149         }
150
151         @Override
152         public boolean awaitTermination(long timeout, @Nullable TimeUnit unit) throws InterruptedException {
153             shutdown();
154             return terminated;
155         }
156
157         @Override
158         public void execute(@Nullable Runnable command) {
159             if (command != null) {
160                 // execute in the same thread in unit tests
161                 command.run();
162             }
163         }
164
165         @Override
166         public ScheduledFuture<?> schedule(@Nullable Runnable command, long delay, @Nullable TimeUnit unit) {
167             // not used in this tests
168             return new NullScheduledFuture<Object>();
169         }
170
171         @Override
172         public <V> ScheduledFuture<V> schedule(@Nullable Callable<V> callable, long delay, @Nullable TimeUnit unit) {
173             return new NullScheduledFuture<V>();
174         }
175
176         @Override
177         public ScheduledFuture<?> scheduleAtFixedRate(@Nullable Runnable command, long initialDelay, long period,
178                 @Nullable TimeUnit unit) {
179             if (command != null) {
180                 command.run();
181             }
182             return new NullScheduledFuture<Object>();
183         }
184
185         @Override
186         public ScheduledFuture<?> scheduleWithFixedDelay(@Nullable Runnable command, long initialDelay, long delay,
187                 @Nullable TimeUnit unit) {
188             if (command != null) {
189                 command.run();
190             }
191             return new NullScheduledFuture<Object>();
192         }
193     }
194
195     private @NonNullByDefault({}) LongPolling fixture;
196
197     private @NonNullByDefault({}) BoschHttpClient httpClient;
198
199     private @Mock @NonNullByDefault({}) Consumer<@NonNull LongPollResult> longPollHandler;
200
201     private @Mock @NonNullByDefault({}) Consumer<@NonNull Throwable> failureHandler;
202
203     @BeforeEach
204     void beforeEach() {
205         fixture = new LongPolling(new SameThreadExecutorService(), longPollHandler, failureHandler);
206         httpClient = mock(BoschHttpClient.class);
207     }
208
209     @Test
210     void start() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
211         // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
212         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
213
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);
219
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);
223
224         fixture.start(httpClient);
225
226         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
227         verify(longPollRequest).send(completeListener.capture());
228
229         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
230
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)));
235
236         Result result = mock(Result.class);
237         bufferingResponseListener.onComplete(result);
238
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());
251     }
252
253     @Test
254     void startLongPollingReceiveScenario()
255             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
256         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
257
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);
263
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);
267
268         fixture.start(httpClient);
269
270         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
271         verify(longPollRequest).send(completeListener.capture());
272
273         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
274
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)));
279
280         Result result = mock(Result.class);
281         bufferingResponseListener.onComplete(result);
282
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);
292     }
293
294     @Test
295     void startLongPollingReceiveUserDefinedState()
296             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
297         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
298
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);
304
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);
308
309         fixture.start(httpClient);
310
311         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
312         verify(longPollRequest).send(completeListener.capture());
313
314         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
315
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)));
320
321         Result result = mock(Result.class);
322         bufferingResponseListener.onComplete(result);
323
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());
333     }
334
335     @Test
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));
340
341         LongPollingFailedException e = assertThrows(LongPollingFailedException.class, () -> fixture.start(httpClient));
342         assertTrue(e.getMessage().contains("Subscription failed."));
343     }
344
345     @Test
346     void startLongPollFailure() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
347         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
348
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);
354
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);
358
359         fixture.start(httpClient);
360
361         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
362         verify(longPollRequest).send(completeListener.capture());
363
364         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
365
366         Result result = mock(Result.class);
367         ExecutionException exception = new ExecutionException("test exception", null);
368         when(result.getFailure()).thenReturn(exception);
369         bufferingResponseListener.onComplete(result);
370
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());
376     }
377
378     @Test
379     void startSubscriptionInvalid()
380             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
381         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
382
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);
388
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);
392
393         fixture.start(httpClient);
394
395         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
396         verify(longPollRequest).send(completeListener.capture());
397
398         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
399
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)));
404
405         Result result = mock(Result.class);
406         bufferingResponseListener.onComplete(result);
407     }
408
409     /**
410      * Tests a case in which the Smart Home Controller returns a HTML error response that is not parsable as JSON.
411      * <p>
412      * See <a href="https://github.com/openhab/openhab-addons/issues/15912">Issue 15912</a>
413      */
414     @Test
415     void startLongPollingInvalidLongPollResponse()
416             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
417         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
418
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);
424
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);
428
429         fixture.start(httpClient);
430
431         ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
432         verify(longPollRequest).send(completeListener.capture());
433
434         BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
435
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)));
440
441         Result result = mock(Result.class);
442         bufferingResponseListener.onComplete(result);
443
444         ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
445         verify(failureHandler).accept(throwableCaptor.capture());
446         Throwable t = throwableCaptor.getValue();
447         assertEquals(
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>'",
449                 t.getMessage());
450         assertTrue(t.getCause() instanceof JsonSyntaxException);
451     }
452
453     @AfterEach
454     void afterEach() {
455         fixture.stop();
456     }
457 }