| Server IP : 3.147.158.171 / Your IP : 216.73.216.216 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/www/html/api.johnturman.net/tests/unit/ |
Upload File : |
"""
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