From 0a72f9be676ebd00be32ae1754e7a53e86ea3773 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 3 Apr 2026 22:55:56 +0200 Subject: [PATCH] test: add tests for useStatusPolling hook and Sidebar component - 12 tests for useStatusPolling: WebSocket lifecycle, auth guards, message routing (status/scan_device_found), error resilience, cleanup - 21 tests for Sidebar: render, collapse/expand, node stats, action callbacks, view navigation, scan trigger, settings panel toggle --- .../panels/__tests__/Sidebar.test.tsx | 269 ++++++++++++++++++ .../hooks/__tests__/useStatusPolling.test.ts | 170 +++++++++++ 2 files changed, 439 insertions(+) create mode 100644 frontend/src/components/panels/__tests__/Sidebar.test.tsx create mode 100644 frontend/src/hooks/__tests__/useStatusPolling.test.ts diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..79ce5eb --- /dev/null +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Sidebar } from '../Sidebar' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/stores/canvasStore') + +vi.mock('@/api/client', () => ({ + scanApi: { + trigger: vi.fn().mockResolvedValue({}), + pending: vi.fn().mockResolvedValue({ data: [] }), + hidden: vi.fn().mockResolvedValue({ data: [] }), + runs: vi.fn().mockResolvedValue({ data: [] }), + stop: vi.fn().mockResolvedValue({}), + }, + settingsApi: { + get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), + save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), + }, +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/components/ui/Logo', () => ({ + Logo: ({ showText }: { showText: boolean }) => ( +
+ ), +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null, +})) + +vi.mock('@/components/modals/PendingDeviceModal', () => ({ + PendingDeviceModal: () => null, +})) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node => ({ + id, + type, + position: { x: 0, y: 0 }, + data: { label: id, type, status, services: [] }, +}) + +const mockToggleHideIp = vi.fn() + +function mockStore(overrides: Partial> = {}) { + vi.mocked(useCanvasStore).mockReturnValue({ + nodes: [], + hasUnsavedChanges: false, + hideIp: false, + toggleHideIp: mockToggleHideIp, + addNode: vi.fn(), + scanEventTs: 0, + ...overrides, + } as ReturnType) +} + +const defaultProps = { + onAddNode: vi.fn(), + onAddGroupRect: vi.fn(), + onScan: vi.fn(), + onSave: vi.fn(), + onNodeApproved: vi.fn(), +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Sidebar', () => { + beforeEach(() => { + mockStore() + vi.clearAllMocks() + }) + + // ── Render ───────────────────────────────────────────────────────────────── + + it('renders logo and nav items', () => { + render() + expect(screen.getByTestId('logo')).toBeInTheDocument() + expect(screen.getByText('Add Node')).toBeInTheDocument() + expect(screen.getByText('Save Canvas')).toBeInTheDocument() + expect(screen.getByText('Scan Network')).toBeInTheDocument() + }) + + it('shows all view nav items', () => { + render() + expect(screen.getByText('Canvas')).toBeInTheDocument() + expect(screen.getByText('Pending Devices')).toBeInTheDocument() + expect(screen.getByText('Hidden Devices')).toBeInTheDocument() + expect(screen.getByText('Scan History')).toBeInTheDocument() + }) + + // ── Stats ────────────────────────────────────────────────────────────────── + + it('displays total / online / offline counts from store', () => { + mockStore({ + nodes: [ + makeNode('n1', 'online'), + makeNode('n2', 'online'), + makeNode('n3', 'offline'), + makeNode('n4', 'unknown'), + ], + }) + render() + // Total (excludes groupRect) + expect(screen.getByText('4')).toBeInTheDocument() + // Online + expect(screen.getByText('2')).toBeInTheDocument() + // Offline + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('excludes groupRect nodes from stats', () => { + mockStore({ + nodes: [ + makeNode('n1', 'unknown'), // 1 real node, not online/offline + makeNode('zone', 'unknown', 'groupRect'), + ], + }) + render() + // Total row shows 1 (groupRect excluded), online/offline both 0 + const totalRow = screen.getByText('Total').closest('div')! + expect(totalRow).toHaveTextContent('1') + expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0 + }) + + // ── Collapse ─────────────────────────────────────────────────────────────── + + it('collapses sidebar on toggle button click', () => { + render() + const aside = screen.getByRole('complementary') + expect(aside).toHaveStyle({ width: '220px' }) + + const toggle = aside.querySelector('button')! + fireEvent.click(toggle) + expect(aside).toHaveStyle({ width: '48px' }) + }) + + it('hides label text when collapsed', () => { + render() + const aside = screen.getByRole('complementary') + const toggle = aside.querySelector('button')! + fireEvent.click(toggle) + expect(screen.queryByText('Add Node')).not.toBeInTheDocument() + }) + + it('hides stats footer when collapsed', () => { + render() + expect(screen.getByText('Total')).toBeInTheDocument() + const toggle = screen.getByRole('complementary').querySelector('button')! + fireEvent.click(toggle) + expect(screen.queryByText('Total')).not.toBeInTheDocument() + }) + + it('shows logo with showText=false when collapsed', () => { + render() + const logo = screen.getByTestId('logo') + expect(logo).toHaveAttribute('data-show-text', 'true') + const toggle = screen.getByRole('complementary').querySelector('button')! + fireEvent.click(toggle) + expect(logo).toHaveAttribute('data-show-text', 'false') + }) + + // ── Action callbacks ─────────────────────────────────────────────────────── + + it('calls onAddNode when Add Node is clicked', () => { + render() + fireEvent.click(screen.getByText('Add Node')) + expect(defaultProps.onAddNode).toHaveBeenCalledOnce() + }) + + it('calls onAddGroupRect when Add Zone is clicked', () => { + render() + fireEvent.click(screen.getByText('Add Zone')) + expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce() + }) + + it('calls onSave when Save Canvas is clicked', () => { + render() + fireEvent.click(screen.getByText('Save Canvas')) + expect(defaultProps.onSave).toHaveBeenCalledOnce() + }) + + it('calls toggleHideIp when Hide IPs is clicked', () => { + render() + fireEvent.click(screen.getByText('Hide IPs')) + expect(mockToggleHideIp).toHaveBeenCalledOnce() + }) + + it('shows Show IPs label when hideIp is true', () => { + mockStore({ hideIp: true }) + render() + expect(screen.getByText('Show IPs')).toBeInTheDocument() + }) + + // ── Unsaved changes badge ────────────────────────────────────────────────── + + it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => { + mockStore({ hasUnsavedChanges: true }) + render() + // The badge is a span sibling of the Save Canvas button icon + const saveBtn = screen.getByText('Save Canvas').closest('button')! + const badge = saveBtn.querySelector('span.rounded-full') + expect(badge).toBeInTheDocument() + }) + + it('does not show unsaved badge when no changes', () => { + mockStore({ hasUnsavedChanges: false }) + render() + const saveBtn = screen.getByText('Save Canvas').closest('button')! + const badge = saveBtn.querySelector('span.rounded-full') + expect(badge).not.toBeInTheDocument() + }) + + // ── Scan action ──────────────────────────────────────────────────────────── + + it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => { + const { scanApi } = await import('@/api/client') + render() + fireEvent.click(screen.getByText('Scan Network')) + await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce()) + expect(defaultProps.onScan).toHaveBeenCalledOnce() + }) + + it('switches to Scan History view after scan is triggered', async () => { + render() + fireEvent.click(screen.getByText('Scan Network')) + await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument()) + }) + + // ── Navigation ───────────────────────────────────────────────────────────── + + it('shows Pending panel when Pending Devices nav item is clicked', async () => { + render() + fireEvent.click(screen.getByText('Pending Devices')) + await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument()) + }) + + it('shows Hidden panel when Hidden Devices nav item is clicked', async () => { + render() + fireEvent.click(screen.getByText('Hidden Devices')) + await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument()) + }) + + it('shows History panel when Scan History nav item is clicked', async () => { + render() + fireEvent.click(screen.getByText('Scan History')) + await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) + }) + + it('toggles Settings panel on Settings click', async () => { + render() + fireEvent.click(screen.getByText('Settings')) + await waitFor(() => + expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(), + ) + // Click the nav button again to close (use role to avoid matching the panel heading) + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/hooks/__tests__/useStatusPolling.test.ts b/frontend/src/hooks/__tests__/useStatusPolling.test.ts new file mode 100644 index 0000000..845c231 --- /dev/null +++ b/frontend/src/hooks/__tests__/useStatusPolling.test.ts @@ -0,0 +1,170 @@ +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 mockUpdateNode = vi.fn() +const mockNotifyScanDeviceFound = 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({ + updateNode: mockUpdateNode, + notifyScanDeviceFound: mockNotifyScanDeviceFound, + } as ReturnType) + + vi.mocked(useAuthStore).mockReturnValue({ + isAuthenticated: true, + token: 'test-token', + } as ReturnType) + + Object.defineProperty(window, 'location', { + value: { protocol: 'http:', host: 'localhost:5173' }, + writable: true, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + mockUpdateNode.mockClear() + mockNotifyScanDeviceFound.mockClear() + }) + + it('does not open WebSocket when not authenticated', () => { + vi.mocked(useAuthStore).mockReturnValue({ + isAuthenticated: false, + 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, + token: null, + } as ReturnType) + renderHook(() => useStatusPolling()) + expect(MockWebSocket.instances).toHaveLength(0) + }) + + 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('calls updateNode with correct data on status message', () => { + renderHook(() => useStatusPolling()) + const ws = MockWebSocket.instances[0] + ws.onmessage?.({ + data: JSON.stringify({ + node_id: 'node-1', + status: 'online', + checked_at: '2024-01-01T12:00:00Z', + response_time_ms: 42, + }), + }) + expect(mockUpdateNode).toHaveBeenCalledWith('node-1', { + status: 'online', + response_time_ms: 42, + last_seen: '2024-01-01T12:00:00Z', + }) + }) + + it('sets last_seen to undefined when status is offline', () => { + renderHook(() => useStatusPolling()) + const ws = MockWebSocket.instances[0] + ws.onmessage?.({ + data: JSON.stringify({ + node_id: 'node-1', + status: 'offline', + checked_at: '2024-01-01T12:00:00Z', + }), + }) + expect(mockUpdateNode).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({ node_id: 'node-1', status: 'online', response_time_ms: null }), + }) + expect(mockUpdateNode).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(mockUpdateNode).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(mockUpdateNode).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(mockUpdateNode).not.toHaveBeenCalled() + }) + + it('closes WebSocket on unmount', () => { + const { unmount } = renderHook(() => useStatusPolling()) + const ws = MockWebSocket.instances[0] + unmount() + expect(ws.close).toHaveBeenCalledOnce() + }) +})