test: add component tests for LoginPage, modals, and CanvasContainer

This commit is contained in:
Pouzor
2026-03-28 12:21:21 +01:00
parent 565f4337c8
commit b35b51d5b2
7 changed files with 1019 additions and 0 deletions
@@ -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(<LoginPage />)
expect(screen.getByLabelText('Username')).toBeDefined()
expect(screen.getByLabelText('Password')).toBeDefined()
})
it('renders a Sign in button', () => {
render(<LoginPage />)
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.type).toBe('password')
})
it('username field has autocomplete="username"', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.getAttribute('autocomplete')).toBe('username')
})
it('password field has autocomplete="current-password" (supports password managers)', () => {
render(<LoginPage />)
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(<LoginPage />)
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(<LoginPage />)
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(<LoginPage />)
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(<LoginPage />)
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(<LoginPage />)
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(<LoginPage />)
// 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(<LoginPage />)
expect(screen.queryByText('Invalid username or password')).toBeNull()
})
it('requires username (HTML required attribute)', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.required).toBe(true)
})
it('requires password (HTML required attribute)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.required).toBe(true)
})
})
@@ -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<string, unknown> = {}
vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
return <div data-testid="react-flow" />
},
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
function makeNode(id: string): Node<NodeData> {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'unknown', services: [] },
}
}
function makeEdge(id: string): Edge<EdgeData> {
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(<CanvasContainer />)
expect(getByTestId('react-flow')).toBeDefined()
})
it('passes nodes from store to ReactFlow', () => {
useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] })
render(<CanvasContainer />)
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(<CanvasContainer />)
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(<CanvasContainer />)
;(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(<CanvasContainer />)
;(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(<CanvasContainer onEdgeDoubleClick={onEdgeDoubleClick} />)
;(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(<CanvasContainer />)
expect(() => {
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
}).not.toThrow()
})
// ── Connection validation ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => {
render(<CanvasContainer />)
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(<CanvasContainer />)
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(<CanvasContainer onConnect={onConnect} />)
;(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(<CanvasContainer onNodeDragStart={onNodeDragStart} />)
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {
render(<CanvasContainer />)
expect(rfProps.snapToGrid).toBe(true)
})
it('sets snapGrid to [16, 16]', () => {
render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16])
})
})
@@ -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(<EdgeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form when open', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Connect Nodes')).toBeDefined()
})
it('uses custom title when provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Link" />)
expect(screen.getByText('Edit Link')).toBeDefined()
})
// ── Submit button label ───────────────────────────────────────────────────
it('shows "Connect" button when onDelete is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined()
})
it('shows "Save" button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
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(<EdgeModal open onClose={onClose} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull()
})
it('submits integer vlan_id when type is vlan', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'vlan', vlan_id: 20 }} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// 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(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ path_style: 'smooth' }} />)
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(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined()
})
it('does not show Delete button without onDelete', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
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(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -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> = {}): 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(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
// ── Device info display ───────────────────────────────────────────────────
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// 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(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// 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(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
})
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
// ── Services ──────────────────────────────────────────────────────────────
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
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(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
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(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
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(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
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(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
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(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -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(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('loads config from API on open', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
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(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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'] })
)
})
})
})
@@ -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<NodeData> = {}): Node<NodeData> {
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(<SearchModal open={false} onClose={vi.fn()} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={vi.fn()} />)
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(<SearchModal open onClose={onClose} />)
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(<SearchModal open onClose={onClose} />)
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(<SearchModal open onClose={onClose} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
// 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(<SearchModal open onClose={onClose} />)
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(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
})
@@ -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(<ThemeModal open={false} onClose={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders all available themes', () => {
render(<ThemeModal open onClose={vi.fn()} />)
// 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(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined()
})
it('shows Cancel button', () => {
render(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
})
it('live-previews theme when a card is clicked', () => {
render(<ThemeModal open onClose={vi.fn()} />)
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(<ThemeModal open onClose={onClose} />)
// 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(<ThemeModal open onClose={vi.fn()} />)
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(<ThemeModal open onClose={onClose} />)
// 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(<ThemeModal open onClose={vi.fn()} />)
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)
})
})