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