diff --git a/CLAUDE-SETUP.md b/CLAUDE-SETUP.md index 27baf204..64d3b906 100644 --- a/CLAUDE-SETUP.md +++ b/CLAUDE-SETUP.md @@ -2,7 +2,7 @@ This document catalogs all tools, plugins, and MCP servers available to Claude Code when developing Apoklisis, along with guidelines for their effective use. -**Last Updated**: 2026-01-27 +**Last Updated**: 2026-01-28 **Project**: Apoklisis **Working Directory**: `c:\Dev\Projects\Apoklisis` **Platform**: Windows (win32) @@ -316,20 +316,24 @@ These tools must be loaded via ToolSearch before use. **Common Use Cases**: ```sql --- View all trees with their categories -SELECT id, name, category, version, is_deleted FROM trees; +-- View all active trees with their categories +SELECT id, name, category, version, is_active, usage_count FROM trees WHERE is_active = true; -- Inspect JSONB tree structure SELECT id, name, tree_structure FROM trees WHERE id = ''; --- Check user authentication -SELECT id, email, role, team_id, is_active FROM users; +-- Check user accounts +SELECT id, email, name, role, team_id, created_at FROM users; --- View active sessions -SELECT s.id, u.email, t.name, s.current_node_id, s.status +-- View active sessions with user and tree info +SELECT s.id, u.email, t.name, s.ticket_number, s.started_at, s.completed_at FROM sessions s JOIN users u ON s.user_id = u.id -JOIN trees t ON s.tree_id = t.id; +JOIN trees t ON s.tree_id = t.id +WHERE s.completed_at IS NULL; + +-- Analyze session path tracking (JSONB) +SELECT id, ticket_number, path_taken, decisions FROM sessions WHERE id = ''; ``` ### Fetch MCP Server @@ -403,18 +407,20 @@ curl -X GET "http://localhost:8000/api/v1/trees" -H "Authorization: Bearer Generator: + """Create an instance of the default event loop for each test case.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +async def test_db() -> AsyncGenerator[AsyncSession, None]: + """ + Create a fresh database for each test function. + + This fixture: + 1. Creates a test database engine + 2. Creates all tables + 3. Yields a session for the test + 4. Drops all tables after the test + """ + # Create async engine for tests (with NullPool to avoid connection reuse issues) + engine = create_async_engine( + TEST_DATABASE_URL, + poolclass=NullPool, + echo=False + ) + + # Create all tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Create async session maker + async_session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False + ) + + # Provide session to test + async with async_session_maker() as session: + yield session + + # Drop all tables after test + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest.fixture +async def client(test_db: AsyncSession): + """ + Create an async HTTP client for testing API endpoints. + + Overrides the database dependency to use the test database. + """ + + async def override_get_db(): + yield test_db + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +async def test_user(client): + """ + Create a test user and return their credentials. + + Returns: + dict with email, password, and user_data + """ + user_data = { + "email": "test@example.com", + "password": "TestPassword123!", + "name": "Test User", + "role": "engineer" + } + + response = await client.post("/api/v1/auth/register", json=user_data) + assert response.status_code == 200 or response.status_code == 201 + + return { + "email": user_data["email"], + "password": user_data["password"], + "user_data": response.json() + } + + +@pytest.fixture +async def auth_headers(client, test_user): + """ + Get authentication headers for an authenticated test user. + + Returns: + dict with Authorization header + """ + login_data = { + "email": test_user["email"], + "password": test_user["password"] + } + + response = await client.post("/api/v1/auth/login/json", json=login_data) + assert response.status_code == 200 + + token_data = response.json() + return {"Authorization": f"Bearer {token_data['access_token']}"} + + +@pytest.fixture +async def test_tree(client, auth_headers): + """ + Create a test decision tree. + + Returns: + dict with tree data + """ + tree_data = { + "name": "Test Troubleshooting Tree", + "description": "A test tree for integration tests", + "category": "Testing", + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Is this a test?", + "options": [ + {"id": "yes", "label": "Yes", "next_node_id": "solution1"}, + {"id": "no", "label": "No", "next_node_id": "solution2"} + ], + "children": [ + { + "id": "solution1", + "type": "solution", + "title": "Test Confirmed", + "description": "This is a test tree" + }, + { + "id": "solution2", + "type": "solution", + "title": "Not a Test", + "description": "This should not happen" + } + ] + } + } + + response = await client.post( + "/api/v1/trees", + json=tree_data, + headers=auth_headers + ) + assert response.status_code == 201 + + return response.json() + + +@pytest.fixture +async def test_admin(client): + """ + Create a test admin user and return their credentials. + + Returns: + dict with email, password, and user_data + """ + admin_data = { + "email": "admin@example.com", + "password": "AdminPassword123!", + "name": "Test Admin", + "role": "admin" + } + + response = await client.post("/api/v1/auth/register", json=admin_data) + assert response.status_code == 200 or response.status_code == 201 + + return { + "email": admin_data["email"], + "password": admin_data["password"], + "user_data": response.json() + } + + +@pytest.fixture +async def admin_auth_headers(client, test_admin): + """ + Get authentication headers for an authenticated admin user. + + Returns: + dict with Authorization header + """ + login_data = { + "email": test_admin["email"], + "password": test_admin["password"] + } + + response = await client.post("/api/v1/auth/login/json", json=login_data) + assert response.status_code == 200 + + token_data = response.json() + return {"Authorization": f"Bearer {token_data['access_token']}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 00000000..5df9ecd7 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,95 @@ +"""Integration tests for authentication endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAuthentication: + """Test suite for authentication endpoints.""" + + @pytest.mark.asyncio + async def test_register_user(self, client: AsyncClient): + """Test user registration.""" + user_data = { + "email": "newuser@example.com", + "password": "SecurePass123!", + "name": "New User", + "role": "engineer" + } + + response = await client.post("/api/v1/auth/register", json=user_data) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == user_data["email"] + assert data["name"] == user_data["name"] + assert data["role"] == user_data["role"] + assert "id" in data + assert "password" not in data # Password should not be returned + + @pytest.mark.asyncio + async def test_register_duplicate_email( + self, client: AsyncClient, test_user: dict + ): + """Test that registering with duplicate email fails.""" + user_data = { + "email": test_user["email"], # Use existing email + "password": "AnotherPass123!", + "name": "Another User", + "role": "engineer" + } + + response = await client.post("/api/v1/auth/register", json=user_data) + + assert response.status_code == 400 + assert "already registered" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_login_json(self, client: AsyncClient, test_user: dict): + """Test JSON login endpoint.""" + login_data = { + "email": test_user["email"], + "password": test_user["password"] + } + + response = await client.post("/api/v1/auth/login/json", json=login_data) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + @pytest.mark.asyncio + async def test_login_invalid_credentials( + self, client: AsyncClient, test_user: dict + ): + """Test login with wrong password.""" + login_data = { + "email": test_user["email"], + "password": "WrongPassword123!" + } + + response = await client.post("/api/v1/auth/login/json", json=login_data) + + assert response.status_code == 401 + assert "incorrect" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_get_current_user( + self, client: AsyncClient, auth_headers: dict, test_user: dict + ): + """Test getting current authenticated user.""" + response = await client.get("/api/v1/auth/me", headers=auth_headers) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == test_user["email"] + assert "password" not in data + + @pytest.mark.asyncio + async def test_get_current_user_unauthorized(self, client: AsyncClient): + """Test that unauthenticated request fails.""" + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 401 diff --git a/backend/tests/test_trees.py b/backend/tests/test_trees.py new file mode 100644 index 00000000..f7d4bc4a --- /dev/null +++ b/backend/tests/test_trees.py @@ -0,0 +1,185 @@ +"""Integration tests for tree endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestTrees: + """Test suite for decision tree endpoints.""" + + @pytest.mark.asyncio + async def test_create_tree(self, client: AsyncClient, auth_headers: dict): + """Test creating a new decision tree.""" + tree_data = { + "name": "Network Troubleshooting", + "description": "Troubleshoot network connectivity issues", + "category": "Networking", + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Can you ping the gateway?", + "options": [ + {"id": "yes", "label": "Yes", "next_node_id": "check_dns"}, + {"id": "no", "label": "No", "next_node_id": "check_cable"} + ], + "children": [ + { + "id": "check_dns", + "type": "decision", + "question": "Can you resolve DNS?", + "options": [], + "children": [] + }, + { + "id": "check_cable", + "type": "solution", + "title": "Check Network Cable", + "description": "Verify the network cable is connected" + } + ] + } + } + + response = await client.post( + "/api/v1/trees", + json=tree_data, + headers=auth_headers + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == tree_data["name"] + assert data["category"] == tree_data["category"] + assert data["is_active"] is True + assert data["version"] == 1 + assert "id" in data + + @pytest.mark.asyncio + async def test_list_trees( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test listing decision trees.""" + response = await client.get("/api/v1/trees", headers=auth_headers) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + # Check that our test tree is in the list + tree_ids = [tree["id"] for tree in data] + assert test_tree["id"] in tree_ids + + @pytest.mark.asyncio + async def test_get_tree_by_id( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test getting a specific tree by ID.""" + response = await client.get( + f"/api/v1/trees/{test_tree['id']}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == test_tree["id"] + assert data["name"] == test_tree["name"] + assert "tree_structure" in data + + @pytest.mark.asyncio + async def test_get_nonexistent_tree( + self, client: AsyncClient, auth_headers: dict + ): + """Test getting a tree that doesn't exist.""" + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.get( + f"/api/v1/trees/{fake_id}", + headers=auth_headers + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_search_trees( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test full-text search for trees.""" + response = await client.get( + "/api/v1/trees/search?q=test", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Should find our test tree + if len(data) > 0: + assert any(tree["id"] == test_tree["id"] for tree in data) + + @pytest.mark.asyncio + async def test_get_categories( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test getting unique tree categories.""" + response = await client.get( + "/api/v1/trees/categories", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Should include our test tree's category + assert test_tree["category"] in data + + @pytest.mark.asyncio + async def test_update_tree( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test updating a tree.""" + update_data = { + "name": "Updated Tree Name", + "description": "Updated description" + } + + response = await client.put( + f"/api/v1/trees/{test_tree['id']}", + json=update_data, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == update_data["name"] + assert data["description"] == update_data["description"] + # Version only increments when tree_structure is updated + assert data["version"] == 1 + + @pytest.mark.asyncio + async def test_delete_tree( + self, client: AsyncClient, admin_auth_headers: dict, test_tree: dict + ): + """Test soft-deleting a tree (admin only).""" + response = await client.delete( + f"/api/v1/trees/{test_tree['id']}", + headers=admin_auth_headers + ) + + assert response.status_code == 204 + + # Verify tree is no longer in active list + list_response = await client.get("/api/v1/trees", headers=admin_auth_headers) + active_trees = list_response.json() + active_ids = [tree["id"] for tree in active_trees] + assert test_tree["id"] not in active_ids + + @pytest.mark.asyncio + async def test_create_tree_unauthorized(self, client: AsyncClient): + """Test that creating a tree without auth fails.""" + tree_data = { + "name": "Unauthorized Tree", + "tree_structure": {"id": "root", "type": "decision"} + } + + response = await client.post("/api/v1/trees", json=tree_data) + + assert response.status_code == 401