test: shared typed fixtures to prevent mock drift from backend contracts

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

@ -0,0 +1,66 @@
import type {
SynthesisListItem,
Synthesis,
NewsSection,
Source,
UserSettings,
ProviderConfig,
GenerateResponse,
} from '~/types';
import { DEFAULT_SETTINGS } from '~/types';
// ---- Syntheses (list view) ----
export const MOCK_SYNTHESIS_LIST_ITEM: SynthesisListItem = {
id: 'test-synth-1',
week: '2026-W12',
status: 'completed',
created_at: '2026-03-21T10:00:00Z',
first_section_title: 'Annonces majeures',
first_section_item_count: 3,
};
export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [
MOCK_SYNTHESIS_LIST_ITEM,
{ ...MOCK_SYNTHESIS_LIST_ITEM, id: 'test-synth-2', week: '2026-W11', first_section_title: null, first_section_item_count: 0 },
];
export const MOCK_SYNTHESIS_IN_PROGRESS: SynthesisListItem = {
...MOCK_SYNTHESIS_LIST_ITEM, id: 'test-synth-progress', status: 'in_progress',
};
// ---- Syntheses (detail view) ----
export const MOCK_SECTIONS: NewsSection[] = [
{ 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' },
]},
];
export const MOCK_SYNTHESIS_DETAIL: Synthesis = {
id: 'test-synth-1', user_id: 'test-user-1', week: '2026-W12',
sections: MOCK_SECTIONS, status: 'completed', created_at: '2026-03-21T10:00:00Z',
};
// ---- Sources ----
export const MOCK_SOURCE: Source = {
id: 'src-1', user_id: 'u1', title: 'Test Blog', url: 'https://test.example.com/blog', created_at: '2026-03-21T10:00:00Z',
};
export const MOCK_SOURCES: Source[] = [
MOCK_SOURCE,
{ ...MOCK_SOURCE, id: 'src-2', title: 'News Site', url: 'https://news.example.com' },
];
// ---- Settings ----
export const MOCK_SETTINGS: UserSettings = {
...DEFAULT_SETTINGS, theme: 'Intelligence Artificielle', ai_provider: 'gemini', ai_model: 'gemini-2.5-pro', ai_model_writing: 'gemini-2.5-flash',
};
// ---- Providers ----
export const MOCK_PROVIDER_CONFIG: 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' }],
};
export const MOCK_PROVIDER_CONFIGS: ProviderConfig[] = [MOCK_PROVIDER_CONFIG];
// ---- Generate ----
export const MOCK_GENERATE_RESPONSE: GenerateResponse = { job_id: 'job-test-1' };

@ -1,8 +1,7 @@
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';
import { MOCK_SETTINGS, MOCK_PROVIDER_CONFIGS } from '../fixtures';
// Mock API modules
vi.mock('~/api/syntheses', () => ({
@ -46,33 +45,14 @@ 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);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
renderWithProviders(() => <GenerateSynthesis />);
@ -87,8 +67,8 @@ describe('GenerateSynthesis Page', () => {
});
it('should call POST /syntheses/generate on launch', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
mockedGenerate.mockResolvedValue({ job_id: 'job-123' });
mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress');
@ -119,8 +99,8 @@ describe('GenerateSynthesis Page', () => {
});
it('should show progress bar from SSE events', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
mockedGenerate.mockResolvedValue({ job_id: 'job-123' });
mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress');
@ -152,8 +132,8 @@ describe('GenerateSynthesis Page', () => {
});
it('should render step checklist with correct states', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
mockedGenerate.mockResolvedValue({ job_id: 'job-123' });
mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress');
@ -194,8 +174,8 @@ describe('GenerateSynthesis Page', () => {
});
it('should show retry button on error', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
mockedGenerate.mockResolvedValue({ job_id: 'job-123' });
mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress');
@ -227,8 +207,8 @@ describe('GenerateSynthesis Page', () => {
});
it('should show completion message on success', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(MOCK_PROVIDER_CONFIGS);
mockedGenerate.mockResolvedValue({ job_id: 'job-123' });
mockedProgressUrl.mockReturnValue('/api/v1/syntheses/generate/job-123/progress');

@ -2,7 +2,7 @@ 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';
import { MOCK_SYNTHESIS_LIST, MOCK_SYNTHESIS_IN_PROGRESS } from '../fixtures';
// Mock the syntheses API module
vi.mock('~/api/syntheses', () => ({
@ -21,31 +21,6 @@ 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();
@ -53,7 +28,7 @@ afterEach(() => {
describe('Home Page', () => {
it('should render synthesis list from mocked API data', async () => {
mockedList.mockResolvedValue(mockSyntheses);
mockedList.mockResolvedValue(MOCK_SYNTHESIS_LIST);
renderWithProviders(() => <Home />);
@ -88,7 +63,7 @@ describe('Home Page', () => {
});
it('should show "Confirmer" text on first delete click', async () => {
mockedList.mockResolvedValue(mockSyntheses);
mockedList.mockResolvedValue(MOCK_SYNTHESIS_LIST);
renderWithProviders(() => <Home />);
@ -105,7 +80,7 @@ describe('Home Page', () => {
});
it('should remove item from list on second delete click (confirm)', async () => {
mockedList.mockResolvedValue([...mockSyntheses]);
mockedList.mockResolvedValue([...MOCK_SYNTHESIS_LIST]);
mockedRemove.mockResolvedValue(undefined);
renderWithProviders(() => <Home />);
@ -127,7 +102,7 @@ describe('Home Page', () => {
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(mockedRemove).toHaveBeenCalledWith('s1');
expect(mockedRemove).toHaveBeenCalledWith('test-synth-1');
});
await waitFor(() => {
@ -139,7 +114,7 @@ describe('Home Page', () => {
it('should auto-cancel delete confirmation after 3 seconds', async () => {
vi.useFakeTimers();
mockedList.mockResolvedValue(mockSyntheses);
mockedList.mockResolvedValue(MOCK_SYNTHESIS_LIST);
renderWithProviders(() => <Home />);
@ -162,17 +137,7 @@ describe('Home Page', () => {
});
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);
mockedList.mockResolvedValue([MOCK_SYNTHESIS_IN_PROGRESS]);
renderWithProviders(() => <Home />);

@ -2,7 +2,8 @@ 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';
import type { UserSettings } from '~/types';
import { MOCK_SETTINGS, MOCK_PROVIDER_CONFIGS } from '../fixtures';
// Mock API modules
vi.mock('~/api/settings', () => ({
@ -38,25 +39,9 @@ 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' },
],
},
// Settings page tests need two providers to test switching
const mockProvidersWithOpenAI = [
...MOCK_PROVIDER_CONFIGS,
{
provider_name: 'openai',
display_name: 'OpenAI',
@ -73,8 +58,8 @@ afterEach(() => {
describe('Settings Page', () => {
it('should render form fields populated from API response', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -88,10 +73,10 @@ describe('Settings Page', () => {
});
it('should call PUT /settings when save button is clicked', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
mockedUpdateSettings.mockResolvedValue(mockSettings);
mockedUpdateSettings.mockResolvedValue(MOCK_SETTINGS);
renderWithProviders(() => <Settings />);
@ -110,8 +95,8 @@ describe('Settings Page', () => {
});
it('should populate provider dropdown from config API', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -132,8 +117,8 @@ describe('Settings Page', () => {
});
it('should update model list when selecting a different provider', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -162,8 +147,8 @@ describe('Settings Page', () => {
});
it('should render two model dropdowns (research + writing)', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -179,8 +164,8 @@ describe('Settings Page', () => {
});
it('should render rate limit inputs (empty when null)', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -200,12 +185,12 @@ describe('Settings Page', () => {
it('should show and clear rate limit values with the reset link', async () => {
const settingsWithRateLimits: UserSettings = {
...mockSettings,
...MOCK_SETTINGS,
rate_limit_max_requests: 10,
rate_limit_time_window_seconds: 60,
};
mockedGetSettings.mockResolvedValue(settingsWithRateLimits);
mockedListProviders.mockResolvedValue(mockProviders);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -229,8 +214,8 @@ describe('Settings Page', () => {
});
it('should trigger download when export button is clicked', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
// Mock URL.createObjectURL and revokeObjectURL
@ -260,8 +245,8 @@ describe('Settings Page', () => {
});
it('should populate form from imported JSON file', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
renderWithProviders(() => <Settings />);
@ -303,8 +288,8 @@ describe('Settings Page', () => {
});
it('should show validation error for empty theme', async () => {
mockedGetSettings.mockResolvedValue(mockSettings);
mockedListProviders.mockResolvedValue(mockProviders);
mockedGetSettings.mockResolvedValue(MOCK_SETTINGS);
mockedListProviders.mockResolvedValue(mockProvidersWithOpenAI);
mockedListKeys.mockResolvedValue([]);
// Simulate backend returning a validation error
mockedUpdateSettings.mockRejectedValue({

@ -2,6 +2,7 @@ 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';
import { MOCK_SOURCES } from '../fixtures';
// Mock the sources API module
vi.mock('~/api/sources', () => ({
@ -36,21 +37,11 @@ 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',
},
// Sources page tests use specific titles that the UI renders, so we define
// page-specific sources based on the shared fixture shape.
const pageSources: Source[] = [
{ ...MOCK_SOURCES[0], id: 'src1', title: 'OpenAI Blog', url: 'https://openai.com/blog', created_at: '2026-03-01T10:00:00Z' },
{ ...MOCK_SOURCES[1], id: 'src2', title: 'Google AI Blog', url: 'https://ai.googleblog.com', created_at: '2026-03-02T10:00:00Z' },
];
afterEach(() => {
@ -59,7 +50,7 @@ afterEach(() => {
describe('Sources Page', () => {
it('should render source list from API data', async () => {
mockedList.mockResolvedValue(mockSources);
mockedList.mockResolvedValue(pageSources);
renderWithProviders(() => <Sources />);
@ -165,8 +156,8 @@ describe('Sources Page', () => {
mockedRemove.mockResolvedValue(undefined);
// First call returns all sources, second call after delete returns one
mockedList
.mockResolvedValueOnce(mockSources)
.mockResolvedValueOnce([mockSources[1]]);
.mockResolvedValueOnce(pageSources)
.mockResolvedValueOnce([pageSources[1]]);
renderWithProviders(() => <Sources />);
@ -198,7 +189,7 @@ describe('Sources Page', () => {
errors: [],
};
mockedBulkImport.mockResolvedValue(bulkResponse);
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce(mockSources);
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce(pageSources);
renderWithProviders(() => <Sources />);
@ -229,7 +220,7 @@ describe('Sources Page', () => {
});
it('should trigger CSV export download', async () => {
mockedList.mockResolvedValue(mockSources);
mockedList.mockResolvedValue(pageSources);
mockedExportCsv.mockResolvedValue(undefined);
renderWithProviders(() => <Sources />);

Loading…
Cancel
Save