Refactor: Move backend files to backend/ directory and split .gitignore
This commit is contained in:
64
backend/tests/api/test_agent.py
Normal file
64
backend/tests/api/test_agent.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from ea_chatbot.api.main import app
|
||||
from ea_chatbot.api.dependencies import get_current_user
|
||||
from ea_chatbot.history.models import User
|
||||
from ea_chatbot.api.utils import create_access_token
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
return User(id="user-123", username="test@example.com", display_name="Test User")
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header(mock_user):
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
token = create_access_token(data={"sub": mock_user.id})
|
||||
yield {"Authorization": f"Bearer {token}"}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_stream_agent_unauthorized():
|
||||
"""Test that streaming requires authentication."""
|
||||
response = client.post("/chat/stream", json={"message": "hello"})
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_success(auth_header, mock_user):
|
||||
"""Test successful agent streaming with SSE."""
|
||||
# We need to mock the LangGraph app.astream_events
|
||||
mock_events = [
|
||||
{"event": "on_chat_model_start", "name": "gpt-5", "data": {"input": "..."}},
|
||||
{"event": "on_chat_model_stream", "name": "gpt-5", "data": {"chunk": "Hello"}},
|
||||
{"event": "on_chain_end", "name": "agent", "data": {"output": "..."}}
|
||||
]
|
||||
|
||||
async def mock_astream_events(*args, **kwargs):
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
with patch("ea_chatbot.api.routers.agent.app.astream_events", side_effect=mock_astream_events), \
|
||||
patch("ea_chatbot.api.routers.agent.get_checkpointer") as mock_cp, \
|
||||
patch("ea_chatbot.api.routers.agent.history_manager") as mock_hm:
|
||||
|
||||
mock_cp.return_value.__aenter__.return_value = AsyncMock()
|
||||
|
||||
# Mock session and DB objects
|
||||
mock_session = MagicMock()
|
||||
mock_hm.get_session.return_value.__enter__.return_value = mock_session
|
||||
from ea_chatbot.history.models import Conversation
|
||||
mock_conv = Conversation(id="t1", user_id=mock_user.id)
|
||||
mock_session.get.return_value = mock_conv
|
||||
|
||||
# Using TestClient with a stream context
|
||||
with client.stream("POST", "/chat/stream",
|
||||
json={"message": "hello", "thread_id": "t1"},
|
||||
headers=auth_header) as response:
|
||||
assert response.status_code == 200
|
||||
assert "text/event-stream" in response.headers["content-type"]
|
||||
|
||||
lines = list(response.iter_lines())
|
||||
# Each event should start with 'data: ' and be valid JSON
|
||||
data_lines = [line for line in lines if line.startswith("data: ")]
|
||||
assert len(data_lines) >= len(mock_events)
|
||||
107
backend/tests/api/test_api_auth.py
Normal file
107
backend/tests/api/test_api_auth.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch
|
||||
from ea_chatbot.api.main import app
|
||||
from ea_chatbot.history.models import User
|
||||
|
||||
# We will need to mock HistoryManager and get_db dependencies later
|
||||
# For now, we define the expected behavior of the auth endpoints.
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
return User(
|
||||
id="user-123",
|
||||
username="test@example.com",
|
||||
display_name="Test User",
|
||||
password_hash="hashed_password"
|
||||
)
|
||||
|
||||
def test_register_user_success():
|
||||
"""Test successful user registration."""
|
||||
# We mock it where it is used in the router
|
||||
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
|
||||
mock_hm.get_user.return_value = None
|
||||
mock_hm.create_user.return_value = User(id="1", username="new@example.com", display_name="New")
|
||||
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={"email": "new@example.com", "password": "password123", "display_name": "New"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "new@example.com"
|
||||
|
||||
def test_login_success():
|
||||
"""Test successful login and JWT return."""
|
||||
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
|
||||
mock_hm.authenticate_user.return_value = User(id="1", username="test@example.com")
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "test@example.com", "password": "password123"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
|
||||
def test_login_invalid_credentials():
|
||||
"""Test login with wrong password."""
|
||||
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
|
||||
mock_hm.authenticate_user.return_value = None
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "test@example.com", "password": "wrongpassword"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "detail" in response.json()
|
||||
|
||||
def test_protected_route_without_token():
|
||||
"""Test that protected routes require a token."""
|
||||
response = client.get("/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_oidc_login_redirect():
|
||||
"""Test that OIDC login returns a redirect URL."""
|
||||
with patch("ea_chatbot.api.routers.auth.oidc_client") as mock_oidc:
|
||||
mock_oidc.get_login_url.return_value = "https://oidc-provider.com/auth"
|
||||
|
||||
response = client.get("/auth/oidc/login")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["url"] == "https://oidc-provider.com/auth"
|
||||
|
||||
def test_oidc_callback_success():
|
||||
"""Test successful OIDC callback and JWT issuance."""
|
||||
with patch("ea_chatbot.api.routers.auth.oidc_client") as mock_oidc, \
|
||||
patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
|
||||
|
||||
mock_oidc.exchange_code_for_token.return_value = {"access_token": "oidc-token"}
|
||||
mock_oidc.get_user_info.return_value = {"email": "sso@example.com", "name": "SSO User"}
|
||||
mock_hm.sync_user_from_oidc.return_value = User(id="sso-123", username="sso@example.com", display_name="SSO User")
|
||||
|
||||
response = client.get("/auth/oidc/callback?code=some-code")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
|
||||
def test_get_me_success():
|
||||
"""Test getting current user with a valid token."""
|
||||
from ea_chatbot.api.utils import create_access_token
|
||||
token = create_access_token(data={"sub": "123"})
|
||||
|
||||
with patch("ea_chatbot.api.dependencies.history_manager") as mock_hm:
|
||||
mock_hm.get_user_by_id.return_value = User(id="123", username="test@example.com", display_name="Test")
|
||||
|
||||
response = client.get(
|
||||
"/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == "test@example.com"
|
||||
assert response.json()["id"] == "123"
|
||||
116
backend/tests/api/test_api_history.py
Normal file
116
backend/tests/api/test_api_history.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch
|
||||
from ea_chatbot.api.main import app
|
||||
from ea_chatbot.api.dependencies import get_current_user
|
||||
from ea_chatbot.history.models import Conversation, Message, Plot, User
|
||||
from ea_chatbot.api.utils import create_access_token
|
||||
from datetime import datetime, timezone
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
user = User(id="user-123", username="test@example.com", display_name="Test User")
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header(mock_user):
|
||||
# Override get_current_user to return our mock user
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
token = create_access_token(data={"sub": mock_user.id})
|
||||
yield {"Authorization": f"Bearer {token}"}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_conversations_success(auth_header, mock_user):
|
||||
"""Test retrieving list of conversations."""
|
||||
with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm:
|
||||
mock_hm.get_conversations.return_value = [
|
||||
Conversation(
|
||||
id="c1",
|
||||
name="Conv 1",
|
||||
user_id=mock_user.id,
|
||||
data_state="nj",
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
]
|
||||
|
||||
response = client.get("/conversations", headers=auth_header)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["name"] == "Conv 1"
|
||||
|
||||
def test_create_conversation_success(auth_header, mock_user):
|
||||
"""Test creating a new conversation."""
|
||||
with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm:
|
||||
mock_hm.create_conversation.return_value = Conversation(
|
||||
id="c2",
|
||||
name="New Conv",
|
||||
user_id=mock_user.id,
|
||||
data_state="nj",
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/conversations",
|
||||
json={"name": "New Conv"},
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["name"] == "New Conv"
|
||||
assert response.json()["id"] == "c2"
|
||||
|
||||
def test_get_messages_success(auth_header):
|
||||
"""Test retrieving messages for a conversation."""
|
||||
with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm:
|
||||
mock_hm.get_messages.return_value = [
|
||||
Message(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="Hello",
|
||||
conversation_id="c1",
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
]
|
||||
|
||||
response = client.get("/conversations/c1/messages", headers=auth_header)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["content"] == "Hello"
|
||||
|
||||
def test_delete_conversation_success(auth_header):
|
||||
"""Test deleting a conversation."""
|
||||
with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm:
|
||||
mock_hm.delete_conversation.return_value = True
|
||||
|
||||
response = client.delete("/conversations/c1", headers=auth_header)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_get_plot_success(auth_header, mock_user):
|
||||
"""Test retrieving a plot artifact."""
|
||||
with patch("ea_chatbot.api.routers.artifacts.history_manager") as mock_hm:
|
||||
# Mocking finding a plot by ID
|
||||
mock_session = MagicMock()
|
||||
mock_hm.get_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mocking the models and their relationships
|
||||
mock_conv = Conversation(id="c1", user_id=mock_user.id, user=mock_user)
|
||||
mock_msg = Message(id="m1", conversation_id="c1", conversation=mock_conv)
|
||||
mock_plot = Plot(id="p1", image_data=b"fake-image-data", message_id="m1", message=mock_msg)
|
||||
|
||||
def mock_get(model, id):
|
||||
if model == Plot: return mock_plot
|
||||
if model == Message: return mock_msg
|
||||
if model == Conversation: return mock_conv
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = mock_get
|
||||
|
||||
response = client.get("/artifacts/plots/p1", headers=auth_header)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"fake-image-data"
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
9
backend/tests/api/test_api_main.py
Normal file
9
backend/tests/api/test_api_main.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from ea_chatbot.api.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_health_check():
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
63
backend/tests/api/test_persistence.py
Normal file
63
backend/tests/api/test_persistence.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from ea_chatbot.api.main import app
|
||||
from ea_chatbot.api.dependencies import get_current_user
|
||||
from ea_chatbot.history.models import User, Conversation, Message, Plot
|
||||
from ea_chatbot.api.utils import create_access_token
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
return User(id="user-123", username="test@example.com", display_name="Test User")
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header(mock_user):
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
token = create_access_token(data={"sub": mock_user.id})
|
||||
yield {"Authorization": f"Bearer {token}"}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_persistence_integration_success(auth_header, mock_user):
|
||||
"""Test that messages and plots are persisted correctly during streaming."""
|
||||
mock_events = [
|
||||
{"event": "on_chat_model_stream", "name": "summarizer", "data": {"chunk": "Final answer"}},
|
||||
{"event": "on_chain_end", "name": "summarizer", "data": {"output": {"messages": [{"content": "Final answer"}]}}},
|
||||
{"event": "on_chain_end", "name": "summarize_conversation", "data": {"output": {"summary": "New summary"}}}
|
||||
]
|
||||
|
||||
async def mock_astream_events(*args, **kwargs):
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
with patch("ea_chatbot.api.routers.agent.app.astream_events", side_effect=mock_astream_events), \
|
||||
patch("ea_chatbot.api.routers.agent.get_checkpointer") as mock_cp, \
|
||||
patch("ea_chatbot.api.routers.agent.history_manager") as mock_hm:
|
||||
|
||||
mock_cp.return_value.__aenter__.return_value = AsyncMock()
|
||||
|
||||
# Mock session and DB objects
|
||||
mock_session = MagicMock()
|
||||
mock_hm.get_session.return_value.__enter__.return_value = mock_session
|
||||
mock_conv = Conversation(id="t1", user_id=mock_user.id)
|
||||
mock_session.get.return_value = mock_conv
|
||||
|
||||
# Act
|
||||
with client.stream("POST", "/chat/stream",
|
||||
json={"message": "persistence test", "thread_id": "t1"},
|
||||
headers=auth_header) as response:
|
||||
assert response.status_code == 200
|
||||
list(response.iter_lines()) # Consume stream
|
||||
|
||||
# Assertions
|
||||
# 1. User message should be saved immediately
|
||||
mock_hm.add_message.assert_any_call("t1", "user", "persistence test")
|
||||
|
||||
# 2. Assistant message should be saved at the end
|
||||
mock_hm.add_message.assert_any_call("t1", "assistant", "Final answer", plots=[])
|
||||
|
||||
# 3. Summary should be updated
|
||||
mock_hm.update_conversation_summary.assert_called_once_with("t1", "New summary")
|
||||
51
backend/tests/api/test_utils.py
Normal file
51
backend/tests/api/test_utils.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import timedelta
|
||||
from ea_chatbot.api.utils import create_access_token, decode_access_token, convert_to_json_compatible
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
def test_create_and_decode_access_token():
|
||||
"""Test that a token can be created and then decoded."""
|
||||
data = {"sub": "test@example.com", "user_id": "123"}
|
||||
token = create_access_token(data)
|
||||
|
||||
decoded = decode_access_token(token)
|
||||
assert decoded["sub"] == data["sub"]
|
||||
assert decoded["user_id"] == data["user_id"]
|
||||
assert "exp" in decoded
|
||||
|
||||
def test_decode_invalid_token():
|
||||
"""Test that an invalid token returns None."""
|
||||
assert decode_access_token("invalid-token") is None
|
||||
|
||||
def test_expired_token():
|
||||
"""Test that an expired token returns None."""
|
||||
data = {"sub": "test@example.com"}
|
||||
# Create a token that expired 1 minute ago
|
||||
token = create_access_token(data, expires_delta=timedelta(minutes=-1))
|
||||
|
||||
assert decode_access_token(token) is None
|
||||
|
||||
def test_convert_to_json_compatible_complex_message():
|
||||
"""Test that list-based message content is handled correctly."""
|
||||
# Mock a message with list-based content (blocks)
|
||||
msg = AIMessage(content=[
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world!"},
|
||||
{"type": "other", "data": "ignore me"}
|
||||
])
|
||||
|
||||
result = convert_to_json_compatible(msg)
|
||||
assert result["content"] == "Hello world!"
|
||||
assert result["type"] == "ai"
|
||||
|
||||
def test_convert_to_json_compatible_message_with_text_prop():
|
||||
"""Test that .text property is prioritized if available."""
|
||||
# Using a MagicMock to simulate the property safely
|
||||
from unittest.mock import MagicMock
|
||||
msg = MagicMock(spec=AIMessage)
|
||||
msg.content = "Raw content"
|
||||
msg.text = "Just the text"
|
||||
msg.type = "ai"
|
||||
msg.additional_kwargs = {}
|
||||
|
||||
result = convert_to_json_compatible(msg)
|
||||
assert result["content"] == "Just the text"
|
||||
Reference in New Issue
Block a user