""" Tests for Claude Code hook simulation and integration. """ import pytest import json import subprocess from unittest.mock import patch, MagicMock from httpx import AsyncClient from tests.fixtures import TestDataFactory class TestHookSimulation: """Test hook payload generation and API integration.""" def test_session_start_hook_payload(self): """Test generating SessionStart hook payload.""" # Simulate environment variables that would be set by Claude Code mock_env = { "PWD": "/home/user/test-project", "USER": "testuser" } with patch.dict("os.environ", mock_env): with patch("subprocess.check_output") as mock_git: # Mock git commands mock_git.side_effect = [ b"main\n", # git branch --show-current b"https://github.com/user/test-project.git\n" # git config --get remote.origin.url ] payload = { "session_type": "startup", "working_directory": mock_env["PWD"], "git_branch": "main", "git_repo": "https://github.com/user/test-project.git", "environment": { "pwd": mock_env["PWD"], "user": mock_env["USER"], "timestamp": "2024-01-01T12:00:00Z" } } # Verify payload structure matches API expectations assert "session_type" in payload assert "working_directory" in payload assert payload["session_type"] in ["startup", "resume", "clear"] def test_user_prompt_hook_payload(self): """Test generating UserPromptSubmit hook payload.""" mock_prompt = "How do I implement user authentication?" mock_session_id = "123" payload = { "session_id": int(mock_session_id), "timestamp": "2024-01-01T12:00:00Z", "user_prompt": mock_prompt, "exchange_type": "user_prompt" } assert payload["session_id"] == 123 assert payload["user_prompt"] == mock_prompt assert payload["exchange_type"] == "user_prompt" def test_post_tool_use_edit_payload(self): """Test generating PostToolUse Edit hook payload.""" mock_file_path = "/home/user/test-project/main.py" mock_session_id = "123" payload = { "session_id": int(mock_session_id), "tool_name": "Edit", "action": "file_edit", "file_path": mock_file_path, "timestamp": "2024-01-01T12:00:00Z", "metadata": {"success": True}, "success": True } assert payload["tool_name"] == "Edit" assert payload["file_path"] == mock_file_path assert payload["success"] is True def test_post_tool_use_bash_payload(self): """Test generating PostToolUse Bash hook payload.""" mock_command = "pytest --cov=app" mock_session_id = "123" payload = { "session_id": int(mock_session_id), "tool_name": "Bash", "action": "command_execution", "timestamp": "2024-01-01T12:00:00Z", "metadata": { "command": mock_command, "success": True }, "success": True } assert payload["tool_name"] == "Bash" assert payload["metadata"]["command"] == mock_command def test_notification_hook_payload(self): """Test generating Notification hook payload.""" mock_session_id = "123" payload = { "session_id": int(mock_session_id), "timestamp": "2024-01-01T12:00:00Z", "context_before": "Claude is waiting for input" } assert payload["session_id"] == 123 assert "context_before" in payload def test_stop_hook_payload(self): """Test generating Stop hook payload.""" mock_session_id = "123" payload = { "session_id": int(mock_session_id), "timestamp": "2024-01-01T12:00:00Z", "claude_response": "Response completed", "exchange_type": "claude_response" } assert payload["exchange_type"] == "claude_response" def test_json_escaping_in_payloads(self): """Test that special characters in payloads are properly escaped.""" # Test prompt with quotes and newlines problematic_prompt = 'How do I handle "quotes" and\nnewlines in JSON?' payload = { "session_id": 1, "user_prompt": problematic_prompt, "exchange_type": "user_prompt" } # Should be able to serialize to JSON without errors json_str = json.dumps(payload) parsed = json.loads(json_str) assert parsed["user_prompt"] == problematic_prompt def test_file_path_escaping(self): """Test that file paths with spaces are handled correctly.""" file_path_with_spaces = "/home/user/My Projects/test project/main.py" payload = { "session_id": 1, "tool_name": "Edit", "file_path": file_path_with_spaces } json_str = json.dumps(payload) parsed = json.loads(json_str) assert parsed["file_path"] == file_path_with_spaces class TestHookIntegration: """Test actual hook integration with the API.""" @pytest.mark.hooks async def test_session_start_hook_integration(self, test_client: AsyncClient): """Test complete SessionStart hook workflow.""" # Simulate the payload that would be sent by the hook hook_payload = TestDataFactory.create_session_data( session_type="startup", working_directory="/home/user/hook-test", git_branch="main", git_repo="https://github.com/user/hook-test.git" ) response = await test_client.post("/api/session/start", json=hook_payload) assert response.status_code == 201 data = response.json() assert "session_id" in data assert "project_id" in data # Store session ID for subsequent hook calls return data["session_id"] @pytest.mark.hooks async def test_user_prompt_hook_integration(self, test_client: AsyncClient): """Test UserPromptSubmit hook integration.""" # First create a session session_id = await self.test_session_start_hook_integration(test_client) # Simulate user prompt hook hook_payload = { "session_id": session_id, "timestamp": "2024-01-01T12:00:00Z", "user_prompt": "This prompt came from a hook", "exchange_type": "user_prompt" } response = await test_client.post("/api/conversation", json=hook_payload) assert response.status_code == 201 @pytest.mark.hooks async def test_post_tool_use_hook_integration(self, test_client: AsyncClient): """Test PostToolUse hook integration.""" session_id = await self.test_session_start_hook_integration(test_client) # Simulate Edit tool hook hook_payload = { "session_id": session_id, "tool_name": "Edit", "action": "file_edit", "file_path": "/home/user/hook-test/main.py", "timestamp": "2024-01-01T12:01:00Z", "metadata": {"lines_changed": 5}, "success": True, "lines_added": 3, "lines_removed": 2 } response = await test_client.post("/api/activity", json=hook_payload) assert response.status_code == 201 @pytest.mark.hooks async def test_waiting_period_hook_integration(self, test_client: AsyncClient): """Test Notification and waiting period hooks.""" session_id = await self.test_session_start_hook_integration(test_client) # Start waiting period start_payload = { "session_id": session_id, "timestamp": "2024-01-01T12:00:00Z", "context_before": "Claude finished responding" } start_response = await test_client.post("/api/waiting/start", json=start_payload) assert start_response.status_code == 201 # End waiting period end_payload = { "session_id": session_id, "timestamp": "2024-01-01T12:05:00Z", "duration_seconds": 300, "context_after": "User submitted new prompt" } end_response = await test_client.post("/api/waiting/end", json=end_payload) assert end_response.status_code == 200 @pytest.mark.hooks async def test_stop_hook_integration(self, test_client: AsyncClient): """Test Stop hook integration.""" session_id = await self.test_session_start_hook_integration(test_client) # Simulate Stop hook (Claude response) hook_payload = { "session_id": session_id, "timestamp": "2024-01-01T12:10:00Z", "claude_response": "Here's the implementation you requested...", "tools_used": ["Edit", "Write"], "files_affected": ["main.py", "utils.py"], "exchange_type": "claude_response" } response = await test_client.post("/api/conversation", json=hook_payload) assert response.status_code == 201 @pytest.mark.hooks async def test_git_hook_integration(self, test_client: AsyncClient): """Test git operation hook integration.""" session_id = await self.test_session_start_hook_integration(test_client) hook_payload = TestDataFactory.create_git_operation_data( session_id=session_id, operation="commit", command="git commit -m 'Test commit from hook'", files_changed=["main.py"], lines_added=10, lines_removed=2 ) response = await test_client.post("/api/git", json=hook_payload) assert response.status_code == 201 class TestHookEnvironment: """Test hook environment and configuration.""" def test_session_id_persistence(self): """Test session ID storage and retrieval mechanism.""" session_file = "/tmp/claude-session-id" test_session_id = "12345" # Simulate writing session ID to file (as hooks would do) with open(session_file, "w") as f: f.write(test_session_id) # Simulate reading session ID from file (as subsequent hooks would do) with open(session_file, "r") as f: retrieved_id = f.read().strip() assert retrieved_id == test_session_id # Clean up import os if os.path.exists(session_file): os.remove(session_file) def test_git_environment_detection(self): """Test git repository detection logic.""" with patch("subprocess.check_output") as mock_subprocess: # Mock successful git commands mock_subprocess.side_effect = [ b"main\n", # git branch --show-current b"https://github.com/user/repo.git\n" # git config --get remote.origin.url ] # Simulate what hooks would do try: branch = subprocess.check_output( ["git", "branch", "--show-current"], stderr=subprocess.DEVNULL, text=True ).strip() repo = subprocess.check_output( ["git", "config", "--get", "remote.origin.url"], stderr=subprocess.DEVNULL, text=True ).strip() assert branch == "main" assert repo == "https://github.com/user/repo.git" except subprocess.CalledProcessError: # If git commands fail, hooks should handle gracefully branch = "unknown" repo = "null" # Test non-git directory handling with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(128, "git")): try: branch = subprocess.check_output( ["git", "branch", "--show-current"], stderr=subprocess.DEVNULL, text=True ).strip() except subprocess.CalledProcessError: branch = "unknown" assert branch == "unknown" def test_hook_error_handling(self): """Test hook error handling and graceful failures.""" # Test network failure (API unreachable) import requests from unittest.mock import patch with patch("requests.post", side_effect=requests.exceptions.ConnectionError("Connection refused")): # Hooks should not crash if API is unreachable # This would be handled by curl in the actual hooks with > /dev/null 2>&1 try: # Simulate what would happen in a hook response = requests.post("http://localhost:8000/api/session/start", json={}) assert False, "Should have raised ConnectionError" except requests.exceptions.ConnectionError: # This is expected - hook should handle gracefully pass def test_hook_command_construction(self): """Test building hook commands with proper escaping.""" # Test session start hook command construction session_data = { "session_type": "startup", "working_directory": "/home/user/test project", # Space in path "git_branch": "feature/new-feature", "environment": {"user": "testuser"} } # Construct JSON payload like the hook would json_payload = json.dumps(session_data) # Verify it can be parsed back parsed = json.loads(json_payload) assert parsed["working_directory"] == "/home/user/test project" def test_hook_timing_and_ordering(self): """Test that hook timing and ordering work correctly.""" # Simulate rapid-fire hook calls (as would happen during active development) timestamps = [ "2024-01-01T12:00:00Z", # Session start "2024-01-01T12:00:01Z", # User prompt "2024-01-01T12:00:02Z", # Waiting start "2024-01-01T12:00:05Z", # Tool use (Edit) "2024-01-01T12:00:06Z", # Tool use (Write) "2024-01-01T12:00:10Z", # Waiting end "2024-01-01T12:00:11Z", # Claude response ] # Verify timestamps are in chronological order for i in range(1, len(timestamps)): assert timestamps[i] > timestamps[i-1] class TestHookConfiguration: """Test hook configuration validation and setup.""" def test_hook_config_validation(self): """Test validation of hook configuration JSON.""" # Load and validate the provided hook configuration with open("config/claude-hooks.json", "r") as f: config = json.load(f) # Verify required hook types are present required_hooks = ["SessionStart", "UserPromptSubmit", "PostToolUse", "Notification", "Stop"] for hook_type in required_hooks: assert hook_type in config["hooks"] # Verify SessionStart has all session types session_start_hooks = config["hooks"]["SessionStart"] matchers = [hook.get("matcher") for hook in session_start_hooks if "matcher" in hook] assert "startup" in matchers assert "resume" in matchers assert "clear" in matchers # Verify PostToolUse has main tools post_tool_hooks = config["hooks"]["PostToolUse"] tool_matchers = [hook.get("matcher") for hook in post_tool_hooks if "matcher" in hook] assert "Edit" in tool_matchers assert "Write" in tool_matchers assert "Read" in tool_matchers assert "Bash" in tool_matchers def test_hook_command_structure(self): """Test that hook commands have proper structure.""" with open("config/claude-hooks.json", "r") as f: config = json.load(f) for hook_type, hooks in config["hooks"].items(): for hook in hooks: assert "command" in hook command = hook["command"] # All commands should make HTTP requests to localhost:8000 assert "http://localhost:8000" in command assert "curl" in command # Commands should run in background and suppress output assert "> /dev/null 2>&1 &" in command def test_session_id_handling_in_config(self): """Test that hook configuration properly handles session ID.""" with open("config/claude-hooks.json", "r") as f: config = json.load(f) # Non-SessionStart hooks should use session ID from temp file for hook_type, hooks in config["hooks"].items(): if hook_type != "SessionStart": for hook in hooks: command = hook["command"] # Should reference the session ID temp file assert "CLAUDE_SESSION_FILE" in command assert "/tmp/claude-session-id" in command