403Webshell
Server IP : 3.147.158.171  /  Your IP : 216.73.216.88
Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5
System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64
User : ec2-user ( 1000)
PHP Version : 8.4.21
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /tsai/repo/api/tests/unit/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/api/tests/unit/test_chat_lc.py
"""
Unit tests for the /chat-lc/ LangChain endpoints.

Tests cover:
- GET /chat-lc/models-langchain/ - List available LangChain models
- POST /chat-lc/store/ - Store message for LangChain streaming
- GET /chat-lc/stream-langchain/{key} - Stream response via LangChain
- GET /chat-lc/stream-responses/{key} - Stream with OpenAI Responses API
- GET /chat-lc/stream-agent/{key} - Stream with LangChain agent and tools
- Error handling for invalid keys and missing data
"""

import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from app.main import app
from app.utilities.site_auth import get_current_site


@pytest.fixture(autouse=True)
def _override_site_auth():
    """Auto-apply: every test in this file gets a stub site so the new
    Origin-based dependency in /chat-lc/store/ resolves cleanly."""
    app.dependency_overrides[get_current_site] = lambda: MagicMock()
    yield
    app.dependency_overrides.clear()


@pytest.fixture
def client():
    """Create test client."""
    return TestClient(app)


class TestLangChainModels:
    """Tests for GET /api/v1/chat-lc/models-langchain/"""

    def test_get_langchain_models(self, client, monkeypatch):
        """Test that models endpoint returns available models list."""
        response = client.get("/api/v1/chat-lc/models-langchain/")
        assert response.status_code == 200
        data = response.json()
        assert "models" in data
        assert isinstance(data["models"], list)
        # Should have at least OpenAI models
        assert len(data["models"]) >= 1

    def test_get_langchain_models_contains_openai(self, client, monkeypatch):
        """Test that OpenAI models are included in the list."""
        response = client.get("/api/v1/chat-lc/models-langchain/")
        data = response.json()
        providers = [m["provider"] for m in data["models"]]
        assert "OpenAI" in providers

    def test_get_langchain_models_structure(self, client, monkeypatch):
        """Test that model entries have expected fields."""
        response = client.get("/api/v1/chat-lc/models-langchain/")
        data = response.json()

        if len(data["models"]) > 0:
            model = data["models"][0]
            assert "provider" in model
            assert "name" in model
            assert "supports_streaming" in model


class TestLangChainStore:
    """Tests for POST /api/v1/chat-lc/store/"""

    def test_store_langchain_message_success(self, client):
        """Test storing a message returns a unique key."""
        payload = {
            "userId": 999,
            "body": "Hello, this is a test message",
            "instructions": "You are a helpful assistant."
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data
        # Key should be a numeric string (timestamp)
        assert data["message"].isdigit()

    def test_store_langchain_message_with_openai_model(self, client):
        """Test storing a message with OpenAI model configuration."""
        payload = {
            "userId": 999,
            "body": "Tell me a joke",
            "instructions": "You are a comedian.",
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o-mini",
                "temperature": 0.7,
                "stream": True
            }
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data

    def test_store_langchain_message_with_claude_model(self, client):
        """Test storing a message with Claude model configuration."""
        payload = {
            "userId": 999,
            "body": "Hello Claude!",
            "instructions": "You are Claude, an AI assistant.",
            "model": {
                "provider": "Claude",
                "name": "claude-sonnet-4-5-20250929",
                "temperature": 0.7,
                "maxTokens": 4096,
                "stream": True
            }
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data

    def test_store_langchain_message_with_conversation(self, client):
        """Test storing a message with conversation history."""
        payload = {
            "userId": 999,
            "body": "What did I just say?",
            "instructions": "You are a helpful assistant.",
            "conversation": [
                {"userId": 0, "body": "My favorite color is blue."},
                {"userId": 1, "body": "That's a nice color!"}
            ],
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o-mini",
                "temperature": 0.7
            }
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data

    def test_store_langchain_message_with_structured_output(self, client):
        """Test storing a message requesting structured output."""
        payload = {
            "userId": 999,
            "body": "Generate a product description for wireless headphones",
            "instructions": "You are a product writer.",
            "use_structured_output": True,
            "response_schema": {
                "name": "ProductDescription",
                "schema": {
                    "type": "object",
                    "properties": {
                        "title": {"type": "string"},
                        "description": {"type": "string"},
                        "features": {
                            "type": "array",
                            "items": {"type": "string"}
                        }
                    },
                    "required": ["title", "description", "features"]
                }
            },
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o",
                "temperature": 0.7
            }
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data

    def test_store_langchain_message_with_images(self, client):
        """Test storing a message with image URLs."""
        payload = {
            "userId": 999,
            "body": "What is in this image?",
            "instructions": "You are a helpful assistant that can see images.",
            "images": ["https://example.com/image.jpg"],
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o",
                "temperature": 0.7
            }
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert "message" in data

    def test_store_langchain_message_missing_body(self, client):
        """Test that missing body field returns validation error."""
        payload = {
            "userId": 999
            # Missing "body" field
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 422  # Validation error

    def test_store_langchain_message_missing_user_id(self, client):
        """Test that missing userId field returns validation error."""
        payload = {
            "body": "Hello"
            # Missing "userId" field
        }

        response = client.post("/api/v1/chat-lc/store/", json=payload)
        assert response.status_code == 422  # Validation error


class TestLangChainStream:
    """Tests for GET /api/v1/chat-lc/stream-langchain/{key}"""

    def test_stream_langchain_invalid_key(self, client):
        """Test streaming with an invalid key returns error in stream."""
        # Clear the data store first
        import app.api.v1.endpoints.responses_langchain as lc_module
        lc_module.data_store.clear()

        # Try to stream with a non-existent key
        response = client.get("/api/v1/chat-lc/stream-langchain/invalid_key_12345")
        # SSE always returns 200, error is in the stream content
        assert response.status_code == 200
        # Check the response content for error message
        content = response.text
        assert "Error: Message not found" in content

    def test_store_and_data_consumed(self, client):
        """Test that stored data is consumed (removed) after retrieval."""
        import app.api.v1.endpoints.responses_langchain as lc_module

        # Store a message
        payload = {
            "userId": 999,
            "body": "Test message for consumption check"
        }
        store_response = client.post("/api/v1/chat-lc/store/", json=payload)
        key = store_response.json()["message"]

        # Verify key exists in data store
        assert key in lc_module.data_store

        # Get the data (simulating what stream does)
        data = lc_module.get_data(key)
        assert data is not None
        assert data.body == "Test message for consumption check"

        # Verify key is now consumed
        assert key not in lc_module.data_store

        # Second get should return None
        data_again = lc_module.get_data(key)
        assert data_again is None


class TestStreamResponses:
    """Tests for GET /api/v1/chat-lc/stream-responses/{key}"""

    def test_stream_responses_invalid_key(self, client):
        """Test streaming responses with an invalid key returns error."""
        import app.api.v1.endpoints.responses_langchain as lc_module
        lc_module.data_store.clear()

        response = client.get("/api/v1/chat-lc/stream-responses/invalid_key_99999")
        assert response.status_code == 200
        content = response.text
        assert "Error: Message not found" in content

    def test_stream_responses_without_structured_output_falls_back(self, client, monkeypatch):
        """Test that without structured output flag, falls back to regular streaming."""
        import app.api.v1.endpoints.responses_langchain as lc_module

        # Mock the create_chat_model to avoid real API calls
        mock_model = MagicMock()

        async def mock_astream(messages):
            yield MagicMock(content="Fallback response")

        mock_model.astream = mock_astream

        monkeypatch.setattr(
            lc_module.LangChainChatProvider,
            "create_chat_model",
            MagicMock(return_value=mock_model)
        )

        # Store a message without structured output
        payload = {
            "userId": 999,
            "body": "Simple message without structured output",
            "use_structured_output": False,
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o",
                "temperature": 0.7
            }
        }

        store_response = client.post("/api/v1/chat-lc/store/", json=payload)
        key = store_response.json()["message"]

        # Stream should work (falls back to langchain generator)
        response = client.get(f"/api/v1/chat-lc/stream-responses/{key}")
        assert response.status_code == 200


class TestStreamAgent:
    """Tests for GET /api/v1/chat-lc/stream-agent/{key}"""

    def test_stream_agent_invalid_key(self, client):
        """Test streaming agent with an invalid key returns error."""
        import app.api.v1.endpoints.responses_langchain as lc_module
        lc_module.data_store.clear()

        response = client.get("/api/v1/chat-lc/stream-agent/invalid_key_88888")
        assert response.status_code == 200
        content = response.text
        assert "Error: Message not found" in content

    def test_stream_agent_no_tools_falls_back(self, client, monkeypatch):
        """Test that agent without tools falls back to regular streaming."""
        import app.api.v1.endpoints.responses_langchain as lc_module
        import os

        # Remove TAVILY_API_KEY to ensure no tools are configured
        monkeypatch.delenv("TAVILY_API_KEY", raising=False)

        # Mock the create_chat_model to avoid real API calls
        mock_model = MagicMock()

        async def mock_astream(messages):
            yield MagicMock(content="Fallback agent response")

        mock_model.astream = mock_astream

        monkeypatch.setattr(
            lc_module.LangChainChatProvider,
            "create_chat_model",
            MagicMock(return_value=mock_model)
        )

        # Store a message
        payload = {
            "userId": 999,
            "body": "What is the weather?",
            "model": {
                "provider": "OpenAI",
                "name": "gpt-4o",
                "temperature": 0.7
            }
        }

        store_response = client.post("/api/v1/chat-lc/store/", json=payload)
        key = store_response.json()["message"]

        # Stream should work (falls back due to no tools)
        response = client.get(f"/api/v1/chat-lc/stream-agent/{key}")
        assert response.status_code == 200

    def test_stream_agent_emits_search_results(self, client, monkeypatch):
        """Test that a tool-using agent emits a search_results frame the client can render.

        Guards two regressions: AgentExecutor omitting intermediate_steps (no
        frame at all), and forwarding Tavily's envelope instead of its `results`
        list (the client iterates it, so a dict breaks rendering).
        """
        import json
        import app.api.v1.endpoints.responses_langchain as lc_module
        from types import SimpleNamespace

        monkeypatch.setenv("TAVILY_API_KEY", "test-tavily-key")
        monkeypatch.setattr(lc_module, "TavilySearch", MagicMock())
        monkeypatch.setattr(
            lc_module.LangChainChatProvider,
            "create_chat_model",
            MagicMock(return_value=MagicMock())
        )
        monkeypatch.setattr(
            lc_module, "create_tool_calling_agent", MagicMock(return_value=MagicMock())
        )

        # Tavily hands the agent the full envelope; only `results` should ship.
        tool_output = {
            "query": "today's news",
            "answer": "Some summary",
            "response_time": 1.2,
            "results": [
                {"title": "Headline", "url": "https://example.com", "content": "Body", "score": 0.9}
            ],
        }
        mock_executor = MagicMock()
        mock_executor.ainvoke = AsyncMock(return_value={
            "output": "Here is what I found.",
            "intermediate_steps": [
                (SimpleNamespace(tool="search_current_events", tool_input="today's news"), tool_output)
            ],
        })
        mock_executor_cls = MagicMock(return_value=mock_executor)
        monkeypatch.setattr(lc_module, "AgentExecutor", mock_executor_cls)

        payload = {
            "userId": 999,
            "body": "What happened in the news today?",
            "model": {"provider": "OpenAI", "name": "gpt-4o", "temperature": 0.7},
        }
        store_response = client.post("/api/v1/chat-lc/store/", json=payload)
        key = store_response.json()["message"]

        response = client.get(f"/api/v1/chat-lc/stream-agent/{key}")
        assert response.status_code == 200

        # AgentExecutor omits intermediate_steps unless asked, which would make
        # the frame below unreachable. The mock can't catch that for us.
        assert mock_executor_cls.call_args.kwargs.get("return_intermediate_steps") is True

        frames = [
            json.loads(line[len("data: "):])
            for line in response.text.splitlines()
            if line.startswith("data: {") and '"type"' in line
        ]
        search_frames = [f for f in frames if f.get("type") == "search_results"]
        assert len(search_frames) == 1, f"expected one search_results frame, got {frames}"

        frame = search_frames[0]
        assert frame["query"] == "today's news"
        # Must be the unwrapped list, not Tavily's dict envelope.
        assert isinstance(frame["results"], list)
        assert frame["results"][0]["url"] == "https://example.com"

        # Citations paint before the prose answer.
        assert response.text.index('"search_results"') < response.text.index("Here is what I found.")

    def test_stream_agent_without_tool_call_emits_no_search_frame(self, client, monkeypatch):
        """Test that an agent answering without tools emits no search_results frame."""
        import app.api.v1.endpoints.responses_langchain as lc_module

        monkeypatch.setenv("TAVILY_API_KEY", "test-tavily-key")
        monkeypatch.setattr(lc_module, "TavilySearch", MagicMock())
        monkeypatch.setattr(
            lc_module.LangChainChatProvider,
            "create_chat_model",
            MagicMock(return_value=MagicMock())
        )
        monkeypatch.setattr(
            lc_module, "create_tool_calling_agent", MagicMock(return_value=MagicMock())
        )

        mock_executor = MagicMock()
        mock_executor.ainvoke = AsyncMock(
            return_value={"output": "An old silent pond", "intermediate_steps": []}
        )
        monkeypatch.setattr(lc_module, "AgentExecutor", MagicMock(return_value=mock_executor))

        payload = {
            "userId": 999,
            "body": "Write me a haiku",
            "model": {"provider": "OpenAI", "name": "gpt-4o", "temperature": 0.7},
        }
        store_response = client.post("/api/v1/chat-lc/store/", json=payload)
        key = store_response.json()["message"]

        response = client.get(f"/api/v1/chat-lc/stream-agent/{key}")
        assert response.status_code == 200
        assert "search_results" not in response.text
        assert "An old silent pond" in response.text


class TestLangChainChatProvider:
    """Tests for the LangChainChatProvider utility class."""

    def test_get_available_models_returns_list(self):
        """Test that get_available_models returns a list of ModelInfo."""
        from app.api.v1.endpoints.responses_langchain import LangChainChatProvider

        models = LangChainChatProvider.get_available_models()
        assert isinstance(models, list)
        assert len(models) >= 1  # At least OpenAI models

    def test_get_available_models_has_openai(self):
        """Test that OpenAI models are always available."""
        from app.api.v1.endpoints.responses_langchain import LangChainChatProvider

        models = LangChainChatProvider.get_available_models()
        openai_models = [m for m in models if m.provider == "OpenAI"]
        assert len(openai_models) >= 1

    def test_get_available_models_model_structure(self):
        """Test that models have expected fields."""
        from app.api.v1.endpoints.responses_langchain import LangChainChatProvider

        models = LangChainChatProvider.get_available_models()
        if len(models) > 0:
            model = models[0]
            assert hasattr(model, 'provider')
            assert hasattr(model, 'name')
            assert hasattr(model, 'supports_streaming')
            assert hasattr(model, 'supports_structured_output')

    def test_create_chat_model_openai(self, monkeypatch):
        """Test creating an OpenAI chat model."""
        from app.api.v1.endpoints.responses_langchain import LangChainChatProvider
        from app.appTypes import LLModel, LLMProvider

        # Mock ChatOpenAI to avoid real initialization
        mock_chat_openai = MagicMock()
        monkeypatch.setattr(
            "app.api.v1.endpoints.responses_langchain.ChatOpenAI",
            MagicMock(return_value=mock_chat_openai)
        )

        # Clear model cache to ensure fresh creation
        LangChainChatProvider._model_cache.clear()

        model_config = LLModel(
            provider=LLMProvider.OPEN_AI,
            name="gpt-4o-mini",
            temperature=0.7
        )

        result = LangChainChatProvider.create_chat_model(model_config)
        assert result is not None

    def test_create_chat_model_claude_requires_api_key(self, monkeypatch):
        """Test that creating a Claude model requires ANTHROPIC_API_KEY."""
        from app.api.v1.endpoints.responses_langchain import LangChainChatProvider
        from app.appTypes import LLModel, LLMProvider
        from fastapi import HTTPException

        # Remove the API key
        monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

        # Clear model cache
        LangChainChatProvider._model_cache.clear()

        model_config = LLModel(
            provider=LLMProvider.CLAUDE,
            name="claude-sonnet-4-5-20250929",
            temperature=0.7
        )

        with pytest.raises(HTTPException) as exc_info:
            LangChainChatProvider.create_chat_model(model_config)

        assert exc_info.value.status_code == 500
        assert "ANTHROPIC_API_KEY" in str(exc_info.value.detail)


class TestConvertToLangChainMessages:
    """Tests for message conversion utility."""

    def test_convert_simple_message(self):
        """Test converting a simple message to LangChain format."""
        from app.api.v1.endpoints.responses_langchain import convert_to_langchain_messages
        from langchain_core.messages import HumanMessage, SystemMessage

        messages = convert_to_langchain_messages(
            instructions="You are helpful.",
            conversation=None,
            body="Hello!"
        )

        assert len(messages) == 2  # System + Human
        assert isinstance(messages[0], SystemMessage)
        assert isinstance(messages[1], HumanMessage)
        assert messages[0].content == "You are helpful."
        assert messages[1].content == "Hello!"

    def test_convert_message_with_conversation(self):
        """Test converting messages with conversation history."""
        from app.api.v1.endpoints.responses_langchain import convert_to_langchain_messages
        from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
        from app.appTypes import Message

        conversation = [
            Message(userId=0, body="First question"),
            Message(userId=1, body="First answer"),
            Message(userId=0, body="Second question"),
        ]

        messages = convert_to_langchain_messages(
            instructions="Be helpful",
            conversation=conversation,
            body="Current message"
        )

        # System + 3 conversation messages + current message
        assert len(messages) == 5
        assert isinstance(messages[0], SystemMessage)
        assert isinstance(messages[1], HumanMessage)
        assert isinstance(messages[2], AIMessage)
        assert isinstance(messages[3], HumanMessage)
        assert isinstance(messages[4], HumanMessage)

    def test_convert_message_without_instructions(self):
        """Test converting messages without system instructions."""
        from app.api.v1.endpoints.responses_langchain import convert_to_langchain_messages
        from langchain_core.messages import HumanMessage

        messages = convert_to_langchain_messages(
            instructions=None,
            conversation=None,
            body="Just a message"
        )

        assert len(messages) == 1
        assert isinstance(messages[0], HumanMessage)

    def test_convert_message_with_images(self):
        """Test converting messages with image URLs."""
        from app.api.v1.endpoints.responses_langchain import convert_to_langchain_messages
        from langchain_core.messages import HumanMessage

        messages = convert_to_langchain_messages(
            instructions="Describe the image.",
            conversation=None,
            body="What is this?",
            images=["https://example.com/image.jpg"]
        )

        assert len(messages) == 2
        # The last message should be a HumanMessage with image content
        human_msg = messages[1]
        assert isinstance(human_msg, HumanMessage)
        # Content should be a list for multimodal input
        assert isinstance(human_msg.content, list)
        assert len(human_msg.content) == 2  # text + image

Youez - 2016 - github.com/yon3zu
LinuXploit