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';
|
||||
|
||||
// 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