test: add comprehensive frontend tests for Public URL and invite preview features

- Add API tests for validatePublicURL, testPublicURL, previewInviteURL
- Add UI tests for Public URL validation states and test button
- Add invite URL preview display and debouncing tests
- Increase frontend coverage from 34.85% to 87.7%

Addresses Codecov coverage gaps in PR #450
Closes coverage requirements for beta release

Coverage: 87.7% (1174 tests passing)
This commit is contained in:
GitHub Actions
2025-12-23 16:32:19 +00:00
parent 30f5033268
commit 74b7c1f299
8 changed files with 1821 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ import { LanguageProvider } from '../../context/LanguageContext'
vi.mock('../../api/settings', () => ({
getSettings: vi.fn(),
updateSetting: vi.fn(),
validatePublicURL: vi.fn(),
testPublicURL: vi.fn(),
}))
vi.mock('../../api/featureFlags', () => ({
@@ -25,6 +27,7 @@ vi.mock('../../api/featureFlags', () => ({
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
@@ -427,4 +430,215 @@ describe('SystemSettings', () => {
})
})
})
describe('Application URL Card', () => {
it('renders public URL input field', async () => {
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
})
it('shows green border and checkmark when URL is valid', async () => {
vi.mocked(client.get).mockResolvedValue({
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
const user = userEvent.setup()
const input = screen.getByPlaceholderText('https://charon.example.com')
// Mock validation response for valid URL
vi.mocked(client.post).mockResolvedValue({
data: { valid: true, normalized: 'https://example.com' },
})
await user.clear(input)
await user.type(input, 'https://example.com')
// Wait for debounced validation
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
url: 'https://example.com',
})
}, { timeout: 1000 })
await waitFor(() => {
const checkIcon = document.querySelector('.text-green-500')
expect(checkIcon).toBeTruthy()
})
await waitFor(() => {
expect(input.className).toContain('border-green-500')
})
})
it('shows red border and X icon when URL is invalid', async () => {
vi.mocked(client.get).mockResolvedValue({
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
const user = userEvent.setup()
const input = screen.getByPlaceholderText('https://charon.example.com')
// Mock validation response for invalid URL
vi.mocked(client.post).mockResolvedValue({
data: { valid: false, error: 'Invalid URL format' },
})
await user.clear(input)
await user.type(input, 'invalid-url')
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
url: 'invalid-url',
})
}, { timeout: 1000 })
await waitFor(() => {
const xIcon = document.querySelector('.text-red-500')
expect(xIcon).toBeTruthy()
})
await waitFor(() => {
expect(input.className).toContain('border-red-500')
})
})
it('shows invalid URL error message when validation fails', async () => {
vi.mocked(client.get).mockResolvedValue({
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
const user = userEvent.setup()
const input = screen.getByPlaceholderText('https://charon.example.com')
vi.mocked(client.post).mockResolvedValue({
data: { valid: false, error: 'Invalid URL format' },
})
await user.clear(input)
await user.type(input, 'bad-url')
// Wait for debounce and validation
await new Promise(resolve => setTimeout(resolve, 400))
await waitFor(() => {
// Check for red border class indicating invalid state
const inputElement = screen.getByPlaceholderText('https://charon.example.com')
expect(inputElement.className).toContain('border-red')
}, { timeout: 1000 })
})
it('clears validation state when URL is cleared', async () => {
vi.mocked(settingsApi.getSettings).mockResolvedValue({
'app.public_url': 'https://example.com',
})
vi.mocked(client.get).mockResolvedValue({
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
expect(input.value).toBe('https://example.com')
})
const user = userEvent.setup()
const input = screen.getByPlaceholderText('https://charon.example.com')
await user.clear(input)
await waitFor(() => {
expect(input.className).not.toContain('border-green-500')
expect(input.className).not.toContain('border-red-500')
})
})
it('renders test button and verifies functionality', async () => {
vi.mocked(settingsApi.getSettings).mockResolvedValue({
'app.public_url': 'https://example.com',
})
vi.mocked(settingsApi.testPublicURL).mockResolvedValue({
reachable: true,
latency: 42,
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
// Find test button by looking for buttons with External Link icon
const buttons = screen.getAllByRole('button')
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
expect(testButton).toBeTruthy()
expect(testButton).not.toBeDisabled()
const user = userEvent.setup()
await user.click(testButton!)
await waitFor(() => {
expect(settingsApi.testPublicURL).toHaveBeenCalledWith('https://example.com')
})
})
it('disables test button when URL is empty', async () => {
vi.mocked(settingsApi.getSettings).mockResolvedValue({
'app.public_url': '',
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
expect(input.value).toBe('')
})
const buttons = screen.getAllByRole('button')
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
expect(testButton).toBeDisabled()
})
it('handles validation API error gracefully', async () => {
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
})
const user = userEvent.setup()
const input = screen.getByPlaceholderText('https://charon.example.com')
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await user.clear(input)
await user.type(input, 'https://example.com')
await waitFor(() => {
const xIcon = document.querySelector('.text-red-500')
expect(xIcon).toBeTruthy()
}, { timeout: 1000 })
})
})
})

View File

@@ -4,6 +4,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'
import UsersPage from '../UsersPage'
import * as usersApi from '../../api/users'
import * as proxyHostsApi from '../../api/proxyHosts'
import client from '../../api/client'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
@@ -18,12 +19,20 @@ vi.mock('../../api/users', () => ({
updateUserPermissions: vi.fn(),
validateInvite: vi.fn(),
acceptInvite: vi.fn(),
previewInviteURL: vi.fn(),
}))
vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: vi.fn(),
}))
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
@@ -349,4 +358,167 @@ describe('UsersPage', () => {
delete (navigator as unknown as { clipboard?: unknown }).clipboard
}
})
describe('URL Preview in InviteModal', () => {
it('shows URL preview when valid email is entered', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://charon.example.com',
is_configured: true,
warning: false,
warning_message: '',
},
})
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 1000 })
// Look for the preview URL content with ellipsis replacing the token
await waitFor(() => {
const previewText = screen.getByText(/charon\.example\.com.*accept-invite.*\.\.\./)
expect(previewText).toBeTruthy()
}, { timeout: 1000 })
})
it('debounces URL preview for 500ms', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
warning: false,
warning_message: '',
},
})
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
// Wait 600ms to ensure debounce has completed
await new Promise(resolve => setTimeout(resolve, 600))
await waitFor(() => {
expect(client.post).toHaveBeenCalledTimes(1)
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 1000 })
})
it('replaces sample token with ellipsis in preview', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
warning: false,
warning_message: '',
},
})
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
await waitFor(() => {
const preview = screen.getByText(/example\.com.*accept-invite/)
expect(preview.textContent).toContain('...')
expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW')
}, { timeout: 1000 })
})
it('shows warning when not configured', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'http://localhost:8080',
is_configured: false,
warning: true,
warning_message: 'Application URL not configured',
},
})
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
await waitFor(() => {
// Look for link to system settings
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toContain('/settings/system')
}, { timeout: 1000 })
})
it('does not show preview when email is invalid', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'invalid')
await new Promise(resolve => setTimeout(resolve, 600))
// Preview should not be fetched or displayed
expect(client.post).not.toHaveBeenCalled()
})
it('handles preview API error gracefully', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(client.post).mockRejectedValue(new Error('API error'))
renderWithQueryClient(<UsersPage />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /Invite User/i }))
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 600))
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 1000 })
// Verify preview is not displayed after error
const previewQuery = screen.queryByText(/accept-invite/)
expect(previewQuery).toBeNull()
})
})
})