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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View 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()
})
})

View 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()
})
})

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