mirror of
https://github.com/grillazz/fastapi-sqlalchemy-asyncpg.git
synced 2026-07-28 05:00:38 +03:00
feat: update chat backend to use stub and add websocket chat service tests
This commit is contained in:
@@ -22,6 +22,6 @@ EMAIL_HOST=
|
|||||||
EMAIL_HOST_USER=
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=
|
EMAIL_HOST_PASSWORD=
|
||||||
|
|
||||||
CHAT_BACKEND=ollama
|
CHAT_BACKEND=stub
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await app.logger.aerror("Error during app startup", error=repr(e))
|
await app.logger.aerror("Error during app startup", error=repr(e))
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await app.redis.close()
|
await app.redis.aclose()
|
||||||
await app.chat_agent.aclose()
|
await app.chat_agent.aclose()
|
||||||
# await app.postgres_pool.close()
|
# await app.postgres_pool.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Tests for the websocket chat service using the local stub agent.
|
||||||
|
|
||||||
|
Uses Starlette's synchronous ``TestClient`` websocket support (rather than
|
||||||
|
the ``httpx.AsyncClient`` fixture used elsewhere) since it triggers the
|
||||||
|
FastAPI lifespan on ``__enter__``/``__exit__``, wiring up
|
||||||
|
``app.chat_agent`` / ``app.state.chat_sessions`` exactly like a real
|
||||||
|
server run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_ws_full_conversation_flow() -> None:
|
||||||
|
with (
|
||||||
|
TestClient(app) as client,
|
||||||
|
client.websocket_connect("/v1/chat/ws") as websocket,
|
||||||
|
):
|
||||||
|
# 1. connect
|
||||||
|
connected = websocket.receive_json()
|
||||||
|
assert connected["type"] == "connected"
|
||||||
|
session_id = connected["session_id"]
|
||||||
|
|
||||||
|
# 2. start conversation with a system prompt
|
||||||
|
websocket.send_json(
|
||||||
|
{"type": "start", "system_prompt": "You are a helpful assistant."}
|
||||||
|
)
|
||||||
|
started = websocket.receive_json()
|
||||||
|
print(f"{started=}")
|
||||||
|
assert started == {"type": "started", "session_id": session_id}
|
||||||
|
|
||||||
|
# 3. send a user message and aggregate the streamed assistant chunks
|
||||||
|
websocket.send_json({"type": "user_message", "content": "hello"})
|
||||||
|
|
||||||
|
event = websocket.receive_json()
|
||||||
|
aggregated = ""
|
||||||
|
while event["type"] == "assistant_chunk":
|
||||||
|
assert event["index"] >= 0
|
||||||
|
aggregated += event["content"]
|
||||||
|
event = websocket.receive_json()
|
||||||
|
|
||||||
|
assert event["type"] == "assistant_message"
|
||||||
|
assert event["message"]["role"] == "assistant"
|
||||||
|
assert event["message"]["content"] == aggregated
|
||||||
|
print(f"{event=}")
|
||||||
|
print(f"{aggregated=}")
|
||||||
|
assert "hello" in aggregated.lower()
|
||||||
|
|
||||||
|
# 4. request full history (system + user + assistant)
|
||||||
|
websocket.send_json({"type": "history_request"})
|
||||||
|
history = websocket.receive_json()
|
||||||
|
assert history["type"] == "history"
|
||||||
|
roles = [m["role"] for m in history["messages"]]
|
||||||
|
assert roles == ["system", "user", "assistant"]
|
||||||
|
|
||||||
|
# 5. gracefully end the conversation
|
||||||
|
websocket.send_json({"type": "end"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_ws_invalid_event_returns_error() -> None:
|
||||||
|
with (
|
||||||
|
TestClient(app) as client,
|
||||||
|
client.websocket_connect("/v1/chat/ws") as websocket,
|
||||||
|
):
|
||||||
|
websocket.receive_json() # connected
|
||||||
|
|
||||||
|
websocket.send_json({"type": "not_a_real_event"})
|
||||||
|
error = websocket.receive_json()
|
||||||
|
assert error["type"] == "error"
|
||||||
|
|
||||||
|
websocket.send_json({"type": "end"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_ws_ask_question_and_print_model_answer() -> None:
|
||||||
|
"""Ask a real question over the websocket chat and print the model's answer.
|
||||||
|
|
||||||
|
Runs against whatever ``ChatAgent`` is wired up via ``app.state.chat_agent``
|
||||||
|
(the local stub agent by default, or a real Ollama-backed agent when
|
||||||
|
``CHAT_BACKEND=ollama`` is configured), exercising the full websocket
|
||||||
|
conversation flow end-to-end.
|
||||||
|
"""
|
||||||
|
question = "What is the capital of France?"
|
||||||
|
|
||||||
|
with (
|
||||||
|
TestClient(app) as client,
|
||||||
|
client.websocket_connect("/v1/chat/ws") as websocket,
|
||||||
|
):
|
||||||
|
# 1. connect
|
||||||
|
connected = websocket.receive_json()
|
||||||
|
assert connected["type"] == "connected"
|
||||||
|
session_id = connected["session_id"]
|
||||||
|
|
||||||
|
# 2. start conversation
|
||||||
|
websocket.send_json(
|
||||||
|
{"type": "start", "system_prompt": "You are a helpful assistant."}
|
||||||
|
)
|
||||||
|
started = websocket.receive_json()
|
||||||
|
assert started == {"type": "started", "session_id": session_id}
|
||||||
|
|
||||||
|
# 3. ask the question and aggregate the streamed answer
|
||||||
|
websocket.send_json({"type": "user_message", "content": question})
|
||||||
|
|
||||||
|
event = websocket.receive_json()
|
||||||
|
answer = ""
|
||||||
|
while event["type"] == "assistant_chunk":
|
||||||
|
answer += event["content"]
|
||||||
|
event = websocket.receive_json()
|
||||||
|
|
||||||
|
assert event["type"] == "assistant_message"
|
||||||
|
answer = event["message"]["content"]
|
||||||
|
|
||||||
|
print(f"\nQuestion: {question}")
|
||||||
|
print(f"Model answer: {answer}")
|
||||||
|
|
||||||
|
assert isinstance(answer, str)
|
||||||
|
assert answer.strip() != ""
|
||||||
|
|
||||||
|
# 4. gracefully end the conversation
|
||||||
|
websocket.send_json({"type": "end"})
|
||||||
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Test suite for refactored OllamaChatAgent with attrs features."""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.chat_agent import OllamaChatAgent, LocalEchoAgent, build_chat_agent
|
||||||
|
from app.config import ChatConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestOllamaChatAgentAttrsFeatures:
|
||||||
|
"""Test the attrs library implementation and features."""
|
||||||
|
|
||||||
|
def test_instantiation_with_valid_config(self):
|
||||||
|
"""Test basic instantiation with valid configuration."""
|
||||||
|
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
|
assert agent.model == "llama3.2"
|
||||||
|
assert agent.base_url == "http://localhost:11434/v1"
|
||||||
|
assert agent.timeout == 60.0
|
||||||
|
|
||||||
|
def test_url_validation_rejects_non_http_schemes(self):
|
||||||
|
"""Test that base_url validator rejects non-HTTP schemes."""
|
||||||
|
with pytest.raises(ValueError, match="must be HTTP"):
|
||||||
|
OllamaChatAgent(model="llama3.2", base_url="ftp://invalid.com")
|
||||||
|
|
||||||
|
def test_url_validation_rejects_missing_host(self):
|
||||||
|
"""Test that base_url validator rejects URLs without a host."""
|
||||||
|
with pytest.raises(ValueError, match="must include a host"):
|
||||||
|
OllamaChatAgent(model="llama3.2", base_url="http://")
|
||||||
|
|
||||||
|
def test_timeout_validation_rejects_negative(self):
|
||||||
|
"""Test that timeout validator rejects negative values."""
|
||||||
|
with pytest.raises(ValueError, match="must be positive"):
|
||||||
|
OllamaChatAgent(
|
||||||
|
model="llama3.2",
|
||||||
|
base_url="http://localhost:11434/v1",
|
||||||
|
timeout=-5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timeout_validation_rejects_zero(self):
|
||||||
|
"""Test that timeout validator rejects zero."""
|
||||||
|
with pytest.raises(ValueError, match="must be positive"):
|
||||||
|
OllamaChatAgent(
|
||||||
|
model="llama3.2",
|
||||||
|
base_url="http://localhost:11434/v1",
|
||||||
|
timeout=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timeout_converter_string_to_float(self):
|
||||||
|
"""Test that timeout converter coerces strings to float."""
|
||||||
|
agent = OllamaChatAgent(
|
||||||
|
model="llama3.2",
|
||||||
|
base_url="http://localhost:11434/v1",
|
||||||
|
timeout="30", # Pass as string
|
||||||
|
)
|
||||||
|
assert isinstance(agent.timeout, float)
|
||||||
|
assert agent.timeout == 30.0
|
||||||
|
|
||||||
|
def test_factory_initialization_of_client(self):
|
||||||
|
"""Test that _client is created via factory function."""
|
||||||
|
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
|
assert hasattr(agent, "_client")
|
||||||
|
assert isinstance(agent._client, httpx.AsyncClient)
|
||||||
|
# httpx normalizes URLs by adding a trailing slash
|
||||||
|
assert str(agent._client.base_url) == "http://localhost:11434/v1/"
|
||||||
|
assert agent._client.timeout == httpx.Timeout(60.0)
|
||||||
|
|
||||||
|
def test_slots_enabled_no_dict(self):
|
||||||
|
"""Test that slots=True prevents __dict__ attribute."""
|
||||||
|
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
|
# With slots=True, instances shouldn't have __dict__
|
||||||
|
# (unless also inherited from a class with __dict__)
|
||||||
|
assert not hasattr(agent, "__dict__")
|
||||||
|
|
||||||
|
def test_equality_disabled(self):
|
||||||
|
"""Test that eq=False means instances are not equal even with same values."""
|
||||||
|
agent1 = OllamaChatAgent(
|
||||||
|
model="llama3.2", base_url="http://localhost:11434/v1"
|
||||||
|
)
|
||||||
|
agent2 = OllamaChatAgent(
|
||||||
|
model="llama3.2", base_url="http://localhost:11434/v1"
|
||||||
|
)
|
||||||
|
# With eq=False, only identity comparison works
|
||||||
|
assert agent1 != agent2
|
||||||
|
assert agent1 == agent1
|
||||||
|
|
||||||
|
def test_repr_hides_client(self):
|
||||||
|
"""Test that repr=False on _client hides it from string representation."""
|
||||||
|
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
|
agent_repr = repr(agent)
|
||||||
|
# _client should not appear in repr
|
||||||
|
assert "_client" not in agent_repr
|
||||||
|
# But model and base_url should
|
||||||
|
assert "llama3.2" in agent_repr
|
||||||
|
assert "localhost" in agent_repr
|
||||||
|
|
||||||
|
def test_build_chat_agent_factory_still_works(self):
|
||||||
|
"""Test that the build_chat_agent factory function works unchanged."""
|
||||||
|
config = ChatConfig(backend="stub")
|
||||||
|
local_agent = build_chat_agent(config)
|
||||||
|
assert isinstance(local_agent, LocalEchoAgent)
|
||||||
|
|
||||||
|
def test_build_chat_agent_ollama_backend(self):
|
||||||
|
"""Test that build_chat_agent can create OllamaChatAgent."""
|
||||||
|
config = ChatConfig(
|
||||||
|
backend="ollama",
|
||||||
|
base_url="http://localhost:11434/v1",
|
||||||
|
model="llama3.2",
|
||||||
|
)
|
||||||
|
agent = build_chat_agent(config)
|
||||||
|
assert isinstance(agent, OllamaChatAgent)
|
||||||
|
assert agent.model == "llama3.2"
|
||||||
|
assert agent.base_url == "http://localhost:11434/v1"
|
||||||
|
|
||||||
|
def test_https_urls_accepted(self):
|
||||||
|
"""Test that HTTPS URLs are properly accepted."""
|
||||||
|
agent = OllamaChatAgent(
|
||||||
|
model="llama3.2", base_url="https://api.example.com/v1"
|
||||||
|
)
|
||||||
|
assert agent.base_url == "https://api.example.com/v1"
|
||||||
|
|
||||||
|
def test_metadata_annotations_present(self):
|
||||||
|
"""Test that metadata is properly attached to fields."""
|
||||||
|
import attrs
|
||||||
|
|
||||||
|
fields = attrs.fields(OllamaChatAgent)
|
||||||
|
model_field = fields.model
|
||||||
|
assert "description" in model_field.metadata
|
||||||
|
assert "LLM model identifier" in model_field.metadata["description"]
|
||||||
|
|
||||||
|
base_url_field = fields.base_url
|
||||||
|
assert "description" in base_url_field.metadata
|
||||||
|
assert "OpenAI-compatible" in base_url_field.metadata["description"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
|
|
||||||
Reference in New Issue
Block a user