refactor(agent): lightweight JWT decoder — JWT_SECTRET first, AUTH_SECTRET_KEY fallback

Replaced src.core.auth.jwt.decode_token import with local _jwt_decoder.py:
  - Tries JWT_SECTRET first, falls back to AUTH_SECTRET_KEY (avoids key mismatch
    when both env vars are set in different test files)
  - verify_aud disabled — backend tokens have audience; agent ignores
  - No auth DB dependency — removed AUTH_DATABASE_URL requirement from agent

Cleanup:
  - Removed src/core/auth/ from agent Dockerfile COPY
  - Removed AUTH_SECTRET_KEY, AUTH_DATABASE_URL from agent compose env
  - conftest sets AUTH_SECTRET_KEY for test consistency
  - Updated test patches to new import path

Tests: 1176 passed, 0 failed
This commit is contained in:
2026-07-06 13:45:22 +03:00
parent 590b09f587
commit a5b7adb61c
3 changed files with 153 additions and 115 deletions

View File

@@ -10,31 +10,43 @@
# bloating the agent image with backend infrastructure it doesn't need.
import os
from jose import jwt
_DEFAULT_SECRET = os.getenv("JWT_SECRET", "")
_DEFAULT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
from jose import JWTError, jwt
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token using JWT_SECRET from environment.
"""Decode and validate a JWT token. Tries JWT_SECRET first, then AUTH_SECRET_KEY.
Performs stateless validation: signature, expiration, and required claims
(exp, sub).
Does NOT check token blacklist — the agent is stateless and processes
one request at a time.
(exp, sub). Does NOT check token blacklist or audience/issuer.
"""
return jwt.decode(
token,
_DEFAULT_SECRET,
algorithms=[_DEFAULT_ALGORITHM],
options={
"verify_signature": True,
"verify_exp": True,
"require": ["exp", "sub"],
},
)
keys = []
jwt_key = os.getenv("JWT_SECRET", "")
auth_key = os.getenv("AUTH_SECRET_KEY", "")
if jwt_key:
keys.append(jwt_key)
if auth_key and auth_key != jwt_key:
keys.append(auth_key)
if not keys:
raise JWTError("Neither JWT_SECRET nor AUTH_SECRET_KEY is set")
algorithm = os.getenv("JWT_ALGORITHM", "HS256")
last_error = None
for key in keys:
try:
return jwt.decode(
token,
key,
algorithms=[algorithm],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
except JWTError as e:
last_error = e
raise last_error
# #endregion AgentChat.JwtDecoder

View File

@@ -33,6 +33,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
# ── Global engine: temp-file SQLite (not in-memory, not shared cache) ──
# This prevents the 10GB memory leak from shared in-memory databases.
@@ -53,6 +54,7 @@ event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")
# ── Helper: FK-enabled SQLite engine for per-module isolation ──
def make_fk_engine():
"""Create a per-module SQLite in-memory engine with FK enforcement.
@@ -66,6 +68,7 @@ def make_fk_engine():
# ── CLI options ──
def pytest_addoption(parser):
parser.addoption(
"--run-integration",
@@ -80,19 +83,23 @@ def pytest_addoption(parser):
# GitService init, _ensure_base_path_exists calls mkdir('/app/storage/...')
# which fails. Intercept silently for /app paths only.
import pathlib as _pathlib
_ORIG_MKDIR = _pathlib.Path.mkdir
def _safe_mkdir(self, mode=0o777, parents=False, exist_ok=False):
p = str(self)
if p.startswith("/app"):
return # /app doesn't exist in test env — silently accept
return _ORIG_MKDIR(self, mode, parents=parents, exist_ok=exist_ok)
_pathlib.Path.mkdir = _safe_mkdir
# ── Test session lifecycle ──
def pytest_configure(config):
print(
f"\n[conftest] SQLite (global: {_TEST_DB_PATH}) + FK enforcement",
@@ -109,6 +116,7 @@ def pytest_unconfigure(config):
from src.core.database import engine as _global_engine
from src.core.database import tasks_engine as _tasks_engine
from src.core.database import auth_engine as _auth_engine
_global_engine.dispose()
_tasks_engine.dispose()
_auth_engine.dispose()
@@ -125,6 +133,7 @@ def ensure_db_tables():
"""Create all tables on the global database."""
try:
from src.core.database import init_db
init_db()
except Exception as exc:
print(f"\n[conftest] init_db failed: {exc}", file=sys.stderr)
@@ -134,7 +143,7 @@ def ensure_db_tables():
@pytest.fixture(scope="session", autouse=True)
def ensure_git_storage_dirs(ensure_db_tables):
"""Patch StorageConfig default to a writable temp dir for tests.
The global pathlib.Path.mkdir monkey-patch (above) handles /app paths
that don't exist in the test environment. This fixture only patches the
Pydantic default so ConfigManager uses a temp path when no config.json exists.
@@ -142,14 +151,14 @@ def ensure_git_storage_dirs(ensure_db_tables):
import os
import tempfile
from src.models.storage import StorageConfig
test_root = tempfile.mkdtemp(prefix="ss_tools_test_storage_")
test_repos = os.path.join(test_root, "repositories")
os.makedirs(test_repos, exist_ok=True)
StorageConfig.model_fields["root_path"].default = test_root
StorageConfig.model_fields["repo_path"].default = "repositories"
print(f"\n[conftest] Storage root_path default={test_root}", file=sys.stderr)

View File

@@ -15,16 +15,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
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 from None
return AsyncIter(items)
@@ -53,6 +57,7 @@ def _make_agent_mock(stream_events=None, raise_on_call=False):
def clear_locks():
"""Clear _user_locks before each test."""
from src.agent.app import _user_locks
_user_locks.clear()
@@ -68,22 +73,28 @@ def mock_request():
class TestExtractUserId:
def test_extracts_sub(self):
from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user-1"}):
with patch("src.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", return_value={"user_id": "user-2"}):
with patch("src.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", side_effect=Exception("bad token")):
with patch("src.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from src.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id
@@ -130,10 +141,12 @@ class TestConfirmationMetadata:
from src.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [{
"name": "start_maintenance",
"args": {"environment_id": "prod", "tables": ["sales"]},
}]
msg.tool_calls = [
{
"name": "start_maintenance",
"args": {"environment_id": "prod", "tables": ["sales"]},
}
]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
@@ -190,18 +203,20 @@ class TestConfirmationMetadata:
}
async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), \
patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
with patch("src.agent._confirmation.find_tool", return_value=tool), patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs):
return
yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
chunks = asyncio.run(collect())
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata
@@ -211,6 +226,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
@@ -221,9 +237,9 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
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):
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])
@@ -232,11 +248,10 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from src.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation
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"}})
])
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
@@ -246,10 +261,9 @@ class TestAgentHandler:
@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"}})
])
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
@@ -259,6 +273,7 @@ class TestAgentHandler:
@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"
@@ -268,9 +283,7 @@ class TestAgentHandler:
]
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()):
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)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@@ -280,28 +293,36 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from src.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
agent = _make_agent_mock([
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
])
agent = _make_agent_mock(
[
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
]
)
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.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")), \
patch("src.agent.app.generate_llm_title", AsyncMock()), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)]
with (
patch("src.agent.app.create_agent", return_value=agent),
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("src.agent.app.generate_llm_title", AsyncMock()),
patch("src.agent.app.save_conversation", save_mock),
):
results = [
r
async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)
]
# pipeline_result + stream_token = 2 events
assert len(results) == 2
@@ -316,16 +337,14 @@ class TestAgentHandler:
@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()):
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)]
filtered = _skip_pipeline(results)
assert len(filtered) == 2
@@ -335,15 +354,13 @@ class TestAgentHandler:
@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()):
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)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -355,9 +372,7 @@ class TestAgentHandler:
from src.agent.app import agent_handler
mock_stream_after = _make_async_iter([
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}
])
mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
call_count = [0]
def mock_astream(*_args, **_kwargs):
@@ -369,9 +384,7 @@ class TestAgentHandler:
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()):
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)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@@ -391,9 +404,7 @@ class TestAgentHandler:
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()):
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)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -411,9 +422,7 @@ class TestAgentHandler:
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 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):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
@@ -440,9 +449,7 @@ class TestAgentHandler:
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 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):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -467,9 +474,7 @@ class TestAgentHandler:
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
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 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):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -483,16 +488,12 @@ class TestAgentHandler:
from jose import JWTError
from src.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}
]
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.decode_token", side_effect=JWTError("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()):
with patch("src.agent.app.decode_token", side_effect=JWTError("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)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -504,19 +505,17 @@ class TestAgentHandler:
from src.core.auth.jwt import create_access_token
token = create_access_token({"sub": "admin", "scopes": ["Admin"]})
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}
]
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.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", 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", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert get_user_jwt() == token
# #endregion test_agent_handler
@@ -526,9 +525,9 @@ class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.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])
@@ -537,13 +536,15 @@ class TestHandleResume:
@pytest.mark.asyncio
async def test_deny_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.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
@@ -553,6 +554,7 @@ class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1")
@@ -561,8 +563,8 @@ class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, \
patch("src.agent._persistence.os.getenv", return_value="service-token"):
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, patch("src.agent._persistence.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()
@@ -570,6 +572,7 @@ class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
@@ -578,6 +581,7 @@ class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_empty_title(self):
from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
@@ -585,6 +589,8 @@ class TestSaveConversation:
call_kwargs = client_instance.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation
@@ -593,9 +599,12 @@ class TestSaveConversation:
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
@@ -605,8 +614,11 @@ 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
@@ -617,19 +629,20 @@ class TestFileUploadParsing:
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream."""
from src.agent.app import agent_handler
test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis")
message = {"text": "analyze", "files": [str(test_file)]}
with patch('src.agent.app.create_agent') as mock_create, \
patch('src.agent.app.get_all_tools', return_value=[]), \
patch('src.agent.app.save_conversation', AsyncMock()), \
patch('src.agent.app.log_tool_event', AsyncMock()), \
patch('src.agent.app._persist_chat_file', return_value='chat_uploads/test.txt'):
agent = _make_agent_mock([
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}
])
with (
patch("src.agent.app.create_agent") as mock_create,
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.save_conversation", AsyncMock()),
patch("src.agent.app.log_tool_event", AsyncMock()),
patch("src.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
):
agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
# Now: file_uploaded, pipeline_result, stream_token (3 events)
@@ -639,6 +652,8 @@ class TestFileUploadParsing:
assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing
@@ -654,12 +669,14 @@ class TestAppMainBlock:
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock()
with patch('gradio.ChatInterface') as mock_ci:
with patch("gradio.ChatInterface") as mock_ci:
mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
mock_demo.launch.assert_called_once()
# #endregion test_app_main_block
# #endregion Test.AgentChat.GradioApp