diff --git a/apps/server-e2e/src/ai_settings.spec.ts b/apps/server-e2e/src/ai_settings.spec.ts new file mode 100644 index 000000000..bd3df1c8b --- /dev/null +++ b/apps/server-e2e/src/ai_settings.spec.ts @@ -0,0 +1,251 @@ +import { test, expect } from "@playwright/test"; +import App from "./support/app"; + +test.describe("AI Settings", () => { + test("Should access AI settings page", async ({ page, context }) => { + page.setDefaultTimeout(15_000); + + const app = new App(page, context); + await app.goto(); + + // Go to settings + await app.goToSettings(); + + // Navigate to AI settings + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + // Verify we're on the AI settings page + await expect(app.currentNoteSplitTitle).toHaveValue("AI Settings"); + + // Check that AI settings content is visible + const aiSettingsContent = app.currentNoteSplitContent; + await aiSettingsContent.waitFor({ state: "visible" }); + + // Verify basic AI settings elements are present + await expect(aiSettingsContent).toBeVisible(); + }); + + test("Should toggle AI features", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + // Look for AI enable/disable toggle + const aiToggle = app.currentNoteSplitContent.locator('input[type="checkbox"]').first(); + + if (await aiToggle.isVisible()) { + // Get initial state + const initialState = await aiToggle.isChecked(); + + // Toggle the setting + await aiToggle.click(); + + // Wait for the change to be saved + await page.waitForTimeout(1000); + + // Verify the state changed + const newState = await aiToggle.isChecked(); + expect(newState).toBe(!initialState); + + // Toggle back to original state + await aiToggle.click(); + await page.waitForTimeout(1000); + + // Verify we're back to the original state + const finalState = await aiToggle.isChecked(); + expect(finalState).toBe(initialState); + } + }); + + test("Should configure AI provider settings", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + // Look for provider configuration elements + const settingsContent = app.currentNoteSplitContent; + + // Check for common AI provider setting elements + const providerSelects = settingsContent.locator('select'); + const apiKeyInputs = settingsContent.locator('input[type="password"], input[type="text"]'); + + if (await providerSelects.count() > 0) { + // Test provider selection + const firstSelect = providerSelects.first(); + await firstSelect.click(); + + // Verify options are available + const options = firstSelect.locator('option'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThan(0); + } + + if (await apiKeyInputs.count() > 0) { + // Test API key field interaction (without actually setting a key) + const firstInput = apiKeyInputs.first(); + await firstInput.click(); + + // Verify the field is interactive + await expect(firstInput).toBeFocused(); + } + }); + + test("Should display AI model options", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + const settingsContent = app.currentNoteSplitContent; + + // Look for model selection elements + const modelSelects = settingsContent.locator('select').filter({ hasText: /model|gpt|claude|llama/i }); + + if (await modelSelects.count() > 0) { + const modelSelect = modelSelects.first(); + await modelSelect.click(); + + // Verify model options are present + const options = modelSelect.locator('option'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThanOrEqual(1); + } + }); + + test("Should save AI settings changes", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + const settingsContent = app.currentNoteSplitContent; + + // Look for save button or auto-save indication + const saveButton = settingsContent.locator('button').filter({ hasText: /save|apply/i }); + + if (await saveButton.count() > 0) { + // Test save functionality + await saveButton.first().click(); + + // Wait for save to complete + await page.waitForTimeout(1000); + + // Look for success indication (toast, message, etc.) + const successMessage = page.locator('.toast, .notification, .success-message'); + if (await successMessage.count() > 0) { + await expect(successMessage.first()).toBeVisible(); + } + } + }); + + test("Should handle invalid AI configuration", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + const settingsContent = app.currentNoteSplitContent; + + // Look for API key input to test invalid configuration + const apiKeyInput = settingsContent.locator('input[type="password"], input[type="text"]').first(); + + if (await apiKeyInput.isVisible()) { + // Enter invalid API key + await apiKeyInput.fill("invalid-api-key-test"); + + // Look for test/validate button + const testButton = settingsContent.locator('button').filter({ hasText: /test|validate|check/i }); + + if (await testButton.count() > 0) { + await testButton.first().click(); + + // Wait for validation + await page.waitForTimeout(2000); + + // Look for error message + const errorMessage = page.locator('.error, .alert-danger, .text-danger'); + if (await errorMessage.count() > 0) { + await expect(errorMessage.first()).toBeVisible(); + } + } + + // Clear the invalid input + await apiKeyInput.fill(""); + } + }); + + test("Should navigate between AI setting sections", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + // Look for sub-sections or tabs in AI settings + const tabs = app.currentNoteSplitContent.locator('.nav-tabs a, .tab-header, .section-header'); + + if (await tabs.count() > 1) { + // Test navigation between sections + const firstTab = tabs.first(); + const secondTab = tabs.nth(1); + + await firstTab.click(); + await page.waitForTimeout(500); + + await secondTab.click(); + await page.waitForTimeout(500); + + // Verify navigation worked by checking if content changed + await expect(app.currentNoteSplitContent).toBeVisible(); + } + }); + + test("Should display AI feature documentation", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Go to AI settings + await app.goToSettings(); + await app.clickNoteOnNoteTreeByTitle("AI Settings"); + + const settingsContent = app.currentNoteSplitContent; + + // Look for help or documentation links + const helpLinks = settingsContent.locator('a').filter({ hasText: /help|documentation|learn more|guide/i }); + const helpButtons = settingsContent.locator('button, .help-icon, .info-icon').filter({ hasText: /\?|help|info/i }); + + if (await helpLinks.count() > 0) { + // Test help link accessibility + const firstHelpLink = helpLinks.first(); + await expect(firstHelpLink).toBeVisible(); + } + + if (await helpButtons.count() > 0) { + // Test help button functionality + const helpButton = helpButtons.first(); + await helpButton.click(); + + // Wait for help content to appear + await page.waitForTimeout(1000); + + // Look for help modal or tooltip + const helpContent = page.locator('.modal, .tooltip, .popover, .help-content'); + if (await helpContent.count() > 0) { + await expect(helpContent.first()).toBeVisible(); + } + } + }); +}); \ No newline at end of file diff --git a/apps/server-e2e/src/llm_chat.spec.ts b/apps/server-e2e/src/llm_chat.spec.ts new file mode 100644 index 000000000..73c5ed86c --- /dev/null +++ b/apps/server-e2e/src/llm_chat.spec.ts @@ -0,0 +1,377 @@ +import { test, expect } from "@playwright/test"; +import App from "./support/app"; + +test.describe("LLM Chat Features", () => { + test("Should access LLM chat interface", async ({ page, context }) => { + page.setDefaultTimeout(15_000); + + const app = new App(page, context); + await app.goto(); + + // Look for AI/LLM chat access points in the interface + // This could be a launcher button, menu item, or widget + const aiButtons = page.locator('[data-trigger-command*="ai"], [data-trigger-command*="llm"], [data-trigger-command*="chat"]'); + const aiMenuItems = page.locator('a, button').filter({ hasText: /ai chat|llm|assistant|chat/i }); + + // Try the launcher bar first + const launcherAiButton = app.launcherBar.locator('.launcher-button').filter({ hasText: /ai|chat|assistant/i }); + + if (await launcherAiButton.count() > 0) { + await launcherAiButton.first().click(); + + // Wait for chat interface to load + await page.waitForTimeout(1000); + + // Look for chat interface elements + const chatInterface = page.locator('.llm-chat, .ai-chat, .chat-widget, .chat-panel'); + if (await chatInterface.count() > 0) { + await expect(chatInterface.first()).toBeVisible(); + } + } else if (await aiButtons.count() > 0) { + await aiButtons.first().click(); + await page.waitForTimeout(1000); + } else if (await aiMenuItems.count() > 0) { + await aiMenuItems.first().click(); + await page.waitForTimeout(1000); + } + + // Verify some form of AI/chat interface is accessible + const possibleChatElements = page.locator('.chat, .llm, .ai, [class*="chat"], [class*="llm"], [class*="ai"]'); + const elementCount = await possibleChatElements.count(); + + // If no specific chat elements found, at least verify the page is responsive + if (elementCount === 0) { + await expect(app.currentNoteSplit).toBeVisible(); + } + }); + + test("Should create new LLM chat session", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to trigger new chat creation + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + + // Alternative: Look for chat creation buttons + const newChatButtons = page.locator('button, a').filter({ hasText: /new chat|create chat|start chat/i }); + + if (await newChatButtons.count() > 0) { + await newChatButtons.first().click(); + await page.waitForTimeout(1000); + } + + // Look for chat input elements + const chatInputs = page.locator('textarea, input[type="text"]').filter({ hasText: /message|chat|type/i }); + const possibleChatInputs = page.locator('textarea[placeholder*="message"], textarea[placeholder*="chat"], input[placeholder*="message"]'); + + if (await chatInputs.count() > 0) { + await expect(chatInputs.first()).toBeVisible(); + } else if (await possibleChatInputs.count() > 0) { + await expect(possibleChatInputs.first()).toBeVisible(); + } + }); + + test("Should handle chat message input", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // If command doesn't exist, continue with alternative methods + } + + // Look for message input areas + const messageInputs = page.locator('textarea, input[type="text"]'); + const chatAreas = page.locator('[contenteditable="true"]'); + + // Try to find and interact with chat input + for (let i = 0; i < await messageInputs.count(); i++) { + const input = messageInputs.nth(i); + const placeholder = await input.getAttribute('placeholder') || ''; + + if (placeholder.toLowerCase().includes('message') || + placeholder.toLowerCase().includes('chat') || + placeholder.toLowerCase().includes('type')) { + + // Test message input + await input.click(); + await input.fill("Hello, this is a test message for the LLM chat."); + + // Look for send button + const sendButtons = page.locator('button').filter({ hasText: /send|submit/i }); + const enterHint = page.locator('.hint, .help-text').filter({ hasText: /enter|send/i }); + + if (await sendButtons.count() > 0) { + // Don't actually send to avoid API calls in tests + await expect(sendButtons.first()).toBeVisible(); + } else if (await enterHint.count() > 0) { + // Test Enter key functionality indication + await expect(enterHint.first()).toBeVisible(); + } + + // Clear the input + await input.fill(""); + break; + } + } + }); + + test("Should display chat history", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // Continue with alternative access methods + } + + // Look for chat history or previous conversations + const chatHistory = page.locator('.chat-history, .conversation-list, .message-list'); + const previousChats = page.locator('.chat-item, .conversation-item'); + + if (await chatHistory.count() > 0) { + await expect(chatHistory.first()).toBeVisible(); + } + + if (await previousChats.count() > 0) { + // Test clicking on a previous chat + await previousChats.first().click(); + await page.waitForTimeout(500); + + // Look for loaded conversation + const messages = page.locator('.message, .chat-message'); + if (await messages.count() > 0) { + await expect(messages.first()).toBeVisible(); + } + } + }); + + test("Should handle chat settings and configuration", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // Continue + } + + // Look for chat settings or configuration options + const settingsButtons = page.locator('button, a').filter({ hasText: /settings|config|options|preferences/i }); + const gearIcons = page.locator('.fa-cog, .fa-gear, .bx-cog, .settings-icon'); + + if (await settingsButtons.count() > 0) { + await settingsButtons.first().click(); + await page.waitForTimeout(1000); + + // Look for settings panel + const settingsPanel = page.locator('.settings-panel, .config-panel, .options-panel'); + if (await settingsPanel.count() > 0) { + await expect(settingsPanel.first()).toBeVisible(); + } + } else if (await gearIcons.count() > 0) { + await gearIcons.first().click(); + await page.waitForTimeout(1000); + } + + // Look for common chat settings + const temperatureSliders = page.locator('input[type="range"]'); + const modelSelects = page.locator('select'); + + if (await temperatureSliders.count() > 0) { + // Test temperature adjustment + const slider = temperatureSliders.first(); + await slider.click(); + await expect(slider).toBeVisible(); + } + + if (await modelSelects.count() > 0) { + // Test model selection + const select = modelSelects.first(); + await select.click(); + + const options = select.locator('option'); + if (await options.count() > 1) { + await expect(options.nth(1)).toBeVisible(); + } + } + }); + + test("Should handle context and note integration", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Create or select a note first + await app.addNewTab(); + + // Try to access chat with note context + try { + await app.triggerCommand("openLlmChatWithContext"); + await page.waitForTimeout(1000); + } catch (error) { + // Try alternative method + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error2) { + // Continue with UI-based approach + } + } + + // Look for context integration features + const contextButtons = page.locator('button, a').filter({ hasText: /context|include note|add note/i }); + const atMentions = page.locator('[data-mention], .mention-button'); + + if (await contextButtons.count() > 0) { + await contextButtons.first().click(); + await page.waitForTimeout(1000); + + // Look for note selection interface + const noteSelector = page.locator('.note-selector, .note-picker'); + if (await noteSelector.count() > 0) { + await expect(noteSelector.first()).toBeVisible(); + } + } + + if (await atMentions.count() > 0) { + await atMentions.first().click(); + await page.waitForTimeout(1000); + } + }); + + test("Should display AI provider status", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // Continue + } + + // Look for AI provider status indicators + const statusIndicators = page.locator('.status-indicator, .connection-status, .provider-status'); + const providerLabels = page.locator('.provider-name, .model-name'); + const errorMessages = page.locator('.error-message, .alert').filter({ hasText: /api|provider|connection/i }); + + if (await statusIndicators.count() > 0) { + await expect(statusIndicators.first()).toBeVisible(); + } + + if (await providerLabels.count() > 0) { + const label = providerLabels.first(); + await expect(label).toBeVisible(); + + // Verify it contains a known provider name + const text = await label.textContent(); + const knownProviders = ['openai', 'anthropic', 'claude', 'gpt', 'ollama']; + const hasKnownProvider = knownProviders.some(provider => + text?.toLowerCase().includes(provider) + ); + + // Either has a known provider or at least some text + expect(text?.length).toBeGreaterThan(0); + } + + if (await errorMessages.count() > 0) { + // If there are error messages, they should be visible + await expect(errorMessages.first()).toBeVisible(); + } + }); + + test("Should handle chat export and sharing", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // Continue + } + + // Look for export or sharing features + const exportButtons = page.locator('button, a').filter({ hasText: /export|download|save|share/i }); + const menuButtons = page.locator('.menu-button, .dropdown-toggle'); + + if (await exportButtons.count() > 0) { + await exportButtons.first().click(); + await page.waitForTimeout(1000); + + // Look for export options + const exportOptions = page.locator('.export-options, .download-options'); + if (await exportOptions.count() > 0) { + await expect(exportOptions.first()).toBeVisible(); + } + } + + if (await menuButtons.count() > 0) { + await menuButtons.first().click(); + await page.waitForTimeout(500); + + // Look for menu items + const menuItems = page.locator('.dropdown-menu a, .menu-item'); + if (await menuItems.count() > 0) { + const exportMenuItem = menuItems.filter({ hasText: /export|download|save/i }); + if (await exportMenuItem.count() > 0) { + await expect(exportMenuItem.first()).toBeVisible(); + } + } + } + }); + + test("Should handle keyboard shortcuts in chat", async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Try to access chat interface + try { + await app.triggerCommand("openLlmChat"); + await page.waitForTimeout(1000); + } catch (error) { + // Continue + } + + // Look for message input to test keyboard shortcuts + const messageInputs = page.locator('textarea'); + + if (await messageInputs.count() > 0) { + const input = messageInputs.first(); + await input.click(); + + // Test common keyboard shortcuts + // Ctrl+Enter or Enter for sending + await input.fill("Test message for keyboard shortcuts"); + + // Test Ctrl+A for select all + await input.press('Control+a'); + + // Test Escape for clearing/canceling + await input.press('Escape'); + + // Verify input is still functional + await expect(input).toBeVisible(); + await expect(input).toBeFocused(); + } + + // Test global chat shortcuts + try { + await page.press('body', 'Control+Shift+l'); // Common LLM chat shortcut + await page.waitForTimeout(500); + } catch (error) { + // Shortcut might not exist, that's fine + } + }); +}); \ No newline at end of file diff --git a/apps/server/src/routes/api/llm.spec.ts b/apps/server/src/routes/api/llm.spec.ts new file mode 100644 index 000000000..76da95f13 --- /dev/null +++ b/apps/server/src/routes/api/llm.spec.ts @@ -0,0 +1,355 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it, vi, beforeEach } from "vitest"; +import supertest from "supertest"; +import config from "../../services/config.js"; + +// Import the login utility from ETAPI tests +async function login(app: Application) { + // Obtain auth token. + const response = await supertest(app) + .post("/etapi/auth/login") + .send({ + "password": "demo1234" + }) + .expect(201); + const token = response.body.authToken; + expect(token).toBeTruthy(); + return token; +} + +let app: Application; + +describe("LLM API Tests", () => { + let token: string; + let createdChatId: string; + + beforeAll(async () => { + // Enable authentication and use ETAPI auth (bypasses CSRF) + config.General.noAuthentication = false; + const buildApp = (await import("../../app.js")).default; + app = await buildApp(); + token = await login(app); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Chat Session Management", () => { + it("should create a new chat session", async () => { + const response = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Test Chat Session", + systemPrompt: "You are a helpful assistant for testing.", + temperature: 0.7, + maxTokens: 1000, + model: "gpt-3.5-turbo", + provider: "openai" + }) + .expect(200); + + expect(response.body).toMatchObject({ + sessionId: expect.any(String), + title: "Test Chat Session", + createdAt: expect.any(String) + }); + + createdChatId = response.body.sessionId; + }); + + it("should list all chat sessions", async () => { + const response = await supertest(app) + .get("/api/llm/chat") + .expect(200); + + expect(response.body).toHaveProperty('sessions'); + expect(Array.isArray(response.body.sessions)).toBe(true); + + if (response.body.sessions.length > 0) { + expect(response.body.sessions[0]).toMatchObject({ + id: expect.any(String), + title: expect.any(String), + createdAt: expect.any(String), + lastActive: expect.any(String), + messageCount: expect.any(Number) + }); + } + }); + + it("should retrieve a specific chat session", async () => { + if (!createdChatId) { + // Create a chat first if we don't have one + const createResponse = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Test Retrieval Chat" + }) + .expect(200); + + createdChatId = createResponse.body.sessionId; + } + + const response = await supertest(app) + .get(`/api/llm/chat/${createdChatId}`) + .expect(200); + + expect(response.body).toMatchObject({ + id: createdChatId, + title: expect.any(String), + messages: expect.any(Array), + createdAt: expect.any(String) + }); + }); + + it("should update a chat session", async () => { + if (!createdChatId) { + // Create a chat first if we don't have one + const createResponse = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Test Update Chat" + }) + .expect(200); + + createdChatId = createResponse.body.sessionId; + } + + const response = await supertest(app) + .patch(`/api/llm/chat/${createdChatId}`) + .send({ + title: "Updated Chat Title", + temperature: 0.8 + }) + .expect(200); + + expect(response.body).toMatchObject({ + id: createdChatId, + title: "Updated Chat Title", + updatedAt: expect.any(String) + }); + }); + + it("should return 404 for non-existent chat session", async () => { + await supertest(app) + .get("/api/llm/chat/nonexistent-chat-id") + .expect(404); + }); + }); + + describe("Chat Messaging", () => { + let testChatId: string; + + beforeEach(async () => { + // Create a fresh chat for each test + const createResponse = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Message Test Chat" + }) + .expect(200); + + testChatId = createResponse.body.sessionId; + }); + + it("should handle sending a message to a chat", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages`) + .send({ + message: "Hello, how are you?", + options: { + temperature: 0.7, + maxTokens: 100 + }, + includeContext: false, + useNoteContext: false + }); + + // The response depends on whether AI is actually configured + // We should get either a successful response or an error about AI not being configured + expect([200, 400, 500]).toContain(response.status); + + if (response.status === 200) { + expect(response.body).toMatchObject({ + response: expect.any(String), + sessionId: testChatId + }); + } else { + // AI not configured is expected in test environment + expect(response.body).toHaveProperty('error'); + } + }); + + it("should handle empty message content", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages`) + .send({ + message: "", + options: {} + }); + + expect([400, 500]).toContain(response.status); + expect(response.body).toHaveProperty('error'); + }); + + it("should handle invalid chat ID for messaging", async () => { + const response = await supertest(app) + .post("/api/llm/chat/invalid-chat-id/messages") + .send({ + message: "Hello", + options: {} + }); + + expect([404, 500]).toContain(response.status); + }); + }); + + describe("Chat Streaming", () => { + let testChatId: string; + + beforeEach(async () => { + // Create a fresh chat for each test + const createResponse = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Streaming Test Chat" + }) + .expect(200); + + testChatId = createResponse.body.sessionId; + }); + + it("should initiate streaming for a chat message", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages/stream`) + .send({ + content: "Tell me a short story", + useAdvancedContext: false, + showThinking: false + }); + + // The streaming endpoint should immediately return success + // indicating that streaming has been initiated + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + success: true, + message: "Streaming initiated successfully" + }); + }); + + it("should handle empty content for streaming", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages/stream`) + .send({ + content: "", + useAdvancedContext: false, + showThinking: false + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + success: false, + error: "Content cannot be empty" + }); + }); + + it("should handle whitespace-only content for streaming", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages/stream`) + .send({ + content: " \n\t ", + useAdvancedContext: false, + showThinking: false + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + success: false, + error: "Content cannot be empty" + }); + }); + + it("should handle invalid chat ID for streaming", async () => { + const response = await supertest(app) + .post("/api/llm/chat/invalid-chat-id/messages/stream") + .send({ + content: "Hello", + useAdvancedContext: false, + showThinking: false + }); + + // Should still return 200 for streaming initiation + // Errors would be communicated via WebSocket + expect(response.status).toBe(200); + }); + + it("should handle streaming with note mentions", async () => { + const response = await supertest(app) + .post(`/api/llm/chat/${testChatId}/messages/stream`) + .send({ + content: "Tell me about this note", + useAdvancedContext: true, + showThinking: true, + mentions: [ + { + noteId: "root", + title: "Root Note" + } + ] + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + success: true, + message: "Streaming initiated successfully" + }); + }); + }); + + describe("Error Handling", () => { + it("should handle malformed JSON in request body", async () => { + const response = await supertest(app) + .post("/api/llm/chat") + .set('Content-Type', 'application/json') + .send('{ invalid json }'); + + expect([400, 500]).toContain(response.status); + }); + + it("should handle missing required fields", async () => { + const response = await supertest(app) + .post("/api/llm/chat") + .send({ + // Missing required fields + }); + + // Should still work as title can be auto-generated + expect([200, 400, 500]).toContain(response.status); + }); + + it("should handle invalid parameter types", async () => { + const response = await supertest(app) + .post("/api/llm/chat") + .send({ + title: "Test Chat", + temperature: "invalid", // Should be number + maxTokens: "also-invalid" // Should be number + }); + + // API should handle type conversion or validation + expect([200, 400, 500]).toContain(response.status); + }); + }); + + afterAll(async () => { + // Clean up: delete any created chats + if (createdChatId) { + try { + await supertest(app) + .delete(`/api/llm/chat/${createdChatId}`); + } catch (error) { + // Ignore cleanup errors + } + } + }); +}); \ No newline at end of file