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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user