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
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';
|
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…
Reference in New Issue