Skip to content

Commit a6f0f75

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 a6f0f75

4 files changed

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

0 commit comments

Comments
 (0)