import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useShoppingListSync } from '../../src/lib/useShoppingListSync'; vi.mock('@/services/shopping-lists', () => ({ getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'), })); class MockWebSocket { static OPEN = 1; static CONNECTING = 0; url: string; readyState = 1; // OPEN onopen: any = null; onmessage: any = null; onerror: any = null; onclose: any = null; send = vi.fn(); close = vi.fn(); constructor(url: string) { this.url = url; } } describe('useShoppingListSync', () => { let originalWebSocket: any; let createdSockets: MockWebSocket[] = []; beforeEach(() => { vi.clearAllMocks(); createdSockets = []; originalWebSocket = global.WebSocket; const MockClass = class extends MockWebSocket { constructor(url: string) { super(url); createdSockets.push(this); } }; (MockClass as any).OPEN = 1; (MockClass as any).CONNECTING = 0; global.WebSocket = MockClass as any; }); afterEach(() => { global.WebSocket = originalWebSocket; }); it('initializes and connects to the correct socket URL', () => { const onSync = vi.fn(); renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); expect(createdSockets).toHaveLength(1); expect(createdSockets[0].url).toBe('ws://localhost/sync'); }); it('handles incoming item_updated messages correctly', () => { const onSync = vi.fn(); renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; act(() => { if (ws.onopen) ws.onopen(); }); act(() => { if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) }); }); expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }); }); it('broadcasts toggle item messages when connected', () => { const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; act(() => { if (ws.onopen) ws.onopen(); }); act(() => { result.current.toggleItemCheck('item1', true); }); expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'TOGGLE_ITEM', itemId: 'item1', checked: true, })); }); it('handles disconnect and reconnect backoff', () => { vi.useFakeTimers(); const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); let ws = createdSockets[0]; act(() => { if (ws.onopen) ws.onopen(); }); expect(result.current.isConnected).toBe(true); act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); }); expect(result.current.isConnected).toBe(false); // After 1000ms it should attempt reconnect act(() => { vi.advanceTimersByTime(1000); }); expect(createdSockets).toHaveLength(2); vi.useRealTimers(); }); it('handles invalid onmessage payload gracefully', () => { const onSync = vi.fn(); renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; act(() => { if (ws.onopen) ws.onopen(); }); // Should swallow invalid JSON parse error act(() => { if (ws.onmessage) ws.onmessage({ data: 'invalid-json' }); }); expect(onSync).not.toHaveBeenCalled(); }); it('handles ws error states', () => { const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; act(() => { if (ws.onerror) ws.onerror(); }); expect(result.current.error).toBe('Connection interrupt'); }); it('handles websocket initialization errors gracefully', () => { global.WebSocket = function() { throw new Error('WS Blocked'); } as any; const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); expect(result.current.error).toBe('Sync failed to initialize'); }); it('does not connect if householdId or listId is missing', () => { const onSync = vi.fn(); renderHook(() => useShoppingListSync('', 'list1', onSync)); expect(createdSockets).toHaveLength(0); renderHook(() => useShoppingListSync('hh1', '', onSync)); expect(createdSockets).toHaveLength(0); }); it('handles disconnect and uses default severed message when reason is missing', () => { const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; act(() => { if (ws.onopen) ws.onopen(); }); act(() => { if (ws.onclose) ws.onclose({} as any); }); expect(result.current.isConnected).toBe(false); }); it('stops attempting to reconnect after 5 consecutive failures', () => { vi.useFakeTimers(); const onSync = vi.fn(); renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); expect(createdSockets).toHaveLength(1); for (let attempt = 1; attempt <= 5; attempt++) { const currentSocket = createdSockets[createdSockets.length - 1]; act(() => { if (currentSocket.onclose) currentSocket.onclose({ reason: 'fail' }); }); act(() => { vi.advanceTimersByTime(attempt * 1000); }); } const totalSocketsCreated = createdSockets.length; expect(totalSocketsCreated).toBe(6); const finalSocket = createdSockets[totalSocketsCreated - 1]; act(() => { if (finalSocket.onclose) finalSocket.onclose({ reason: 'fail' }); }); act(() => { vi.advanceTimersByTime(6000); }); expect(createdSockets.length).toBe(totalSocketsCreated); vi.useRealTimers(); }); it('does not send toggle item message if socket is not open', () => { const onSync = vi.fn(); const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); const ws = createdSockets[0]; ws.readyState = 0; // CONNECTING act(() => { result.current.toggleItemCheck('item1', true); }); expect(ws.send).not.toHaveBeenCalled(); }); });