2 * Copyright (c) 2010-2023 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.webthing.internal.client;
15 import static org.junit.jupiter.api.Assertions.*;
16 import static org.mockito.Mockito.*;
18 import java.io.IOException;
19 import java.net.InetSocketAddress;
21 import java.net.http.WebSocket;
22 import java.nio.ByteBuffer;
23 import java.nio.charset.StandardCharsets;
24 import java.nio.file.Files;
25 import java.nio.file.Paths;
26 import java.time.Duration;
28 import java.util.concurrent.CompletableFuture;
29 import java.util.concurrent.Executors;
30 import java.util.concurrent.Future;
31 import java.util.concurrent.ScheduledExecutorService;
32 import java.util.concurrent.atomic.AtomicBoolean;
33 import java.util.concurrent.atomic.AtomicReference;
34 import java.util.function.BiConsumer;
35 import java.util.function.Consumer;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.eclipse.jetty.client.HttpClient;
40 import org.eclipse.jetty.websocket.api.BatchMode;
41 import org.eclipse.jetty.websocket.api.CloseStatus;
42 import org.eclipse.jetty.websocket.api.RemoteEndpoint;
43 import org.eclipse.jetty.websocket.api.Session;
44 import org.eclipse.jetty.websocket.api.SuspendToken;
45 import org.eclipse.jetty.websocket.api.UpgradeRequest;
46 import org.eclipse.jetty.websocket.api.UpgradeResponse;
47 import org.eclipse.jetty.websocket.api.WebSocketListener;
48 import org.eclipse.jetty.websocket.api.WebSocketPingPongListener;
49 import org.eclipse.jetty.websocket.api.WebSocketPolicy;
50 import org.eclipse.jetty.websocket.api.WriteCallback;
51 import org.junit.jupiter.api.Test;
52 import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
54 import com.google.gson.Gson;
59 * @author Gregor Roth - Initial contribution
62 public class WebthingTest {
63 private static final Gson GSON = new Gson();
66 public void testWebthingDescription() throws Exception {
67 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
68 var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
69 when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
71 var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
72 when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
74 var webthing = createTestWebthing("http://example.org:8090", httpClient);
75 var metadata = webthing.getThingDescription();
76 assertEquals("Wind", metadata.title);
80 public void testWebthingDescriptionUnsetSchema() throws Exception {
81 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
82 var request = Mocks.mockRequest(null, load("/unsetschema_response.json"));
83 when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
85 var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
86 when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
88 var webthing = createTestWebthing("http://example.org:8090", httpClient);
89 var metadata = webthing.getThingDescription();
90 assertEquals("Wind", metadata.title);
94 public void testWebthingDescriptionUNsupportedSchema() throws Exception {
95 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
96 var request = Mocks.mockRequest(null, load("/unknownschema_response.json"));
97 when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
100 createTestWebthing("http://example.org:8090", httpClient);
102 } catch (IOException e) {
104 "unsupported schema (@context parameter) https://www.w3.org/2019/wot/td/v1 (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)",
110 public void testReadReadOnlyProperty() throws Exception {
111 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
112 var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
113 when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
115 var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
116 when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
118 var webthing = createTestWebthing("http://example.org:8090", httpClient);
120 assertEquals(34.0, webthing.readProperty("windspeed"));
122 webthing.writeProperty("windspeed", 23.0);
124 } catch (PropertyAccessException e) {
126 "could not write windspeed (http://example.org:8090/properties/windspeed) with 23.0. Property is readOnly",
132 public void testReadPropertyTest() throws Exception {
133 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
134 var request = Mocks.mockRequest(null, load("/awning_response.json"));
135 when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
137 var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
138 when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
139 .thenReturn(request2);
141 var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
143 assertEquals(85.0, webthing.readProperty("target_position"));
147 public void testWriteProperty() throws Exception {
148 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
149 var request = Mocks.mockRequest(null, load("/awning_response.json"));
150 when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
152 var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
153 when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
154 .thenReturn(request2);
156 var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
157 webthing.writeProperty("target_position", 10);
161 public void testWritePropertyError() throws Exception {
162 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
163 var request = Mocks.mockRequest(null, load("/awning_response.json"));
164 when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
166 var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"), 200, 400);
167 when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
168 .thenReturn(request2);
170 var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
172 webthing.writeProperty("target_position", 10);
174 } catch (PropertyAccessException e) {
176 "could not write target_position (http://example.org:8090/0/properties/target_position) with 10",
182 public void testReadPropertyError() throws Exception {
183 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
184 var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
185 when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
187 var request2 = Mocks.mockRequest(null, load("/windsensor_response.json"), 500, 200);
188 when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
190 var webthing = createTestWebthing("http://example.org:8090", httpClient);
192 webthing.readProperty("windspeed");
194 } catch (PropertyAccessException e) {
195 assertEquals("could not read windspeed (http://example.org:8090/properties/windspeed)", e.getMessage());
200 public void testWebSocket() throws Exception {
201 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
202 var request = Mocks.mockRequest(null, load("/awning_response.json"));
203 when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
205 var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
206 when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
207 .thenReturn(request2);
209 var errorHandler = new ErrorHandler();
210 var webSocketFactory = new TestWebsocketConnectionFactory();
211 var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory);
213 var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
214 webthing.observeProperty("target_position", propertyChangedListenerImpl);
216 var webSocketServerSide = webSocketFactory.webSocketRef.get();
217 var message = new PropertyStatusMessage();
218 message.messageType = "propertyStatus";
219 message.data = Map.of("target_position", 33);
220 webSocketServerSide.sendToClient(message);
222 while (propertyChangedListenerImpl.valueRef.get() == null) {
225 } catch (InterruptedException ignore) {
228 assertEquals(33.0, propertyChangedListenerImpl.valueRef.get());
230 webSocketServerSide.sendCloseToClient();
231 assertEquals("websocket closed by peer. ", errorHandler.errorRef.get());
235 public void testWebSocketReceiveTimout() throws Exception {
236 var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
237 var request = Mocks.mockRequest(null, load("/awning_response.json"));
238 when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
240 var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
241 when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
242 .thenReturn(request2);
244 var errorHandler = new ErrorHandler();
245 var webSocketFactory = new TestWebsocketConnectionFactory();
246 var pingPeriod = Duration.ofMillis(300);
247 var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory,
250 var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
251 webthing.observeProperty("target_position", propertyChangedListenerImpl);
252 webSocketFactory.webSocketRef.get().ignorePing.set(true);
255 Thread.sleep(pingPeriod.dividedBy(2).toMillis());
256 } catch (InterruptedException ignore) {
258 assertNull(errorHandler.errorRef.get());
261 Thread.sleep(pingPeriod.multipliedBy(4).toMillis());
262 } catch (InterruptedException ignore) {
264 assertTrue(errorHandler.errorRef.get().startsWith("connection seems to be broken (last message received at"));
267 public static String load(String name) throws Exception {
268 return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
271 public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient) throws IOException {
272 return createTestWebthing(uri, httpClient, (String) -> {
273 }, new TestWebsocketConnectionFactory());
276 public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
277 WebSocketConnectionFactory websocketConnectionFactory, Duration pingPeriod) throws IOException {
278 return new ConsumedThingImpl(httpClient, URI.create(uri), Executors.newSingleThreadScheduledExecutor(),
279 errorHandler, websocketConnectionFactory, pingPeriod);
282 public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
283 WebSocketConnectionFactory websocketConnectionFactory) throws IOException {
284 return createTestWebthing(uri, httpClient, errorHandler, websocketConnectionFactory, Duration.ofSeconds(100));
287 public static class TestWebsocketConnectionFactory implements WebSocketConnectionFactory {
288 public final AtomicReference<WebSocketImpl> webSocketRef = new AtomicReference<>();
291 public WebSocketConnection create(URI webSocketURI, ScheduledExecutorService executor,
292 Consumer<String> errorHandler, Duration pingPeriod) {
293 var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod);
294 var webSocket = new WebSocketImpl(webSocketConnection);
295 webSocketRef.set(webSocket);
296 webSocketConnection.onWebSocketConnect(webSocket);
297 return webSocketConnection;
301 public static final class WebSocketImpl implements Session {
302 private final WebSocketListener listener;
303 private final WebSocketPingPongListener pongListener;
304 public AtomicBoolean ignorePing = new AtomicBoolean(false);
306 WebSocketImpl(WebSocketConnectionImpl connection) {
307 this.listener = connection;
308 this.pongListener = connection;
312 public void close() {
316 public void close(@Nullable CloseStatus closeStatus) {
320 public void close(int statusCode, @Nullable String reason) {
324 public void disconnect() throws IOException {
328 public long getIdleTimeout() {
333 public InetSocketAddress getLocalAddress() {
334 return InetSocketAddress.createUnresolved("test", 23);
338 public WebSocketPolicy getPolicy() {
339 return WebSocketPolicy.newClientPolicy();
343 public String getProtocolVersion() {
348 public RemoteEndpoint getRemote() {
349 return new RemoteEndpoint() {
351 public void sendBytes(@Nullable ByteBuffer data) throws IOException {
355 public Future sendBytesByFuture(@Nullable ByteBuffer data) {
356 throw new UnsupportedOperationException();
360 public void sendBytes(@Nullable ByteBuffer data, @Nullable WriteCallback callback) {
364 public void sendPartialBytes(@Nullable ByteBuffer fragment, boolean isLast) throws IOException {
368 public void sendPartialString(@Nullable String fragment, boolean isLast) throws IOException {
372 public void sendPing(@Nullable ByteBuffer applicationData) throws IOException {
373 if (!ignorePing.get()) {
374 pongListener.onWebSocketPong(applicationData);
379 public void sendPong(@Nullable ByteBuffer applicationData) throws IOException {
383 public void sendString(@Nullable String text) throws IOException {
387 public Future sendStringByFuture(@Nullable String text) {
388 throw new UnsupportedOperationException();
392 public void sendString(@Nullable String text, @Nullable WriteCallback callback) {
396 public BatchMode getBatchMode() {
397 return BatchMode.AUTO;
401 public void setBatchMode(@Nullable BatchMode mode) {
405 public InetSocketAddress getInetSocketAddress() {
406 throw new UnsupportedOperationException();
410 public void flush() throws IOException {
414 public int getMaxOutgoingFrames() {
419 public void setMaxOutgoingFrames(int maxOutgoingFrames) {
425 public InetSocketAddress getRemoteAddress() {
426 return InetSocketAddress.createUnresolved("test", 12);
430 public UpgradeRequest getUpgradeRequest() {
431 throw new UnsupportedOperationException();
435 public UpgradeResponse getUpgradeResponse() {
436 throw new UnsupportedOperationException();
440 public boolean isOpen() {
445 public boolean isSecure() {
450 public void setIdleTimeout(long ms) {
454 public SuspendToken suspend() {
455 return new SuspendToken() {
458 public void resume() {
463 public void sendToClient(PropertyStatusMessage message) {
464 var data = GSON.toJson(message);
465 listener.onWebSocketText(data);
468 public void sendCloseToClient() {
469 listener.onWebSocketClose(200, "");
472 public CompletableFuture<WebSocket> sendPing(String message) {
473 if (!ignorePing.get()) {
474 var bytes = message.getBytes(StandardCharsets.UTF_8);
475 listener.onWebSocketBinary(bytes, 0, bytes.length);
477 return CompletableFuture.completedFuture(null);
481 private static final class PropertyChangedListenerImpl implements BiConsumer<String, Object> {
482 public final AtomicReference<String> propertyNameRef = new AtomicReference<>();
483 public final AtomicReference<Object> valueRef = new AtomicReference<>();
486 public void accept(String propertyName, Object value) {
487 propertyNameRef.set(propertyName);
492 public static class ErrorHandler implements Consumer<String> {
493 public final AtomicReference<String> errorRef = new AtomicReference<>();
496 public void accept(String error) {