|
| 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