You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

338 lines
10 KiB
TypeScript

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(() => <Settings />);
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(() => <Settings />);
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(() => <Settings />);
// The provider names appear in the dropdown (<option>) AND in the ApiKeyManager.
// Use getAllByText and check that at least one match exists.
await waitFor(() => {
expect(screen.getAllByText('Google Gemini').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getAllByText('OpenAI').length).toBeGreaterThanOrEqual(1);
// Verify the provider select has the options
const providerSelect = screen.getByLabelText("Fournisseur d'IA") as HTMLSelectElement;
const options = providerSelect.querySelectorAll('option');
const optionTexts = Array.from(options).map((o) => o.textContent);
expect(optionTexts).toContain('Google Gemini');
expect(optionTexts).toContain('OpenAI');
});
it('should update model list when selecting a different provider', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
await waitFor(() => {
expect(screen.getAllByText('Google Gemini').length).toBeGreaterThanOrEqual(1);
});
// Initially Gemini is selected, so its models should be visible in the research model dropdown
const researchSelect = screen.getByLabelText("Modele d'IA (Recherche et Extraction)") as HTMLSelectElement;
const geminiOptions = researchSelect.querySelectorAll('option');
const geminiTexts = Array.from(geminiOptions).map((o) => o.textContent);
expect(geminiTexts).toContain('Gemini 2.5 Pro');
// Change provider to OpenAI
const providerSelect = screen.getByLabelText(
"Fournisseur d'IA",
) as HTMLSelectElement;
fireEvent.change(providerSelect, { target: { value: 'openai' } });
await waitFor(() => {
const updatedOptions = researchSelect.querySelectorAll('option');
const updatedTexts = Array.from(updatedOptions).map((o) => o.textContent);
expect(updatedTexts).toContain('GPT-4o');
});
});
it('should render two model dropdowns (research + writing)', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
await waitFor(() => {
expect(
screen.getByLabelText("Modele d'IA (Recherche et Extraction)"),
).toBeInTheDocument();
});
expect(
screen.getByLabelText("Modele d'IA (Redaction et Synthese)"),
).toBeInTheDocument();
});
it('should render rate limit inputs (empty when null)', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
await waitFor(() => {
const maxReqInput = screen.getByLabelText(
'Requetes maximum',
) as HTMLInputElement;
expect(maxReqInput.value).toBe('');
});
const timeWindowInput = screen.getByLabelText(
'Fenetre de temps (secondes)',
) as HTMLInputElement;
expect(timeWindowInput.value).toBe('');
});
it('should show and clear rate limit values with the reset link', async () => {
const settingsWithRateLimits: UserSettings = {
...mockSettings,
rate_limit_max_requests: 10,
rate_limit_time_window_seconds: 60,
};
mockedGetSettings.mockResolvedValue(settingsWithRateLimits);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
await waitFor(() => {
const maxReqInput = screen.getByLabelText(
'Requetes maximum',
) as HTMLInputElement;
expect(maxReqInput.value).toBe('10');
});
const resetLink = screen.getByText('Reinitialiser');
fireEvent.click(resetLink);
await waitFor(() => {
const maxReqInput = screen.getByLabelText(
'Requetes maximum',
) as HTMLInputElement;
expect(maxReqInput.value).toBe('');
});
});
it('should trigger download when export button is clicked', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
// Mock URL.createObjectURL and revokeObjectURL
const mockCreateObjectURL = vi.fn(() => 'blob:test');
const mockRevokeObjectURL = vi.fn();
const origCreateObjectURL = URL.createObjectURL;
const origRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = mockCreateObjectURL;
URL.revokeObjectURL = mockRevokeObjectURL;
renderWithProviders(() => <Settings />);
await waitFor(() => {
expect(screen.getByTitle('Exporter')).toBeInTheDocument();
});
const exportButton = screen.getByTitle('Exporter');
fireEvent.click(exportButton);
await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalled();
});
// Restore
URL.createObjectURL = origCreateObjectURL;
URL.revokeObjectURL = origRevokeObjectURL;
});
it('should populate form from imported JSON file', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
await waitFor(() => {
expect(screen.getByTitle('Importer')).toBeInTheDocument();
});
const importedSettings = {
...DEFAULT_SETTINGS,
theme: 'Cybersecurite',
categories: ['Cat A', 'Cat B'],
};
const file = new File(
[JSON.stringify(importedSettings)],
'settings.json',
{ type: 'application/json' },
);
// Find the hidden file input
const fileInput = document.querySelector(
'input[type="file"][accept=".json"]',
) as HTMLInputElement;
expect(fileInput).toBeTruthy();
// Simulate file selection
Object.defineProperty(fileInput, 'files', {
value: [file],
writable: false,
});
fireEvent.change(fileInput);
await waitFor(() => {
expect(
screen.getByText("Configuration importee avec succes. N'oubliez pas d'enregistrer."),
).toBeInTheDocument();
});
});
it('should show validation error for empty theme', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListKeys.mockResolvedValue([]);
// Simulate backend returning a validation error
mockedUpdateSettings.mockRejectedValue({
status: 422,
message: 'Theme is required',
});
renderWithProviders(() => <Settings />);
await waitFor(() => {
expect(
screen.getByLabelText('Theme de la recherche'),
).toBeInTheDocument();
});
// Clear the theme field
const themeInput = screen.getByLabelText(
'Theme de la recherche',
) as HTMLInputElement;
fireEvent.input(themeInput, { target: { value: '' } });
// Click save
const saveButton = screen.getByText('Enregistrer les parametres');
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Theme is required')).toBeInTheDocument();
});
});
});