diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..4bcc19f Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md index 69cfa7d..f18c043 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,40 @@ response = await claude.query( print(response.content) ``` +## 🧪 Running Tests + +This project includes a comprehensive test suite covering unit, integration, and end-to-end scenarios. + +### Prerequisites + +1. **Install Dependencies:** + ```bash + poetry install + ``` + +2. **Verify Environment:** + Run the environment check script to ensure Node.js, Claude CLI, and authentication are properly set up: + ```bash + python scripts/check_env.py + ``` + +### Running Tests + +**Unit Tests (Offline):** +These tests mock the Claude CLI and do not require authentication or internet access. +```bash +poetry run pytest tests/test_basic.py tests/test_cli_parsing.py tests/test_config_fallback.py tests/test_robustness.py +``` + +**E2E Live Tests (Online):** +These tests interact with the real Claude API via CLI. They require authentication (`claude auth login`) and will consume tokens/cost. +To enable them, set the `RUN_LIVE_TESTS` environment variable: +```bash +export RUN_LIVE_TESTS=1 +poetry run pytest tests/test_e2e_live.py +``` +*Note: If you are not authenticated, these tests will be skipped automatically.* + ## 📚 Complete Documentation ### Configuration diff --git a/dist/claude_cli_auth-1.0.0-py3-none-any.whl b/dist/claude_cli_auth-1.0.0-py3-none-any.whl new file mode 100644 index 0000000..5764cf2 Binary files /dev/null and b/dist/claude_cli_auth-1.0.0-py3-none-any.whl differ diff --git a/dist/claude_cli_auth-1.0.0.tar.gz b/dist/claude_cli_auth-1.0.0.tar.gz new file mode 100644 index 0000000..d3eecf9 Binary files /dev/null and b/dist/claude_cli_auth-1.0.0.tar.gz differ diff --git a/scripts/check_env.py b/scripts/check_env.py new file mode 100644 index 0000000..9111a4a --- /dev/null +++ b/scripts/check_env.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Environment Validation Script for Claude CLI Auth Project. + +This script checks: +1. Node.js installation +2. Claude CLI (@anthropic-ai/claude-code) presence in PATH +3. Authentication state (claude auth whoami) + +Usage: + python scripts/check_env.py +""" + +import sys +import shutil +import subprocess +import os + +def print_status(message, status="INFO"): + colors = { + "INFO": "\033[94m", # Blue + "SUCCESS": "\033[92m", # Green + "WARNING": "\033[93m", # Yellow + "ERROR": "\033[91m", # Red + "RESET": "\033[0m" + } + prefix = f"[{status}]" + print(f"{colors.get(status, '')}{prefix} {message}{colors['RESET']}") + +def check_command(command, name): + path = shutil.which(command) + if path: + print_status(f"Found {name} at: {path}", "SUCCESS") + return path + else: + print_status(f"{name} ({command}) not found in PATH.", "ERROR") + return None + +def check_claude_auth(): + print_status("Checking Claude authentication status...", "INFO") + try: + # Run 'claude auth whoami' + # We need to capture output to check for "not authenticated" messages + # varying by version, but exit code 0 usually means success or at least CLI ran. + result = subprocess.run( + ["claude", "auth", "whoami"], + capture_output=True, + text=True, + timeout=10 + ) + + output = result.stdout.strip() + stderr = result.stderr.strip() + + print_status(f"Output: {output}") + if stderr: + print_status(f"Stderr: {stderr}", "WARNING") + + if result.returncode == 0: + # Heuristic check for success message + # Adjust these strings based on actual CLI output if needed + if "not authenticated" in output.lower() or "login" in output.lower(): + print_status("Claude CLI is NOT authenticated. Please run 'claude auth login'.", "ERROR") + return False + + print_status("Claude CLI appears authenticated.", "SUCCESS") + return True + else: + print_status(f"Claude auth check failed with exit code {result.returncode}.", "ERROR") + return False + + except subprocess.TimeoutExpired: + print_status("Claude auth check timed out.", "ERROR") + return False + except Exception as e: + print_status(f"An unexpected error occurred: {e}", "ERROR") + return False + +def main(): + print_status("Starting Environment Sanity Check...", "INFO") + + # 1. Check Node.js (prerequisite for Claude CLI) + if not check_command("node", "Node.js"): + print_status("Node.js is required to run Claude CLI.", "ERROR") + sys.exit(1) + + if not check_command("npm", "NPM"): + print_status("NPM is required to install/update Claude CLI.", "WARNING") + + # 2. Check Claude CLI + claude_path = check_command("claude", "Claude CLI") + if not claude_path: + print_status("Claude CLI package (@anthropic-ai/claude-code) is missing.", "ERROR") + print_status("Install it using: npm install -g @anthropic-ai/claude-code", "INFO") + sys.exit(1) + + # 3. Check Authentication + if not check_claude_auth(): + print_status("Authentication check failed. Tests requiring live API will fail.", "ERROR") + # We exit with error to ensure CI/pipeline stops here if this is a prerequisite + sys.exit(1) + + print_status("Environment validation passed! 🚀", "SUCCESS") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/src/claude_cli_auth/__pycache__/__init__.cpython-312.pyc b/src/claude_cli_auth/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..45e116f Binary files /dev/null and b/src/claude_cli_auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/auth_manager.cpython-312.pyc b/src/claude_cli_auth/__pycache__/auth_manager.cpython-312.pyc new file mode 100644 index 0000000..f8ca3c7 Binary files /dev/null and b/src/claude_cli_auth/__pycache__/auth_manager.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/cli_interface.cpython-312.pyc b/src/claude_cli_auth/__pycache__/cli_interface.cpython-312.pyc new file mode 100644 index 0000000..eefe19b Binary files /dev/null and b/src/claude_cli_auth/__pycache__/cli_interface.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/exceptions.cpython-312.pyc b/src/claude_cli_auth/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000..0e1012c Binary files /dev/null and b/src/claude_cli_auth/__pycache__/exceptions.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/facade.cpython-312.pyc b/src/claude_cli_auth/__pycache__/facade.cpython-312.pyc new file mode 100644 index 0000000..7e9032a Binary files /dev/null and b/src/claude_cli_auth/__pycache__/facade.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/models.cpython-312.pyc b/src/claude_cli_auth/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..39e65a0 Binary files /dev/null and b/src/claude_cli_auth/__pycache__/models.cpython-312.pyc differ diff --git a/src/claude_cli_auth/__pycache__/sdk_interface.cpython-312.pyc b/src/claude_cli_auth/__pycache__/sdk_interface.cpython-312.pyc new file mode 100644 index 0000000..3b2a73b Binary files /dev/null and b/src/claude_cli_auth/__pycache__/sdk_interface.cpython-312.pyc differ diff --git a/src/claude_cli_auth/auth_manager.py b/src/claude_cli_auth/auth_manager.py index 6249043..1ac5d37 100644 --- a/src/claude_cli_auth/auth_manager.py +++ b/src/claude_cli_auth/auth_manager.py @@ -115,9 +115,8 @@ def is_authenticated(self) -> bool: return True else: logger.warning( - "Claude auth command failed", - returncode=result.returncode, - stderr=result.stderr.strip() + f"Claude auth command failed - returncode: {result.returncode}, " + f"stderr: {result.stderr.strip()}" ) return False diff --git a/src/claude_cli_auth/cli_interface.py b/src/claude_cli_auth/cli_interface.py index fce2999..d116605 100644 --- a/src/claude_cli_auth/cli_interface.py +++ b/src/claude_cli_auth/cli_interface.py @@ -453,10 +453,8 @@ async def _handle_process_output( await stream_callback(update) except Exception as e: logger.warning( - "Stream callback failed", - error=str(e), - update_type=update.type, - process_id=process_id, + f"Stream callback failed - error: {str(e)}, " + f"update_type: {update.type}, process_id: {process_id}" ) # Check for final result @@ -466,10 +464,8 @@ async def _handle_process_output( except json.JSONDecodeError as e: parsing_errors.append(f"JSON error: {str(e)}") logger.debug( - "JSON parsing failed", - line=line[:200], - error=str(e), - process_id=process_id, + f"JSON parsing failed - line: {line[:200]}, " + f"error: {str(e)}, process_id: {process_id}" ) continue @@ -482,10 +478,8 @@ async def _handle_process_output( stderr_text = stderr.decode("utf-8", errors="replace") logger.error( - "Claude process failed", - return_code=return_code, - stderr=stderr_text[:500], - process_id=process_id, + f"Claude process failed - return_code: {return_code}, " + f"stderr: {stderr_text[:500]}, process_id: {process_id}" ) # Handle specific error types @@ -499,10 +493,8 @@ async def _handle_process_output( # Parse final result if not result_message: logger.error( - "No result message received", - message_count=len(message_buffer), - parsing_errors=len(parsing_errors), - process_id=process_id, + f"No result message received - message_count: {len(message_buffer)}, " + f"parsing_errors: {len(parsing_errors)}, process_id: {process_id}" ) raise ClaudeParsingError( @@ -525,12 +517,9 @@ async def _handle_process_output( ) logger.debug( - "Parsed Claude response", - content_length=len(response.content), - cost=response.cost, - tools_used=len(response.tools_used), - parsing_errors=len(parsing_errors), - process_id=process_id, + f"Parsed Claude response - content_length: {len(response.content)}, " + f"cost: {response.cost}, tools_used: {len(response.tools_used)}, " + f"parsing_errors: {len(parsing_errors)}, process_id: {process_id}" ) return response @@ -540,10 +529,8 @@ async def _handle_process_output( raise else: logger.error( - "Unexpected error in output handling", - error=str(e), - error_type=type(e).__name__, - process_id=process_id, + f"Unexpected error in output handling - error: {str(e)}, " + f"error_type: {type(e).__name__}, process_id: {process_id}" ) raise ClaudeParsingError( @@ -719,7 +706,14 @@ def _parse_tool_result_message(self, msg: Dict[str, Any]) -> StreamUpdate: def _parse_error_update(self, msg: Dict[str, Any]) -> StreamUpdate: """Parse error message for streaming.""" - error_message = msg.get("message", msg.get("error", str(msg))) + # Try to get error content + error_data = msg.get("message") or msg.get("error") or msg + + # If error_data is a dictionary, try to extract message field + if isinstance(error_data, dict): + error_message = error_data.get("message") or str(error_data) + else: + error_message = str(error_data) return StreamUpdate( type=StreamType.ERROR, diff --git a/tests/__pycache__/test_basic.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_basic.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..ca116d4 Binary files /dev/null and b/tests/__pycache__/test_basic.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/__pycache__/test_cli_parsing.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_cli_parsing.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..7bd4ee8 Binary files /dev/null and b/tests/__pycache__/test_cli_parsing.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/__pycache__/test_config_fallback.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_config_fallback.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..bacec79 Binary files /dev/null and b/tests/__pycache__/test_config_fallback.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/__pycache__/test_e2e_live.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_e2e_live.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..e02c79b Binary files /dev/null and b/tests/__pycache__/test_e2e_live.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/__pycache__/test_robustness.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_robustness.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..d47a1db Binary files /dev/null and b/tests/__pycache__/test_robustness.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/test_basic.py b/tests/test_basic.py index e1c8f3e..3089bd8 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch from claude_cli_auth import ClaudeAuthManager, AuthConfig -from claude_cli_auth.exceptions import ClaudeAuthError +from claude_cli_auth.exceptions import ClaudeAuthError, ClaudeAuthManagerError def test_imports(): @@ -30,7 +30,8 @@ def test_auth_config(): """Test AuthConfig creation and defaults.""" config = AuthConfig() - assert config.timeout_seconds == 30 + # Default is 120 in models.py + assert config.timeout_seconds == 120 assert config.session_timeout_hours == 24 assert config.use_sdk is True assert config.enable_streaming is True @@ -47,14 +48,22 @@ def test_auth_config(): assert custom_config.use_sdk is False -def test_claude_auth_manager_init(): +@patch('claude_cli_auth.facade.AuthManager') +def test_claude_auth_manager_init(mock_auth_manager_cls): """Test ClaudeAuthManager initialization.""" - manager = ClaudeAuthManager() + # Mock AuthManager to be authenticated + mock_instance = mock_auth_manager_cls.return_value + mock_instance.is_authenticated.return_value = True - assert manager is not None - assert manager.auth_manager is not None - assert hasattr(manager, 'list_sessions') - assert hasattr(manager, 'get_session') + # Also patch SDK/CLI interfaces to avoid real initialization logic + with patch('claude_cli_auth.facade.SDKInterface'), \ + patch('claude_cli_auth.facade.CLIInterface'): + manager = ClaudeAuthManager() + + assert manager is not None + assert manager.auth_manager is not None + assert hasattr(manager, 'list_sessions') + assert hasattr(manager, 'get_session') @patch('claude_cli_auth.auth_manager.AuthManager.is_authenticated') @@ -62,8 +71,20 @@ def test_authentication_check(mock_is_authenticated): """Test authentication status check.""" mock_is_authenticated.return_value = True - manager = ClaudeAuthManager() - is_auth = manager.auth_manager.is_authenticated() + # When initializing ClaudeAuthManager, it calls is_authenticated internally + # We patch it, so it works. + + # Need to also patch the Interfaces if we want init to succeed fully without raising ClaudeAuthManagerError + # Or expect the error if we only care about auth check logic. + # But for this test, we are calling manager.auth_manager.is_authenticated() manually. + + # Let's mock AuthManager fully to isolate the test of is_authenticated call + # But ClaudeAuthManager creates its own AuthManager. + + # Simpler: just create AuthManager directly + from claude_cli_auth.auth_manager import AuthManager + auth = AuthManager() + is_auth = auth.is_authenticated() assert is_auth is True mock_is_authenticated.assert_called_once() @@ -79,7 +100,7 @@ def test_claude_response_model(): cost=0.01, duration_ms=1000, num_turns=1, - tools_used=["test_tool"] + tools_used=[{"name": "test_tool"}] # tools_used is list of dicts ) assert response.content == "Test response" @@ -87,15 +108,15 @@ def test_claude_response_model(): assert response.cost == 0.01 assert response.duration_ms == 1000 assert response.num_turns == 1 - assert response.tools_used == ["test_tool"] + assert response.tools_used == [{"name": "test_tool"}] def test_session_info_model(): """Test SessionInfo model.""" from claude_cli_auth.models import SessionInfo, SessionStatus - from datetime import datetime + import time - now = datetime.now() + now = time.time() session = SessionInfo( session_id="test-session", created_at=now, @@ -130,4 +151,4 @@ def test_exceptions_hierarchy(): # Test exception creation error = ClaudeAuthError("Test error", suggestions=["Try again"]) assert str(error) == "Test error" - assert error.suggestions == ["Try again"] \ No newline at end of file + assert error.suggestions == ["Try again"] diff --git a/tests/test_cli_parsing.py b/tests/test_cli_parsing.py new file mode 100644 index 0000000..aa5bbd3 --- /dev/null +++ b/tests/test_cli_parsing.py @@ -0,0 +1,176 @@ +"""Unit tests for CLI interface parsing using mock data.""" + +import asyncio +import json +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + +from claude_cli_auth.cli_interface import CLIInterface, CLIResponse +from claude_cli_auth.models import StreamType, StreamUpdate, AuthConfig + +# Mock data based on provided patterns +MOCK_ASSISTANT_MSG = { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Ahoj, jak ti mohu pomoci?"}], + "role": "assistant" + }, + "session_id": "uuid-session-123", + "id": "msg_123" +} + +MOCK_TOOL_USE_MSG = { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "ReadFile", + "input": {"path": "main.py"}, + "id": "tool_call_xyz" + } + ], + "role": "assistant" + }, + "session_id": "uuid-session-123", + "id": "msg_124" +} + +MOCK_RESULT_MSG = { + "type": "result", + "result": "Obsah souboru main.py je...", + "cost_usd": 0.0015, + "duration_ms": 450, + "num_turns": 2, + "is_error": False, + "session_id": "uuid-session-123" +} + +MOCK_ERROR_MSG = { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Claude is currently overloaded" + }, + "session_id": "uuid-session-123" +} + +@pytest.fixture +def cli_interface(): + # Mock AuthManager to bypass authentication check + mock_auth = MagicMock() + mock_auth.is_authenticated.return_value = True + + interface = CLIInterface(auth_manager=mock_auth) + return interface + +@pytest.mark.asyncio +async def test_parse_assistant_message(cli_interface): + """Test parsing of standard assistant text message.""" + update = cli_interface._create_stream_update(MOCK_ASSISTANT_MSG) + + assert update is not None + assert update.type == StreamType.ASSISTANT + assert update.content == "Ahoj, jak ti mohu pomoci?" + assert update.session_id == "uuid-session-123" + assert update.metadata.get("message_id") == "msg_123" + +@pytest.mark.asyncio +async def test_parse_tool_use_message(cli_interface): + """Test parsing of tool use message.""" + update = cli_interface._create_stream_update(MOCK_TOOL_USE_MSG) + + assert update is not None + assert update.type == StreamType.ASSISTANT + assert update.tool_calls is not None + assert len(update.tool_calls) == 1 + + tool_call = update.tool_calls[0] + assert tool_call["name"] == "ReadFile" + assert tool_call["input"] == {"path": "main.py"} + assert tool_call["id"] == "tool_call_xyz" + +@pytest.mark.asyncio +async def test_parse_error_update(cli_interface): + """Test parsing of error message.""" + update = cli_interface._create_stream_update(MOCK_ERROR_MSG) + + assert update is not None + assert update.type == StreamType.ERROR + assert "Claude is currently overloaded" in update.content + assert update.error_info["message"] == "Claude is currently overloaded" + +@pytest.mark.asyncio +async def test_handle_process_output_success(cli_interface): + """Test full output handling with success sequence.""" + + # Create a mock process with stdout + mock_process = AsyncMock() + mock_process.wait.return_value = 0 + + # Setup mock stdout stream + # Sequence: Assistant msg -> Tool use -> Result + mock_lines = [ + json.dumps(MOCK_ASSISTANT_MSG), + json.dumps(MOCK_TOOL_USE_MSG), + json.dumps(MOCK_RESULT_MSG) + ] + + # Mock _read_stream_bounded to yield lines + async def mock_read_stream(stream): + for line in mock_lines: + yield line + + with patch.object(cli_interface, '_read_stream_bounded', side_effect=mock_read_stream): + # We need a callback to capture stream updates + updates = [] + async def callback(update): + updates.append(update) + + response = await cli_interface._handle_process_output( + process=mock_process, + stream_callback=callback, + process_id="test_proc" + ) + + # Verify response + assert isinstance(response, CLIResponse) + assert response.content == "Obsah souboru main.py je..." + assert response.session_id == "uuid-session-123" + assert response.cost == 0.0015 + assert response.is_error is False + + # Verify stream updates were captured + assert len(updates) == 2 # Assistant + Tool Use (Result is not a stream update usually, or handled internally) + assert updates[0].type == StreamType.ASSISTANT + assert updates[1].type == StreamType.ASSISTANT + assert updates[1].tool_calls[0]["name"] == "ReadFile" + + # Verify tools_used in response (extracted from messages) + # The logic in _parse_result_message reconstructs tools from buffered messages + assert len(response.tools_used) == 1 + assert response.tools_used[0]["name"] == "ReadFile" + +@pytest.mark.asyncio +async def test_handle_process_output_error(cli_interface): + """Test handling of process failure output.""" + mock_process = AsyncMock() + mock_process.wait.return_value = 1 + mock_process.stderr.read.return_value = b"Error: Usage limit reached reset at 10pm" + + # Mock _read_stream_bounded to yield empty or partial + async def mock_read_stream(stream): + yield "" + + with patch.object(cli_interface, '_read_stream_bounded', side_effect=mock_read_stream): + # Should raise ClaudeCLIError + from claude_cli_auth.exceptions import ClaudeCLIError + + with pytest.raises(ClaudeCLIError) as exc_info: + await cli_interface._handle_process_output( + process=mock_process, + stream_callback=None, + process_id="test_proc_err" + ) + + assert "usage limit reached" in str(exc_info.value).lower() diff --git a/tests/test_config_fallback.py b/tests/test_config_fallback.py new file mode 100644 index 0000000..b7e6f4e --- /dev/null +++ b/tests/test_config_fallback.py @@ -0,0 +1,190 @@ +"""Unit tests for Configuration and Fallback logic.""" + +import pytest +import shutil +from pathlib import Path +from unittest.mock import MagicMock, AsyncMock, patch + +from claude_cli_auth.models import AuthConfig +from claude_cli_auth.facade import ClaudeAuthManager, SDK_AVAILABLE +from claude_cli_auth.exceptions import ClaudeConfigError, ClaudeAuthError, ClaudeAuthManagerError + +# --- Configuration Tests --- + +def test_config_defaults(): + """Test default configuration values.""" + config = AuthConfig() + assert config.timeout_seconds == 120 + assert config.use_sdk is True + assert config.claude_config_dir == Path.home() / ".claude" + +def test_config_validation_valid(): + """Test validation of a valid configuration.""" + # We use temporary directory to ensure paths exist + config = AuthConfig( + claude_config_dir=Path("."), + working_directory=Path("."), + timeout_seconds=60 + ) + issues = config.validate() + assert len(issues) == 0 + +def test_config_validation_invalid_paths(): + """Test validation with non-existent paths.""" + config = AuthConfig( + claude_config_dir=Path("/non/existent/path/123"), + working_directory=Path("/non/existent/path/456") + ) + issues = config.validate() + assert len(issues) >= 2 + assert any("Claude config directory not found" in i for i in issues) + assert any("Working directory does not exist" in i for i in issues) + +def test_config_validation_invalid_values(): + """Test validation with invalid numeric values.""" + config = AuthConfig( + timeout_seconds=-1, + max_turns=0, + session_timeout_hours=-5.0 + ) + issues = config.validate() + assert len(issues) >= 3 + assert any("timeout_seconds must be positive" in i for i in issues) + assert any("max_turns must be positive" in i for i in issues) + assert any("session_timeout_hours must be positive" in i for i in issues) + +# --- Fallback Logic Tests --- + +@pytest.fixture +def mock_auth_manager_cls(): + """Mock AuthManager class to avoid real disk/auth checks.""" + with patch("claude_cli_auth.facade.AuthManager") as mock: + mock_instance = mock.return_value + # Mock successful initialization + mock_instance.is_authenticated.return_value = True + yield mock + +@pytest.fixture +def mock_sdk_interface(): + """Mock SDK Interface.""" + with patch("claude_cli_auth.facade.SDKInterface") as mock: + yield mock + +@pytest.fixture +def mock_cli_interface(): + """Mock CLI Interface.""" + with patch("claude_cli_auth.facade.CLIInterface") as mock: + yield mock + +@pytest.fixture +def enable_sdk_available(): + """Force SDK_AVAILABLE to True for tests.""" + with patch("claude_cli_auth.facade.SDK_AVAILABLE", True): + yield + +@pytest.mark.asyncio +async def test_manager_init_success(mock_auth_manager_cls): + """Test successful initialization of ClaudeAuthManager.""" + manager = ClaudeAuthManager() + assert manager.auth_manager is not None + +@pytest.mark.asyncio +async def test_fallback_sdk_to_cli( + enable_sdk_available, + mock_auth_manager_cls, + mock_sdk_interface, + mock_cli_interface +): + """Test automatic fallback from SDK to CLI when SDK fails.""" + + # Setup manager with fallback enabled + manager = ClaudeAuthManager(prefer_sdk=True, enable_fallback=True) + + # Mock SDK to raise an exception + sdk_instance = mock_sdk_interface.return_value + sdk_instance.execute = AsyncMock(side_effect=Exception("SDK Crash")) + + # Mock CLI to succeed + cli_instance = mock_cli_interface.return_value + cli_response = MagicMock() + # Mocking to_claude_response to return a plain object, NOT a coroutine + cli_response.to_claude_response.return_value = MagicMock( + cost=0.1, num_turns=1, tools_used=[], session_id="test_sess" + ) + cli_instance.execute = AsyncMock(return_value=cli_response) + + # Manually ensure both interfaces are "initialized" on the manager + manager.sdk_interface = sdk_instance + manager.cli_interface = cli_instance + + # Execute query + response = await manager.query("Test prompt") + + # Verifications + assert response is not None + # SDK should have been called + sdk_instance.execute.assert_called_once() + # CLI should have been called as fallback + cli_instance.execute.assert_called_once() + + # Stats should reflect failure and fallback + stats = manager.get_stats() + assert stats["sdk_failures"] == 1 + assert stats["fallback_successes"] == 1 + +@pytest.mark.asyncio +async def test_fallback_disabled( + enable_sdk_available, + mock_auth_manager_cls, + mock_sdk_interface, + mock_cli_interface +): + """Test that fallback does NOT occur when disabled.""" + + manager = ClaudeAuthManager(prefer_sdk=True, enable_fallback=False) + + # Mock SDK to raise an exception + sdk_instance = mock_sdk_interface.return_value + sdk_instance.execute = AsyncMock(side_effect=ClaudeAuthError("SDK Auth Fail")) + + # Mock CLI (should not be called) + cli_instance = mock_cli_interface.return_value + cli_instance.execute = AsyncMock() + + manager.sdk_interface = sdk_instance + manager.cli_interface = cli_instance + + # Expect failure + with pytest.raises(ClaudeAuthError): + await manager.query("Test prompt") + + # SDK called + sdk_instance.execute.assert_called_once() + # CLI NOT called + cli_instance.execute.assert_not_called() + +@pytest.mark.asyncio +async def test_all_methods_fail( + enable_sdk_available, + mock_auth_manager_cls, + mock_sdk_interface, + mock_cli_interface +): + """Test behavior when both SDK and CLI fail.""" + + manager = ClaudeAuthManager(prefer_sdk=True, enable_fallback=True) + + sdk_instance = mock_sdk_interface.return_value + sdk_instance.execute = AsyncMock(side_effect=Exception("SDK Error")) + + cli_instance = mock_cli_interface.return_value + cli_instance.execute = AsyncMock(side_effect=Exception("CLI Error")) + + manager.sdk_interface = sdk_instance + manager.cli_interface = cli_instance + + # Expect ClaudeAuthManagerError (wrapper for all methods failed) + with pytest.raises(ClaudeAuthManagerError) as exc_info: + await manager.query("Test prompt") + + assert "All Claude methods failed" in str(exc_info.value) diff --git a/tests/test_e2e_live.py b/tests/test_e2e_live.py new file mode 100644 index 0000000..5c7eddb --- /dev/null +++ b/tests/test_e2e_live.py @@ -0,0 +1,140 @@ +import pytest +import pytest_asyncio +import asyncio +from pathlib import Path +import tempfile +import os +from claude_cli_auth import ClaudeAuthManager, AuthConfig, ClaudeTimeoutError + +# Skip tests if not explicitly enabled to prevent accidental costs/api calls +# Run with: pytest tests/test_e2e_live.py --run-live +# OR env var RUN_LIVE_TESTS=1 +run_live = pytest.mark.skipif( + not os.getenv("RUN_LIVE_TESTS"), + reason="Nutné nastavit env proměnnou RUN_LIVE_TESTS=1 pro spuštění živých testů (stojí peníze/tokeny)" +) + +@pytest_asyncio.fixture +async def auth_manager(): + """Fixture pro inicializaci managera s krátkým timeoutem pro testy.""" + config = AuthConfig( + timeout_seconds=60, + use_sdk=False, # Vynutíme CLI pro jistotu, pokud nemáme SDK nainstalované + working_directory=Path(".") + ) + # We catch init error here to skip gracefully if not authenticated + try: + manager = ClaudeAuthManager(config=config) + except Exception as e: + if "not authenticated" in str(e).lower() or "no interfaces available" in str(e).lower(): + pytest.skip(f"Claude Auth initialization failed (not authenticated?): {e}") + raise e + + # Check if authenticated (redundant if init passed, but good for safety) + if not manager.auth_manager.is_authenticated(): + pytest.skip("Claude CLI není autentizováno. Spusťte 'claude auth login'.") + + yield manager + await manager.shutdown() + +@run_live +@pytest.mark.asyncio +async def test_01_basic_connectivity(auth_manager): + """Ověří základní spojení s Claude.""" + print("\n[TEST] Zkouším základní spojení...") + response = await auth_manager.query("Odpověz pouze slovem 'FUNGUJU'.") + + assert response.is_successful() + assert "FUNGUJU" in response.content + assert response.cost >= 0 + print(f"✅ Basic connectivity OK. Cost: ${response.cost}") + +@run_live +@pytest.mark.asyncio +async def test_02_session_memory(auth_manager): + """Ověří, že si Claude pamatuje kontext v rámci session.""" + session_id = f"test_mem_{os.urandom(4).hex()}" + secret_code = "BLUE_PINEAPPLE_99" + + # 1. Řekneme mu tajemství + await auth_manager.query( + f"Zapamatuj si tento kód: {secret_code}. Neodpovídej nic, jen si to ulož.", + session_id=session_id + ) + + # 2. Zeptáme se na něj + response = await auth_manager.query( + "Jaký byl ten kód, který jsem ti právě řekl?", + session_id=session_id, + continue_session=True + ) + + assert secret_code in response.content + + # Ověříme metadata session + session_info = auth_manager.get_session(session_id) + assert session_info is not None + assert session_info.total_turns >= 2 + print(f"✅ Session memory OK. Total turns: {session_info.total_turns}") + +@run_live +@pytest.mark.asyncio +async def test_03_file_context_analysis(auth_manager): + """Ověří, že Claude umí číst soubory.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as tmp: + tmp.write("def hello():\n return 'Hello World'") + tmp_path = Path(tmp.name) + + try: + # Prompt explicitly mentions file + prompt = f"Přečti soubor {tmp_path} a řekni mi název funkce v něm. Odpověz pouze názvem funkce." + + response = await auth_manager.query( + prompt, + working_directory=Path(tempfile.gettempdir()) # Working dir where file is + ) + assert "hello" in response.content + print("✅ File context reading OK.") + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + +@run_live +@pytest.mark.asyncio +async def test_04_streaming_callback(auth_manager): + """Ověří, že streaming vrací data po kouscích.""" + chunks = [] + + async def on_stream(update): + if update.content: + chunks.append(update.content) + + await auth_manager.query( + "Napiš čísla od 1 do 5 slovy (jedno na řádek).", + stream_callback=on_stream + ) + + assert len(chunks) > 0 + full_text = "".join(chunks) + assert "jedna" in full_text.lower() or "one" in full_text.lower() + print(f"✅ Streaming OK. Received {len(chunks)} chunks.") + +@run_live +@pytest.mark.asyncio +async def test_05_error_recovery(auth_manager): + """Ověří chování při timeoutu.""" + # Vytvoříme novou instanci s extrémně krátkým timeoutem + short_config = AuthConfig(timeout_seconds=0.1) # 100ms + + # Check auth before creating manager to avoid confusing errors + if not auth_manager.auth_manager.is_authenticated(): + pytest.skip("Claude CLI not authenticated") + + manager_short = ClaudeAuthManager(config=short_config) + + try: + with pytest.raises(ClaudeTimeoutError): + await manager_short.query("Napiš dlouhou báseň o vesmíru.") + print("✅ Timeout error handled correctly.") + finally: + await manager_short.shutdown() diff --git a/tests/test_robustness.py b/tests/test_robustness.py new file mode 100644 index 0000000..8bcee6d --- /dev/null +++ b/tests/test_robustness.py @@ -0,0 +1,100 @@ +"""Stress and robustness tests for Claude Auth Manager.""" + +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch + +from claude_cli_auth.facade import ClaudeAuthManager +from claude_cli_auth.models import AuthConfig, SessionStatus +from claude_cli_auth.exceptions import ClaudeTimeoutError + +@pytest.fixture +def mock_managers(): + """Mock underlying managers.""" + with patch("claude_cli_auth.facade.AuthManager") as am_mock, \ + patch("claude_cli_auth.facade.CLIInterface") as cli_mock, \ + patch("claude_cli_auth.facade.SDKInterface") as sdk_mock: + + am_instance = am_mock.return_value + am_instance.is_authenticated.return_value = True + + yield am_instance, cli_mock, sdk_mock + +@pytest.mark.asyncio +async def test_timeout_handling(mock_managers): + """Test behavior when query times out.""" + am_mock, cli_cls, sdk_cls = mock_managers + + # Setup manager with short timeout + config = AuthConfig(timeout_seconds=0.1) + manager = ClaudeAuthManager(config=config, prefer_sdk=False) + + # Mock CLI to hang (simulate timeout) + cli_instance = cli_cls.return_value + manager.cli_interface = cli_instance + + async def delayed_execute(*args, **kwargs): + await asyncio.sleep(0.5) + return MagicMock() + + cli_instance.execute = AsyncMock(side_effect=delayed_execute) + + # Expect timeout error + # Note: CLIInterface internally handles asyncio.wait_for, so it might raise ClaudeTimeoutError directly + # or asyncio.TimeoutError depending on implementation. + # ClaudeAuthManager catches exceptions. + # We need to ensure CLIInterface simulates the timeout behavior correctly OR + # check if ClaudeAuthManager wraps it. + # Looking at CLIInterface code, it uses asyncio.wait_for and raises ClaudeTimeoutError. + + # Let's mock execute to raise ClaudeTimeoutError directly to simulate what CLIInterface does + cli_instance.execute = AsyncMock(side_effect=ClaudeTimeoutError("Timeout")) + + with pytest.raises(ClaudeTimeoutError): + await manager.query("Test prompt") + +@pytest.mark.asyncio +async def test_invalid_session_recovery(mock_managers): + """Test handling of invalid or expired session.""" + am_mock, cli_cls, sdk_cls = mock_managers + + manager = ClaudeAuthManager(prefer_sdk=False) + cli_instance = cli_cls.return_value + manager.cli_interface = cli_instance + + # Mock AuthManager to return None for session + am_mock.get_session.return_value = None + + # When querying with non-existent session, it should probably create a new one or fail? + # ClaudeAuthManager._get_or_create_session calls auth_manager.get_session. + # If it returns None, it calls auth_manager.create_session. + + am_mock.create_session.return_value = MagicMock(session_id="new_session") + + # Execute + cli_response = MagicMock() + cli_response.to_claude_response.return_value = MagicMock( + cost=0.0, num_turns=0, tools_used=[], session_id="new_session" + ) + cli_instance.execute = AsyncMock(return_value=cli_response) + + response = await manager.query("Test", session_id="invalid_old_session") + + assert response is not None + # Should have tried to create a new session + am_mock.create_session.assert_called() + +@pytest.mark.asyncio +async def test_cleanup_sessions(mock_managers): + """Test session cleanup logic.""" + am_mock, _, _ = mock_managers + + manager = ClaudeAuthManager() + + # Mock cleanup return value + am_mock.cleanup_expired_sessions.return_value = 5 + + count = await manager.cleanup_sessions() + + assert count == 5 + am_mock.cleanup_expired_sessions.assert_called_once()