diff --git a/frontend/src/__tests__/ai/AISettings.test.tsx b/frontend/src/__tests__/ai/AISettings.test.tsx
new file mode 100644
index 0000000..a3cf615
--- /dev/null
+++ b/frontend/src/__tests__/ai/AISettings.test.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+
+vi.mock('@/api/ai', () => ({
+ fetchProviders: vi.fn().mockResolvedValue([
+ { id: 'p1', name: 'OpenAI', provider_type: 'openai', api_key: 'sk-***', base_url: '', is_default: true },
+ { id: 'p2', name: 'Ollama', provider_type: 'ollama', api_key: '', base_url: 'http://localhost:11434', is_default: false },
+ ]),
+ createProvider: vi.fn().mockResolvedValue({ id: 'p3' }),
+ updateProvider: vi.fn().mockResolvedValue({}),
+ deleteProvider: vi.fn().mockResolvedValue({}),
+ fetchPresets: vi.fn().mockResolvedValue([
+ { id: 'pr1', name: 'GPT-4o', model_id: 'gpt-4o', provider_id: 'p1', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' },
+ ]),
+ createPreset: vi.fn().mockResolvedValue({ id: 'pr2' }),
+ updatePreset: vi.fn().mockResolvedValue({}),
+ deletePreset: vi.fn().mockResolvedValue({}),
+ fetchAgents: vi.fn().mockResolvedValue([
+ { id: 'a1', name: 'Default Agent', description: 'Default assistant', system_prompt: 'You are helpful', preset_id: 'pr1', tool_ids: ['search'], is_default: true },
+ ]),
+ createAgent: vi.fn().mockResolvedValue({ id: 'a2' }),
+ updateAgent: vi.fn().mockResolvedValue({}),
+ deleteAgent: vi.fn().mockResolvedValue({}),
+ fetchTools: vi.fn().mockResolvedValue([
+ { name: 'search', description: 'Search tool', plugin_name: 'core', category: 'utility', required_permission: null },
+ ]),
+}));
+
+import { AISettingsPage } from '@/pages/AISettings';
+
+describe('AISettingsPage', () => {
+ it('renders AI settings page with title', () => {
+ render();
+ expect(screen.getByText('KI Assistent Einstellungen')).toBeInTheDocument();
+ });
+
+ it('renders provider tab', () => {
+ render();
+ expect(screen.getByText('Anbieter')).toBeInTheDocument();
+ });
+
+ it('renders presets tab', () => {
+ render();
+ expect(screen.getByText('Modelle & Presets')).toBeInTheDocument();
+ });
+
+ it('renders agents tab', () => {
+ render();
+ expect(screen.getByText('Agenten')).toBeInTheDocument();
+ });
+
+ it('renders tools tab', () => {
+ render();
+ expect(screen.getByText('Tools')).toBeInTheDocument();
+ });
+
+ it('renders provider list after loading', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('OpenAI')).toBeInTheDocument();
+ expect(screen.getByText('Ollama')).toBeInTheDocument();
+ });
+ });
+
+ it('renders add provider button', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('+ Hinzufügen')).toBeInTheDocument();
+ });
+ });
+
+ it('renders edit and delete buttons for providers', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getAllByText('Bearbeiten').length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/frontend/src/__tests__/ai/ChatWindow.test.tsx b/frontend/src/__tests__/ai/ChatWindow.test.tsx
new file mode 100644
index 0000000..37b274b
--- /dev/null
+++ b/frontend/src/__tests__/ai/ChatWindow.test.tsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { ChatWindow } from '@/components/ai/ChatWindow';
+
+const mockMessages = [
+ { id: 'm1', session_id: 's1', role: 'user', content: 'Hello AI', tokens: 10, model_used: '' },
+ { id: 'm2', session_id: 's1', role: 'assistant', content: 'Hello User', tokens: 10, model_used: 'gpt-4' },
+];
+
+const fetchMessagesMock = vi.fn();
+const fetchAttachmentsMock = vi.fn();
+const streamChatMock = vi.fn();
+const uploadAttachmentMock = vi.fn();
+
+vi.mock('@/api/ai', () => ({
+ fetchMessages: (...args: any[]) => fetchMessagesMock(...args),
+ fetchAttachments: (...args: any[]) => fetchAttachmentsMock(...args),
+ streamChat: (...args: any[]) => streamChatMock(...args),
+ uploadAttachment: (...args: any[]) => uploadAttachmentMock(...args),
+ getAttachmentDownloadUrl: (id: string) => `/api/v1/ai/attachments/${id}/download`,
+}));
+
+vi.mock('react-markdown', () => ({
+ default: ({ children }: { children: string }) =>
{children}
,
+}));
+
+vi.mock('remark-gfm', () => ({
+ default: () => ({}),
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ fetchMessagesMock.mockResolvedValue(mockMessages);
+ fetchAttachmentsMock.mockResolvedValue([]);
+ streamChatMock.mockReturnValue({
+ async *[Symbol.asyncIterator]() {
+ yield { type: 'token', content: 'Hi' };
+ yield { type: 'done' };
+ },
+ });
+});
+
+describe('ChatWindow', () => {
+ it('renders chat window with messages', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Hello AI')).toBeInTheDocument();
+ expect(screen.getByText('Hello User')).toBeInTheDocument();
+ });
+ });
+
+ it('renders textarea input for message', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
+ });
+ });
+
+ it('renders send button', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Senden')).toBeInTheDocument();
+ });
+ });
+
+ it('disables send button when input is empty', async () => {
+ render();
+ await waitFor(() => {
+ const sendBtn = screen.getByText('Senden');
+ expect(sendBtn).toBeDisabled();
+ });
+ });
+
+ it('enables send button when input has text', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
+ });
+ const input = screen.getByPlaceholderText('Nachricht eingeben...');
+ fireEvent.change(input, { target: { value: 'Test message' } });
+ expect(screen.getByText('Senden')).not.toBeDisabled();
+ });
+
+ it('shows empty state when no messages', async () => {
+ fetchMessagesMock.mockResolvedValueOnce([]);
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Starte eine Konversation...')).toBeInTheDocument();
+ });
+ });
+
+ it('displays error when fetchMessages fails', async () => {
+ fetchMessagesMock.mockRejectedValueOnce(new Error('Load failed'));
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Load failed')).toBeInTheDocument();
+ });
+ });
+
+ it('sends message on button click', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
+ });
+ const input = screen.getByPlaceholderText('Nachricht eingeben...');
+ fireEvent.change(input, { target: { value: 'Test message' } });
+ fireEvent.click(screen.getByText('Senden'));
+ await waitFor(() => {
+ expect(streamChatMock).toHaveBeenCalledWith('s1', 'Test message', undefined);
+ });
+ });
+});
diff --git a/frontend/src/__tests__/ai/ProactiveAISettings.test.tsx b/frontend/src/__tests__/ai/ProactiveAISettings.test.tsx
new file mode 100644
index 0000000..34250e4
--- /dev/null
+++ b/frontend/src/__tests__/ai/ProactiveAISettings.test.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { ProactiveAISettings } from '@/pages/ProactiveAISettings';
+
+const mockSettings = {
+ enabled: true,
+ suggestion_categories: ['mail', 'tasks'],
+ confidence_threshold: 0.7,
+ rate_limit_seconds: 15,
+ model: 'ollama/deepseek-v4',
+ heartbeat_enabled: true,
+ heartbeat_interval_seconds: 300,
+ heartbeat_target_room: 'Live KI',
+};
+
+const apiGetMock = vi.fn();
+const apiPutMock = vi.fn();
+
+vi.mock('@/api/client', () => ({
+ apiClient: {
+ get: (...args: any[]) => apiGetMock(...args),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ put: (...args: any[]) => apiPutMock(...args),
+ },
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ apiGetMock.mockResolvedValue({ data: mockSettings });
+ apiPutMock.mockResolvedValue({ data: mockSettings });
+});
+
+describe('ProactiveAISettings', () => {
+ it('renders proactive AI settings with title', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Proaktive KI')).toBeInTheDocument();
+ });
+ });
+
+ it('renders enable/disable toggle', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Proaktive KI')).toBeInTheDocument();
+ });
+ });
+
+ it('renders category checkboxes', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('E-Mails')).toBeInTheDocument();
+ expect(screen.getByText('Aufgaben')).toBeInTheDocument();
+ expect(screen.getByText('Kontakte')).toBeInTheDocument();
+ expect(screen.getByText('Firmen')).toBeInTheDocument();
+ expect(screen.getByText('Erkenntnisse')).toBeInTheDocument();
+ });
+ });
+
+ it('renders confidence threshold section', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Confidence Schwellwert')).toBeInTheDocument();
+ });
+ });
+
+ it('renders rate limit section', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Rate Limit')).toBeInTheDocument();
+ });
+ });
+
+ it('renders model selection dropdown', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('KI Modell (Ollama Cloud)')).toBeInTheDocument();
+ });
+ });
+
+ it('renders heartbeat section', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Heartbeat')).toBeInTheDocument();
+ });
+ });
+
+ it('renders loading state when settings not loaded', () => {
+ apiGetMock.mockReturnValue(new Promise(() => {}));
+ render();
+ expect(screen.getByText('Lade Einstellungen...')).toBeInTheDocument();
+ });
+
+ it('shows confidence percentage', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('70%')).toBeInTheDocument();
+ });
+ });
+
+ it('shows rate limit value', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('15s')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/ai/SessionList.test.tsx b/frontend/src/__tests__/ai/SessionList.test.tsx
new file mode 100644
index 0000000..2dfa3b5
--- /dev/null
+++ b/frontend/src/__tests__/ai/SessionList.test.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { SessionList } from '@/components/ai/SessionList';
+
+const mockSessions = [
+ { id: 's1', title: 'First Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
+ { id: 's2', title: 'Second Chat', folder_id: 'f1', agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
+];
+
+const mockFolders = [
+ { id: 'f1', name: 'My Folder', parent_id: null, created_at: '2024-01-01T00:00:00Z' },
+];
+
+const fetchSessionsMock = vi.fn();
+const fetchFoldersMock = vi.fn();
+const createSessionMock = vi.fn();
+const deleteSessionMock = vi.fn();
+const updateSessionMock = vi.fn();
+const createFolderMock = vi.fn();
+const deleteFolderMock = vi.fn();
+const updateFolderMock = vi.fn();
+
+vi.mock('@/api/ai', () => ({
+ fetchSessions: (...args: any[]) => fetchSessionsMock(...args),
+ fetchFolders: (...args: any[]) => fetchFoldersMock(...args),
+ createSession: (...args: any[]) => createSessionMock(...args),
+ deleteSession: (...args: any[]) => deleteSessionMock(...args),
+ updateSession: (...args: any[]) => updateSessionMock(...args),
+ createFolder: (...args: any[]) => createFolderMock(...args),
+ deleteFolder: (...args: any[]) => deleteFolderMock(...args),
+ updateFolder: (...args: any[]) => updateFolderMock(...args),
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ fetchSessionsMock.mockResolvedValue(mockSessions);
+ fetchFoldersMock.mockResolvedValue(mockFolders);
+ createSessionMock.mockResolvedValue({ id: 's3', title: 'New Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '', updated_at: '' });
+ deleteSessionMock.mockResolvedValue({});
+ updateSessionMock.mockResolvedValue({});
+ createFolderMock.mockResolvedValue({ id: 'f2', name: 'New Folder', parent_id: null, created_at: '' });
+ deleteFolderMock.mockResolvedValue({});
+ updateFolderMock.mockResolvedValue({});
+});
+
+describe('SessionList', () => {
+ it('renders session list with sessions', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('First Chat')).toBeInTheDocument();
+ });
+ });
+
+ it('renders folder names', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('My Folder')).toBeInTheDocument();
+ });
+ });
+
+ it('renders sessions inside folders', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Second Chat')).toBeInTheDocument();
+ });
+ });
+
+ it('calls onSelectSession when session clicked', async () => {
+ const onSelect = vi.fn();
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('First Chat')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByText('First Chat'));
+ expect(onSelect).toHaveBeenCalledWith('s1');
+ });
+
+ it('shows loading state initially', () => {
+ fetchSessionsMock.mockReturnValue(new Promise(() => {}));
+ fetchFoldersMock.mockReturnValue(new Promise(() => {}));
+ render();
+ expect(screen.getByText('Laden...')).toBeInTheDocument();
+ });
+
+ it('displays error when fetch fails', async () => {
+ fetchSessionsMock.mockRejectedValueOnce(new Error('Failed to load'));
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Failed to load')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/ai/SuggestionSidebar.test.tsx b/frontend/src/__tests__/ai/SuggestionSidebar.test.tsx
new file mode 100644
index 0000000..e220dc7
--- /dev/null
+++ b/frontend/src/__tests__/ai/SuggestionSidebar.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { SuggestionList, SuggestionSidebar } from '@/components/ai/SuggestionSidebar';
+
+const mockSuggestions = [
+ {
+ id: 'sug1',
+ entity_type: 'mail',
+ entity_id: 'm1',
+ suggestion_type: 'info',
+ title: 'Email suggestion',
+ content: 'You have an important email',
+ confidence: 0.85,
+ actions: [{ method: 'POST', path: '/mail/read', body: {}, description: 'Mark as read' }],
+ created_at: '2024-01-01T00:00:00Z',
+ is_dismissed: false,
+ is_acted_upon: false,
+ },
+ {
+ id: 'sug2',
+ entity_type: 'task',
+ entity_id: 't1',
+ suggestion_type: 'warning',
+ title: 'Task overdue',
+ content: 'Task is overdue',
+ confidence: 0.92,
+ actions: [],
+ created_at: '2024-01-01T00:00:00Z',
+ is_dismissed: false,
+ is_acted_upon: false,
+ },
+];
+
+const apiGetMock = vi.fn();
+const apiPostMock = vi.fn();
+const apiPutMock = vi.fn();
+
+vi.mock('@/api/client', () => ({
+ apiClient: {
+ get: (...args: any[]) => apiGetMock(...args),
+ post: (...args: any[]) => apiPostMock(...args),
+ put: (...args: any[]) => apiPutMock(...args),
+ },
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ apiGetMock.mockResolvedValue({ data: { items: mockSuggestions } });
+ apiPostMock.mockResolvedValue({ data: {} });
+ apiPutMock.mockResolvedValue({ data: {} });
+});
+
+describe('SuggestionList', () => {
+ it('renders suggestion list container', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('suggestion-list')).toBeInTheDocument();
+ });
+ });
+
+ it('renders suggestion titles', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Email suggestion')).toBeInTheDocument();
+ expect(screen.getByText('Task overdue')).toBeInTheDocument();
+ });
+ });
+
+ it('renders filter buttons', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Alle')).toBeInTheDocument();
+ expect(screen.getByText('Info')).toBeInTheDocument();
+ expect(screen.getByText('Warnung')).toBeInTheDocument();
+ });
+ });
+
+ it('renders suggestion count', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText(/2 Vorschläge/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows empty state when no suggestions', async () => {
+ apiGetMock.mockResolvedValueOnce({ data: { items: [] } });
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Keine Vorschläge vorhanden')).toBeInTheDocument();
+ });
+ });
+
+ it('filters suggestions by type', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Email suggestion')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByText('Warnung'));
+ await waitFor(() => {
+ expect(screen.getByText('Task overdue')).toBeInTheDocument();
+ expect(screen.queryByText('Email suggestion')).not.toBeInTheDocument();
+ });
+ });
+});
+
+describe('SuggestionSidebar', () => {
+ it('renders sidebar with close button when open', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('KI Vorschläge')).toBeInTheDocument();
+ expect(screen.getAllByText('✕').length).toBeGreaterThan(0);
+ });
+ });
+
+ it('calls onClose when close button clicked', async () => {
+ const onClose = vi.fn();
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('KI Vorschläge')).toBeInTheDocument();
+ });
+ // The close button is the first ✕ in the sidebar header
+ const closeButtons = screen.getAllByText('✕');
+ fireEvent.click(closeButtons[0]);
+ expect(onClose).toHaveBeenCalled();
+ });
+});