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>
This commit is contained in:
commit
44ed9936b7
48 changed files with 9707 additions and 0 deletions
256
tests/conftest.py
Normal file
256
tests/conftest.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.main import app
|
||||
from app.database.connection import get_db
|
||||
from app.models.base import Base
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.activity import Activity
|
||||
from app.models.waiting_period import WaitingPeriod
|
||||
from app.models.git_operation import GitOperation
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
},
|
||||
poolclass=StaticPool,
|
||||
echo=False, # Set to True for SQL debugging
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create test database session."""
|
||||
# Create all tables
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Create session factory
|
||||
async_session = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(test_db: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test HTTP client."""
|
||||
def override_get_db():
|
||||
return test_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
# Clean up
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_project(test_db: AsyncSession) -> Project:
|
||||
"""Create a sample project for testing."""
|
||||
project = Project(
|
||||
name="Test Project",
|
||||
path="/home/user/test-project",
|
||||
git_repo="https://github.com/user/test-project",
|
||||
languages=["python", "javascript"]
|
||||
)
|
||||
test_db.add(project)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(project)
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_session(test_db: AsyncSession, sample_project: Project) -> Session:
|
||||
"""Create a sample session for testing."""
|
||||
session = Session(
|
||||
project_id=sample_project.id,
|
||||
session_type="startup",
|
||||
working_directory="/home/user/test-project",
|
||||
git_branch="main",
|
||||
environment={"user": "testuser", "pwd": "/home/user/test-project"}
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(session)
|
||||
return session
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_conversation(test_db: AsyncSession, sample_session: Session) -> Conversation:
|
||||
"""Create a sample conversation for testing."""
|
||||
conversation = Conversation(
|
||||
session_id=sample_session.id,
|
||||
user_prompt="How do I implement a feature?",
|
||||
claude_response="You can implement it by following these steps...",
|
||||
tools_used=["Edit", "Write"],
|
||||
files_affected=["main.py", "utils.py"],
|
||||
exchange_type="user_prompt"
|
||||
)
|
||||
test_db.add(conversation)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(conversation)
|
||||
return conversation
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_activity(test_db: AsyncSession, sample_session: Session) -> Activity:
|
||||
"""Create a sample activity for testing."""
|
||||
activity = Activity(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Edit",
|
||||
action="file_edit",
|
||||
file_path="/home/user/test-project/main.py",
|
||||
metadata={"lines_changed": 10},
|
||||
success=True,
|
||||
lines_added=5,
|
||||
lines_removed=2
|
||||
)
|
||||
test_db.add(activity)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(activity)
|
||||
return activity
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_waiting_period(test_db: AsyncSession, sample_session: Session) -> WaitingPeriod:
|
||||
"""Create a sample waiting period for testing."""
|
||||
waiting_period = WaitingPeriod(
|
||||
session_id=sample_session.id,
|
||||
duration_seconds=30,
|
||||
context_before="Claude finished responding",
|
||||
context_after="User asked a follow-up question",
|
||||
likely_activity="thinking"
|
||||
)
|
||||
test_db.add(waiting_period)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(waiting_period)
|
||||
return waiting_period
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_git_operation(test_db: AsyncSession, sample_session: Session) -> GitOperation:
|
||||
"""Create a sample git operation for testing."""
|
||||
git_operation = GitOperation(
|
||||
session_id=sample_session.id,
|
||||
operation="commit",
|
||||
command="git commit -m 'Add new feature'",
|
||||
result="[main 123abc] Add new feature",
|
||||
success=True,
|
||||
files_changed=["main.py", "utils.py"],
|
||||
lines_added=15,
|
||||
lines_removed=3,
|
||||
commit_hash="123abc456def"
|
||||
)
|
||||
test_db.add(git_operation)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(git_operation)
|
||||
return git_operation
|
||||
|
||||
# Faker fixtures for generating test data
|
||||
@pytest.fixture
|
||||
def fake():
|
||||
"""Faker instance for generating test data."""
|
||||
from faker import Faker
|
||||
return Faker()
|
||||
|
||||
@pytest.fixture
|
||||
def project_factory(fake):
|
||||
"""Factory for creating project test data."""
|
||||
def _create_project_data(**overrides):
|
||||
data = {
|
||||
"name": fake.company(),
|
||||
"path": fake.file_path(depth=3),
|
||||
"git_repo": fake.url(),
|
||||
"languages": fake.random_elements(
|
||||
elements=["python", "javascript", "typescript", "go", "rust"],
|
||||
length=fake.random_int(min=1, max=3),
|
||||
unique=True
|
||||
)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_project_data
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(fake):
|
||||
"""Factory for creating session test data."""
|
||||
def _create_session_data(**overrides):
|
||||
data = {
|
||||
"session_type": fake.random_element(elements=["startup", "resume", "clear"]),
|
||||
"working_directory": fake.file_path(depth=3),
|
||||
"git_branch": fake.word(),
|
||||
"environment": {
|
||||
"user": fake.user_name(),
|
||||
"pwd": fake.file_path(depth=3),
|
||||
"timestamp": fake.iso8601()
|
||||
}
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_session_data
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_factory(fake):
|
||||
"""Factory for creating conversation test data."""
|
||||
def _create_conversation_data(**overrides):
|
||||
data = {
|
||||
"user_prompt": fake.sentence(nb_words=10),
|
||||
"claude_response": fake.paragraph(nb_sentences=3),
|
||||
"tools_used": fake.random_elements(
|
||||
elements=["Edit", "Write", "Read", "Bash", "Grep"],
|
||||
length=fake.random_int(min=1, max=3),
|
||||
unique=True
|
||||
),
|
||||
"files_affected": [fake.file_path() for _ in range(fake.random_int(min=0, max=3))],
|
||||
"exchange_type": fake.random_element(elements=["user_prompt", "claude_response"])
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_conversation_data
|
||||
|
||||
# Utility functions for tests
|
||||
@pytest.fixture
|
||||
def assert_response():
|
||||
"""Helper for asserting API response structure."""
|
||||
def _assert_response(response, status_code=200, required_keys=None):
|
||||
assert response.status_code == status_code
|
||||
if required_keys:
|
||||
data = response.json()
|
||||
for key in required_keys:
|
||||
assert key in data
|
||||
return response.json()
|
||||
return _assert_response
|
||||
|
||||
@pytest.fixture
|
||||
def create_test_data():
|
||||
"""Helper for creating test data in database."""
|
||||
async def _create_test_data(db: AsyncSession, model_class, count=1, **kwargs):
|
||||
items = []
|
||||
for i in range(count):
|
||||
item = model_class(**kwargs)
|
||||
db.add(item)
|
||||
items.append(item)
|
||||
await db.commit()
|
||||
for item in items:
|
||||
await db.refresh(item)
|
||||
return items[0] if count == 1 else items
|
||||
return _create_test_data
|
||||
413
tests/fixtures.py
Normal file
413
tests/fixtures.py
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
"""
|
||||
Test fixtures and sample data for the Claude Code Project Tracker.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from faker import Faker
|
||||
|
||||
fake = Faker()
|
||||
|
||||
class TestDataFactory:
|
||||
"""Factory for creating realistic test data."""
|
||||
|
||||
@staticmethod
|
||||
def create_project_data(**overrides) -> Dict[str, Any]:
|
||||
"""Create sample project data."""
|
||||
data = {
|
||||
"name": fake.company(),
|
||||
"path": fake.file_path(depth=3, extension=""),
|
||||
"git_repo": fake.url().replace("http://", "https://github.com/"),
|
||||
"languages": fake.random_elements(
|
||||
elements=["python", "javascript", "typescript", "go", "rust", "java", "cpp"],
|
||||
length=fake.random_int(min=1, max=4),
|
||||
unique=True
|
||||
),
|
||||
"created_at": fake.date_time_between(start_date="-1y", end_date="now"),
|
||||
"last_session": fake.date_time_between(start_date="-30d", end_date="now"),
|
||||
"total_sessions": fake.random_int(min=1, max=50),
|
||||
"total_time_minutes": fake.random_int(min=30, max=2000),
|
||||
"files_modified_count": fake.random_int(min=5, max=100),
|
||||
"lines_changed_count": fake.random_int(min=100, max=5000)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_session_data(project_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample session data."""
|
||||
start_time = fake.date_time_between(start_date="-7d", end_date="now")
|
||||
duration = fake.random_int(min=5, max=180) # 5 minutes to 3 hours
|
||||
|
||||
data = {
|
||||
"project_id": project_id,
|
||||
"start_time": start_time,
|
||||
"end_time": start_time + timedelta(minutes=duration),
|
||||
"session_type": fake.random_element(elements=["startup", "resume", "clear"]),
|
||||
"working_directory": fake.file_path(depth=3, extension=""),
|
||||
"git_branch": fake.random_element(elements=["main", "develop", "feature/new-feature", "bugfix/issue-123"]),
|
||||
"environment": {
|
||||
"user": fake.user_name(),
|
||||
"pwd": fake.file_path(depth=3, extension=""),
|
||||
"python_version": "3.11.0",
|
||||
"node_version": "18.17.0"
|
||||
},
|
||||
"duration_minutes": duration,
|
||||
"activity_count": fake.random_int(min=3, max=50),
|
||||
"conversation_count": fake.random_int(min=2, max=30),
|
||||
"files_touched": [fake.file_path() for _ in range(fake.random_int(min=1, max=8))]
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_conversation_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample conversation data."""
|
||||
prompts = [
|
||||
"How do I implement user authentication?",
|
||||
"Can you help me debug this error?",
|
||||
"What's the best way to structure this code?",
|
||||
"Help me optimize this function for performance",
|
||||
"How do I add tests for this component?",
|
||||
"Can you review this code for best practices?",
|
||||
"What libraries should I use for this feature?",
|
||||
"How do I handle errors in this async function?"
|
||||
]
|
||||
|
||||
responses = [
|
||||
"I can help you implement user authentication. Here's a comprehensive approach...",
|
||||
"Let me analyze this error and provide a solution...",
|
||||
"For code structure, I recommend following these patterns...",
|
||||
"Here are several optimization strategies for your function...",
|
||||
"Let's add comprehensive tests for this component...",
|
||||
"I've reviewed your code and here are my recommendations...",
|
||||
"For this feature, I suggest these well-maintained libraries...",
|
||||
"Here's how to properly handle errors in async functions..."
|
||||
]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"user_prompt": fake.random_element(elements=prompts),
|
||||
"claude_response": fake.random_element(elements=responses),
|
||||
"tools_used": fake.random_elements(
|
||||
elements=["Edit", "Write", "Read", "Bash", "Grep", "Glob", "Task"],
|
||||
length=fake.random_int(min=1, max=4),
|
||||
unique=True
|
||||
),
|
||||
"files_affected": [
|
||||
fake.file_path(extension=ext)
|
||||
for ext in fake.random_elements(
|
||||
elements=["py", "js", "ts", "go", "rs", "java", "cpp", "md"],
|
||||
length=fake.random_int(min=0, max=3),
|
||||
unique=True
|
||||
)
|
||||
],
|
||||
"context": {
|
||||
"intent": fake.random_element(elements=["debugging", "implementation", "learning", "optimization"]),
|
||||
"complexity": fake.random_element(elements=["low", "medium", "high"])
|
||||
},
|
||||
"tokens_input": fake.random_int(min=50, max=500),
|
||||
"tokens_output": fake.random_int(min=100, max=1000),
|
||||
"exchange_type": fake.random_element(elements=["user_prompt", "claude_response"])
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_activity_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample activity data."""
|
||||
tools = {
|
||||
"Edit": {
|
||||
"action": "file_edit",
|
||||
"metadata": {"lines_changed": fake.random_int(min=1, max=50)},
|
||||
"lines_added": fake.random_int(min=0, max=30),
|
||||
"lines_removed": fake.random_int(min=0, max=20)
|
||||
},
|
||||
"Write": {
|
||||
"action": "file_write",
|
||||
"metadata": {"new_file": fake.boolean()},
|
||||
"lines_added": fake.random_int(min=10, max=100),
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Read": {
|
||||
"action": "file_read",
|
||||
"metadata": {"file_size": fake.random_int(min=100, max=5000)},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Bash": {
|
||||
"action": "command_execution",
|
||||
"metadata": {
|
||||
"command": fake.random_element(elements=[
|
||||
"npm install",
|
||||
"pytest",
|
||||
"git status",
|
||||
"python main.py",
|
||||
"docker build ."
|
||||
]),
|
||||
"exit_code": 0
|
||||
},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Grep": {
|
||||
"action": "search",
|
||||
"metadata": {
|
||||
"pattern": fake.word(),
|
||||
"matches_found": fake.random_int(min=0, max=20)
|
||||
},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
}
|
||||
}
|
||||
|
||||
tool_name = fake.random_element(elements=list(tools.keys()))
|
||||
tool_config = tools[tool_name]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"tool_name": tool_name,
|
||||
"action": tool_config["action"],
|
||||
"file_path": fake.file_path() if tool_name in ["Edit", "Write", "Read"] else None,
|
||||
"metadata": tool_config["metadata"],
|
||||
"success": fake.boolean(chance_of_getting_true=90),
|
||||
"error_message": None if fake.boolean(chance_of_getting_true=90) else "Operation failed",
|
||||
"lines_added": tool_config.get("lines_added", 0),
|
||||
"lines_removed": tool_config.get("lines_removed", 0)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_waiting_period_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample waiting period data."""
|
||||
start_time = fake.date_time_between(start_date="-7d", end_date="now")
|
||||
duration = fake.random_int(min=5, max=300) # 5 seconds to 5 minutes
|
||||
|
||||
activities = {
|
||||
"thinking": "User is contemplating the response",
|
||||
"research": "User is looking up documentation",
|
||||
"external_work": "User is working in another application",
|
||||
"break": "User stepped away from the computer"
|
||||
}
|
||||
|
||||
likely_activity = fake.random_element(elements=list(activities.keys()))
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"start_time": start_time,
|
||||
"end_time": start_time + timedelta(seconds=duration),
|
||||
"duration_seconds": duration,
|
||||
"context_before": "Claude finished providing a detailed explanation",
|
||||
"context_after": fake.random_element(elements=[
|
||||
"User asked a follow-up question",
|
||||
"User requested clarification",
|
||||
"User provided additional context",
|
||||
"User asked about a different topic"
|
||||
]),
|
||||
"likely_activity": likely_activity
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_git_operation_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample git operation data."""
|
||||
operations = {
|
||||
"commit": {
|
||||
"command": "git commit -m 'Add new feature'",
|
||||
"result": "[main abc123] Add new feature\\n 2 files changed, 15 insertions(+), 3 deletions(-)",
|
||||
"files_changed": ["main.py", "utils.py"],
|
||||
"lines_added": 15,
|
||||
"lines_removed": 3,
|
||||
"commit_hash": fake.sha1()[:8]
|
||||
},
|
||||
"push": {
|
||||
"command": "git push origin main",
|
||||
"result": "To https://github.com/user/repo.git\\n abc123..def456 main -> main",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
},
|
||||
"pull": {
|
||||
"command": "git pull origin main",
|
||||
"result": "Already up to date.",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
},
|
||||
"branch": {
|
||||
"command": "git checkout -b feature/new-feature",
|
||||
"result": "Switched to a new branch 'feature/new-feature'",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
}
|
||||
}
|
||||
|
||||
operation = fake.random_element(elements=list(operations.keys()))
|
||||
op_config = operations[operation]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"operation": operation,
|
||||
"command": op_config["command"],
|
||||
"result": op_config["result"],
|
||||
"success": fake.boolean(chance_of_getting_true=95),
|
||||
"files_changed": op_config["files_changed"],
|
||||
"lines_added": op_config["lines_added"],
|
||||
"lines_removed": op_config["lines_removed"],
|
||||
"commit_hash": op_config["commit_hash"],
|
||||
"branch_from": fake.random_element(elements=["main", "develop", "feature/old-feature"]) if operation == "merge" else None,
|
||||
"branch_to": fake.random_element(elements=["main", "develop"]) if operation == "merge" else None
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
class SampleDataSets:
|
||||
"""Pre-defined sample data sets for different test scenarios."""
|
||||
|
||||
@staticmethod
|
||||
def productive_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a highly productive development session."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="E-commerce Platform",
|
||||
languages=["python", "javascript", "typescript"],
|
||||
total_sessions=25,
|
||||
total_time_minutes=800
|
||||
)
|
||||
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="startup",
|
||||
duration_minutes=120,
|
||||
activity_count=45,
|
||||
conversation_count=12
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="How do I implement user authentication with JWT?",
|
||||
tools_used=["Edit", "Write"],
|
||||
files_affected=["auth.py", "models.py"]
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="Can you help me optimize this database query?",
|
||||
tools_used=["Edit", "Read"],
|
||||
files_affected=["queries.py"]
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="What's the best way to handle async errors?",
|
||||
tools_used=["Edit"],
|
||||
files_affected=["error_handler.py"]
|
||||
)
|
||||
]
|
||||
|
||||
activities = [
|
||||
TestDataFactory.create_activity_data(tool_name="Edit", lines_added=25, lines_removed=5),
|
||||
TestDataFactory.create_activity_data(tool_name="Write", lines_added=50, lines_removed=0),
|
||||
TestDataFactory.create_activity_data(tool_name="Bash", metadata={"command": "pytest"}),
|
||||
TestDataFactory.create_activity_data(tool_name="Read", file_path="/docs/api.md")
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"session": session_data,
|
||||
"conversations": conversations,
|
||||
"activities": activities
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def debugging_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a debugging-focused session with lots of investigation."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="Bug Tracking System",
|
||||
languages=["go", "typescript"]
|
||||
)
|
||||
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="resume",
|
||||
duration_minutes=90,
|
||||
activity_count=35,
|
||||
conversation_count=8
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="I'm getting a panic in my Go application, can you help debug?",
|
||||
tools_used=["Read", "Grep"],
|
||||
files_affected=["main.go", "handler.go"],
|
||||
context={"intent": "debugging", "complexity": "high"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="The tests are failing intermittently, what could be wrong?",
|
||||
tools_used=["Read", "Bash"],
|
||||
files_affected=["test_handler.go"]
|
||||
)
|
||||
]
|
||||
|
||||
# Lots of read operations for debugging
|
||||
activities = [
|
||||
TestDataFactory.create_activity_data(tool_name="Read") for _ in range(8)
|
||||
] + [
|
||||
TestDataFactory.create_activity_data(tool_name="Grep", metadata={"pattern": "error", "matches_found": 12}),
|
||||
TestDataFactory.create_activity_data(tool_name="Bash", metadata={"command": "go test -v"}),
|
||||
TestDataFactory.create_activity_data(tool_name="Edit", lines_added=3, lines_removed=1)
|
||||
]
|
||||
|
||||
# Longer thinking periods during debugging
|
||||
waiting_periods = [
|
||||
TestDataFactory.create_waiting_period_data(
|
||||
duration_seconds=180,
|
||||
likely_activity="research",
|
||||
context_after="User asked about error patterns"
|
||||
),
|
||||
TestDataFactory.create_waiting_period_data(
|
||||
duration_seconds=120,
|
||||
likely_activity="thinking",
|
||||
context_after="User provided stack trace"
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"session": session_data,
|
||||
"conversations": conversations,
|
||||
"activities": activities,
|
||||
"waiting_periods": waiting_periods
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def learning_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a learning-focused session with lots of questions."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="Learning Rust",
|
||||
languages=["rust"],
|
||||
total_sessions=5,
|
||||
total_time_minutes=200
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="What's the difference between String and &str in Rust?",
|
||||
context={"intent": "learning", "complexity": "medium"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="How do I handle ownership and borrowing correctly?",
|
||||
context={"intent": "learning", "complexity": "high"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="Can you explain Rust's trait system?",
|
||||
context={"intent": "learning", "complexity": "high"}
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"conversations": conversations
|
||||
}
|
||||
525
tests/test_api.py
Normal file
525
tests/test_api.py
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
"""
|
||||
Tests for the Claude Code Project Tracker API endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from tests.fixtures import TestDataFactory
|
||||
|
||||
|
||||
class TestSessionAPI:
|
||||
"""Test session management endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_startup(self, test_client: AsyncClient):
|
||||
"""Test starting a new session with startup type."""
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="startup",
|
||||
working_directory="/home/user/test-project",
|
||||
git_branch="main"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "session_id" in data
|
||||
assert "project_id" in data
|
||||
assert data["status"] == "started"
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_creates_project(self, test_client: AsyncClient):
|
||||
"""Test that starting a session creates a project if it doesn't exist."""
|
||||
session_data = {
|
||||
"session_type": "startup",
|
||||
"working_directory": "/home/user/new-project",
|
||||
"git_repo": "https://github.com/user/new-project",
|
||||
"environment": {"user": "testuser"}
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["project_id"] is not None
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_resume(self, test_client: AsyncClient):
|
||||
"""Test resuming an existing session."""
|
||||
session_data = TestDataFactory.create_session_data(session_type="resume")
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "session_id" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_session(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test ending an active session."""
|
||||
end_data = {
|
||||
"session_id": sample_session.id,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/end", json=end_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["session_id"] == sample_session.id
|
||||
assert data["status"] == "ended"
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_nonexistent_session(self, test_client: AsyncClient):
|
||||
"""Test ending a session that doesn't exist."""
|
||||
end_data = {
|
||||
"session_id": 99999,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/end", json=end_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestConversationAPI:
|
||||
"""Test conversation tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test logging a conversation exchange."""
|
||||
conversation_data = TestDataFactory.create_conversation_data(
|
||||
session_id=sample_session.id,
|
||||
user_prompt="How do I implement authentication?",
|
||||
claude_response="You can implement authentication using...",
|
||||
tools_used=["Edit", "Write"]
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation_user_prompt_only(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test logging just a user prompt."""
|
||||
conversation_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": "How do I fix this error?",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation_invalid_session(self, test_client: AsyncClient):
|
||||
"""Test logging conversation with invalid session ID."""
|
||||
conversation_data = TestDataFactory.create_conversation_data(session_id=99999)
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestActivityAPI:
|
||||
"""Test activity tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_edit(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording an Edit tool activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Edit",
|
||||
action="file_edit",
|
||||
file_path="/home/user/test.py",
|
||||
lines_added=10,
|
||||
lines_removed=3
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_bash(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a Bash command activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Bash",
|
||||
action="command_execution",
|
||||
metadata={"command": "pytest", "exit_code": 0}
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_failed(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a failed activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
success=False,
|
||||
error_message="Permission denied"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestWaitingAPI:
|
||||
"""Test waiting period tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_waiting_period(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test starting a waiting period."""
|
||||
waiting_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude finished responding"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/waiting/start", json=waiting_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_waiting_period(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test ending a waiting period."""
|
||||
# First start a waiting period
|
||||
start_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude finished responding"
|
||||
}
|
||||
await test_client.post("/api/waiting/start", json=start_data)
|
||||
|
||||
# Then end it
|
||||
end_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:05:00Z",
|
||||
"duration_seconds": 300,
|
||||
"context_after": "User asked follow-up question"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/waiting/end", json=end_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGitAPI:
|
||||
"""Test git operation tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_git_commit(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a git commit operation."""
|
||||
git_data = TestDataFactory.create_git_operation_data(
|
||||
session_id=sample_session.id,
|
||||
operation="commit",
|
||||
command="git commit -m 'Add feature'",
|
||||
files_changed=["main.py", "utils.py"],
|
||||
lines_added=20,
|
||||
lines_removed=5
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/git", json=git_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_git_push(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a git push operation."""
|
||||
git_data = TestDataFactory.create_git_operation_data(
|
||||
session_id=sample_session.id,
|
||||
operation="push",
|
||||
command="git push origin main"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/git", json=git_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestProjectAPI:
|
||||
"""Test project data retrieval endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_list_projects(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test listing all projects."""
|
||||
response = await test_client.get("/api/projects")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
|
||||
project = data[0]
|
||||
assert "id" in project
|
||||
assert "name" in project
|
||||
assert "path" in project
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_list_projects_with_pagination(self, test_client: AsyncClient):
|
||||
"""Test project listing with pagination parameters."""
|
||||
response = await test_client.get("/api/projects?limit=5&offset=0")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) <= 5
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_project_timeline(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting detailed project timeline."""
|
||||
response = await test_client.get(f"/api/projects/{sample_project.id}/timeline")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "project" in data
|
||||
assert "timeline" in data
|
||||
assert isinstance(data["timeline"], list)
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_nonexistent_project_timeline(self, test_client: AsyncClient):
|
||||
"""Test getting timeline for non-existent project."""
|
||||
response = await test_client.get("/api/projects/99999/timeline")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAnalyticsAPI:
|
||||
"""Test analytics and insights endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_productivity_metrics(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting productivity analytics."""
|
||||
response = await test_client.get("/api/analytics/productivity")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "engagement_score" in data
|
||||
assert "average_session_length" in data
|
||||
assert "think_time_average" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_productivity_metrics_for_project(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting productivity analytics for specific project."""
|
||||
response = await test_client.get(f"/api/analytics/productivity?project_id={sample_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_search_conversations(self, test_client: AsyncClient, sample_conversation):
|
||||
"""Test searching through conversations."""
|
||||
response = await test_client.get("/api/conversations/search?query=implement feature")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
if data: # If there are results
|
||||
result = data[0]
|
||||
assert "id" in result
|
||||
assert "user_prompt" in result
|
||||
assert "relevance_score" in result
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_search_conversations_with_project_filter(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test searching conversations within a specific project."""
|
||||
response = await test_client.get(f"/api/conversations/search?query=debug&project_id={sample_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling and edge cases."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_malformed_json(self, test_client: AsyncClient):
|
||||
"""Test handling of malformed JSON requests."""
|
||||
response = await test_client.post(
|
||||
"/api/session/start",
|
||||
content="{'invalid': json}",
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_missing_required_fields(self, test_client: AsyncClient):
|
||||
"""Test handling of requests with missing required fields."""
|
||||
response = await test_client.post("/api/session/start", json={})
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_invalid_session_id_type(self, test_client: AsyncClient):
|
||||
"""Test handling of invalid data types."""
|
||||
conversation_data = {
|
||||
"session_id": "invalid", # Should be int
|
||||
"user_prompt": "Test message",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_nonexistent_endpoint(self, test_client: AsyncClient):
|
||||
"""Test accessing non-existent endpoint."""
|
||||
response = await test_client.get("/api/nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Test complete workflow scenarios."""
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_complete_session_workflow(self, test_client: AsyncClient):
|
||||
"""Test a complete session from start to finish."""
|
||||
# Start session
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
working_directory="/home/user/integration-test"
|
||||
)
|
||||
|
||||
start_response = await test_client.post("/api/session/start", json=session_data)
|
||||
assert start_response.status_code == 201
|
||||
session_info = start_response.json()
|
||||
session_id = session_info["session_id"]
|
||||
|
||||
# Log user prompt
|
||||
prompt_data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": "Help me implement a REST API",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
prompt_response = await test_client.post("/api/conversation", json=prompt_data)
|
||||
assert prompt_response.status_code == 201
|
||||
|
||||
# Start waiting period
|
||||
waiting_start = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:01Z",
|
||||
"context_before": "Claude is processing the request"
|
||||
}
|
||||
|
||||
waiting_response = await test_client.post("/api/waiting/start", json=waiting_start)
|
||||
assert waiting_response.status_code == 201
|
||||
|
||||
# Record some activities
|
||||
activities = [
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tool_name": "Write",
|
||||
"action": "file_write",
|
||||
"file_path": "/home/user/integration-test/api.py",
|
||||
"timestamp": "2024-01-01T12:01:00Z",
|
||||
"success": True,
|
||||
"lines_added": 50
|
||||
},
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tool_name": "Edit",
|
||||
"action": "file_edit",
|
||||
"file_path": "/home/user/integration-test/main.py",
|
||||
"timestamp": "2024-01-01T12:02:00Z",
|
||||
"success": True,
|
||||
"lines_added": 10,
|
||||
"lines_removed": 2
|
||||
}
|
||||
]
|
||||
|
||||
for activity in activities:
|
||||
activity_response = await test_client.post("/api/activity", json=activity)
|
||||
assert activity_response.status_code == 201
|
||||
|
||||
# End waiting period
|
||||
waiting_end = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:05:00Z",
|
||||
"duration_seconds": 300,
|
||||
"context_after": "User reviewed the implementation"
|
||||
}
|
||||
|
||||
waiting_end_response = await test_client.post("/api/waiting/end", json=waiting_end)
|
||||
assert waiting_end_response.status_code == 200
|
||||
|
||||
# Record git commit
|
||||
git_data = {
|
||||
"session_id": session_id,
|
||||
"operation": "commit",
|
||||
"command": "git commit -m 'Implement REST API'",
|
||||
"timestamp": "2024-01-01T12:06:00Z",
|
||||
"result": "[main abc123] Implement REST API",
|
||||
"success": True,
|
||||
"files_changed": ["api.py", "main.py"],
|
||||
"lines_added": 60,
|
||||
"lines_removed": 2,
|
||||
"commit_hash": "abc12345"
|
||||
}
|
||||
|
||||
git_response = await test_client.post("/api/git", json=git_data)
|
||||
assert git_response.status_code == 201
|
||||
|
||||
# End session
|
||||
end_data = {
|
||||
"session_id": session_id,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
end_response = await test_client.post("/api/session/end", json=end_data)
|
||||
assert end_response.status_code == 200
|
||||
|
||||
# Verify project was created and populated
|
||||
projects_response = await test_client.get("/api/projects")
|
||||
assert projects_response.status_code == 200
|
||||
projects = projects_response.json()
|
||||
|
||||
# Find our project
|
||||
test_project = None
|
||||
for project in projects:
|
||||
if project["path"] == "/home/user/integration-test":
|
||||
test_project = project
|
||||
break
|
||||
|
||||
assert test_project is not None
|
||||
assert test_project["total_sessions"] >= 1
|
||||
|
||||
# Get project timeline
|
||||
timeline_response = await test_client.get(f"/api/projects/{test_project['id']}/timeline")
|
||||
assert timeline_response.status_code == 200
|
||||
timeline = timeline_response.json()
|
||||
|
||||
assert len(timeline["timeline"]) > 0 # Should have recorded events
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.slow
|
||||
async def test_analytics_with_sample_data(self, test_client: AsyncClient, test_db: AsyncSession):
|
||||
"""Test analytics calculations with realistic sample data."""
|
||||
# This test would create a full dataset and verify analytics work correctly
|
||||
# Marked as slow since it involves more data processing
|
||||
|
||||
productivity_response = await test_client.get("/api/analytics/productivity?days=7")
|
||||
assert productivity_response.status_code == 200
|
||||
|
||||
metrics = productivity_response.json()
|
||||
assert "engagement_score" in metrics
|
||||
assert isinstance(metrics["engagement_score"], (int, float))
|
||||
assert 0 <= metrics["engagement_score"] <= 100
|
||||
469
tests/test_hooks.py
Normal file
469
tests/test_hooks.py
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue