| import importlib |
| from types import SimpleNamespace |
| from unittest.mock import AsyncMock, MagicMock, patch |
|
|
| import pytest |
| from fastapi.testclient import TestClient |
|
|
|
|
| def test_create_app_provider_error_handler_returns_anthropic_format(): |
| from api.app import create_app |
| from providers.exceptions import AuthenticationError |
|
|
| app = create_app() |
|
|
| @app.get("/raise_provider") |
| async def _raise_provider(): |
| raise AuthenticationError("bad key") |
|
|
| api_app_mod = importlib.import_module("api.app") |
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token=None, |
| allowed_telegram_user_id=None, |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir="", |
| claude_workspace="./agent_workspace", |
| host="127.0.0.1", |
| port=8082, |
| log_file="server.log", |
| ) |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=AsyncMock()), |
| ): |
| with TestClient(app) as client: |
| resp = client.get("/raise_provider") |
| assert resp.status_code == 401 |
| body = resp.json() |
| assert body["type"] == "error" |
| assert body["error"]["type"] == "authentication_error" |
|
|
|
|
| def test_create_app_general_exception_handler_returns_500(): |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| @app.get("/raise_general") |
| async def _raise_general(): |
| raise RuntimeError("boom") |
|
|
| api_app_mod = importlib.import_module("api.app") |
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token=None, |
| allowed_telegram_user_id=None, |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir="", |
| claude_workspace="./agent_workspace", |
| host="127.0.0.1", |
| port=8082, |
| log_file="server.log", |
| ) |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=AsyncMock()), |
| ): |
| with TestClient(app, raise_server_exceptions=False) as client: |
| resp = client.get("/raise_general") |
| assert resp.status_code == 500 |
| body = resp.json() |
| assert body["type"] == "error" |
| assert body["error"]["type"] == "api_error" |
|
|
|
|
| @pytest.mark.parametrize( |
| "messaging_enabled", [True, False], ids=["with_platform", "no_platform"] |
| ) |
| def test_app_lifespan_sets_state_and_cleans_up(tmp_path, messaging_enabled): |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token="token" if messaging_enabled else None, |
| allowed_telegram_user_id="123", |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir=str(tmp_path / "workspace"), |
| claude_workspace=str(tmp_path / "data"), |
| host="127.0.0.1", |
| port=8082, |
| log_file=str(tmp_path / "server.log"), |
| ) |
|
|
| fake_platform = MagicMock() |
| fake_platform.name = "fake" |
| fake_platform.on_message = MagicMock() |
| fake_platform.start = AsyncMock() |
| fake_platform.stop = AsyncMock() |
|
|
| session_store = MagicMock() |
| session_store.get_all_trees.return_value = [{"t": 1}] if messaging_enabled else [] |
| session_store.get_node_mapping.return_value = {"n": "t"} |
| session_store.sync_from_tree_data = MagicMock() |
|
|
| fake_queue = MagicMock() |
| fake_queue.cleanup_stale_nodes.return_value = 1 |
| fake_queue.to_dict.return_value = { |
| "trees": [{"t": 1}], |
| "node_to_tree": {"n": "t"}, |
| } |
|
|
| cli_manager = MagicMock() |
| cli_manager.stop_all = AsyncMock() |
|
|
| api_app_mod = importlib.import_module("api.app") |
|
|
| cleanup_provider = AsyncMock() |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), |
| patch( |
| "messaging.platforms.factory.create_messaging_platform", |
| return_value=fake_platform if messaging_enabled else None, |
| ) as create_platform, |
| patch("messaging.session.SessionStore", return_value=session_store), |
| patch("cli.manager.CLISessionManager", return_value=cli_manager), |
| patch( |
| "messaging.trees.queue_manager.TreeQueueManager.from_dict", |
| return_value=fake_queue, |
| ), |
| TestClient(app), |
| ): |
| pass |
|
|
| if messaging_enabled: |
| create_platform.assert_called_once() |
| fake_platform.on_message.assert_called_once() |
| fake_platform.start.assert_awaited_once() |
| fake_platform.stop.assert_awaited_once() |
| cli_manager.stop_all.assert_awaited_once() |
| assert getattr(app.state, "message_handler", None) is not None |
| session_store.sync_from_tree_data.assert_called_once_with( |
| [{"t": 1}], |
| {"n": "t"}, |
| ) |
| else: |
| fake_platform.start.assert_not_awaited() |
| fake_platform.stop.assert_not_awaited() |
| cli_manager.stop_all.assert_not_awaited() |
| assert getattr(app.state, "messaging_platform", "missing") is None |
|
|
| cleanup_provider.assert_awaited_once() |
|
|
|
|
| def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path): |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token="token", |
| allowed_telegram_user_id="123", |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir=str(tmp_path / "workspace"), |
| claude_workspace=str(tmp_path / "data"), |
| host="127.0.0.1", |
| port=8082, |
| log_file=str(tmp_path / "server.log"), |
| ) |
|
|
| fake_platform = MagicMock() |
| fake_platform.name = "fake" |
| fake_platform.on_message = MagicMock() |
| fake_platform.start = AsyncMock() |
| fake_platform.stop = AsyncMock(side_effect=RuntimeError("stop failed")) |
|
|
| session_store = MagicMock() |
| session_store.get_all_trees.return_value = [] |
| session_store.get_node_mapping.return_value = {} |
| session_store.sync_from_tree_data = MagicMock() |
|
|
| cli_manager = MagicMock() |
| cli_manager.stop_all = AsyncMock() |
|
|
| api_app_mod = importlib.import_module("api.app") |
| cleanup_provider = AsyncMock() |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), |
| patch( |
| "messaging.platforms.factory.create_messaging_platform", |
| return_value=fake_platform, |
| ), |
| patch("messaging.session.SessionStore", return_value=session_store), |
| patch("cli.manager.CLISessionManager", return_value=cli_manager), |
| TestClient(app), |
| ): |
| pass |
|
|
| fake_platform.stop.assert_awaited_once() |
| cli_manager.stop_all.assert_awaited_once() |
| cleanup_provider.assert_awaited_once() |
|
|
|
|
| def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog): |
| """Messaging import failure logs warning and continues without crash.""" |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token="token", |
| allowed_telegram_user_id="123", |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir=str(tmp_path / "workspace"), |
| claude_workspace=str(tmp_path / "data"), |
| host="127.0.0.1", |
| port=8082, |
| log_file=str(tmp_path / "server.log"), |
| ) |
|
|
| api_app_mod = importlib.import_module("api.app") |
| cleanup_provider = AsyncMock() |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), |
| patch( |
| "messaging.platforms.factory.create_messaging_platform", |
| side_effect=ImportError("discord not installed"), |
| ), |
| TestClient(app), |
| ): |
| pass |
|
|
| assert getattr(app.state, "messaging_platform", None) is None |
| cleanup_provider.assert_awaited_once() |
|
|
|
|
| def test_app_lifespan_platform_start_exception_cleanup_still_runs(tmp_path): |
| """Exception during platform.start() logs error, cleanup still runs.""" |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token="token", |
| allowed_telegram_user_id="123", |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir=str(tmp_path / "workspace"), |
| claude_workspace=str(tmp_path / "data"), |
| host="127.0.0.1", |
| port=8082, |
| log_file=str(tmp_path / "server.log"), |
| ) |
|
|
| fake_platform = MagicMock() |
| fake_platform.name = "fake" |
| fake_platform.on_message = MagicMock() |
| fake_platform.start = AsyncMock(side_effect=RuntimeError("start failed")) |
| fake_platform.stop = AsyncMock() |
|
|
| session_store = MagicMock() |
| session_store.get_all_trees.return_value = [] |
| session_store.get_node_mapping.return_value = {} |
| session_store.sync_from_tree_data = MagicMock() |
|
|
| cli_manager = MagicMock() |
| cli_manager.stop_all = AsyncMock() |
|
|
| api_app_mod = importlib.import_module("api.app") |
| cleanup_provider = AsyncMock() |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), |
| patch( |
| "messaging.platforms.factory.create_messaging_platform", |
| return_value=fake_platform, |
| ), |
| patch("messaging.session.SessionStore", return_value=session_store), |
| patch("cli.manager.CLISessionManager", return_value=cli_manager), |
| TestClient(app), |
| ): |
| pass |
|
|
| cleanup_provider.assert_awaited_once() |
|
|
|
|
| def test_app_lifespan_flush_pending_save_exception_warning_only(tmp_path): |
| """Session store flush exception on shutdown is logged as warning, no crash.""" |
| from api.app import create_app |
|
|
| app = create_app() |
|
|
| settings = SimpleNamespace( |
| messaging_platform="telegram", |
| telegram_bot_token="token", |
| allowed_telegram_user_id="123", |
| discord_bot_token=None, |
| allowed_discord_channels=None, |
| allowed_dir=str(tmp_path / "workspace"), |
| claude_workspace=str(tmp_path / "data"), |
| host="127.0.0.1", |
| port=8082, |
| log_file=str(tmp_path / "server.log"), |
| ) |
|
|
| fake_platform = MagicMock() |
| fake_platform.name = "fake" |
| fake_platform.on_message = MagicMock() |
| fake_platform.start = AsyncMock() |
| fake_platform.stop = AsyncMock() |
|
|
| session_store = MagicMock() |
| session_store.get_all_trees.return_value = [] |
| session_store.get_node_mapping.return_value = {} |
| session_store.sync_from_tree_data = MagicMock() |
| session_store.flush_pending_save = MagicMock(side_effect=OSError("disk full")) |
|
|
| cli_manager = MagicMock() |
| cli_manager.stop_all = AsyncMock() |
|
|
| api_app_mod = importlib.import_module("api.app") |
| cleanup_provider = AsyncMock() |
| with ( |
| patch.object(api_app_mod, "get_settings", return_value=settings), |
| patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), |
| patch( |
| "messaging.platforms.factory.create_messaging_platform", |
| return_value=fake_platform, |
| ), |
| patch("messaging.session.SessionStore", return_value=session_store), |
| patch("cli.manager.CLISessionManager", return_value=cli_manager), |
| TestClient(app), |
| ): |
| pass |
|
|
| session_store.flush_pending_save.assert_called_once() |
| cleanup_provider.assert_awaited_once() |
|
|