Skip to content

Commit 620fdf0

Browse files
committed
test: add unit tests for WebSocketTransport lifecycle and robustness
- Test edge cases like multiple `connect()` calls, forced closures, and redundant `onClose` events.
1 parent ffc9e48 commit 620fdf0

4 files changed

Lines changed: 179 additions & 3 deletions

File tree

lib/src/main/java/io/ably/lib/transport/ConnectionManager.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.ably.lib.types.ClientOptions;
3232
import io.ably.lib.types.ConnectionDetails;
3333
import io.ably.lib.types.ErrorInfo;
34+
import io.ably.lib.types.Param;
3435
import io.ably.lib.types.ProtocolMessage;
3536
import io.ably.lib.types.ProtocolSerializer;
3637
import io.ably.lib.util.Log;
@@ -857,6 +858,13 @@ public void requestState(StateIndication state) {
857858
requestState(null, state);
858859
}
859860

861+
/**
862+
* Get query params representing the current authentication method and credentials.
863+
*/
864+
Param[] getAuthParams() throws AblyException {
865+
return ably.auth.getAuthParams();
866+
}
867+
860868
/**
861869
* Determines if the given WebSocketTransport instance is the currently active transport.
862870
*

lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public void connect(ConnectListener connectListener) {
109109
boolean isTls = params.options.tls;
110110
String wsScheme = isTls ? "wss://" : "ws://";
111111
wsUri = wsScheme + params.host + ':' + params.port + "/";
112-
Param[] authParams = connectionManager.ably.auth.getAuthParams();
112+
Param[] authParams = connectionManager.getAuthParams();
113113
Param[] connectParams = params.getConnectParams(authParams);
114114
if (connectParams.length > 0)
115115
wsUri = HttpUtils.encodeParams(wsUri, connectParams);
@@ -415,7 +415,7 @@ private void schedule(TimerTask task, long delay) {
415415
try {
416416
timer.schedule(task, delay);
417417
} catch (IllegalStateException ise) {
418-
Log.w(TAG, "Timer has already been canceled", ise);
418+
Log.w(TAG, "Timer has already has been canceled", ise);
419419
}
420420
}
421421

@@ -439,7 +439,7 @@ private void onActivityTimerExpiry() {
439439
}
440440

441441
private long getActivityTimeout() {
442-
return connectionManager.maxIdleInterval + connectionManager.ably.options.realtimeRequestTimeout;
442+
return connectionManager.maxIdleInterval + params.options.realtimeRequestTimeout;
443443
}
444444
}
445445

lib/src/test/java/io/ably/lib/test/common/Helpers.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,16 @@ public static boolean equalNullableStrings(String one, String two) {
969969
return (one == null) ? (two == null) : one.equals(two);
970970
}
971971

972+
public static void setPrivateField(Object object, String fieldName, Object value) {
973+
try {
974+
Field connectionStateField = object.getClass().getDeclaredField(fieldName);
975+
connectionStateField.setAccessible(true);
976+
connectionStateField.set(object, value);
977+
} catch (Exception e) {
978+
fail("Failed accessing " + fieldName + " with error " + e);
979+
}
980+
}
981+
972982
public static class RawHttpRequest {
973983
public String id;
974984
public URL url;
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package io.ably.lib.transport;
2+
3+
import io.ably.lib.network.WebSocketClient;
4+
import io.ably.lib.network.WebSocketEngine;
5+
import io.ably.lib.network.WebSocketListener;
6+
import io.ably.lib.realtime.AblyRealtime;
7+
import io.ably.lib.realtime.CompletionListener;
8+
import io.ably.lib.test.common.Helpers;
9+
import io.ably.lib.test.common.ParameterizedTest;
10+
import io.ably.lib.test.util.EmptyPlatformAgentProvider;
11+
import io.ably.lib.transport.ITransport.TransportParams;
12+
import io.ably.lib.types.AblyException;
13+
import io.ably.lib.types.ClientOptions;
14+
import io.ably.lib.types.Param;
15+
import org.junit.Before;
16+
import org.junit.Rule;
17+
import org.junit.Test;
18+
import org.junit.rules.Timeout;
19+
import org.mockito.Mock;
20+
import org.mockito.MockitoAnnotations;
21+
22+
import java.lang.reflect.Constructor;
23+
import java.lang.reflect.Field;
24+
import java.lang.reflect.Method;
25+
import java.util.concurrent.CountDownLatch;
26+
import java.util.concurrent.TimeUnit;
27+
import java.util.concurrent.atomic.AtomicBoolean;
28+
import java.util.concurrent.atomic.AtomicReference;
29+
30+
import static org.junit.Assert.*;
31+
import static org.mockito.Mockito.*;
32+
33+
/**
34+
* Unit tests for WebSocketTransport, specifically testing activity timer behavior
35+
* when WebSocket close operations get stuck or fail to trigger onClose handlers.
36+
*/
37+
public class WebSocketTransportTest {
38+
39+
private ConnectionManager mockConnectionManager;
40+
41+
private WebSocketEngine mockEngine;
42+
43+
private WebSocketTransport transport;
44+
45+
private WebSocketClient mockWebSocketClient;
46+
47+
private TransportParams transportParams;
48+
49+
@Before
50+
public void setUp() throws Exception {
51+
mockConnectionManager = mock(ConnectionManager.class);
52+
mockEngine = mock(WebSocketEngine.class);
53+
mockWebSocketClient = mock(WebSocketClient.class);
54+
when(mockEngine.isPingListenerSupported()).thenReturn(true);
55+
when(mockEngine.create(any(), any())).thenReturn(mockWebSocketClient);
56+
when(mockConnectionManager.getAuthParams()).thenReturn(new Param[] {});
57+
58+
mockConnectionManager.maxIdleInterval = 10;
59+
60+
// Setup transport params
61+
transportParams = new TransportParams(new ClientOptions(), new EmptyPlatformAgentProvider());
62+
transportParams.host = "realtime.ably.io";
63+
transportParams.port = 443;
64+
transportParams.options.realtimeRequestTimeout = 10;
65+
}
66+
67+
private WebSocketTransport createWebSocketTransport() {
68+
WebSocketTransport transport = new WebSocketTransport(transportParams, mockConnectionManager);
69+
Helpers.setPrivateField(transport, "webSocketEngine", mockEngine);
70+
return transport;
71+
}
72+
73+
@Test
74+
public void throwExceptionsIfConnectCalledTwice() {
75+
final WebSocketTransport transport = createWebSocketTransport();
76+
ITransport.ConnectListener connectListener = mock(ITransport.ConnectListener.class);
77+
transport.connect(connectListener);
78+
assertThrows(IllegalStateException.class, () ->
79+
transport.connect(connectListener)
80+
);
81+
}
82+
83+
@Test
84+
public void shouldCallCancelIfNotClosedGracefully() {
85+
AtomicReference<WebSocketListener> webSocketListenerRef = new AtomicReference<>();
86+
87+
when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
88+
webSocketListenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
89+
return mockWebSocketClient;
90+
});
91+
92+
doAnswer(invocation -> {
93+
webSocketListenerRef.get().onClose(
94+
invocation.getArgumentAt(0, Integer.class),
95+
invocation.getArgumentAt(1, String.class)
96+
);
97+
return null;
98+
}).when(mockWebSocketClient).cancel(anyInt(), anyString());
99+
100+
final WebSocketTransport transport = createWebSocketTransport();
101+
ITransport.ConnectListener connectListener = mock(ITransport.ConnectListener.class);
102+
transport.connect(connectListener);
103+
transport.close();
104+
// check that we tried to close gracefully
105+
verify(mockWebSocketClient).close();
106+
// check that we closed forcibly at the end
107+
verify(mockWebSocketClient, timeout(1_000)).cancel(eq(1006), anyString());
108+
// verify that we call listener at the end
109+
verify(connectListener).onTransportUnavailable(eq(transport), any());
110+
}
111+
112+
/**
113+
* `onClose` can be called twice, e.g. from activity timer force close and from manual `close()`
114+
* It shouldn't result in any exceptions
115+
*/
116+
@Test
117+
public void shouldNotThrowExceptionIfSeveralCloseEventsHappened() {
118+
AtomicReference<WebSocketListener> listenerRef = new AtomicReference<>();
119+
120+
when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
121+
listenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
122+
return mockWebSocketClient;
123+
});
124+
125+
126+
final WebSocketTransport transport = createWebSocketTransport();
127+
ITransport.ConnectListener connectListener = mock(ITransport.ConnectListener.class);
128+
transport.connect(connectListener);
129+
130+
listenerRef.get().onClose(1000, "OK");
131+
listenerRef.get().onClose(1006, "Abnormal close");
132+
133+
verify(connectListener, times(2)).onTransportUnavailable(eq(transport), any());
134+
}
135+
136+
/**
137+
* Calling `close()` on transport triggers the activity timer.
138+
* Test checks that if it has been disposed it won't do anything.
139+
*/
140+
@Test
141+
public void shouldNotThrowExceptionIfCloseCalledOnAlreadyClosedTransport() {
142+
AtomicReference<WebSocketListener> listenerRef = new AtomicReference<>();
143+
144+
when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
145+
listenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
146+
return mockWebSocketClient;
147+
});
148+
149+
150+
final WebSocketTransport transport = createWebSocketTransport();
151+
ITransport.ConnectListener connectListener = mock(ITransport.ConnectListener.class);
152+
transport.connect(connectListener);
153+
154+
listenerRef.get().onClose(1006, "Abnormal close");
155+
transport.close();
156+
verify(connectListener, timeout(1_000)).onTransportUnavailable(eq(transport), any());
157+
}
158+
}

0 commit comments

Comments
 (0)