claude-code-tracker/tests/test_hooks.py
Ryan Malloy 44ed9936b7 Initial commit: Claude Code Project Tracker
Add comprehensive development intelligence system that tracks:
- Development sessions with automatic start/stop
- Full conversation history with semantic search
- Tool usage and file operation analytics
- Think time and engagement analysis
- Git activity correlation
- Learning pattern recognition
- Productivity insights and metrics

Features:
- FastAPI backend with SQLite database
- Modern web dashboard with interactive charts
- Claude Code hook integration for automatic tracking
- Comprehensive test suite with 100+ tests
- Complete API documentation (OpenAPI/Swagger)
- Privacy-first design with local data storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 02:59:21 -06:00

469 lines
18 KiB
Python

"""
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