Spaces:
Sleeping
Sleeping
| // IMPORTANT: unmock must be the very first statement before any imports | |
| vi.unmock('../../../src/api/websocket'); | |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../helpers/msw/server'; | |
| import { | |
| connect, | |
| disconnect, | |
| joinTrip, | |
| leaveTrip, | |
| addListener, | |
| removeListener, | |
| getSocketId, | |
| setRefetchCallback, | |
| } from '../../../src/api/websocket'; | |
| // ββ Fake WebSocket ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class MockWebSocket { | |
| static CONNECTING = 0; | |
| static OPEN = 1; | |
| static CLOSING = 2; | |
| static CLOSED = 3; | |
| readyState: number = MockWebSocket.OPEN; | |
| send = vi.fn(); | |
| close = vi.fn(); | |
| onopen: (() => void) | null = null; | |
| onmessage: ((event: { data: string }) => void) | null = null; | |
| onclose: (() => void) | null = null; | |
| onerror: (() => void) | null = null; | |
| constructor(public url: string) { | |
| MockWebSocket.instances.push(this); | |
| } | |
| static instances: MockWebSocket[] = []; | |
| static reset() { | |
| MockWebSocket.instances = []; | |
| } | |
| } | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| MockWebSocket.reset(); | |
| // Replace globalThis.WebSocket with MockWebSocket directly. | |
| // jsdom marks WebSocket as non-writable, so we must use defineProperty. | |
| Object.defineProperty(globalThis, 'WebSocket', { | |
| writable: true, | |
| configurable: true, | |
| value: MockWebSocket, | |
| }); | |
| // Default handler: ws-token returns a valid token | |
| server.use( | |
| http.post('/api/auth/ws-token', () => | |
| HttpResponse.json({ token: 'test-ws-token' }) | |
| ) | |
| ); | |
| }); | |
| afterEach(() => { | |
| disconnect(); | |
| setRefetchCallback(null); | |
| vi.useRealTimers(); | |
| server.resetHandlers(); | |
| }); | |
| // Helper to get the most recently created MockWebSocket instance | |
| function lastSocket(): MockWebSocket { | |
| return MockWebSocket.instances[MockWebSocket.instances.length - 1]; | |
| } | |
| // ββ connect / disconnect ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('connect / disconnect', () => { | |
| it('FE-COMP-WS-001: connect() fetches ws-token and creates a WebSocket with it', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(1); | |
| expect(MockWebSocket.instances[0].url).toContain('token=test-ws-token'); | |
| }); | |
| it('FE-COMP-WS-002: connect() sets shouldReconnect so onclose triggers reconnect', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(1); | |
| // Simulate socket close (triggers scheduleReconnect) | |
| lastSocket().onclose!(); | |
| // Advance past initial reconnect delay (1000ms) β reconnect fires | |
| await vi.advanceTimersByTimeAsync(1001); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(2); | |
| }); | |
| it('FE-COMP-WS-003: disconnect() prevents reconnect after socket close', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| disconnect(); | |
| // After disconnect, onclose is nulled β simulating close should be safe | |
| // but we also fire it manually to be sure | |
| if (sock.onclose) sock.onclose(); | |
| await vi.advanceTimersByTimeAsync(5000); | |
| await vi.advanceTimersByTimeAsync(0); | |
| // Still only the original socket | |
| expect(MockWebSocket.instances).toHaveLength(1); | |
| }); | |
| it('FE-COMP-WS-004: connect() is idempotent β calling twice creates only one socket', async () => { | |
| connect(); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(1); | |
| }); | |
| }); | |
| // ββ ws-token fetch failures βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('ws-token fetch failures', () => { | |
| it('FE-COMP-WS-005: 401 on ws-token fetch stops reconnect entirely', async () => { | |
| server.use( | |
| http.post('/api/auth/ws-token', () => | |
| new HttpResponse(null, { status: 401 }) | |
| ) | |
| ); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| // No socket should be created | |
| expect(MockWebSocket.instances).toHaveLength(0); | |
| // Advance timers β no retry should fire | |
| await vi.advanceTimersByTimeAsync(10000); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(0); | |
| }); | |
| it('FE-COMP-WS-006: non-401 error on ws-token schedules a reconnect', async () => { | |
| server.use( | |
| http.post('/api/auth/ws-token', () => | |
| new HttpResponse(null, { status: 503 }) | |
| ) | |
| ); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| // No socket yet | |
| expect(MockWebSocket.instances).toHaveLength(0); | |
| // Now allow the next fetch to succeed | |
| server.use( | |
| http.post('/api/auth/ws-token', () => | |
| HttpResponse.json({ token: 'retry-token' }) | |
| ) | |
| ); | |
| // Advance past initial reconnect delay | |
| await vi.advanceTimersByTimeAsync(1001); | |
| await vi.advanceTimersByTimeAsync(0); | |
| // A socket should now be created | |
| expect(MockWebSocket.instances).toHaveLength(1); | |
| }); | |
| }); | |
| // ββ onopen / join on reconnect ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('onopen / join on reconnect', () => { | |
| it('FE-COMP-WS-007: onopen sends join messages for all active trips', async () => { | |
| joinTrip(42); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| expect(sock.send).toHaveBeenCalledWith( | |
| JSON.stringify({ type: 'join', tripId: '42' }) | |
| ); | |
| }); | |
| it('FE-COMP-WS-008: onopen invokes refetchCallback for each active trip', async () => { | |
| const refetch = vi.fn(); | |
| setRefetchCallback(refetch); | |
| joinTrip(1); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| lastSocket().onopen!(); | |
| expect(refetch).toHaveBeenCalledWith('1'); | |
| }); | |
| }); | |
| // ββ joinTrip / leaveTrip ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('joinTrip / leaveTrip', () => { | |
| it('FE-COMP-WS-009: joinTrip sends join message immediately when socket is open', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| joinTrip(99); | |
| expect(sock.send).toHaveBeenCalledWith( | |
| JSON.stringify({ type: 'join', tripId: '99' }) | |
| ); | |
| }); | |
| it('FE-COMP-WS-010: joinTrip queues trip when socket is not open yet', async () => { | |
| joinTrip(5); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| expect(sock.send).toHaveBeenCalledWith( | |
| JSON.stringify({ type: 'join', tripId: '5' }) | |
| ); | |
| }); | |
| it('FE-COMP-WS-011: leaveTrip sends leave message and removes from activeTrips', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| joinTrip(7); | |
| leaveTrip(7); | |
| expect(sock.send).toHaveBeenCalledWith( | |
| JSON.stringify({ type: 'leave', tripId: '7' }) | |
| ); | |
| // Simulate close + reconnect β trip 7 should NOT be re-joined | |
| sock.onclose!(); | |
| await vi.advanceTimersByTimeAsync(1001); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock2 = lastSocket(); | |
| sock2.onopen!(); | |
| // send called for initial join (trip 7) but not after leaveTrip | |
| const joinCalls = sock2.send.mock.calls.filter( | |
| c => JSON.parse(c[0]).tripId === '7' | |
| ); | |
| expect(joinCalls).toHaveLength(0); | |
| }); | |
| }); | |
| // ββ handleMessage / listeners βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('handleMessage / listeners', () => { | |
| async function setupConnectedSocket() { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| return sock; | |
| } | |
| it('FE-COMP-WS-012: welcome message sets socketId and is NOT dispatched to listeners', async () => { | |
| const sock = await setupConnectedSocket(); | |
| const listener = vi.fn(); | |
| addListener(listener); | |
| sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-1' }) }); | |
| expect(getSocketId()).toBe('server-sid-1'); | |
| expect(listener).not.toHaveBeenCalled(); | |
| removeListener(listener); | |
| }); | |
| it('FE-COMP-WS-013: non-welcome messages are dispatched to all registered listeners', async () => { | |
| const sock = await setupConnectedSocket(); | |
| const l1 = vi.fn(); | |
| const l2 = vi.fn(); | |
| addListener(l1); | |
| addListener(l2); | |
| const msg = { type: 'place_added', tripId: '1' }; | |
| sock.onmessage!({ data: JSON.stringify(msg) }); | |
| expect(l1).toHaveBeenCalledWith(msg); | |
| expect(l2).toHaveBeenCalledWith(msg); | |
| removeListener(l1); | |
| removeListener(l2); | |
| }); | |
| it('FE-COMP-WS-014: listener error is caught and does not prevent other listeners from firing', async () => { | |
| const sock = await setupConnectedSocket(); | |
| const throwing = vi.fn().mockImplementation(() => { throw new Error('boom'); }); | |
| const working = vi.fn(); | |
| addListener(throwing); | |
| addListener(working); | |
| expect(() => { | |
| sock.onmessage!({ data: JSON.stringify({ type: 'some_event' }) }); | |
| }).not.toThrow(); | |
| expect(working).toHaveBeenCalled(); | |
| removeListener(throwing); | |
| removeListener(working); | |
| }); | |
| it('FE-COMP-WS-015: malformed JSON in message is caught silently', async () => { | |
| const sock = await setupConnectedSocket(); | |
| const listener = vi.fn(); | |
| addListener(listener); | |
| expect(() => { | |
| sock.onmessage!({ data: 'not-json' }); | |
| }).not.toThrow(); | |
| expect(listener).not.toHaveBeenCalled(); | |
| removeListener(listener); | |
| }); | |
| it('FE-COMP-WS-016: removeListener stops a listener from receiving messages', async () => { | |
| const sock = await setupConnectedSocket(); | |
| const listener = vi.fn(); | |
| addListener(listener); | |
| removeListener(listener); | |
| sock.onmessage!({ data: JSON.stringify({ type: 'update' }) }); | |
| expect(listener).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ββ addListener / removeListener βββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('addListener / removeListener symmetry', () => { | |
| it('FE-COMP-WS-017: listener set grows and shrinks correctly', async () => { | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| const sock = lastSocket(); | |
| sock.onopen!(); | |
| const l1 = vi.fn(); | |
| const l2 = vi.fn(); | |
| addListener(l1); | |
| addListener(l2); | |
| sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) }); | |
| expect(l1).toHaveBeenCalledTimes(1); | |
| expect(l2).toHaveBeenCalledTimes(1); | |
| removeListener(l1); | |
| sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) }); | |
| expect(l1).toHaveBeenCalledTimes(1); // no new calls | |
| expect(l2).toHaveBeenCalledTimes(2); | |
| removeListener(l2); | |
| }); | |
| }); | |
| // ββ getSocketId / setRefetchCallback βββββββββββββββββββββββββββββββββββββββββ | |
| describe('getSocketId / setRefetchCallback', () => { | |
| it('FE-COMP-WS-018: getSocketId() returns null before welcome message', async () => { | |
| // mySocketId is a module-level singleton that persists between tests. | |
| // Use vi.resetModules() + dynamic import to get a fresh module state. | |
| vi.resetModules(); | |
| const freshWs = await import('../../../src/api/websocket'); | |
| expect(freshWs.getSocketId()).toBeNull(); | |
| // Clean up: restore the real module for subsequent tests by resetting again | |
| vi.resetModules(); | |
| }); | |
| it('FE-COMP-WS-019: setRefetchCallback(null) clears the callback', async () => { | |
| const cb = vi.fn(); | |
| setRefetchCallback(cb); | |
| setRefetchCallback(null); | |
| joinTrip(10); | |
| connect(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| lastSocket().onopen!(); | |
| expect(cb).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ββ Reconnect backoff βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('reconnect backoff', () => { | |
| it('FE-COMP-WS-020: reconnect delay doubles on each failure up to 30s max', async () => { | |
| // Make every fetch fail with 503 so reconnect keeps firing | |
| server.use( | |
| http.post('/api/auth/ws-token', () => | |
| new HttpResponse(null, { status: 503 }) | |
| ) | |
| ); | |
| connect(); | |
| const delays = [1000, 2000, 4000, 8000, 16000, 30000]; | |
| let totalAdvanced = 0; | |
| for (const delay of delays) { | |
| // Wait for the fetch to complete | |
| await vi.advanceTimersByTimeAsync(0); | |
| // No socket should ever be created | |
| expect(MockWebSocket.instances).toHaveLength(0); | |
| // Advance to trigger next reconnect | |
| await vi.advanceTimersByTimeAsync(delay + 1); | |
| totalAdvanced += delay + 1; | |
| } | |
| // After advancing through all delays, still no socket | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(MockWebSocket.instances).toHaveLength(0); | |
| }); | |
| }); | |