free-claude-code/tests/api/test_app_lifespan_and_errors.py
Alishahryar1 f3a7528d49
Some checks are pending
CI / checks (push) Waiting to run
Major refactor: API, providers, messaging, and Anthropic protocol
Consolidates the incremental refactor work into a single change set: modular web tools (api/web_tools), native Anthropic request building and SSE block policy, OpenAI conversion and error handling, provider transports and rate limiting, messaging handler and tree queue, safe logging, smoke tests, and broad test coverage.
2026-04-26 03:01:14 -07:00

488 lines
16 KiB
Python

import importlib
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from config.settings import Settings
from providers.registry import ProviderRegistry
_RUNTIME_EXTRAS = {
"voice_note_enabled": True,
"whisper_model": "base",
"whisper_device": "cpu",
"hf_token": "",
"nvidia_nim_api_key": "",
"claude_cli_bin": "claude",
"uses_process_anthropic_auth_token": lambda: False,
"messaging_rate_limit": 1,
"messaging_rate_window": 1.0,
"max_message_log_entries_per_chat": None,
"debug_platform_edits": False,
"debug_subagent_stack": False,
"log_api_error_tracebacks": False,
"log_raw_messaging_content": False,
"log_raw_cli_diagnostics": False,
"log_messaging_error_details": False,
}
def _app_settings(**kwargs):
"""Minimal settings namespace for AppRuntime (matches typed :class:`Settings` fields used)."""
data = {**_RUNTIME_EXTRAS, **kwargs}
return SimpleNamespace(**data)
def test_warn_if_process_auth_token_logs_warning():
api_runtime_mod = importlib.import_module("api.runtime")
settings = cast(
Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: True)
)
with patch.object(api_runtime_mod.logger, "warning") as warning:
api_runtime_mod.warn_if_process_auth_token(settings)
warning.assert_called_once()
assert "ANTHROPIC_AUTH_TOKEN" in warning.call_args.args[0]
def test_warn_if_process_auth_token_skips_explicit_dotenv_config():
api_runtime_mod = importlib.import_module("api.runtime")
settings = cast(
Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: False)
)
with patch.object(api_runtime_mod.logger, "warning") as warning:
api_runtime_mod.warn_if_process_auth_token(settings)
warning.assert_not_called()
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 = _app_settings(
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(ProviderRegistry, "cleanup", 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_provider_error_default_logs_exclude_provider_message():
"""Provider errors must not log exc.message by default."""
from api.app import create_app
from providers.exceptions import AuthenticationError
app = create_app()
secret = "provider-upstream-secret-detail"
@app.get("/raise_provider_secret")
async def _raise():
raise AuthenticationError(secret)
api_app_mod = importlib.import_module("api.app")
settings = _app_settings(
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",
log_api_error_tracebacks=False,
)
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=AsyncMock()),
patch.object(api_app_mod.logger, "error") as log_err,
):
with TestClient(app) as client:
resp = client.get("/raise_provider_secret")
assert resp.status_code == 401
blob = " ".join(str(a) for c in log_err.call_args_list for a in c.args)
blob += repr([c.kwargs for c in log_err.call_args_list])
assert secret not in blob
assert "authentication_error" in blob
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 = _app_settings(
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(ProviderRegistry, "cleanup", 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"
def test_create_app_general_exception_default_logs_exclude_exception_message():
"""Unhandled errors must not log exception text by default (may echo user content)."""
from api.app import create_app
app = create_app()
secret = "user-provided-secret-token-xyzzy"
@app.get("/raise_secret")
async def _raise_secret():
raise ValueError(secret)
api_app_mod = importlib.import_module("api.app")
settings = _app_settings(
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",
log_api_error_tracebacks=False,
)
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=AsyncMock()),
patch.object(api_app_mod.logger, "error") as log_err,
):
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/raise_secret")
assert resp.status_code == 500
flattened: list[str] = []
for call in log_err.call_args_list:
flattened.extend(str(arg) for arg in call.args)
flattened.append(repr(call.kwargs))
blob = " ".join(flattened)
assert secret not in blob
assert "ValueError" in blob
@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 = _app_settings(
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")
registry_cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=registry_cleanup),
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
registry_cleanup.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 = _app_settings(
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")
registry_cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=registry_cleanup),
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()
registry_cleanup.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 = _app_settings(
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")
registry_cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=registry_cleanup),
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
registry_cleanup.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 = _app_settings(
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")
registry_cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=registry_cleanup),
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
registry_cleanup.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 = _app_settings(
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")
registry_cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "cleanup", new=registry_cleanup),
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()
registry_cleanup.assert_awaited_once()