test: add frontend page interaction tests (Home, Settings, Sources, Login, Register, Generate)

Add test-utils.tsx with renderWithProviders (MemoryRouter + I18n + Toast),
mockFetch, and mockFetchRoutes helpers. Create 39 interaction-level tests
across 6 page components covering rendering, form validation, API calls,
delete confirmation flows, SSE progress, and file import/export.

Also add Blob.text() polyfill in test setup for jsdom compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent a4e618feda
commit fa346dc346

@ -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(() => <GenerateSynthesis />);
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(() => <GenerateSynthesis />);
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(() => <GenerateSynthesis />);
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(() => <GenerateSynthesis />);
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(() => <GenerateSynthesis />);
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(() => <GenerateSynthesis />);
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();
});
});
});

@ -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(() => <Home />);
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(() => <Home />);
await waitFor(() => {
expect(screen.getByText('Aucune synthese')).toBeInTheDocument();
});
});
it('should have "Nouvelle Synthese" button linking to /generate', async () => {
mockedList.mockResolvedValue([]);
renderWithProviders(() => <Home />);
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(() => <Home />);
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(() => <Home />);
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(() => <Home />);
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(() => <Home />);
await waitFor(() => {
expect(
screen.getByText('Une generation est en cours...'),
).toBeInTheDocument();
});
});
});

@ -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(() => <Login />);
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(() => <Login />);
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(() => <Login />);
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(() => <Login />);
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();
});
});
});

@ -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(() => <Register />);
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(() => <Register />);
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(() => <Register />);
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(() => <Register />);
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();
});
});
});

@ -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(() => <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();
});
});
});

@ -0,0 +1,248 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@solidjs/testing-library';
import { renderWithProviders } from '../test-utils';
import type { Source, BulkImportResponse } from '~/types';
// Mock the sources API module
vi.mock('~/api/sources', () => ({
sourcesApi: {
list: vi.fn(),
create: vi.fn(),
remove: vi.fn(),
bulkImport: vi.fn(),
importCsv: vi.fn(),
exportCsv: vi.fn(),
},
}));
// Also mock syntheses since sources imports from it (fetchFile, triggerDownload)
vi.mock('~/api/syntheses', () => ({
synthesesApi: {
list: vi.fn(),
remove: vi.fn(),
generate: vi.fn(),
progressUrl: vi.fn(),
},
fetchFile: vi.fn(),
triggerDownload: vi.fn(),
}));
import { sourcesApi } from '~/api/sources';
import Sources from '~/pages/Sources';
const mockedList = vi.mocked(sourcesApi.list);
const mockedCreate = vi.mocked(sourcesApi.create);
const mockedRemove = vi.mocked(sourcesApi.remove);
const mockedBulkImport = vi.mocked(sourcesApi.bulkImport);
const mockedExportCsv = vi.mocked(sourcesApi.exportCsv);
const mockSources: Source[] = [
{
id: 'src1',
user_id: 'u1',
title: 'OpenAI Blog',
url: 'https://openai.com/blog',
created_at: '2026-03-01T10:00:00Z',
},
{
id: 'src2',
user_id: 'u1',
title: 'Google AI Blog',
url: 'https://ai.googleblog.com',
created_at: '2026-03-02T10:00:00Z',
},
];
afterEach(() => {
vi.clearAllMocks();
});
describe('Sources Page', () => {
it('should render source list from API data', async () => {
mockedList.mockResolvedValue(mockSources);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('OpenAI Blog')).toBeInTheDocument();
});
expect(screen.getByText('Google AI Blog')).toBeInTheDocument();
});
it('should show empty state when no sources', async () => {
mockedList.mockResolvedValue([]);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(
screen.getByText('Aucune source personnalisee pour le moment.'),
).toBeInTheDocument();
});
});
it('should validate empty title on add form submit', async () => {
mockedList.mockResolvedValue([]);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('Ajouter')).toBeInTheDocument();
});
// Fill URL but not title
const urlInput = screen.getByPlaceholderText('https://...');
fireEvent.input(urlInput, { target: { value: 'https://example.com' } });
const addButton = screen.getByText('Ajouter');
fireEvent.click(addButton);
await waitFor(() => {
expect(screen.getByText('Le titre est requis.')).toBeInTheDocument();
});
});
it('should validate empty URL on add form submit', async () => {
mockedList.mockResolvedValue([]);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('Ajouter')).toBeInTheDocument();
});
const titleInput = screen.getByPlaceholderText(
'Nom de la source (ex: Blog de Yann LeCun)',
);
fireEvent.input(titleInput, { target: { value: 'My Blog' } });
const addButton = screen.getByText('Ajouter');
fireEvent.click(addButton);
await waitFor(() => {
expect(screen.getByText("L'URL est requise.")).toBeInTheDocument();
});
});
it('should call API and refresh list when add form is submitted', async () => {
const newSource: Source = {
id: 'src-new',
user_id: 'u1',
title: 'New Blog',
url: 'https://newblog.com',
created_at: '2026-03-20T10:00:00Z',
};
mockedCreate.mockResolvedValue(newSource);
// First call returns empty, second call after create returns the new source
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce([newSource]);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('Ajouter')).toBeInTheDocument();
});
const titleInput = screen.getByPlaceholderText(
'Nom de la source (ex: Blog de Yann LeCun)',
);
const urlInput = screen.getByPlaceholderText('https://...');
fireEvent.input(titleInput, { target: { value: 'New Blog' } });
fireEvent.input(urlInput, { target: { value: 'https://newblog.com' } });
const addButton = screen.getByText('Ajouter');
fireEvent.click(addButton);
await waitFor(() => {
expect(mockedCreate).toHaveBeenCalledWith({
title: 'New Blog',
url: 'https://newblog.com',
});
});
});
it('should handle delete with confirmation flow', async () => {
mockedRemove.mockResolvedValue(undefined);
// First call returns all sources, second call after delete returns one
mockedList
.mockResolvedValueOnce(mockSources)
.mockResolvedValueOnce([mockSources[1]]);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('OpenAI Blog')).toBeInTheDocument();
});
// First click on delete for first source
const deleteButtons = screen.getAllByTitle('Supprimer');
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.getByText('Confirmer ?')).toBeInTheDocument();
});
// Second click to confirm
const confirmBtn = screen.getByText('Confirmer ?');
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(mockedRemove).toHaveBeenCalledWith('src1');
});
});
it('should trigger bulk import API call', async () => {
const bulkResponse: BulkImportResponse = {
imported: 2,
skipped: 0,
errors: [],
};
mockedBulkImport.mockResolvedValue(bulkResponse);
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce(mockSources);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('Importer les sources')).toBeInTheDocument();
});
// The textarea has id="bulk-import"
const textarea = document.getElementById('bulk-import') as HTMLTextAreaElement;
expect(textarea).toBeTruthy();
fireEvent.input(textarea, {
target: {
value: 'OpenAI Blog;https://openai.com/blog\nGoogle AI Blog;https://ai.googleblog.com',
},
});
const importButton = screen.getByText('Importer les sources');
fireEvent.click(importButton);
await waitFor(() => {
expect(mockedBulkImport).toHaveBeenCalledWith({
sources: [
{ title: 'OpenAI Blog', url: 'https://openai.com/blog' },
{ title: 'Google AI Blog', url: 'https://ai.googleblog.com' },
],
});
});
});
it('should trigger CSV export download', async () => {
mockedList.mockResolvedValue(mockSources);
mockedExportCsv.mockResolvedValue(undefined);
renderWithProviders(() => <Sources />);
await waitFor(() => {
expect(screen.getByText('Exporter en CSV')).toBeInTheDocument();
});
const exportButton = screen.getByText('Exporter en CSV');
fireEvent.click(exportButton);
await waitFor(() => {
expect(mockedExportCsv).toHaveBeenCalled();
});
});
});

@ -1 +1,13 @@
import '@testing-library/jest-dom';
// Polyfill Blob.text() for jsdom (not natively available in jsdom 25)
if (typeof Blob.prototype.text !== 'function') {
Blob.prototype.text = function () {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsText(this);
});
};
}

@ -0,0 +1,75 @@
import { vi } from 'vitest';
import { render } from '@solidjs/testing-library';
import { MemoryRouter, Route, type RouteSectionProps } from '@solidjs/router';
import { I18nProvider } from '~/i18n';
import { ToastProvider } from '~/components/ui/Toast';
import type { JSX } from 'solid-js';
/**
* Render a component wrapped in MemoryRouter + I18nProvider + ToastProvider.
* The component is rendered as a catch-all route so that router hooks
* (useNavigate, <A>, etc.) work properly in tests.
*
* AuthContext is NOT included -- mock useAuth per test instead.
*/
export function renderWithProviders(ui: () => JSX.Element) {
const TestWrapper = (_props: RouteSectionProps) => ui();
return render(() => (
<I18nProvider locale="fr">
<ToastProvider>
<MemoryRouter>
<Route path="*" component={TestWrapper} />
</MemoryRouter>
</ToastProvider>
</I18nProvider>
));
}
/**
* Create a simple vi.fn() mock for globalThis.fetch that returns the
* given response body with the specified status (default 200).
*/
export function mockFetch(response: unknown, status = 200) {
const mock = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(response),
blob: () => Promise.resolve(new Blob()),
headers: new Headers(),
});
globalThis.fetch = mock;
return mock;
}
/**
* Create a route-based fetch mock.
* `routes` maps a URL substring to { body, status? }.
* Any unmatched URL returns 404.
*/
export function mockFetchRoutes(
routes: Record<string, { body: unknown; status?: number }>,
) {
const mock = vi.fn().mockImplementation((url: string) => {
for (const [pattern, { body, status = 200 }] of Object.entries(routes)) {
if (url.includes(pattern)) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
blob: () => Promise.resolve(new Blob()),
headers: new Headers(),
});
}
}
return Promise.resolve({
ok: false,
status: 404,
json: () => Promise.resolve({ error: 'Not found' }),
blob: () => Promise.resolve(new Blob()),
headers: new Headers(),
});
});
globalThis.fetch = mock;
return mock;
}
Loading…
Cancel
Save