diff --git a/frontend/src/__tests__/pages/generate.test.tsx b/frontend/src/__tests__/pages/generate.test.tsx new file mode 100644 index 0000000..9608c56 --- /dev/null +++ b/frontend/src/__tests__/pages/generate.test.tsx @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; +import { renderWithProviders } from '../test-utils'; +import { DEFAULT_SETTINGS } from '~/types'; +import type { UserSettings, ProviderConfig } from '~/types'; + +// Mock API modules +vi.mock('~/api/syntheses', () => ({ + synthesesApi: { + list: vi.fn(), + generate: vi.fn(), + progressUrl: vi.fn(), + remove: vi.fn(), + }, + fetchFile: vi.fn(), + triggerDownload: vi.fn(), +})); + +vi.mock('~/api/settings', () => ({ + settingsApi: { + get: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('~/api/config', () => ({ + configApi: { + listProviders: vi.fn(), + }, +})); + +// Mock the SSE connection utility +vi.mock('~/utils/sse', () => ({ + createSSEConnection: vi.fn(), +})); + +import { synthesesApi } from '~/api/syntheses'; +import { settingsApi } from '~/api/settings'; +import { configApi } from '~/api/config'; +import { createSSEConnection } from '~/utils/sse'; +import GenerateSynthesis from '~/pages/GenerateSynthesis'; + +const mockedGetSettings = vi.mocked(settingsApi.get); +const mockedListProviders = vi.mocked(configApi.listProviders); +const mockedGenerate = vi.mocked(synthesesApi.generate); +const mockedProgressUrl = vi.mocked(synthesesApi.progressUrl); +const mockedCreateSSE = vi.mocked(createSSEConnection); + +const mockSettings: UserSettings = { + ...DEFAULT_SETTINGS, + theme: 'Intelligence Artificielle', + ai_provider: 'gemini', + ai_model: 'gemini-2.5-pro', + ai_model_writing: 'gemini-2.5-flash', +}; + +const mockProviders: ProviderConfig[] = [ + { + provider_name: 'gemini', + display_name: 'Google Gemini', + models: [ + { model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' }, + { model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' }, + ], + }, +]; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('GenerateSynthesis Page', () => { + it('should render launch button with settings info', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + + renderWithProviders(() => ); + + await waitFor(() => { + expect( + screen.getByText('Lancer la generation'), + ).toBeInTheDocument(); + }); + + expect(screen.getByText(/Google Gemini/)).toBeInTheDocument(); + expect(screen.getByText(/Gemini 2.5 Pro/)).toBeInTheDocument(); + }); + + it('should call POST /syntheses/generate on launch', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedGenerate.mockResolvedValue({ job_id: 'job-123' }); + mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress'); + + const mockSSEConnection = { + events: vi.fn(() => []), + status: vi.fn(() => 'connecting' as const), + latestProgress: vi.fn(() => null), + completedSynthesisId: vi.fn(() => null), + errorMessage: vi.fn(() => null), + close: vi.fn(), + }; + mockedCreateSSE.mockReturnValue(mockSSEConnection); + + renderWithProviders(() => ); + + await waitFor(() => { + expect( + screen.getByText('Lancer la generation'), + ).toBeInTheDocument(); + }); + + const launchButton = screen.getByText('Lancer la generation'); + fireEvent.click(launchButton); + + await waitFor(() => { + expect(mockedGenerate).toHaveBeenCalled(); + }); + }); + + it('should show progress bar from SSE events', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedGenerate.mockResolvedValue({ job_id: 'job-123' }); + mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress'); + + const mockSSEConnection = { + events: vi.fn(() => [ + { type: 'progress' as const, data: { step: 'search', message: 'Recherche...', percent: 25 } }, + ]), + status: vi.fn(() => 'connected' as const), + latestProgress: vi.fn(() => ({ step: 'search', message: 'Recherche...', percent: 25 })), + completedSynthesisId: vi.fn(() => null), + errorMessage: vi.fn(() => null), + close: vi.fn(), + }; + mockedCreateSSE.mockReturnValue(mockSSEConnection); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Lancer la generation')).toBeInTheDocument(); + }); + + const launchButton = screen.getByText('Lancer la generation'); + fireEvent.click(launchButton); + + await waitFor(() => { + expect(screen.getByText('25%')).toBeInTheDocument(); + }); + expect(screen.getByText('Recherche...')).toBeInTheDocument(); + }); + + it('should render step checklist with correct states', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedGenerate.mockResolvedValue({ job_id: 'job-123' }); + mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress'); + + const mockSSEConnection = { + events: vi.fn(() => [ + { type: 'progress' as const, data: { step: 'search', message: 'Recherche...', percent: 25 } }, + { type: 'progress' as const, data: { step: 'scraping', message: 'Verification...', percent: 50 } }, + ]), + status: vi.fn(() => 'connected' as const), + latestProgress: vi.fn(() => ({ step: 'scraping', message: 'Verification...', percent: 50 })), + completedSynthesisId: vi.fn(() => null), + errorMessage: vi.fn(() => null), + close: vi.fn(), + }; + mockedCreateSSE.mockReturnValue(mockSSEConnection); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Lancer la generation')).toBeInTheDocument(); + }); + + const launchButton = screen.getByText('Lancer la generation'); + fireEvent.click(launchButton); + + await waitFor(() => { + expect( + screen.getByText("Recherche d'actualites"), + ).toBeInTheDocument(); + }); + expect( + screen.getByText('Verification des sources'), + ).toBeInTheDocument(); + expect( + screen.getByText('Redaction des resumes'), + ).toBeInTheDocument(); + expect(screen.getByText('Sauvegarde')).toBeInTheDocument(); + }); + + it('should show retry button on error', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedGenerate.mockResolvedValue({ job_id: 'job-123' }); + mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress'); + + const mockSSEConnection = { + events: vi.fn(() => [ + { type: 'error' as const, message: 'Server error' }, + ]), + status: vi.fn(() => 'error' as const), + latestProgress: vi.fn(() => null), + completedSynthesisId: vi.fn(() => null), + errorMessage: vi.fn(() => 'Server error'), + close: vi.fn(), + }; + mockedCreateSSE.mockReturnValue(mockSSEConnection); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Lancer la generation')).toBeInTheDocument(); + }); + + const launchButton = screen.getByText('Lancer la generation'); + fireEvent.click(launchButton); + + await waitFor(() => { + expect(screen.getByText('Server error')).toBeInTheDocument(); + }); + expect(screen.getByText('Reessayer')).toBeInTheDocument(); + }); + + it('should show completion message on success', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedGenerate.mockResolvedValue({ job_id: 'job-123' }); + mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress'); + + const mockSSEConnection = { + events: vi.fn(() => [ + { type: 'complete' as const, synthesis_id: 'synth-456' }, + ]), + status: vi.fn(() => 'complete' as const), + latestProgress: vi.fn(() => ({ step: 'saving', message: 'Done', percent: 100 })), + completedSynthesisId: vi.fn(() => 'synth-456'), + errorMessage: vi.fn(() => null), + close: vi.fn(), + }; + mockedCreateSSE.mockReturnValue(mockSSEConnection); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Lancer la generation')).toBeInTheDocument(); + }); + + const launchButton = screen.getByText('Lancer la generation'); + fireEvent.click(launchButton); + + await waitFor(() => { + expect( + screen.getByText('Synthese generee avec succes ! Redirection...'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/home.test.tsx b/frontend/src/__tests__/pages/home.test.tsx new file mode 100644 index 0000000..adbfa24 --- /dev/null +++ b/frontend/src/__tests__/pages/home.test.tsx @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; +import { renderWithProviders } from '../test-utils'; +import Home from '~/pages/Home'; +import type { SynthesisListItem } from '~/types'; + +// Mock the syntheses API module +vi.mock('~/api/syntheses', () => ({ + synthesesApi: { + list: vi.fn(), + remove: vi.fn(), + generate: vi.fn(), + progressUrl: vi.fn(), + }, + fetchFile: vi.fn(), + triggerDownload: vi.fn(), +})); + +import { synthesesApi } from '~/api/syntheses'; + +const mockedList = vi.mocked(synthesesApi.list); +const mockedRemove = vi.mocked(synthesesApi.remove); + +const mockSyntheses: SynthesisListItem[] = [ + { + id: 's1', + week: '2026-W12', + status: 'completed', + created_at: '2026-03-21T10:00:00Z', + sections: [ + { + title: 'Annonces majeures', + items: [ + { title: 'GPT-5 Released', url: 'https://example.com/1', summary: 'Summary 1' }, + { title: 'New Chip Launch', url: 'https://example.com/2', summary: 'Summary 2' }, + ], + }, + ], + }, + { + id: 's2', + week: '2026-W11', + status: 'completed', + created_at: '2026-03-14T10:00:00Z', + sections: [], + }, +]; + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('Home Page', () => { + it('should render synthesis list from mocked API data', async () => { + mockedList.mockResolvedValue(mockSyntheses); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText(/Semaine 12/)).toBeInTheDocument(); + }); + + expect(screen.getByText(/Semaine 11/)).toBeInTheDocument(); + }); + + it('should show empty state when list is empty', async () => { + mockedList.mockResolvedValue([]); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Aucune synthese')).toBeInTheDocument(); + }); + }); + + it('should have "Nouvelle Synthese" button linking to /generate', async () => { + mockedList.mockResolvedValue([]); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText('Nouvelle Synthese')).toBeInTheDocument(); + }); + + const link = screen.getByText('Nouvelle Synthese').closest('a'); + expect(link).toHaveAttribute('href', '/generate'); + }); + + it('should show "Confirmer" text on first delete click', async () => { + mockedList.mockResolvedValue(mockSyntheses); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText(/Semaine 12/)).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByTitle('Supprimer'); + fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.getByText('Confirmer')).toBeInTheDocument(); + }); + }); + + it('should remove item from list on second delete click (confirm)', async () => { + mockedList.mockResolvedValue([...mockSyntheses]); + mockedRemove.mockResolvedValue(undefined); + + renderWithProviders(() => ); + + await waitFor(() => { + expect(screen.getByText(/Semaine 12/)).toBeInTheDocument(); + }); + + // First click + const deleteButtons = screen.getAllByTitle('Supprimer'); + fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.getByText('Confirmer')).toBeInTheDocument(); + }); + + // Second click (confirm) + const confirmBtn = screen.getByText('Confirmer').closest('button')!; + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(mockedRemove).toHaveBeenCalledWith('s1'); + }); + + await waitFor(() => { + expect(screen.queryByText(/Semaine 12/)).not.toBeInTheDocument(); + }); + expect(screen.getByText(/Semaine 11/)).toBeInTheDocument(); + }); + + it('should auto-cancel delete confirmation after 3 seconds', async () => { + vi.useFakeTimers(); + + mockedList.mockResolvedValue(mockSyntheses); + + renderWithProviders(() => ); + + await vi.waitFor(() => { + expect(screen.getByText(/Semaine 12/)).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByTitle('Supprimer'); + fireEvent.click(deleteButtons[0]); + + await vi.waitFor(() => { + expect(screen.getByText('Confirmer')).toBeInTheDocument(); + }); + + vi.advanceTimersByTime(3000); + + await vi.waitFor(() => { + expect(screen.queryByText('Confirmer')).not.toBeInTheDocument(); + }); + }); + + it('should show in-progress banner when a synthesis has status "in_progress"', async () => { + const inProgressSyntheses: SynthesisListItem[] = [ + { + id: 's-ip', + week: '2026-W12', + status: 'in_progress', + created_at: '2026-03-21T10:00:00Z', + sections: [], + }, + ]; + + mockedList.mockResolvedValue(inProgressSyntheses); + + renderWithProviders(() => ); + + await waitFor(() => { + expect( + screen.getByText('Une generation est en cours...'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/login.test.tsx b/frontend/src/__tests__/pages/login.test.tsx new file mode 100644 index 0000000..a2b7681 --- /dev/null +++ b/frontend/src/__tests__/pages/login.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; +import { renderWithProviders } from '../test-utils'; + +// Mock the auth API module +vi.mock('~/api/auth', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + me: vi.fn(), + logout: vi.fn(), + verify: vi.fn(), + }, +})); + +// Mock the AuthContext so useAuth returns non-authenticated state +vi.mock('~/contexts/AuthContext', () => ({ + useAuth: () => ({ + user: () => null, + loading: () => false, + isAuthenticated: () => false, + isAdmin: () => false, + logout: vi.fn(), + refreshUser: vi.fn(), + }), + AuthProvider: (props: any) => props.children, +})); + +import { authApi } from '~/api/auth'; +import Login from '~/pages/Login'; + +const mockedLogin = vi.mocked(authApi.login); + +beforeEach(() => { + // Stub Turnstile + (window as any).turnstile = { + render: vi.fn((_container: HTMLElement, options: any) => { + // Immediately invoke callback with a fake token + options.callback('fake-turnstile-token'); + return 'widget-id'; + }), + reset: vi.fn(), + remove: vi.fn(), + }; +}); + +afterEach(() => { + vi.clearAllMocks(); + delete (window as any).turnstile; +}); + +describe('Login Page', () => { + it('should render email input and submit button', async () => { + renderWithProviders(() => ); + + expect(screen.getByLabelText('Adresse email')).toBeInTheDocument(); + expect( + screen.getByText('Recevoir un lien de connexion'), + ).toBeInTheDocument(); + }); + + it('should call POST /auth/login on submit', async () => { + mockedLogin.mockResolvedValue({ message: 'OK' }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + fireEvent.input(emailInput, { + target: { value: 'test@example.com' }, + }); + + const submitButton = screen.getByText('Recevoir un lien de connexion'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockedLogin).toHaveBeenCalledWith({ + email: 'test@example.com', + turnstile_token: 'fake-turnstile-token', + }); + }); + }); + + it('should show "check inbox" message on success', async () => { + mockedLogin.mockResolvedValue({ message: 'OK' }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + fireEvent.input(emailInput, { + target: { value: 'test@example.com' }, + }); + + const submitButton = screen.getByText('Recevoir un lien de connexion'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText('Verifiez votre boite de reception'), + ).toBeInTheDocument(); + }); + }); + + it('should show error message on failure', async () => { + mockedLogin.mockRejectedValue({ + status: 400, + message: 'Invalid email address', + }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + fireEvent.input(emailInput, { + target: { value: 'test@example.com' }, + }); + + const submitButton = screen.getByText('Recevoir un lien de connexion'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText('Invalid email address'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/register.test.tsx b/frontend/src/__tests__/pages/register.test.tsx new file mode 100644 index 0000000..befa9a3 --- /dev/null +++ b/frontend/src/__tests__/pages/register.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; +import { renderWithProviders } from '../test-utils'; + +// Mock the auth API module +vi.mock('~/api/auth', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + me: vi.fn(), + logout: vi.fn(), + verify: vi.fn(), + }, +})); + +// Mock the AuthContext so useAuth returns non-authenticated state +vi.mock('~/contexts/AuthContext', () => ({ + useAuth: () => ({ + user: () => null, + loading: () => false, + isAuthenticated: () => false, + isAdmin: () => false, + logout: vi.fn(), + refreshUser: vi.fn(), + }), + AuthProvider: (props: any) => props.children, +})); + +import { authApi } from '~/api/auth'; +import Register from '~/pages/Register'; + +const mockedRegister = vi.mocked(authApi.register); + +beforeEach(() => { + // Stub Turnstile + (window as any).turnstile = { + render: vi.fn((_container: HTMLElement, options: any) => { + // Immediately invoke callback with a fake token + options.callback('fake-turnstile-token'); + return 'widget-id'; + }), + reset: vi.fn(), + remove: vi.fn(), + }; +}); + +afterEach(() => { + vi.clearAllMocks(); + delete (window as any).turnstile; +}); + +describe('Register Page', () => { + it('should render email, display name inputs and submit button', async () => { + renderWithProviders(() => ); + + expect(screen.getByLabelText('Adresse email')).toBeInTheDocument(); + expect(screen.getByLabelText('Nom (optionnel)')).toBeInTheDocument(); + expect(screen.getByText('Creer mon compte')).toBeInTheDocument(); + }); + + it('should call POST /auth/register on submit', async () => { + mockedRegister.mockResolvedValue({ message: 'OK' }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + const nameInput = screen.getByLabelText('Nom (optionnel)'); + + fireEvent.input(emailInput, { + target: { value: 'new@example.com' }, + }); + fireEvent.input(nameInput, { + target: { value: 'John Doe' }, + }); + + const submitButton = screen.getByText('Creer mon compte'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockedRegister).toHaveBeenCalledWith({ + email: 'new@example.com', + display_name: 'John Doe', + turnstile_token: 'fake-turnstile-token', + }); + }); + }); + + it('should show confirmation message on success', async () => { + mockedRegister.mockResolvedValue({ message: 'OK' }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + fireEvent.input(emailInput, { + target: { value: 'new@example.com' }, + }); + + const submitButton = screen.getByText('Creer mon compte'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText('Verifiez votre boite de reception'), + ).toBeInTheDocument(); + }); + }); + + it('should show error message on failure', async () => { + mockedRegister.mockRejectedValue({ + status: 422, + message: 'Email already exists', + }); + + renderWithProviders(() => ); + + const emailInput = screen.getByLabelText('Adresse email'); + fireEvent.input(emailInput, { + target: { value: 'existing@example.com' }, + }); + + const submitButton = screen.getByText('Creer mon compte'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText('Email already exists'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/settings.test.tsx b/frontend/src/__tests__/pages/settings.test.tsx new file mode 100644 index 0000000..35f0661 --- /dev/null +++ b/frontend/src/__tests__/pages/settings.test.tsx @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; +import { renderWithProviders } from '../test-utils'; +import { DEFAULT_SETTINGS } from '~/types'; +import type { UserSettings, ProviderConfig } from '~/types'; + +// Mock API modules +vi.mock('~/api/settings', () => ({ + settingsApi: { + get: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('~/api/config', () => ({ + configApi: { + listProviders: vi.fn(), + }, +})); + +vi.mock('~/api/apiKeys', () => ({ + apiKeysApi: { + list: vi.fn(), + create: vi.fn(), + remove: vi.fn(), + test: vi.fn(), + exportKeys: vi.fn(), + }, +})); + +import { settingsApi } from '~/api/settings'; +import { configApi } from '~/api/config'; +import { apiKeysApi } from '~/api/apiKeys'; +import Settings from '~/pages/Settings'; + +const mockedGetSettings = vi.mocked(settingsApi.get); +const mockedUpdateSettings = vi.mocked(settingsApi.update); +const mockedListProviders = vi.mocked(configApi.listProviders); +const mockedListKeys = vi.mocked(apiKeysApi.list); + +const mockSettings: UserSettings = { + ...DEFAULT_SETTINGS, + theme: 'Intelligence Artificielle', + ai_provider: 'gemini', + ai_model: 'gemini-2.5-pro', + ai_model_writing: 'gemini-2.5-flash', + rate_limit_max_requests: null, + rate_limit_time_window_seconds: null, +}; + +const mockProviders: ProviderConfig[] = [ + { + provider_name: 'gemini', + display_name: 'Google Gemini', + models: [ + { model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' }, + { model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' }, + ], + }, + { + provider_name: 'openai', + display_name: 'OpenAI', + models: [ + { model_id: 'gpt-4o', display_name: 'GPT-4o' }, + { model_id: 'gpt-4o-mini', display_name: 'GPT-4o Mini' }, + ], + }, +]; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Settings Page', () => { + it('should render form fields populated from API response', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedListKeys.mockResolvedValue([]); + + renderWithProviders(() => ); + + await waitFor(() => { + const themeInput = screen.getByLabelText( + 'Theme de la recherche', + ) as HTMLInputElement; + expect(themeInput.value).toBe('Intelligence Artificielle'); + }); + }); + + it('should call PUT /settings when save button is clicked', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedListKeys.mockResolvedValue([]); + mockedUpdateSettings.mockResolvedValue(mockSettings); + + renderWithProviders(() => ); + + await waitFor(() => { + expect( + screen.getByLabelText('Theme de la recherche'), + ).toBeInTheDocument(); + }); + + const saveButton = screen.getByText('Enregistrer les parametres'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockedUpdateSettings).toHaveBeenCalled(); + }); + }); + + it('should populate provider dropdown from config API', async () => { + mockedGetSettings.mockResolvedValue(mockSettings); + mockedListProviders.mockResolvedValue(mockProviders); + mockedListKeys.mockResolvedValue([]); + + renderWithProviders(() => ); + + // The provider names appear in the dropdown (