diff --git a/frontend/src/api/__tests__/setup.test.ts b/frontend/src/api/__tests__/setup.test.ts
new file mode 100644
index 00000000..c2c633a5
--- /dev/null
+++ b/frontend/src/api/__tests__/setup.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import client from '../../api/client'
+import { getSetupStatus, performSetup } from '../setup'
+
+describe('setup api', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('getSetupStatus returns status', async () => {
+ const data = { setupRequired: true }
+ vi.spyOn(client, 'get').mockResolvedValueOnce({ data })
+ const res = await getSetupStatus()
+ expect(res).toEqual(data)
+ })
+
+ it('performSetup posts data to setup endpoint', async () => {
+ const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: {} })
+ const payload = { name: 'Admin', email: 'admin@example.com', password: 'secret' }
+ await performSetup(payload)
+ expect(spy).toHaveBeenCalledWith('/setup', payload)
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useAuth.test.tsx b/frontend/src/hooks/__tests__/useAuth.test.tsx
new file mode 100644
index 00000000..00808d90
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useAuth.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+import { AuthContext } from '../../context/AuthContextValue'
+import { useAuth } from '../useAuth'
+
+const TestComponent = () => {
+ const auth = useAuth()
+ return
{auth.isAuthenticated ? 'auth' : 'no-auth'}
+}
+
+describe('useAuth hook', () => {
+ it('throws if used outside provider', () => {
+ const renderOutside = () => render()
+ expect(renderOutside).toThrowError('useAuth must be used within an AuthProvider')
+ })
+
+ it('returns context inside provider', () => {
+ const fakeCtx = { user: { user_id: 1, role: 'admin', name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
+ render(
+
+
+
+ )
+ expect(screen.getByText('auth')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/pages/__tests__/Uptime.spec.tsx b/frontend/src/pages/__tests__/Uptime.spec.tsx
index a23e0351..7d2ddcb3 100644
--- a/frontend/src/pages/__tests__/Uptime.spec.tsx
+++ b/frontend/src/pages/__tests__/Uptime.spec.tsx
@@ -48,4 +48,153 @@ describe('Uptime page', () => {
await userEvent.click(toggleBtn)
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false }))
})
+
+ it('shows Never when last_check is missing', async () => {
+ const monitor = {
+ id: 'm2', name: 'NoLastCheck', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: null, latency: 10, max_retries: 3,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('NoLastCheck')).toBeInTheDocument())
+ const lastCheck = screen.getByText('Never')
+ expect(lastCheck).toBeTruthy()
+ })
+
+ it('shows PAUSED state when monitor is disabled', async () => {
+ const monitor = {
+ id: 'm3', name: 'PausedMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: false,
+ status: 'down', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('PausedMonitor')).toBeInTheDocument())
+ expect(screen.getByText('PAUSED')).toBeTruthy()
+ })
+
+ it('renders heartbeat bars from history and displays status in bar titles', async () => {
+ const monitor = {
+ id: 'm4', name: 'WithHistory', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
+ }
+ const now = new Date()
+ const history = [
+ { id: 1, monitor_id: 'm4', status: 'up', latency: 10, message: 'OK', created_at: new Date(now.getTime() - 30000).toISOString() },
+ { id: 2, monitor_id: 'm4', status: 'down', latency: 20, message: 'Fail', created_at: new Date(now.getTime() - 20000).toISOString() },
+ { id: 3, monitor_id: 'm4', status: 'up', latency: 5, message: 'OK', created_at: new Date(now.getTime() - 10000).toISOString() },
+ ]
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue(history)
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('WithHistory')).toBeInTheDocument())
+
+ // Bar titles include 'Status:' and the status should be capitalized
+ await waitFor(() => expect(document.querySelectorAll('[title*="Status:"]').length).toBeGreaterThanOrEqual(history.length))
+ const barTitles = Array.from(document.querySelectorAll('[title*="Status:"]'))
+ expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: UP'))).toBeTruthy()
+ expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: DOWN'))).toBeTruthy()
+ })
+
+ it('deletes monitor when delete confirmed and shows toast', async () => {
+ const monitor = {
+ id: 'm5', name: 'DeleteMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+ vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
+
+ const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('DeleteMe')).toBeInTheDocument())
+ const card = screen.getByText('DeleteMe').closest('div') as HTMLElement
+ const settingsBtn = within(card).getByTitle('Monitor settings')
+ await userEvent.click(settingsBtn)
+ const deleteBtn = within(card).getByText('Delete')
+ await userEvent.click(deleteBtn)
+ await waitFor(() => expect(uptimeApi.deleteMonitor).toHaveBeenCalledWith('m5'))
+ confirmSpy.mockRestore()
+ })
+
+ it('opens configure modal and saves changes via updateMonitor', async () => {
+ const monitor = {
+ id: 'm6', name: 'ConfigMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+ vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, max_retries: 6 })
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('ConfigMe')).toBeInTheDocument())
+ const card = screen.getByText('ConfigMe').closest('div') as HTMLElement
+ await userEvent.click(within(card).getByTitle('Monitor settings'))
+ await userEvent.click(within(card).getByText('Configure'))
+ // Modal should open
+ await waitFor(() => expect(screen.getByText('Configure Monitor')).toBeInTheDocument())
+ const spinbuttons = screen.getAllByRole('spinbutton')
+ const maxRetriesInput = spinbuttons.find(el => el.getAttribute('value') === '3') as HTMLInputElement
+ await userEvent.clear(maxRetriesInput)
+ await userEvent.type(maxRetriesInput, '6')
+ await userEvent.click(screen.getByText('Save Changes'))
+ await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m6', { max_retries: 6, interval: 60 }))
+ })
+
+ it('does not call deleteMonitor when canceling delete', async () => {
+ const monitor = {
+ id: 'm7', name: 'DoNotDelete', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+ vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
+
+ const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => false)
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('DoNotDelete')).toBeInTheDocument())
+ const card = screen.getByText('DoNotDelete').closest('div') as HTMLElement
+ await userEvent.click(within(card).getByTitle('Monitor settings'))
+ await userEvent.click(within(card).getByText('Delete'))
+ expect(uptimeApi.deleteMonitor).not.toHaveBeenCalled()
+ confirmSpy.mockRestore()
+ })
+
+ it('shows toast error when toggle update fails', async () => {
+ const monitor = {
+ id: 'm8', name: 'ToggleFail', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
+ status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
+ }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+ vi.mocked(uptimeApi.updateMonitor).mockRejectedValue(new Error('Update failed'))
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('ToggleFail')).toBeInTheDocument())
+ const card = screen.getByText('ToggleFail').closest('div') as HTMLElement
+ await userEvent.click(within(card).getByTitle('Monitor settings'))
+ await userEvent.click(within(card).getByText('Disable Monitoring'))
+ const toast = (await import('react-hot-toast')).toast
+ await waitFor(() => expect(toast.error).toHaveBeenCalled())
+ })
+
+ it('separates monitors into Proxy Hosts, Remote Servers and Other sections', async () => {
+ const proxyMonitor = { id: 'm9', name: 'ProxyMon', url: 'http://p', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 1, max_retries: 2, proxy_host_id: 1 }
+ const remoteMonitor = { id: 'm10', name: 'RemoteMon', url: 'http://r', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 2, max_retries: 2, remote_server_id: 2 }
+ const otherMonitor = { id: 'm11', name: 'OtherMon', url: 'http://o', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 3, max_retries: 2 }
+ vi.mocked(uptimeApi.getMonitors).mockResolvedValue([proxyMonitor, remoteMonitor, otherMonitor])
+ vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('Proxy Hosts')).toBeInTheDocument())
+ expect(screen.getByText('Remote Servers')).toBeInTheDocument()
+ expect(screen.getByText('Other Monitors')).toBeInTheDocument()
+ expect(screen.getByText('ProxyMon')).toBeInTheDocument()
+ expect(screen.getByText('RemoteMon')).toBeInTheDocument()
+ expect(screen.getByText('OtherMon')).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/test/setup.spec.ts b/frontend/src/test/setup.spec.ts
new file mode 100644
index 00000000..62dc72d5
--- /dev/null
+++ b/frontend/src/test/setup.spec.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest'
+
+describe('Test setup file checks', () => {
+ it('sets the React act environment flag', () => {
+ expect(globalThis.IS_REACT_ACT_ENVIRONMENT).toBe(true)
+ })
+
+ it('stubs window.matchMedia with expected interface', () => {
+ const mq = window.matchMedia('(min-width: 100px)')
+ expect(mq.matches).toBe(false)
+ expect(typeof mq.addListener).toBe('function')
+ expect(typeof mq.removeListener).toBe('function')
+ expect(typeof mq.addEventListener).toBe('function')
+ expect(typeof mq.removeEventListener).toBe('function')
+ })
+})