# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client """ Tests for Chat API endpoints and progress handling. """ import json import time class TestChatSessionAPI: """Tests for chat session endpoints.""" def test_create_chat_session(self, authenticated_client): """Test creating a new chat session.""" response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test research question"}, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "session_id" in data assert data["session_id"] is not None def test_get_chat_sessions(self, authenticated_client): """Test listing chat sessions.""" # First create a session authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) # Then list sessions response = authenticated_client.get("/api/chat/sessions") assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "sessions" in data assert isinstance(data["sessions"], list) def test_get_chat_session_by_id(self, authenticated_client): """Test getting a specific chat session.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Get the session response = authenticated_client.get(f"/api/chat/sessions/{session_id}") assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "session" in data assert data["session"]["id"] == session_id class TestGenerateTitleEndpoint: """Tests for POST /api/chat/sessions//generate-title.""" def test_generate_title_requires_query(self, authenticated_client): """Missing query → 400.""" create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] response = authenticated_client.post( f"/api/chat/sessions/{session_id}/generate-title", json={}, content_type="application/json", ) assert response.status_code == 400 data = json.loads(response.data) assert data["success"] is False def test_generate_title_returns_404_for_missing_session( self, authenticated_client ): """Unknown session → 404.""" response = authenticated_client.post( "/api/chat/sessions/does-not-exist/generate-title", json={"query": "what is x"}, content_type="application/json", ) assert response.status_code == 404 def test_generate_title_no_op_when_llm_disabled(self, authenticated_client): """When chat.llm_title_generation is disabled, endpoint returns success=False and title=None so the frontend keeps the fallback title. This is the default settings state in test fixtures. """ create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] response = authenticated_client.post( f"/api/chat/sessions/{session_id}/generate-title", json={"query": "does quantum entanglement violate locality"}, content_type="application/json", ) # 200 regardless of LLM outcome — caller treats title=null as "no change" assert response.status_code == 200 class TestChatMessageAPI: """Tests for chat message endpoints.""" def test_send_message_without_research(self, authenticated_client): """Test sending a message without triggering research.""" # Create a session first create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Initial query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send a message without research response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "Test message", "trigger_research": False}, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "research_id" not in data or data.get("research_id") is None def test_get_session_messages(self, authenticated_client): """Test getting messages for a session.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Get messages response = authenticated_client.get( f"/api/chat/sessions/{session_id}/messages" ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "messages" in data assert isinstance(data["messages"], list) # Server tells client whether research is currently running so # chat.js loadSession can restore the "thinking" indicator # without inferring it from message metadata. None when no # research is in flight. assert "in_progress_research_id" in data assert data["in_progress_research_id"] is None def test_message_role_and_type_serialize_as_plain_strings( self, authenticated_client ): """JSON response has role/message_type as plain strings, not 'ChatRole.USER'. Guards against the concern that `(str, enum.Enum)` might leak `"ChatRole.USER"` through Flask's jsonify. `(str, enum.Enum)` members are str subclasses, so json.dumps hits the isinstance(str) fast path and emits the value only. """ create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Serialization test"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "hello", "trigger_research": False}, content_type="application/json", ) response = authenticated_client.get( f"/api/chat/sessions/{session_id}/messages" ) data = json.loads(response.data) assert data["success"] is True assert data["messages"], "expected at least one message" for m in data["messages"]: # Plain string, not 'ChatRole.X' or 'ChatMessageType.X' assert m["role"] in {"user", "assistant"}, m["role"] assert m["message_type"] in { "query", "followup", "response", "step", }, m["message_type"] class TestChatSessionUpdate: """Tests for chat session update operations.""" def test_update_session_title(self, authenticated_client): """Test updating a chat session title.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Update the title response = authenticated_client.patch( f"/api/chat/sessions/{session_id}", json={"title": "Updated Title"}, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True # Verify the title was updated get_response = authenticated_client.get( f"/api/chat/sessions/{session_id}" ) session_data = json.loads(get_response.data) assert session_data["session"]["title"] == "Updated Title" def test_update_session_with_empty_title(self, authenticated_client): """Test that empty title is rejected or handled.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Try to update with empty title response = authenticated_client.patch( f"/api/chat/sessions/{session_id}", json={"title": ""}, content_type="application/json", ) # Should either reject or keep old title assert response.status_code in [200, 400] def test_update_nonexistent_session(self, authenticated_client): """Test updating a session that doesn't exist.""" response = authenticated_client.patch( "/api/chat/sessions/nonexistent-id", json={"title": "New Title"}, content_type="application/json", ) # Missing session must return 404; a 500 here would be a real # unhandled-exception regression. assert response.status_code == 404 data = json.loads(response.data) assert "success" in data class TestChatSessionDelete: """Tests for chat session deletion.""" def test_delete_session(self, authenticated_client): """Test deleting a chat session.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Delete the session response = authenticated_client.delete( f"/api/chat/sessions/{session_id}" ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True # After a hard delete the row is gone; subsequent GET must be 404. # 500 here would be an unhandled-exception regression. get_response = authenticated_client.get( f"/api/chat/sessions/{session_id}" ) assert get_response.status_code == 404 def test_delete_nonexistent_session(self, authenticated_client): """Test deleting a session that doesn't exist returns 404.""" response = authenticated_client.delete( "/api/chat/sessions/nonexistent-id" ) # Deleting a non-existent session must return 404, not 500. assert response.status_code == 404 class TestChatMessageValidation: """Tests for chat message validation.""" def test_send_empty_message(self, authenticated_client): """Test sending an empty message.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send empty message response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "", "trigger_research": False}, content_type="application/json", ) # Should be rejected assert response.status_code == 400 def test_send_message_to_nonexistent_session(self, authenticated_client): """Test sending a message to a session that doesn't exist.""" response = authenticated_client.post( "/api/chat/sessions/nonexistent-id/messages", json={"content": "Test message", "trigger_research": False}, content_type="application/json", ) assert response.status_code == 404 def test_message_with_special_characters(self, authenticated_client): """Test sending a message with special characters.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send message with special characters special_message = 'Test with émojis 🚀 and spëcial chars <>&"' response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": special_message, "trigger_research": False}, content_type="application/json", ) assert response.status_code == 200 # Verify message is stored correctly messages_response = authenticated_client.get( f"/api/chat/sessions/{session_id}/messages" ) messages = json.loads(messages_response.data)["messages"] assert any(m["content"] == special_message for m in messages) class TestChatSessionPagination: """Tests for chat session listing and pagination.""" def test_list_sessions_with_limit(self, authenticated_client): """Test listing sessions with a limit.""" # Create multiple sessions for i in range(5): authenticated_client.post( "/api/chat/sessions", json={"initial_query": f"Query {i}"}, content_type="application/json", ) # List with limit response = authenticated_client.get("/api/chat/sessions?limit=3") assert response.status_code == 200 data = json.loads(response.data) assert len(data["sessions"]) <= 3 def test_list_sessions_order(self, authenticated_client): """Test that sessions are listed in order (most recent first).""" # ChatSession.created_at is set by ``utcnow()`` at INSERT time. # SQLite's CURRENT_TIMESTAMP can have second-only resolution on some # platforms, so a small sleep guarantees strictly-increasing # timestamps between sessions and avoids tie-breaking surprises in # the listing query's ORDER BY created_at DESC. session_ids = [] for i in range(3): response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": f"Query {i}"}, content_type="application/json", ) session_ids.append(json.loads(response.data)["session_id"]) time.sleep(0.05) # List sessions response = authenticated_client.get("/api/chat/sessions") data = json.loads(response.data) # Precondition (assert, don't skip): the listing endpoint must # return at least the three sessions we just created — anything # less is itself a failure we want to surface, not silently # bypass with a conditional that masks the ordering assertion. assert len(data["sessions"]) >= 3, ( f"Expected ≥3 sessions, got {len(data['sessions'])}" ) listed_ids = [s["id"] for s in data["sessions"]] # Most recent first: last created session should appear first. assert listed_ids[0] == session_ids[-1] class TestChatResearchIntegration: """Tests for chat research integration.""" def test_message_triggers_research(self, authenticated_client): """Test that sending a message with trigger_research=True returns research_id.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "What is quantum computing?"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send message with research trigger response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "Explain quantum entanglement", "trigger_research": True, }, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True # Research should be triggered assert "research_id" in data def test_multiple_messages_in_session(self, authenticated_client): """Test sending multiple messages in the same session.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Initial query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send multiple messages messages = ["First message", "Second message", "Third message"] for msg in messages: response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": msg, "trigger_research": False}, content_type="application/json", ) assert response.status_code == 200 # Get all messages response = authenticated_client.get( f"/api/chat/sessions/{session_id}/messages" ) data = json.loads(response.data) assert len(data["messages"]) >= len(messages)