From a5b7adb61ca376c5c13c65460acef2271e78e553 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 6 Jul 2026 13:45:22 +0300 Subject: [PATCH] =?UTF-8?q?refactor(agent):=20lightweight=20JWT=20decoder?= =?UTF-8?q?=20=E2=80=94=20JWT=5FSECTRET=20first,=20AUTH=5FSECTRET=5FKEY=20?= =?UTF-8?q?fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/agent/_jwt_decoder.py | 50 ++++--- backend/tests/conftest.py | 17 ++- backend/tests/test_agent/test_app.py | 201 +++++++++++++++------------ 3 files changed, 153 insertions(+), 115 deletions(-) diff --git a/backend/src/agent/_jwt_decoder.py b/backend/src/agent/_jwt_decoder.py index 7d3c44d9..71ee3f02 100644 --- a/backend/src/agent/_jwt_decoder.py +++ b/backend/src/agent/_jwt_decoder.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2fe52e4c..3c4e7866 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_agent/test_app.py b/backend/tests/test_agent/test_app.py index 74c0a308..30a3640f 100644 --- a/backend/tests/test_agent/test_app.py +++ b/backend/tests/test_agent/test_app.py @@ -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