/** * 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(, { 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 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 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 } 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 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 } 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() }) })