ef2bd3c9c5
- GET /api/config renders sections; secret fields shown as empty password inputs - save handles full-field submission semantics: always send non-secret values, send secret only when user typed a new value (blank secret keeps old) - SMTP test button reflects tri-state (success / config-error 400 / failed 502) by reading ApiError.body.result - typed client only; responsive Mantine layout; vitest tests
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
/**
|
|
* Tests for ConfigPage (M2-T08).
|
|
*
|
|
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
|
|
* without a real server.
|
|
*
|
|
* Coverage:
|
|
* 1. Renders config sections from a mocked GET /api/config response.
|
|
* 2. Secret fields start as empty (never display masked value).
|
|
* 3. Non-secret fields show their loaded values.
|
|
* 4. Save: updates map includes all non-secret fields and excludes untouched secrets.
|
|
* 5. Save: updates map includes a secret only when the user typed a new value.
|
|
* 6. Save success → shows success notice.
|
|
* 7. Save error → shows error notice.
|
|
* 8. SMTP test button: success state (200 result=success).
|
|
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
|
* 10. SMTP test button: failed state (502/ApiError result=failed).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
|
import { renderWithProviders } from '../test-utils'
|
|
import { ConfigPage } from './ConfigPage'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture: config sections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOCK_CONFIG = {
|
|
sections: [
|
|
{
|
|
name: 'General',
|
|
fields: [
|
|
{ env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true },
|
|
{ env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true },
|
|
],
|
|
},
|
|
{
|
|
name: 'SMTP',
|
|
fields: [
|
|
{ env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
|
|
{ env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock apiClient
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockGet = vi.fn()
|
|
const mockPut = vi.fn()
|
|
const mockPost = vi.fn()
|
|
|
|
vi.mock('../api/client', () => ({
|
|
default: {
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
|
PUT: (...args: unknown[]) => mockPut(...args),
|
|
POST: (...args: unknown[]) => mockPost(...args),
|
|
},
|
|
ApiError: class ApiError extends Error {
|
|
status: number
|
|
body: unknown
|
|
constructor(status: number, body: unknown) {
|
|
super(`API error ${status}`)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
},
|
|
registerLoginRedirect: vi.fn(),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderConfig() {
|
|
return renderWithProviders(<ConfigPage />, { initialPath: '/config' })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ConfigPage', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Default: GET /api/config returns the fixture
|
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 1. Renders sections
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders section names and field labels', async () => {
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('General')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.getByText('SMTP')).toBeInTheDocument()
|
|
expect(screen.getByText('App Name')).toBeInTheDocument()
|
|
expect(screen.getByText('SMTP Host')).toBeInTheDocument()
|
|
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 2. Secret fields start empty
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders secret fields with empty value (never displays masked value)', async () => {
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
|
|
})
|
|
|
|
// Mantine puts data-testid on the <input> element itself
|
|
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
|
|
expect(secretInput.value).toBe('')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 3. Non-secret fields show their loaded values
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders non-secret fields with their loaded values', async () => {
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument()
|
|
})
|
|
|
|
// Mantine puts data-testid on the <input> element itself for TextInput
|
|
const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement
|
|
expect(appNameInput.value).toBe('My Home')
|
|
|
|
const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement
|
|
expect(smtpHostInput.value).toBe('smtp.example.com')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 4. Save: updates includes all non-secrets, excludes untouched secrets
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => {
|
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
|
// After save, refetch
|
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
|
})
|
|
|
|
// Submit without touching any field
|
|
fireEvent.submit(screen.getByTestId('config-form'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockPut).toHaveBeenCalled()
|
|
})
|
|
|
|
const putCall = mockPut.mock.calls[0]
|
|
const body = putCall[1].body as { updates: Record<string, string> }
|
|
const updates = body.updates
|
|
|
|
// Non-secret fields MUST be present
|
|
expect(updates).toHaveProperty('APP_NAME', 'My Home')
|
|
expect(updates).toHaveProperty('APP_PORT', '8000')
|
|
expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com')
|
|
|
|
// Untouched secret field MUST NOT be present
|
|
expect(updates).not.toHaveProperty('SMTP_PASSWORD')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. Save: updates includes secret when user typed a new value
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('save includes a secret field when the user typed a new value', async () => {
|
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument()
|
|
})
|
|
|
|
// Mantine puts data-testid on the <input> element itself
|
|
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
|
|
fireEvent.change(secretInput, { target: { value: 'new-secret-value' } })
|
|
|
|
fireEvent.submit(screen.getByTestId('config-form'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockPut).toHaveBeenCalled()
|
|
})
|
|
|
|
const putCall = mockPut.mock.calls[0]
|
|
const body = putCall[1].body as { updates: Record<string, string> }
|
|
const updates = body.updates
|
|
|
|
// Secret MUST be included because the user typed a value
|
|
expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value')
|
|
// Non-secrets still present
|
|
expect(updates).toHaveProperty('APP_NAME', 'My Home')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 6. Save success → shows success notice
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows success alert after a successful save', async () => {
|
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.submit(screen.getByTestId('config-form'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('save-success')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 7. Save error → shows error notice
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows error alert when save fails', async () => {
|
|
const { ApiError } = await import('../api/client')
|
|
mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' }))
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.submit(screen.getByTestId('config-form'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('save-error')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.queryByTestId('save-success')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 8. SMTP test button: success state
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows success alert after SMTP test succeeds', async () => {
|
|
mockPost.mockResolvedValueOnce({
|
|
data: { result: 'success', message: 'Email delivered.' },
|
|
response: { status: 200, ok: true },
|
|
})
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 9. SMTP test button: config-error state (400)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows config-error alert when SMTP test returns config-error', async () => {
|
|
const { ApiError } = await import('../api/client')
|
|
mockPost.mockRejectedValueOnce(
|
|
new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }),
|
|
)
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 10. SMTP test button: failed state (502)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows failed alert when SMTP test returns failed', async () => {
|
|
const { ApiError } = await import('../api/client')
|
|
mockPost.mockRejectedValueOnce(
|
|
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
|
|
)
|
|
|
|
renderConfig()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
|
})
|
|
})
|