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)
+ })
+})