import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { useStatusPolling } from '../useStatusPolling' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' vi.mock('@/stores/canvasStore') vi.mock('@/stores/authStore') const mockSetNodeStatus = vi.fn() const mockNotifyScanDeviceFound = vi.fn() const mockSetServiceStatuses = vi.fn() class MockWebSocket { static instances: MockWebSocket[] = [] url: string onopen: (() => void) | null = null onmessage: ((e: { data: string }) => void) | null = null onerror: ((e: unknown) => void) | null = null send = vi.fn() close = vi.fn() constructor(url: string) { this.url = url MockWebSocket.instances.push(this) } } describe('useStatusPolling', () => { beforeEach(() => { MockWebSocket.instances = [] vi.stubGlobal('WebSocket', MockWebSocket) vi.mocked(useCanvasStore).mockReturnValue({ setNodeStatus: mockSetNodeStatus, notifyScanDeviceFound: mockNotifyScanDeviceFound, setServiceStatuses: mockSetServiceStatuses, } as ReturnType) vi.mocked(useAuthStore).mockReturnValue({ isAuthenticated: true, authMethod: 'local', token: 'test-token', } as ReturnType) Object.defineProperty(window, 'location', { value: { protocol: 'http:', host: 'localhost:5173' }, writable: true, }) }) afterEach(() => { vi.restoreAllMocks() mockSetNodeStatus.mockClear() mockNotifyScanDeviceFound.mockClear() mockSetServiceStatuses.mockClear() }) it('does not open WebSocket when not authenticated', () => { vi.mocked(useAuthStore).mockReturnValue({ isAuthenticated: false, authMethod: null, token: null, } as ReturnType) renderHook(() => useStatusPolling()) expect(MockWebSocket.instances).toHaveLength(0) }) it('does not open WebSocket when token is missing', () => { vi.mocked(useAuthStore).mockReturnValue({ isAuthenticated: true, authMethod: 'local', token: null, } as ReturnType) renderHook(() => useStatusPolling()) expect(MockWebSocket.instances).toHaveLength(0) }) it('uses the HttpOnly cookie and sends no credential in OIDC mode', () => { vi.mocked(useAuthStore).mockReturnValue({ isAuthenticated: true, authMethod: 'oidc', token: null, } as ReturnType) renderHook(() => useStatusPolling()) expect(MockWebSocket.instances).toHaveLength(1) const ws = MockWebSocket.instances[0] ws.onopen?.() expect(ws.send).not.toHaveBeenCalled() }) it('connects to correct ws:// URL', () => { renderHook(() => useStatusPolling()) expect(MockWebSocket.instances).toHaveLength(1) expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status') }) it('uses wss:// when page is served over https', () => { Object.defineProperty(window, 'location', { value: { protocol: 'https:', host: 'mylab.local' }, writable: true, }) renderHook(() => useStatusPolling()) expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//) }) it('sends token as first message on open', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onopen?.() expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' })) }) it('lights up every node drawing the checked device', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onmessage?.({ data: JSON.stringify({ device_id: 'dev-1', node_ids: ['node-1', 'node-2'], status: 'online', checked_at: '2024-01-01T12:00:00Z', response_time_ms: 42, }), }) const expected = { status: 'online', response_time_ms: 42, last_seen: '2024-01-01T12:00:00Z', } expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', expected) expect(mockSetNodeStatus).toHaveBeenCalledWith('node-2', expected) }) it('sets last_seen to undefined when status is offline', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onmessage?.({ data: JSON.stringify({ device_id: 'dev-1', node_ids: ['node-1'], status: 'offline', checked_at: '2024-01-01T12:00:00Z', }), }) expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', { status: 'offline', response_time_ms: undefined, last_seen: undefined, }) }) it('sets response_time_ms to undefined when null in message', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onmessage?.({ data: JSON.stringify({ device_id: 'dev-1', node_ids: ['node-1'], status: 'online', response_time_ms: null, }), }) expect(mockSetNodeStatus).toHaveBeenCalledWith( 'node-1', expect.objectContaining({ response_time_ms: undefined }), ) }) it('calls notifyScanDeviceFound on scan_device_found message', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) }) expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce() expect(mockSetNodeStatus).not.toHaveBeenCalled() }) it('routes service_status messages to setServiceStatuses', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] const services = [{ port: 80, protocol: 'tcp', status: 'offline' }] ws.onmessage?.({ data: JSON.stringify({ type: 'service_status', device_id: 'dev-9', node_ids: ['node-9'], services, }), }) expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services) expect(mockSetNodeStatus).not.toHaveBeenCalled() }) it('ignores malformed JSON without throwing', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow() expect(mockSetNodeStatus).not.toHaveBeenCalled() }) it('ignores messages with no node_id or status', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) }) expect(mockSetNodeStatus).not.toHaveBeenCalled() }) it('closes WebSocket on unmount', () => { const { unmount } = renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] unmount() expect(ws.close).toHaveBeenCalledOnce() }) })