Skip to content

Commit f801f49

Browse files
committed
refactor(api-graphql): improve WebSocket health monitoring code quality
Refactors WebSocket health monitoring implementation to improve maintainability, testability, and code organization: - Extract duplicated health check logic into calculateHealthState() - Refactor platform storage initialization into function - Extract storage operations into reusable private methods - Improve reconnect() with error handling and concurrency guards - Fix type consistency in WebSocketHealthState (non-optional fields) Add comprehensive unit test coverage (21 new tests): - getConnectionHealth() - 3 tests covering healthy/unhealthy states - getPersistentConnectionHealth() - 4 tests with storage scenarios - isConnected() - 5 tests for all WebSocket states - reconnect() - 6 tests including concurrency protection - Error handling - 3 tests for graceful degradation All tests pass (170 total). No breaking changes to public API.
1 parent 6c7a1d9 commit f801f49

File tree

3 files changed

+490
-67
lines changed

3 files changed

+490
-67
lines changed
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import { ConsoleLogger } from '@aws-amplify/core';
2+
import { AWSAppSyncRealTimeProvider } from '../src/Providers/AWSAppSyncRealTimeProvider';
3+
import { ConnectionState as CS } from '../src/types/PubSub';
4+
import { FakeWebSocketInterface } from './helpers';
5+
6+
// Mock storage for testing
7+
const mockStorage = {
8+
data: new Map<string, string>(),
9+
setItem: jest.fn((key: string, value: string) => {
10+
mockStorage.data.set(key, value);
11+
return Promise.resolve();
12+
}),
13+
getItem: jest.fn((key: string) => {
14+
return Promise.resolve(mockStorage.data.get(key) || null);
15+
}),
16+
clear: () => {
17+
mockStorage.data.clear();
18+
mockStorage.setItem.mockClear();
19+
mockStorage.getItem.mockClear();
20+
},
21+
};
22+
23+
// Mock localStorage
24+
Object.defineProperty(global, 'localStorage', {
25+
value: mockStorage,
26+
writable: true,
27+
});
28+
29+
// Mock signRequest
30+
jest.mock('@aws-amplify/core/internals/aws-client-utils', () => ({
31+
...jest.requireActual('@aws-amplify/core/internals/aws-client-utils'),
32+
signRequest: () => ({
33+
method: 'test',
34+
headers: { test: 'test' },
35+
url: new URL('http://example/'),
36+
}),
37+
}));
38+
39+
// Mock fetchAuthSession
40+
jest.mock('@aws-amplify/core', () => {
41+
const original = jest.requireActual('@aws-amplify/core');
42+
return {
43+
...original,
44+
fetchAuthSession: () =>
45+
Promise.resolve({
46+
tokens: {
47+
accessToken: {
48+
toString: () => 'test',
49+
},
50+
},
51+
credentials: {
52+
accessKeyId: 'test',
53+
secretAccessKey: 'test',
54+
},
55+
}),
56+
Amplify: {
57+
Auth: {
58+
fetchAuthSession: async () => ({
59+
tokens: {
60+
accessToken: {
61+
toString: () => 'test',
62+
},
63+
},
64+
credentials: {
65+
accessKeyId: 'test',
66+
secretAccessKey: 'test',
67+
},
68+
}),
69+
},
70+
},
71+
browserOrNode() {
72+
return {
73+
isBrowser: true,
74+
isNode: false,
75+
};
76+
},
77+
};
78+
});
79+
80+
describe('WebSocket Health Monitoring', () => {
81+
let provider: AWSAppSyncRealTimeProvider;
82+
let fakeWebSocketInterface: FakeWebSocketInterface;
83+
84+
beforeEach(() => {
85+
provider = new AWSAppSyncRealTimeProvider();
86+
fakeWebSocketInterface = new FakeWebSocketInterface();
87+
mockStorage.clear();
88+
89+
// Mock the WebSocket creation
90+
jest.spyOn(provider as any, '_getNewWebSocket').mockImplementation(() => {
91+
fakeWebSocketInterface.webSocket.readyState = WebSocket.CONNECTING;
92+
return fakeWebSocketInterface.webSocket as unknown as WebSocket;
93+
});
94+
});
95+
96+
afterEach(() => {
97+
fakeWebSocketInterface.teardown();
98+
jest.clearAllMocks();
99+
});
100+
101+
describe('getConnectionHealth', () => {
102+
test('returns healthy state when connected with recent keep-alive', () => {
103+
// Simulate connected state with recent keep-alive
104+
(provider as any).connectionState = CS.Connected;
105+
(provider as any).keepAliveTimestamp = Date.now();
106+
107+
// Get health state
108+
const health = (provider as any).getConnectionHealth();
109+
110+
expect(health.isHealthy).toBe(true);
111+
expect(health.connectionState).toBe(CS.Connected);
112+
expect(health.lastKeepAliveTime).toBeGreaterThan(0);
113+
expect(health.timeSinceLastKeepAlive).toBeLessThan(1000);
114+
});
115+
116+
test('returns unhealthy state when not connected', () => {
117+
const health = (provider as any).getConnectionHealth();
118+
119+
expect(health.isHealthy).toBe(false);
120+
expect(health.connectionState).toBe(CS.Disconnected);
121+
expect(health.lastKeepAliveTime).toBeGreaterThan(0); // Will have initial timestamp
122+
expect(typeof health.timeSinceLastKeepAlive).toBe('number');
123+
});
124+
125+
test('returns unhealthy state when keep-alive is stale (>65 seconds)', () => {
126+
// Simulate connected state with old keep-alive (66 seconds ago)
127+
(provider as any).connectionState = CS.Connected;
128+
(provider as any).keepAliveTimestamp = Date.now() - 66000;
129+
130+
const health = (provider as any).getConnectionHealth();
131+
132+
expect(health.isHealthy).toBe(false);
133+
expect(health.connectionState).toBe(CS.Connected);
134+
expect(health.timeSinceLastKeepAlive).toBeGreaterThan(65000);
135+
});
136+
});
137+
138+
describe('getPersistentConnectionHealth', () => {
139+
test('returns health state using in-memory keep-alive when no persistent data', async () => {
140+
// Simulate connected state with recent keep-alive
141+
(provider as any).connectionState = CS.Connected;
142+
(provider as any).keepAliveTimestamp = Date.now();
143+
144+
const health = await (provider as any).getPersistentConnectionHealth();
145+
146+
expect(health.isHealthy).toBe(true);
147+
expect(health.connectionState).toBe(CS.Connected);
148+
expect(health.lastKeepAliveTime).toBeGreaterThan(0);
149+
expect(health.timeSinceLastKeepAlive).toBeLessThan(1000);
150+
});
151+
152+
test('uses more recent timestamp between in-memory and persistent storage', async () => {
153+
const now = Date.now();
154+
const olderTime = now - 10000; // 10 seconds ago
155+
156+
// Set older persistent time
157+
mockStorage.data.set('AWS_AMPLIFY_LAST_KEEP_ALIVE', `${olderTime}`);
158+
159+
// Set newer in-memory time
160+
(provider as any).connectionState = CS.Connected;
161+
(provider as any).keepAliveTimestamp = now;
162+
163+
const health = await (provider as any).getPersistentConnectionHealth();
164+
165+
// Should use the more recent in-memory timestamp, not the older persistent one
166+
expect(health.lastKeepAliveTime).toBeGreaterThan(olderTime);
167+
expect(health.timeSinceLastKeepAlive).toBeLessThan(1000);
168+
});
169+
170+
test('returns Infinity for timeSinceLastKeepAlive when no keep-alive received', async () => {
171+
// Clear any existing data
172+
mockStorage.data.clear();
173+
174+
// Create new provider instance to ensure no in-memory keep-alive
175+
const newProvider = new AWSAppSyncRealTimeProvider();
176+
177+
// Set keep-alive timestamp to 0 to simulate never received
178+
(newProvider as any).keepAliveTimestamp = 0;
179+
180+
const health = await (newProvider as any).getPersistentConnectionHealth();
181+
182+
expect(health.lastKeepAliveTime).toBe(0);
183+
expect(health.timeSinceLastKeepAlive).toBe(Infinity);
184+
expect(health.isHealthy).toBe(false);
185+
});
186+
187+
test('persists keep-alive timestamp to storage', async () => {
188+
const timestamp = Date.now();
189+
190+
// Call the persist method directly
191+
await (provider as any).persistKeepAliveTimestamp(timestamp);
192+
193+
// Wait a bit for async storage operations
194+
await new Promise(resolve => setTimeout(resolve, 100));
195+
196+
expect(mockStorage.setItem).toHaveBeenCalled();
197+
const storedValue = mockStorage.data.get('AWS_AMPLIFY_LAST_KEEP_ALIVE');
198+
expect(storedValue).toBeTruthy();
199+
expect(Number(storedValue)).toBe(timestamp);
200+
});
201+
});
202+
203+
describe('isConnected', () => {
204+
test('returns true when WebSocket readyState is OPEN', () => {
205+
// Mock WebSocket as OPEN
206+
(provider as any).awsRealTimeSocket = {
207+
readyState: WebSocket.OPEN,
208+
};
209+
210+
expect((provider as any).isConnected()).toBe(true);
211+
});
212+
213+
test('returns false when WebSocket readyState is not OPEN', () => {
214+
expect((provider as any).isConnected()).toBe(false);
215+
});
216+
217+
test('returns false when WebSocket is CONNECTING', () => {
218+
// Mock WebSocket as CONNECTING
219+
(provider as any).awsRealTimeSocket = {
220+
readyState: WebSocket.CONNECTING,
221+
};
222+
223+
expect((provider as any).isConnected()).toBe(false);
224+
});
225+
226+
test('returns false when WebSocket is CLOSED', () => {
227+
// Mock WebSocket as CLOSED
228+
(provider as any).awsRealTimeSocket = {
229+
readyState: WebSocket.CLOSED,
230+
};
231+
232+
expect((provider as any).isConnected()).toBe(false);
233+
});
234+
235+
test('returns false when WebSocket is undefined', () => {
236+
(provider as any).awsRealTimeSocket = undefined;
237+
238+
expect((provider as any).isConnected()).toBe(false);
239+
});
240+
});
241+
242+
describe('reconnect', () => {
243+
test('successfully initiates reconnection when not connected', async () => {
244+
// Mock close to resolve immediately
245+
jest.spyOn(provider as any, 'close').mockResolvedValue(undefined);
246+
247+
// Reconnect should succeed
248+
await expect((provider as any).reconnect()).resolves.toBeUndefined();
249+
});
250+
251+
test('throws error when reconnection is already in progress', async () => {
252+
// Mock close to take some time
253+
jest
254+
.spyOn(provider as any, 'close')
255+
.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
256+
257+
// Start first reconnection (don't await)
258+
const reconnectPromise1 = (provider as any).reconnect();
259+
260+
// Try concurrent reconnection immediately - should throw
261+
await expect((provider as any).reconnect()).rejects.toThrow(
262+
'Reconnection already in progress',
263+
);
264+
265+
// Wait for first reconnection to complete
266+
await reconnectPromise1;
267+
});
268+
269+
test(
270+
'allows reconnection after previous attempt completes',
271+
async () => {
272+
// Mock close to resolve immediately
273+
jest.spyOn(provider as any, 'close').mockResolvedValue(undefined);
274+
275+
// First reconnection
276+
await (provider as any).reconnect();
277+
278+
// Wait for the reconnection flag to reset (1 second timeout)
279+
await new Promise(resolve => setTimeout(resolve, 1100));
280+
281+
// Second reconnection should succeed
282+
await expect((provider as any).reconnect()).resolves.toBeUndefined();
283+
},
284+
15000,
285+
); // Increase timeout to 15 seconds
286+
287+
test('closes existing connection before reconnecting when connected', async () => {
288+
// Mock WebSocket as connected
289+
(provider as any).awsRealTimeSocket = {
290+
readyState: WebSocket.OPEN,
291+
};
292+
293+
const closeSpy = jest
294+
.spyOn(provider as any, 'close')
295+
.mockResolvedValue(undefined);
296+
297+
await (provider as any).reconnect();
298+
299+
expect(closeSpy).toHaveBeenCalled();
300+
});
301+
302+
test('does not call close when not connected', async () => {
303+
// Ensure WebSocket is not connected
304+
(provider as any).awsRealTimeSocket = undefined;
305+
306+
const closeSpy = jest
307+
.spyOn(provider as any, 'close')
308+
.mockResolvedValue(undefined);
309+
310+
await (provider as any).reconnect();
311+
312+
expect(closeSpy).not.toHaveBeenCalled();
313+
});
314+
315+
test('triggers reconnection monitor', async () => {
316+
jest.spyOn(provider as any, 'close').mockResolvedValue(undefined);
317+
318+
const recordSpy = jest.spyOn(
319+
(provider as any).reconnectionMonitor,
320+
'record',
321+
);
322+
323+
await (provider as any).reconnect();
324+
325+
expect(recordSpy).toHaveBeenCalled();
326+
});
327+
});
328+
329+
describe('Error handling', () => {
330+
test('getPersistedKeepAliveTimestamp handles storage errors gracefully', async () => {
331+
// Mock storage to throw error
332+
mockStorage.getItem.mockRejectedValueOnce(new Error('Storage error'));
333+
334+
const timestamp = await (
335+
provider as any
336+
).getPersistedKeepAliveTimestamp();
337+
338+
expect(timestamp).toBe(0);
339+
});
340+
341+
test('persistKeepAliveTimestamp handles storage errors gracefully', async () => {
342+
// Mock storage to throw error
343+
mockStorage.setItem.mockRejectedValueOnce(new Error('Storage error'));
344+
345+
// Should not throw
346+
await expect(
347+
(provider as any).persistKeepAliveTimestamp(Date.now()),
348+
).resolves.toBeUndefined();
349+
});
350+
351+
test('getPersistentConnectionHealth handles invalid stored values', async () => {
352+
// Create a fresh provider and reset keepAliveTimestamp
353+
const freshProvider = new AWSAppSyncRealTimeProvider();
354+
(freshProvider as any).keepAliveTimestamp = 0;
355+
356+
// Store invalid value
357+
mockStorage.data.set('AWS_AMPLIFY_LAST_KEEP_ALIVE', 'invalid');
358+
359+
const health = await (
360+
freshProvider as any
361+
).getPersistentConnectionHealth();
362+
363+
// Should return valid health state with 0 timestamp (invalid converts to 0)
364+
expect(health).toHaveProperty('isHealthy');
365+
expect(health).toHaveProperty('connectionState');
366+
expect(health.lastKeepAliveTime).toBe(0);
367+
});
368+
});
369+
});

0 commit comments

Comments
 (0)