feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
214
frontend/src/components/__tests__/DeviceList.test.tsx
Normal file
214
frontend/src/components/__tests__/DeviceList.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Device list (FleetTable) component tests -- verifies device data rendering,
|
||||
* loading state, empty state, and table structure.
|
||||
*
|
||||
* Tests the FleetTable component directly since DevicesPage is tightly coupled
|
||||
* to TanStack Router file-based routing (Route.useParams/useSearch).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, within } from '@/test/test-utils'
|
||||
import type { DeviceListResponse } from '@/lib/api'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ children, ...props }: { children: React.ReactNode; to?: string }) => (
|
||||
<a href={props.to ?? '#'}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock devicesApi at the module level
|
||||
const mockDevicesList = vi.fn()
|
||||
|
||||
vi.mock('@/lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
|
||||
return {
|
||||
...actual,
|
||||
devicesApi: {
|
||||
...actual.devicesApi,
|
||||
list: (...args: unknown[]) => mockDevicesList(...args),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Test data
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const testDevices: DeviceListResponse = {
|
||||
items: [
|
||||
{
|
||||
id: 'dev-1',
|
||||
hostname: 'router-office-1',
|
||||
ip_address: '192.168.1.1',
|
||||
api_port: 8728,
|
||||
api_ssl_port: 8729,
|
||||
model: 'RB4011',
|
||||
serial_number: 'ABC123',
|
||||
firmware_version: '7.12',
|
||||
routeros_version: '7.12.1',
|
||||
uptime_seconds: 86400,
|
||||
last_seen: '2026-03-01T12:00:00Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
status: 'online',
|
||||
tags: [{ id: 'tag-1', name: 'core', color: '#00ff00' }],
|
||||
groups: [],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'dev-2',
|
||||
hostname: 'ap-floor2',
|
||||
ip_address: '192.168.1.10',
|
||||
api_port: 8728,
|
||||
api_ssl_port: 8729,
|
||||
model: 'cAP ac',
|
||||
serial_number: 'DEF456',
|
||||
firmware_version: '7.10',
|
||||
routeros_version: '7.10.2',
|
||||
uptime_seconds: 3600,
|
||||
last_seen: '2026-03-01T11:00:00Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
status: 'offline',
|
||||
tags: [],
|
||||
groups: [],
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
}
|
||||
|
||||
const emptyDevices: DeviceListResponse = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component import (after mocks)
|
||||
// --------------------------------------------------------------------------
|
||||
import { FleetTable } from '@/components/fleet/FleetTable'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('FleetTable (Device List)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders device list with data', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
// Wait for data to load
|
||||
expect(await screen.findByText('router-office-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('ap-floor2')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders device model and firmware info', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
expect(await screen.findByText('RB4011')).toBeInTheDocument()
|
||||
expect(screen.getByText('cAP ac')).toBeInTheDocument()
|
||||
expect(screen.getByText('7.12.1')).toBeInTheDocument()
|
||||
expect(screen.getByText('7.10.2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when no devices', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(emptyDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
expect(await screen.findByText('No devices found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders loading state', () => {
|
||||
// Make the API hang (never resolve)
|
||||
mockDevicesList.mockReturnValueOnce(new Promise(() => {}))
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
expect(screen.getByText('Loading devices...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders table headers', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
await screen.findByText('router-office-1')
|
||||
|
||||
expect(screen.getByText('Hostname')).toBeInTheDocument()
|
||||
expect(screen.getByText('IP')).toBeInTheDocument()
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
expect(screen.getByText('RouterOS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Firmware')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Last Seen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders device tags', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
expect(await screen.findByText('core')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders formatted uptime', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
await screen.findByText('router-office-1')
|
||||
|
||||
// 86400 seconds = 1d 0h
|
||||
expect(screen.getByText('1d 0h')).toBeInTheDocument()
|
||||
// 3600 seconds = 1h 0m
|
||||
expect(screen.getByText('1h 0m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows pagination info', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
await screen.findByText('router-office-1')
|
||||
|
||||
// "Showing 1-2 of 2 devices"
|
||||
expect(screen.getByText(/Showing 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status indicators for online and offline devices', async () => {
|
||||
mockDevicesList.mockResolvedValueOnce(testDevices)
|
||||
|
||||
render(<FleetTable tenantId="tenant-1" />)
|
||||
|
||||
await screen.findByText('router-office-1')
|
||||
|
||||
// Status dots should be present -- find by title attribute
|
||||
const onlineDot = screen.getByTitle('online')
|
||||
const offlineDot = screen.getByTitle('offline')
|
||||
|
||||
expect(onlineDot).toBeInTheDocument()
|
||||
expect(offlineDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
229
frontend/src/components/__tests__/LoginPage.test.tsx
Normal file
229
frontend/src/components/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* LoginPage component tests -- verifies form rendering, credential submission,
|
||||
* error display, and loading state for the login flow.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { render, screen, waitFor } from '@/test/test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Mock useNavigate from TanStack Router
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => ({
|
||||
component: undefined,
|
||||
}),
|
||||
useNavigate: () => mockNavigate,
|
||||
}))
|
||||
|
||||
// Mock useAuth zustand store -- track login/clearError calls
|
||||
const mockLogin = vi.fn()
|
||||
const mockClearError = vi.fn()
|
||||
let authState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null as string | null,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
checkAuth: vi.fn(),
|
||||
clearError: mockClearError,
|
||||
}
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
useAuth: () => authState,
|
||||
}))
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// --------------------------------------------------------------------------
|
||||
// We need to import LoginPage from the route file. Since createFileRoute is
|
||||
// mocked, we import the default export which is the page component.
|
||||
// The file exports Route (from createFileRoute) and has LoginPage as the
|
||||
// component. We re-export it via a manual approach.
|
||||
|
||||
// Since the login page defines LoginPage as a function inside the module and
|
||||
// assigns it to Route.component, we need a different approach. Let's import
|
||||
// the module and extract the component from the Route object.
|
||||
|
||||
// Actually, with our mock of createFileRoute returning an object, the Route
|
||||
// export won't have the component. Let's mock createFileRoute to capture it.
|
||||
|
||||
let CapturedComponent: React.ComponentType | undefined
|
||||
|
||||
vi.mock('@tanstack/react-router', async () => {
|
||||
return {
|
||||
createFileRoute: () => ({
|
||||
// The real createFileRoute('/login')({component: LoginPage}) returns
|
||||
// an object. Our mock captures the component from the call.
|
||||
__call: true,
|
||||
}),
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// We need a different strategy. Let's directly create the LoginPage component
|
||||
// inline here since the route file couples createFileRoute with the component.
|
||||
// This is a common pattern for testing file-based route components.
|
||||
|
||||
// Instead, let's build a simplified LoginPage that matches the real one's
|
||||
// behavior and test that. OR, we mock createFileRoute properly.
|
||||
|
||||
// Best approach: mock createFileRoute to return a function that captures the
|
||||
// component option.
|
||||
vi.mock('@tanstack/react-router', () => {
|
||||
return {
|
||||
createFileRoute: () => (opts: { component: React.ComponentType }) => {
|
||||
CapturedComponent = opts.component
|
||||
return { component: opts.component }
|
||||
},
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// Now importing the login module will call createFileRoute('/login')({component: LoginPage})
|
||||
// and CapturedComponent will be set to LoginPage.
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('LoginPage', () => {
|
||||
let LoginPage: React.ComponentType
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
CapturedComponent = undefined
|
||||
|
||||
// Reset auth state
|
||||
authState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
checkAuth: vi.fn(),
|
||||
clearError: mockClearError,
|
||||
}
|
||||
|
||||
// Dynamic import to re-trigger module evaluation
|
||||
// Use cache-busting to force re-evaluation
|
||||
const mod = await import('@/routes/login')
|
||||
// The component is set via our mock
|
||||
if (CapturedComponent) {
|
||||
LoginPage = CapturedComponent
|
||||
} else {
|
||||
// Fallback: try to get it from the Route export
|
||||
LoginPage = (mod.Route as { component?: React.ComponentType })?.component ?? (() => null)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders login form with email and password fields', () => {
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders branding elements', () => {
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('TOD - The Other Dude')).toBeInTheDocument()
|
||||
expect(screen.getByText('MSP Fleet Management')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message on failed login', async () => {
|
||||
mockLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||
authState.error = null
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
// After the failed login, the useAuth store would set error.
|
||||
// Since we control the mock, we need to re-render with the error state.
|
||||
// Let's update authState and re-render.
|
||||
authState.error = 'Invalid credentials'
|
||||
|
||||
// The component should re-render via zustand. In our mock, it won't
|
||||
// automatically. Let's re-render.
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits form with entered credentials', async () => {
|
||||
mockLogin.mockResolvedValueOnce(undefined)
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'secret123')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('admin@example.com', 'secret123')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to home on successful login', async () => {
|
||||
mockLogin.mockResolvedValueOnce(undefined)
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'secret123')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' })
|
||||
})
|
||||
})
|
||||
|
||||
it('disables submit button when fields are empty', () => {
|
||||
render(<LoginPage />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows "Signing in..." text while submitting', async () => {
|
||||
// Make login hang (never resolve)
|
||||
mockLogin.mockReturnValueOnce(new Promise(() => {}))
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'secret123')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /signing in/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears error when user starts typing', async () => {
|
||||
authState.error = 'Invalid credentials'
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/email/i), 'a')
|
||||
|
||||
expect(mockClearError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
502
frontend/src/components/__tests__/TemplatePushWizard.test.tsx
Normal file
502
frontend/src/components/__tests__/TemplatePushWizard.test.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* TemplatePushWizard component tests -- verifies multi-step wizard navigation,
|
||||
* device selection, variable input, preview, and confirmation steps.
|
||||
*
|
||||
* The wizard has 5 steps: targets -> variables -> preview -> confirm -> progress.
|
||||
* Tests mock the API layer (metricsApi.fleetSummary, deviceGroupsApi.list,
|
||||
* templatesApi.preview/push) and interact via user events.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@/test/test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { TemplateResponse, VariableDef } from '@/lib/templatesApi'
|
||||
import type { FleetDevice, DeviceGroupResponse } from '@/lib/api'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const mockFleetSummary = vi.fn()
|
||||
const mockGroupsList = vi.fn()
|
||||
const mockPreview = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('@/lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
|
||||
return {
|
||||
...actual,
|
||||
metricsApi: {
|
||||
...actual.metricsApi,
|
||||
fleetSummary: (...args: unknown[]) => mockFleetSummary(...args),
|
||||
},
|
||||
deviceGroupsApi: {
|
||||
...actual.deviceGroupsApi,
|
||||
list: (...args: unknown[]) => mockGroupsList(...args),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/templatesApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/templatesApi')>('@/lib/templatesApi')
|
||||
return {
|
||||
...actual,
|
||||
templatesApi: {
|
||||
...actual.templatesApi,
|
||||
preview: (...args: unknown[]) => mockPreview(...args),
|
||||
push: (...args: unknown[]) => mockPush(...args),
|
||||
pushStatus: vi.fn().mockResolvedValue({ rollout_id: 'r1', jobs: [] }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Test data
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const testDevices: FleetDevice[] = [
|
||||
{
|
||||
id: 'dev-1',
|
||||
hostname: 'router-main',
|
||||
ip_address: '192.168.1.1',
|
||||
status: 'online',
|
||||
model: 'RB4011',
|
||||
last_seen: '2026-03-01T12:00:00Z',
|
||||
uptime_seconds: 86400,
|
||||
last_cpu_load: 15,
|
||||
last_memory_used_pct: 45,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
tenant_id: 'tenant-1',
|
||||
tenant_name: 'Test Tenant',
|
||||
},
|
||||
{
|
||||
id: 'dev-2',
|
||||
hostname: 'ap-office',
|
||||
ip_address: '192.168.1.10',
|
||||
status: 'online',
|
||||
model: 'cAP ac',
|
||||
last_seen: '2026-03-01T11:00:00Z',
|
||||
uptime_seconds: 3600,
|
||||
last_cpu_load: 5,
|
||||
last_memory_used_pct: 30,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
tenant_id: 'tenant-1',
|
||||
tenant_name: 'Test Tenant',
|
||||
},
|
||||
{
|
||||
id: 'dev-3',
|
||||
hostname: 'switch-floor1',
|
||||
ip_address: '192.168.1.20',
|
||||
status: 'offline',
|
||||
model: 'CRS326',
|
||||
last_seen: '2026-02-28T10:00:00Z',
|
||||
uptime_seconds: null,
|
||||
last_cpu_load: null,
|
||||
last_memory_used_pct: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
tenant_id: 'tenant-1',
|
||||
tenant_name: 'Test Tenant',
|
||||
},
|
||||
]
|
||||
|
||||
const testGroups: DeviceGroupResponse[] = [
|
||||
{ id: 'grp-1', name: 'Core Routers', description: null, device_count: 2, created_at: '2026-01-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
const templateWithVars: TemplateResponse = {
|
||||
id: 'tmpl-1',
|
||||
name: 'Firewall Rules',
|
||||
description: 'Standard firewall policy',
|
||||
content: '/ip firewall filter add chain=input action=drop',
|
||||
variables: [
|
||||
{ name: 'device', type: 'string', default: null, description: 'Auto-populated device context' },
|
||||
{ name: 'dns_server', type: 'ip', default: '8.8.8.8', description: 'Primary DNS' },
|
||||
{ name: 'enable_logging', type: 'boolean', default: 'false', description: 'Enable firewall logging' },
|
||||
],
|
||||
tags: ['firewall', 'security'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-03-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const templateNoVars: TemplateResponse = {
|
||||
id: 'tmpl-2',
|
||||
name: 'NTP Config',
|
||||
description: 'Set NTP servers',
|
||||
content: '/system ntp client set enabled=yes',
|
||||
variables: [
|
||||
{ name: 'device', type: 'string', default: null, description: 'Auto-populated device context' },
|
||||
],
|
||||
tags: ['ntp'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-03-01T00:00:00Z',
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component import (after mocks)
|
||||
// --------------------------------------------------------------------------
|
||||
import { TemplatePushWizard } from '@/components/templates/TemplatePushWizard'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('TemplatePushWizard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFleetSummary.mockResolvedValue(testDevices)
|
||||
mockGroupsList.mockResolvedValue(testGroups)
|
||||
mockPreview.mockResolvedValue({ rendered: '/ip firewall filter add chain=input', device_hostname: 'router-main' })
|
||||
mockPush.mockResolvedValue({ rollout_id: 'rollout-1', jobs: [] })
|
||||
})
|
||||
|
||||
it('renders wizard with first step active (target selection)', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
// Title shows template name and step info
|
||||
expect(await screen.findByText(/Push Template: Firewall Rules/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Step 1 of 4/)).toBeInTheDocument()
|
||||
|
||||
// Target selection description
|
||||
expect(screen.getByText(/Select devices to push the template to/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays device list for selection', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
// Wait for devices to load
|
||||
expect(await screen.findByText('router-main')).toBeInTheDocument()
|
||||
expect(screen.getByText('ap-office')).toBeInTheDocument()
|
||||
expect(screen.getByText('switch-floor1')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Next button when no devices selected', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Next button should be disabled with 0 selected
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
expect(nextBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables Next button after selecting a device', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Click on the device label to toggle the checkbox
|
||||
const deviceLabel = screen.getByText('router-main')
|
||||
// The device is inside a <label> element so clicking it toggles checkbox
|
||||
await user.click(deviceLabel)
|
||||
|
||||
// The selected count updates
|
||||
expect(screen.getByText(/1 selected/)).toBeInTheDocument()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
expect(nextBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('navigates to variables step when template has user variables', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select a device
|
||||
await user.click(screen.getByText('router-main'))
|
||||
|
||||
// Click Next
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Should now be on variables step (step 2)
|
||||
expect(screen.getByText(/Step 2 of 4/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Provide values for template variables/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays variable inputs for selected template', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select a device and go to variables
|
||||
await user.click(screen.getByText('router-main'))
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Variable inputs should appear (excluding 'device' which is auto-populated)
|
||||
expect(screen.getByText('dns_server')).toBeInTheDocument()
|
||||
expect(screen.getByText('enable_logging')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Primary DNS/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Enable firewall logging/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips variables step when template has no user variables', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateNoVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select a device
|
||||
await user.click(screen.getByText('router-main'))
|
||||
|
||||
// Click Next -- should skip variables and go to preview (step 3)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Should be on preview step
|
||||
expect(screen.getByText(/Preview the rendered template/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('can navigate back to previous step', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select device and go to variables
|
||||
await user.click(screen.getByText('router-main'))
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
expect(screen.getByText(/Step 2 of 4/)).toBeInTheDocument()
|
||||
|
||||
// Click Back
|
||||
await user.click(screen.getByRole('button', { name: /back/i }))
|
||||
|
||||
// Should be back on step 1
|
||||
expect(screen.getByText(/Step 1 of 4/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Select devices to push the template to/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows confirmation step with summary before push', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select device
|
||||
await user.click(screen.getByText('router-main'))
|
||||
|
||||
// Step 1 -> 2 (variables)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
// Step 2 -> 3 (preview)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Wait for preview to load
|
||||
await waitFor(() => {
|
||||
expect(mockPreview).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Step 3 -> 4 (confirm)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Confirmation step should show warning and summary
|
||||
expect(screen.getByText(/This will push configuration to/)).toBeInTheDocument()
|
||||
// The summary shows "Template: Firewall Rules" and "Devices: router-main"
|
||||
expect(screen.getByText('Template: Firewall Rules')).toBeInTheDocument()
|
||||
expect(screen.getByText('Devices: router-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows push button on confirmation step', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select device
|
||||
await user.click(screen.getByText('router-main'))
|
||||
|
||||
// Step 1 -> 2 (variables)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
// Step 2 -> 3 (preview, via goToPreview which triggers mutation)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPreview).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Step 3 -> 4 (confirm)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Push button should exist
|
||||
const pushBtn = screen.getByRole('button', { name: /push to 1 device/i })
|
||||
expect(pushBtn).toBeInTheDocument()
|
||||
expect(pushBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows auto-populated variable info on variables step', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select device and go to variables
|
||||
await user.click(screen.getByText('router-main'))
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Auto-populated notice should be visible
|
||||
expect(screen.getByText(/Auto-populated/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows selected device count in target step', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Initial: 0 selected
|
||||
expect(screen.getByText(/0 selected/)).toBeInTheDocument()
|
||||
|
||||
// Select two devices
|
||||
await user.click(screen.getByText('router-main'))
|
||||
expect(screen.getByText(/1 selected/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('ap-office'))
|
||||
expect(screen.getByText(/2 selected/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<TemplatePushWizard
|
||||
open={false}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
// Dialog should not render content when closed
|
||||
expect(screen.queryByText(/Push Template/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('triggers preview API when navigating to preview step via goToPreview', async () => {
|
||||
render(
|
||||
<TemplatePushWizard
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
tenantId="tenant-1"
|
||||
template={templateWithVars}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await screen.findByText('router-main')
|
||||
|
||||
// Select device
|
||||
await user.click(screen.getByText('router-main'))
|
||||
|
||||
// Step 1 -> 2 (variables)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
// Step 2 -> 3 (preview via goToPreview, which auto-triggers preview for first device)
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
// Preview API should be called with the template and first selected device
|
||||
await waitFor(() => {
|
||||
expect(mockPreview).toHaveBeenCalledWith(
|
||||
'tenant-1',
|
||||
'tmpl-1',
|
||||
'dev-1',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user