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