Files
ss-tools/backend/tests/test_agent/test_app.py
busya ce0369ae5c test(backend): add 55+ test files to push coverage to 98%
Subagents delivered tests across all uncovered backend modules:

Schemas (100%): agent, auth, health, profile, settings, validation
Services (98-100%): auth, profile, health, llm, mapping, resource,
  security, git, superset_lookup, sql_table_extractor, rbac
API routes (new): auth, admin, health, environments, plugins,
  dashboards (helpers, projection, actions, listing),
  git (config, deps, env, helpers)
Clean Release (100%): DTO, facade, policy_engine, stages,
  repos, preparation, source_isolation, compliance
Git services: base, remote_providers
Agent module: app, run, middleware, langgraph_setup
Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching
Reports: normalizer, report_service, type_profiles
Notifications: service, providers

Also:
- .gitignore: add .coverage, *.cover, coverage-* dirs
- src/schemas/auth.py: fix AD group DN regex (comma in CN=...)
- Remove co-located src/services/__tests__/ (caused pytest module collision)
2026-06-15 13:55:57 +03:00

355 lines
15 KiB
Python

# #region Test.AgentChat.GradioApp [C:3] [TYPE Module] [SEMANTICS test,agent,gradio,chat]
# @BRIEF Tests for agent/app.py — agent_handler, _handle_resume, _extract_user_id, _save_conversation, create_chat_interface, health.
# @RELATION BINDS_TO -> [AgentChat.GradioApp]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
return AsyncIter(items)
def _make_agent_mock(stream_events=None, raise_on_call=False):
"""Create a properly mocked agent for async iteration."""
agent = MagicMock()
if raise_on_call:
agent.astream_events = MagicMock(side_effect=stream_events if isinstance(stream_events, list) else stream_events)
elif stream_events is not None:
agent.astream_events = MagicMock(return_value=_make_async_iter(stream_events))
else:
agent.astream_events = MagicMock(return_value=_make_async_iter([]))
return agent
@pytest.fixture(autouse=True)
def clear_locks():
"""Clear _user_locks before each test."""
from src.agent.app import _user_locks
_user_locks.clear()
@pytest.fixture
def mock_request():
req = MagicMock()
req.headers = {"authorization": ""}
return req
# #region test_extract_user_id [C:2] [TYPE Function]
# @BRIEF Test _extract_user_id for various JWT payloads.
class TestExtractUserId:
def test_extracts_sub(self):
from src.agent.app import _extract_user_id
with patch("src.agent.app.jwt.decode", return_value={"sub": "user-1"}):
assert _extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from src.agent.app import _extract_user_id
with patch("src.agent.app.jwt.decode", return_value={"user_id": "user-2"}):
assert _extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from src.agent.app import _extract_user_id
with patch("src.agent.app.jwt.decode", side_effect=Exception("bad token")):
assert _extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from src.agent.app import _extract_user_id
assert _extract_user_id("") == "unknown"
# #endregion test_extract_user_id
# #region test_agent_handler [C:2] [TYPE Function]
# @BRIEF Test agent_handler for various scenarios.
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import agent_handler, _user_locks
_user_locks["anon_default"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "CONCURRENT_SEND"
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from src.agent.app import agent_handler, MAX_FILE_SIZE_BYTES
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "FILE_TOO_LARGE"
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from src.agent.app import agent_handler
with patch("src.agent.app._handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([
json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})
])
with patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["type"] == "confirm_resolved"
@pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request):
from src.agent.app import agent_handler
with patch("src.agent.app._handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([
json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})
])
with patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request):
from src.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_chain_end", "interrupt": {}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) > 0
token_data = json.loads(results[0])
assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 2
assert json.loads(results[0])["metadata"]["type"] == "tool_start"
assert json.loads(results[1])["metadata"]["type"] == "tool_end"
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
from src.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
assert json.loads(results[0])["metadata"]["type"] == "tool_error"
@pytest.mark.asyncio
async def test_output_parser_exception_retry(self, mock_request):
from src.agent.app import agent_handler
from langchain_core.exceptions import OutputParserException
mock_stream_after = _make_async_iter([
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}
])
call_count = [0]
def mock_astream(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise OutputParserException("bad output")
return mock_stream_after
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) > 0
@pytest.mark.asyncio
async def test_output_parser_exception_final_failure(self, mock_request):
from src.agent.app import agent_handler
from langchain_core.exceptions import OutputParserException
call_count = [0]
def mock_astream(*args, **kwargs):
call_count[0] += 1
raise OutputParserException(f"bad {call_count[0]}")
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "LLM_MALFORMED_OUTPUT"
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from src.agent.app import agent_handler
def mock_astream(*args, **kwargs):
raise RuntimeError("API connection error")
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", save_mock):
with pytest.raises(RuntimeError, match="API connection error"):
await agent_handler("hello", [], mock_request, None, None).__anext__()
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_jwt_passes_gracefully(self):
from src.agent.app import agent_handler
import jwt as pyjwt
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.jwt.decode", side_effect=pyjwt.InvalidTokenError("invalid")), \
patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app._save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
assert len(results) == 1
# #endregion test_agent_handler
# #region test_handle_resume [C:2] [TYPE Function]
# @BRIEF Test _handle_resume confirm and deny paths.
class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm(self):
from src.agent.app import _handle_resume
mock_agent = MagicMock()
with patch("src.agent.app.create_agent", return_value=mock_agent), \
patch("src.agent.app.get_all_tools", return_value=[]):
results = [r async for r in _handle_resume("conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny(self):
from src.agent.app import _handle_resume
mock_agent = MagicMock()
with patch("src.agent.app.create_agent", return_value=mock_agent), \
patch("src.agent.app.get_all_tools", return_value=[]):
results = [r async for r in _handle_resume("conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume
# #region test_save_conversation [C:2] [TYPE Function]
# @BRIEF Test _save_conversation with various scenarios.
class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from src.agent.app import _save_conversation
with patch("src.agent.app.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await _save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from src.agent.app import _save_conversation
with patch("src.agent.app.httpx.AsyncClient") as mock_client, \
patch("src.agent.app.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await _save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from src.agent.app import _save_conversation
with patch("src.agent.app.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
await _save_conversation("conv-1", "msg", "u1")
@pytest.mark.asyncio
async def test_save_empty_title(self):
from src.agent.app import _save_conversation
with patch("src.agent.app.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
await _save_conversation("conv-1", " ", "user-1")
call_kwargs = client_instance.post.call_args[1]
assert call_kwargs["json"]["title"] == "Agent conversation"
# #endregion test_save_conversation
# #region test_create_chat_interface [C:2] [TYPE Function]
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface:
def test_returns_chat_interface(self):
from src.agent.app import create_chat_interface
with patch("src.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface()
assert result is mock_ci.return_value
# #endregion test_create_chat_interface
# #region test_health [C:2] [TYPE Function]
# @BRIEF Test health endpoint returns status ok.
class TestHealth:
@pytest.mark.asyncio
async def test_health_returns_ok(self):
from src.agent.app import health
result = await health()
assert result["status"] == "ok"
# #endregion test_health
# #endregion Test.AgentChat.GradioApp