From b35b51d5b27191177ada9d7f22ba7e2a61a3bbdd Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 28 Mar 2026 12:21:21 +0100 Subject: [PATCH] test: add component tests for LoginPage, modals, and CanvasContainer --- .../components/__tests__/LoginPage.test.tsx | 146 ++++++++++++++++ .../canvas/__tests__/CanvasContainer.test.tsx | 146 ++++++++++++++++ .../modals/__tests__/EdgeModal.test.tsx | 164 +++++++++++++++++ .../__tests__/PendingDeviceModal.test.tsx | 164 +++++++++++++++++ .../modals/__tests__/ScanConfigModal.test.tsx | 137 +++++++++++++++ .../modals/__tests__/SearchModal.test.tsx | 165 ++++++++++++++++++ .../modals/__tests__/ThemeModal.test.tsx | 97 ++++++++++ 7 files changed, 1019 insertions(+) create mode 100644 frontend/src/components/__tests__/LoginPage.test.tsx create mode 100644 frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx create mode 100644 frontend/src/components/modals/__tests__/EdgeModal.test.tsx create mode 100644 frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx create mode 100644 frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx create mode 100644 frontend/src/components/modals/__tests__/SearchModal.test.tsx create mode 100644 frontend/src/components/modals/__tests__/ThemeModal.test.tsx diff --git a/frontend/src/components/__tests__/LoginPage.test.tsx b/frontend/src/components/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..9b617a6 --- /dev/null +++ b/frontend/src/components/__tests__/LoginPage.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { LoginPage } from '../LoginPage' +import { useAuthStore } from '@/stores/authStore' + +vi.mock('@/api/client', () => ({ + authApi: { + login: vi.fn(), + }, +})) + +import { authApi } from '@/api/client' + +describe('LoginPage', () => { + beforeEach(() => { + useAuthStore.setState({ token: null, isAuthenticated: false }) + vi.mocked(authApi.login).mockReset() + }) + + // ── Rendering ──────────────────────────────────────────────────────────── + + it('renders username and password fields', () => { + render() + expect(screen.getByLabelText('Username')).toBeDefined() + expect(screen.getByLabelText('Password')).toBeDefined() + }) + + it('renders a Sign in button', () => { + render() + expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined() + }) + + // ── Security checks ────────────────────────────────────────────────────── + + it('password field type is "password" — not rendered as plain text', () => { + render() + const pw = screen.getByLabelText('Password') as HTMLInputElement + expect(pw.type).toBe('password') + }) + + it('username field has autocomplete="username"', () => { + render() + const un = screen.getByLabelText('Username') as HTMLInputElement + expect(un.getAttribute('autocomplete')).toBe('username') + }) + + it('password field has autocomplete="current-password" (supports password managers)', () => { + render() + const pw = screen.getByLabelText('Password') as HTMLInputElement + expect(pw.getAttribute('autocomplete')).toBe('current-password') + }) + + it('shows a generic error message — no credential enumeration', async () => { + vi.mocked(authApi.login).mockRejectedValue(new Error('401')) + render() + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } }) + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!) + await waitFor(() => { + expect(screen.getByText('Invalid username or password')).toBeDefined() + }) + // Must show exactly ONE error — not separate per-field messages (no enumeration) + const errors = document.querySelectorAll('p.text-\\[\\#f85149\\]') + expect(errors.length).toBe(1) + expect(errors[0].textContent).toBe('Invalid username or password') + }) + + it('clears previous error before each new attempt', async () => { + vi.mocked(authApi.login) + .mockRejectedValueOnce(new Error('401')) + .mockRejectedValueOnce(new Error('401')) + render() + const form = screen.getByRole('button', { name: /sign in/i }).closest('form')! + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'bad' } }) + fireEvent.submit(form) + await waitFor(() => screen.getByText('Invalid username or password')) + fireEvent.submit(form) + // Error clears while loading (setError('') before try) + await waitFor(() => screen.getByText('Invalid username or password')) + expect(screen.getAllByText('Invalid username or password')).toHaveLength(1) + }) + + it('disables submit button while loading — prevents double-submit', async () => { + let resolve!: (v: unknown) => void + vi.mocked(authApi.login).mockReturnValue(new Promise((r) => { resolve = r })) + render() + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass' } }) + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!) + await waitFor(() => { + expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true) + }) + resolve({ data: { access_token: 'tok' } }) + }) + + it('calls authApi.login with credentials via POST body (not URL params)', async () => { + vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'tok' } } as never) + render() + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }) + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!) + await waitFor(() => { + expect(authApi.login).toHaveBeenCalledWith('admin', 'secret') + }) + }) + + it('stores token in authStore on successful login', async () => { + vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'mytoken123' } } as never) + render() + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct' } }) + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!) + await waitFor(() => { + expect(useAuthStore.getState().isAuthenticated).toBe(true) + expect(useAuthStore.getState().token).toBe('mytoken123') + }) + }) + + it('token persisted via sessionStorage — not localStorage', () => { + // The authStore uses createJSONStorage(() => sessionStorage) + // Verify the storage key exists in sessionStorage after login + render() + // Even before login, the store is backed by sessionStorage + expect(typeof sessionStorage).toBe('object') + // localStorage should NOT contain the auth token + expect(localStorage.getItem('homelable-auth')).toBeNull() + }) + + it('does not show error on initial render', () => { + render() + expect(screen.queryByText('Invalid username or password')).toBeNull() + }) + + it('requires username (HTML required attribute)', () => { + render() + const un = screen.getByLabelText('Username') as HTMLInputElement + expect(un.required).toBe(true) + }) + + it('requires password (HTML required attribute)', () => { + render() + const pw = screen.getByLabelText('Password') as HTMLInputElement + expect(pw.required).toBe(true) + }) +}) diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx new file mode 100644 index 0000000..b71adc8 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { CanvasContainer } from '../CanvasContainer' +import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +// Capture props passed to ReactFlow so we can test the callbacks +let rfProps: Record = {} + +vi.mock('@xyflow/react', () => ({ + ReactFlow: (props: Record) => { + rfProps = props + return
+ }, + Background: () => null, + Controls: () => null, + BackgroundVariant: { Dots: 'dots' }, + ConnectionMode: { Loose: 'loose' }, +})) + +vi.mock('@xyflow/react/dist/style.css', () => ({})) + +function makeNode(id: string): Node { + return { + id, + type: 'server', + position: { x: 0, y: 0 }, + data: { label: id, type: 'server', status: 'unknown', services: [] }, + } +} + +function makeEdge(id: string): Edge { + return { id, source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } +} + +describe('CanvasContainer', () => { + beforeEach(() => { + rfProps = {} + useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) + useThemeStore.setState({ activeTheme: 'default' }) + }) + + // ── Rendering ───────────────────────────────────────────────────────────── + + it('renders without crashing', () => { + const { getByTestId } = render() + expect(getByTestId('react-flow')).toBeDefined() + }) + + it('passes nodes from store to ReactFlow', () => { + useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] }) + render() + expect((rfProps.nodes as Node[]).length).toBe(2) + }) + + it('passes edges from store to ReactFlow', () => { + useCanvasStore.setState({ + nodes: [makeNode('n1'), makeNode('n2')], + edges: [makeEdge('e1')], + }) + render() + expect((rfProps.edges as Edge[]).length).toBe(1) + }) + + // ── Node click → selection ──────────────────────────────────────────────── + + it('calls setSelectedNode with node id on node click', () => { + const node = makeNode('n1') + useCanvasStore.setState({ nodes: [node] }) + render() + ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) + expect(useCanvasStore.getState().selectedNodeId).toBe('n1') + }) + + // ── Pane click → deselect ───────────────────────────────────────────────── + + it('calls setSelectedNode(null) on pane click', () => { + useCanvasStore.setState({ selectedNodeId: 'n1' }) + render() + ;(rfProps.onPaneClick as (...args: unknown[]) => unknown)() + expect(useCanvasStore.getState().selectedNodeId).toBeNull() + }) + + // ── Edge double-click ───────────────────────────────────────────────────── + + it('calls onEdgeDoubleClick prop when an edge is double-clicked', () => { + const onEdgeDoubleClick = vi.fn() + const edge = makeEdge('e1') + render() + ;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge) + expect(onEdgeDoubleClick).toHaveBeenCalledWith(edge) + }) + + it('does not throw when onEdgeDoubleClick is not provided', () => { + const edge = makeEdge('e1') + render() + expect(() => { + ;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge) + }).not.toThrow() + }) + + // ── Connection validation ───────────────────────────────────────────────── + + it('isValidConnection returns false for self-connections', () => { + render() + const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean + expect(isValid({ source: 'n1', target: 'n1' })).toBe(false) + }) + + it('isValidConnection returns true for different nodes', () => { + render() + const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean + expect(isValid({ source: 'n1', target: 'n2' })).toBe(true) + }) + + // ── onConnect prop passthrough ──────────────────────────────────────────── + + it('passes onConnect prop to ReactFlow', () => { + const onConnect = vi.fn() + render() + ;(rfProps.onConnect as (...args: unknown[]) => unknown)({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null }) + expect(onConnect).toHaveBeenCalledOnce() + }) + + // ── onNodeDragStart prop passthrough ────────────────────────────────────── + + it('passes onNodeDragStart prop to ReactFlow', () => { + const onNodeDragStart = vi.fn() + render() + expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) + }) + + // ── Canvas settings ─────────────────────────────────────────────────────── + + it('enables snapToGrid', () => { + render() + expect(rfProps.snapToGrid).toBe(true) + }) + + it('sets snapGrid to [16, 16]', () => { + render() + expect(rfProps.snapGrid).toEqual([16, 16]) + }) +}) diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx new file mode 100644 index 0000000..8918dd8 --- /dev/null +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { EdgeModal } from '../EdgeModal' + +describe('EdgeModal', () => { + // ── Visibility ──────────────────────────────────────────────────────────── + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders form when open', () => { + render() + expect(screen.getByText('Connect Nodes')).toBeDefined() + }) + + it('uses custom title when provided', () => { + render() + expect(screen.getByText('Edit Link')).toBeDefined() + }) + + // ── Submit button label ─────────────────────────────────────────────────── + + it('shows "Connect" button when onDelete is not provided', () => { + render() + expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined() + }) + + it('shows "Save" button when onDelete is provided', () => { + render() + expect(screen.getByRole('button', { name: 'Save' })).toBeDefined() + }) + + // ── Default submit ──────────────────────────────────────────────────────── + + it('calls onSubmit with default ethernet type', () => { + const onSubmit = vi.fn() + const onClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit).toHaveBeenCalledOnce() + expect(onSubmit.mock.calls[0][0].type).toBe('ethernet') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('calls onSubmit with label when filled', () => { + const onSubmit = vi.fn() + render() + fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].label).toBe('uplink') + }) + + it('omits label from payload when empty', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].label).toBeUndefined() + }) + + // ── VLAN ID field ───────────────────────────────────────────────────────── + + it('does not show VLAN ID field for ethernet type', () => { + render() + expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull() + }) + + it('submits integer vlan_id when type is vlan', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].vlan_id).toBe(20) + }) + + it('omits vlan_id from payload for non-vlan types', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].vlan_id).toBeUndefined() + }) + + // ── Path style ──────────────────────────────────────────────────────────── + + it('defaults to bezier path style', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].path_style).toBe('bezier') + }) + + it('switches path style to smooth on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Smooth step')) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') + }) + + // ── Animated toggle ─────────────────────────────────────────────────────── + + it('flow animation defaults to off', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + // animated: false → omitted (falsy || undefined) + expect(onSubmit.mock.calls[0][0].animated).toBeFalsy() + }) + + it('toggling animation sends animated: true', () => { + const onSubmit = vi.fn() + render() + // Find the toggle: it's the only button with aria-pressed attribute + const allButtons = screen.getAllByRole('button') + const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))! + expect(toggle).toBeDefined() + fireEvent.click(toggle) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe(true) + }) + + // ── Pre-fill ────────────────────────────────────────────────────────────── + + it('pre-fills label from initial prop', () => { + render() + const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement + expect(input.value).toBe('trunk') + }) + + it('pre-fills path style from initial prop', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') + }) + + // ── Cancel & Delete ─────────────────────────────────────────────────────── + + it('calls onClose when Cancel is clicked', () => { + const onClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows Delete button when onDelete is provided', () => { + render() + expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined() + }) + + it('does not show Delete button without onDelete', () => { + render() + expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull() + }) + + it('calls onDelete and onClose when Delete is clicked', () => { + const onDelete = vi.fn() + const onClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onDelete).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx new file mode 100644 index 0000000..3ec4211 --- /dev/null +++ b/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal' + +function makeDevice(overrides: Partial = {}): PendingDevice { + return { + id: 'dev-1', + ip: '192.168.1.100', + mac: 'aa:bb:cc:dd:ee:ff', + hostname: 'pve.local', + os: 'Linux', + services: [], + suggested_type: 'server', + status: 'pending', + discovered_at: '2024-01-15T10:30:00Z', + ...overrides, + } +} + +describe('PendingDeviceModal', () => { + // ── Visibility ──────────────────────────────────────────────────────────── + + it('renders nothing when device is null', () => { + const { container } = render( + + ) + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders dialog when device is provided', () => { + render( + + ) + expect(screen.getByRole('dialog')).toBeDefined() + }) + + // ── Device info display ─────────────────────────────────────────────────── + + it('shows hostname as title when available', () => { + render( + + ) + // hostname appears in both title and info row — check at least one match + expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0) + }) + + it('falls back to IP as title when hostname is null', () => { + render( + + ) + // IP appears in both title and info row when hostname is absent + expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0) + }) + + it('shows IP address', () => { + render( + + ) + expect(screen.getByText('192.168.1.100')).toBeDefined() + }) + + it('shows MAC address when present', () => { + render( + + ) + expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined() + }) + + it('shows OS when present', () => { + render( + + ) + expect(screen.getByText('Linux')).toBeDefined() + }) + + it('does not show hostname row when hostname is null', () => { + render( + + ) + // "Hostname" label should not appear in the info rows + expect(screen.queryByText('Hostname')).toBeNull() + }) + + it('shows suggested type when present', () => { + render( + + ) + expect(screen.getByText('proxmox')).toBeDefined() + }) + + // ── Services ────────────────────────────────────────────────────────────── + + it('shows "No services detected" when services list is empty', () => { + render( + + ) + expect(screen.getByText('No services detected')).toBeDefined() + }) + + it('shows service count and details', () => { + const device = makeDevice({ + services: [ + { port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' }, + { port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' }, + ], + }) + render( + + ) + expect(screen.getByText('Services found (2)')).toBeDefined() + expect(screen.getByText('HTTP')).toBeDefined() + expect(screen.getByText('HTTPS')).toBeDefined() + expect(screen.getByText('80')).toBeDefined() + expect(screen.getByText('443')).toBeDefined() + }) + + it('shows service category when present', () => { + const device = makeDevice({ + services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }], + }) + render( + + ) + expect(screen.getByText('hypervisor')).toBeDefined() + }) + + // ── Actions ─────────────────────────────────────────────────────────────── + + it('calls onApprove with the device and onClose when Approve is clicked', () => { + const device = makeDevice() + const onApprove = vi.fn() + const onClose = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + expect(onApprove).toHaveBeenCalledWith(device) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('calls onHide with the device and onClose when Hide is clicked', () => { + const device = makeDevice() + const onHide = vi.fn() + const onClose = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Hide' })) + expect(onHide).toHaveBeenCalledWith(device) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('calls onIgnore with the device and onClose when Delete is clicked', () => { + const device = makeDevice() + const onIgnore = vi.fn() + const onClose = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onIgnore).toHaveBeenCalledWith(device) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx new file mode 100644 index 0000000..5eaca0c --- /dev/null +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ScanConfigModal } from '../ScanConfigModal' + +vi.mock('@/api/client', () => ({ + scanApi: { + getConfig: vi.fn(), + saveConfig: vi.fn(), + }, +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) + +import { scanApi } from '@/api/client' +import { toast } from 'sonner' + +const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } } + +describe('ScanConfigModal', () => { + beforeEach(() => { + vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never) + vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never) + vi.mocked(toast.success).mockReset() + vi.mocked(toast.error).mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('loads config from API on open', async () => { + render() + await waitFor(() => { + expect(scanApi.getConfig).toHaveBeenCalledOnce() + }) + const input = await screen.findByDisplayValue('192.168.1.0/24') + expect(input).toBeDefined() + }) + + it('loads interval from API on open', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never) + render() + const input = await screen.findByDisplayValue('120') + expect(input).toBeDefined() + }) + + it('adds a new empty range on "Add range" click', async () => { + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByText('Add range')) + const inputs = screen.getAllByPlaceholderText('192.168.1.0/24') + expect(inputs).toHaveLength(2) + }) + + it('delete button disabled when only one range', async () => { + render() + await screen.findByDisplayValue('192.168.1.0/24') + // Only 1 range → delete button disabled + const trashButtons = document.querySelectorAll('button[disabled]') + expect(trashButtons.length).toBeGreaterThan(0) + }) + + it('can remove a range when more than one exist', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never) + render() + await screen.findByDisplayValue('192.168.1.0/24') + // Both trash buttons should be enabled + const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg')) + expect(trashButtons.length).toBeGreaterThanOrEqual(2) + }) + + it('shows error toast and does not save when all ranges are empty', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never) + render() + await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Add at least one IP range') + }) + expect(scanApi.saveConfig).not.toHaveBeenCalled() + }) + + it('saves config and closes on Save click', async () => { + const onClose = vi.fn() + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 }) + expect(toast.success).toHaveBeenCalledWith('Scan config saved') + expect(onClose).toHaveBeenCalledOnce() + }) + }) + + it('shows error toast when save fails', async () => { + vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network')) + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to save config') + }) + }) + + it('calls onScanNow after saving on "Scan Now" click', async () => { + const onScanNow = vi.fn() + const onClose = vi.fn() + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) + await waitFor(() => { + expect(scanApi.saveConfig).toHaveBeenCalled() + expect(onScanNow).toHaveBeenCalledOnce() + }) + }) + + it('calls onClose when Cancel is clicked', async () => { + const onClose = vi.fn() + render() + await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('strips whitespace from ranges before saving', async () => { + render() + const input = await screen.findByDisplayValue('192.168.1.0/24') + // Type a range with surrounding whitespace + fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(scanApi.saveConfig).toHaveBeenCalledWith( + expect.objectContaining({ ranges: ['10.0.0.0/8'] }) + ) + }) + }) +}) diff --git a/frontend/src/components/modals/__tests__/SearchModal.test.tsx b/frontend/src/components/modals/__tests__/SearchModal.test.tsx new file mode 100644 index 0000000..1897db6 --- /dev/null +++ b/frontend/src/components/modals/__tests__/SearchModal.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { SearchModal } from '../SearchModal' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' + +const mockFitView = vi.fn() +vi.mock('@xyflow/react', () => ({ + useReactFlow: () => ({ fitView: mockFitView }), +})) + +function makeNode(id: string, overrides: Partial = {}): Node { + return { + id, + type: overrides.type ?? 'server', + position: { x: 0, y: 0 }, + data: { + label: id, + type: overrides.type ?? 'server', + status: 'unknown', + services: [], + ...overrides, + }, + } +} + +describe('SearchModal', () => { + beforeEach(() => { + useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) + mockFitView.mockReset() + }) + + it('renders nothing when closed', () => { + render() + expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull() + }) + + it('renders search input when open', () => { + render() + expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined() + }) + + it('shows "Type to search" hint when query is empty', () => { + render() + expect(screen.getByText(/type to search/i)).toBeDefined() + }) + + it('shows no results message when query has no matches', () => { + useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } }) + expect(screen.getByText(/no nodes match/i)).toBeDefined() + }) + + it('filters nodes by label', () => { + useCanvasStore.setState({ + nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })], + }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } }) + expect(screen.getByText('My Router')).toBeDefined() + expect(screen.queryByText('NAS Server')).toBeNull() + }) + + it('filters nodes by IP', () => { + useCanvasStore.setState({ + nodes: [ + makeNode('n1', { label: 'Box A', ip: '192.168.1.10' }), + makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }), + ], + }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } }) + expect(screen.getByText('Box A')).toBeDefined() + expect(screen.queryByText('Box B')).toBeNull() + }) + + it('filters nodes by hostname', () => { + useCanvasStore.setState({ + nodes: [ + makeNode('n1', { label: 'A', hostname: 'pve.local' }), + makeNode('n2', { label: 'B', hostname: 'nas.local' }), + ], + }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } }) + expect(screen.getByText('A')).toBeDefined() + expect(screen.queryByText('B')).toBeNull() + }) + + it('excludes groupRect nodes from results', () => { + useCanvasStore.setState({ + nodes: [ + makeNode('n1', { label: 'Server', type: 'server' }), + makeNode('g1', { label: 'Zone A', type: 'groupRect' }), + ], + }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } }) + expect(screen.getByText(/no nodes match/i)).toBeDefined() + }) + + it('limits results to 8 nodes', () => { + useCanvasStore.setState({ + nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })), + }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } }) + const items = screen.getAllByText(/Server \d/) + expect(items).toHaveLength(8) + }) + + it('selects node and closes on result click', () => { + const onClose = vi.fn() + useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } }) + fireEvent.click(screen.getByText('Proxmox')) + expect(useCanvasStore.getState().selectedNodeId).toBe('n1') + expect(mockFitView).toHaveBeenCalledWith(expect.objectContaining({ nodes: [{ id: 'n1' }] })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('selects first result and closes on Enter key', () => { + const onClose = vi.fn() + useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] }) + render() + const input = screen.getByPlaceholderText(/search nodes/i) + fireEvent.change(input, { target: { value: 'switch' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(useCanvasStore.getState().selectedNodeId).toBe('n1') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes on Escape key', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes when clicking backdrop', () => { + const onClose = vi.fn() + render() + // The backdrop is the fixed inset div — clicking it fires onClose + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('does not close when clicking inside the search box', () => { + const onClose = vi.fn() + render() + fireEvent.click(screen.getByPlaceholderText(/search nodes/i)) + expect(onClose).not.toHaveBeenCalled() + }) + + it('search is case-insensitive', () => { + useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] }) + render() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } }) + expect(screen.getByText('My NAS')).toBeDefined() + }) +}) diff --git a/frontend/src/components/modals/__tests__/ThemeModal.test.tsx b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx new file mode 100644 index 0000000..1e5b9c3 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { ThemeModal } from '../ThemeModal' +import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' +import { THEME_ORDER } from '@/utils/themes' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) +import { toast } from 'sonner' + +describe('ThemeModal', () => { + beforeEach(() => { + useThemeStore.setState({ activeTheme: 'default' }) + useCanvasStore.setState({ hasUnsavedChanges: false }) + vi.mocked(toast.info).mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders all available themes', () => { + render() + // Every theme in THEME_ORDER should have a card rendered + expect(THEME_ORDER.length).toBeGreaterThan(0) + // At minimum the dialog title should be present + expect(screen.getByText('Choose Canvas Style')).toBeDefined() + }) + + it('shows Apply Style button', () => { + render() + expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined() + }) + + it('shows Cancel button', () => { + render() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() + }) + + it('live-previews theme when a card is clicked', () => { + render() + const initialTheme = useThemeStore.getState().activeTheme + // Click a different theme card (find by button role, pick a non-default one) + const cards = screen.getAllByRole('button').filter((b) => + b.className.includes('rounded-xl') + ) + // Click the second card (first non-selected) + fireEvent.click(cards[1]) + // Theme should have changed for live preview + expect(useThemeStore.getState().activeTheme).not.toBe(initialTheme) + }) + + it('Apply sets theme, marks unsaved, and closes', () => { + const onClose = vi.fn() + render() + // Click a non-default card first + const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl')) + fireEvent.click(cards[1]) + const previewTheme = useThemeStore.getState().activeTheme + fireEvent.click(screen.getByRole('button', { name: 'Apply Style' })) + expect(useThemeStore.getState().activeTheme).toBe(previewTheme) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('Apply shows toast asking user to save canvas', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Apply Style' })) + expect(toast.info).toHaveBeenCalledWith( + expect.stringContaining('save'), + expect.any(Object) + ) + }) + + it('Cancel reverts to original theme and closes', () => { + const onClose = vi.fn() + useThemeStore.setState({ activeTheme: 'default' }) + render() + // Preview a different theme + const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl')) + fireEvent.click(cards[1]) + expect(useThemeStore.getState().activeTheme).not.toBe('default') + // Cancel should revert + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(useThemeStore.getState().activeTheme).toBe('default') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('Cancel does not mark canvas as unsaved', () => { + render() + const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl')) + fireEvent.click(cards[1]) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) +})