diff --git a/README.md b/README.md index c1f7819..ec04a65 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,73 @@ Result: Returns member 5's contact details - Working with complex queries or filters (future) - You need the raw JSON response +## Running Tests + +This project uses pytest for automated testing. All tests are organized in the `tests/` directory. + +### Run All Tests + +```bash +uv run pytest +``` + +### Run Specific Test Categories + +```bash +# Unit tests only (validation logic) +uv run pytest tests/unit + +# Integration tests only (database and resolvers) +uv run pytest tests/integration + +# E2E tests only (complete API flows) +uv run pytest tests/e2e + +# MCP server tests only +uv run pytest tests/mcp +``` + +### Run Tests with Verbose Output + +```bash +uv run pytest --verbose +``` + +### Run Tests with Coverage + +```bash +uv run pytest --cov=src --cov-report=term-missing +``` + +## Test Organization + +The test suite is organized into four categories: + +### Unit Tests (`tests/unit/`) +Pure logic tests for validation functions and utilities. +- **`test_validation.py`** - Email, phone, and firstName validation + +### Integration Tests (`tests/integration/`) +Tests for database operations and GraphQL resolvers. +- **`test_member_model.py`** - SQLAlchemy Member model +- **`test_member_queries.py`** - GraphQL queries (member, members) +- **`test_member_mutations.py`** - GraphQL mutations (create, update, delete) + +### E2E Tests (`tests/e2e/`) +Complete API request/response flows over HTTP. +- **`test_graphql_api.py`** - GraphQL API endpoints, introspection + +### MCP Tests (`tests/mcp/`) +MCP server tools and functionality. +- **`test_mcp_server.py`** - All 6 MCP tools (list_members, get_member, create_member, update_member, get_graphql_schema, execute_graphql_query) + +### Test Fixtures + +Shared fixtures are defined in `tests/conftest.py`: +- **`test_engine`** - In-memory SQLite database +- **`async_session`** - Async database session +- **`graphql_client`** - FastAPI test client for GraphQL + ## Development Workflow This project uses [OpenSpec](https://openspec.dev) for specification-driven development: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3a614a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for Clubber member management system.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..55f7704 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,109 @@ +"""Shared pytest fixtures for all tests.""" + +import asyncio +from typing import AsyncGenerator, Generator + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from src.database import Base +from src.main import app + + +@pytest.fixture(scope="session") +def event_loop() -> Generator: + """Create an event loop for the test session.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="function") +async def test_engine(): + """Create an in-memory SQLite database engine for testing.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + ) + + # Create all tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + # Cleanup + await engine.dispose() + + +@pytest.fixture(scope="function") +async def async_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + """Provide an async database session for tests.""" + async_session_maker_local = sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + async with async_session_maker_local() as session: + yield session + + +@pytest.fixture(scope="function") +async def patched_session_maker(test_engine): + """Patch async_session_maker for resolver tests.""" + test_session_maker = sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + # Patch the session maker in both places + import src.database + import src.resolvers.member + + original_db = src.database.async_session_maker + original_resolver = src.resolvers.member.async_session_maker + + src.database.async_session_maker = test_session_maker + src.resolvers.member.async_session_maker = test_session_maker + + yield test_session_maker + + # Restore originals + src.database.async_session_maker = original_db + src.resolvers.member.async_session_maker = original_resolver + + +@pytest.fixture(scope="function") +async def graphql_client(test_engine) -> AsyncGenerator[AsyncClient, None]: + """Provide a test client for GraphQL API testing.""" + # Override the database dependency to use test database + from src.database import async_session_maker as original_session_maker + + test_session_maker = sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + # Monkey patch the session maker for this test + import src.database + original = src.database.async_session_maker + src.database.async_session_maker = test_session_maker + + # Also update the import in resolvers + import src.resolvers.member + src.resolvers.member.async_session_maker = test_session_maker + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test" + ) as client: + yield client + + # Restore original session maker + src.database.async_session_maker = original + src.resolvers.member.async_session_maker = original diff --git a/tests/e2e/test_graphql_api.py b/tests/e2e/test_graphql_api.py new file mode 100644 index 0000000..2ea8df4 --- /dev/null +++ b/tests/e2e/test_graphql_api.py @@ -0,0 +1,155 @@ +"""End-to-end tests for GraphQL API over HTTP.""" + +import pytest + + +class TestGraphQLAPI: + """E2E tests for complete GraphQL request/response flows.""" + + async def test_query_members_via_http(self, graphql_client): + """Test complete query flow via HTTP POST to /graphql.""" + # First create some test data + create_query = """ + mutation { + createMember(input: {firstName: "Alice", lastName: "Smith"}) { + id + } + } + """ + await graphql_client.post("/graphql", json={"query": create_query}) + + # Query members via HTTP + query = """ + query { + members { + id + firstName + lastName + } + } + """ + response = await graphql_client.post("/graphql", json={"query": query}) + + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert "members" in data["data"] + assert len(data["data"]["members"]) == 1 + assert data["data"]["members"][0]["firstName"] == "Alice" + assert data["data"]["members"][0]["lastName"] == "Smith" + + async def test_create_member_mutation_via_http(self, graphql_client): + """Test complete mutation flow via HTTP POST to /graphql.""" + mutation = """ + mutation { + createMember(input: { + firstName: "Bob" + lastName: "Johnson" + email: "bob@example.com" + }) { + id + firstName + lastName + email + } + } + """ + response = await graphql_client.post("/graphql", json={"query": mutation}) + + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert "createMember" in data["data"] + member = data["data"]["createMember"] + assert member["firstName"] == "Bob" + assert member["lastName"] == "Johnson" + assert member["email"] == "bob@example.com" + assert member["id"] is not None + + async def test_graphql_introspection_schema(self, graphql_client): + """Test GraphQL introspection query for __schema.""" + query = """ + query { + __schema { + queryType { + name + } + mutationType { + name + } + types { + name + kind + } + } + } + """ + response = await graphql_client.post("/graphql", json={"query": query}) + + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert "__schema" in data["data"] + schema = data["data"]["__schema"] + assert schema["queryType"]["name"] == "Query" + assert schema["mutationType"]["name"] == "Mutation" + + # Check that Member type exists + type_names = [t["name"] for t in schema["types"]] + assert "Member" in type_names + + async def test_graphql_introspection_type(self, graphql_client): + """Test GraphQL introspection query for __type.""" + query = """ + query { + __type(name: "Member") { + name + kind + fields { + name + type { + name + kind + } + } + } + } + """ + response = await graphql_client.post("/graphql", json={"query": query}) + + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert "__type" in data["data"] + member_type = data["data"]["__type"] + assert member_type["name"] == "Member" + assert member_type["kind"] == "OBJECT" + + # Check that expected fields exist + field_names = [f["name"] for f in member_type["fields"]] + assert "id" in field_names + assert "firstName" in field_names + assert "lastName" in field_names + assert "email" in field_names + + async def test_validation_error_returns_400(self, graphql_client): + """Test that validation errors return proper error response.""" + mutation = """ + mutation { + createMember(input: { + firstName: "Charlie" + email: "invalid-email" + }) { + id + } + } + """ + response = await graphql_client.post("/graphql", json={"query": mutation}) + + # GraphQL typically returns 200 even with errors, but errors are in response + assert response.status_code == 200 + data = response.json() + assert "errors" in data + # The error should mention validation + error_message = str(data["errors"]) + assert "Invalid email format" in error_message or "email" in error_message.lower() diff --git a/tests/integration/test_member_model.py b/tests/integration/test_member_model.py new file mode 100644 index 0000000..bc39681 --- /dev/null +++ b/tests/integration/test_member_model.py @@ -0,0 +1,84 @@ +"""Integration tests for Member database model.""" + +from datetime import datetime + +import pytest + +from src.models.member import Member + + +class TestMemberModel: + """Tests for Member SQLAlchemy model.""" + + async def test_create_member_with_minimal_data(self, async_session): + """Test creating a member with only firstName (minimal required data).""" + member = Member(first_name="Alice") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + assert member.id is not None + assert member.first_name == "Alice" + assert member.last_name is None + assert member.street is None + assert member.apartment_number is None + assert member.zip is None + assert member.city is None + assert member.country is None + assert member.email is None + assert member.phone is None + assert isinstance(member.created_at, datetime) + assert isinstance(member.updated_at, datetime) + + async def test_create_member_with_complete_data(self, async_session): + """Test creating a member with all fields populated.""" + member = Member( + first_name="Bob", + last_name="Johnson", + street="123 Main St", + apartment_number="4B", + zip="12345", + city="Springfield", + country="USA", + email="bob@example.com", + phone="+14155551234", + ) + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + assert member.id is not None + assert member.first_name == "Bob" + assert member.last_name == "Johnson" + assert member.street == "123 Main St" + assert member.apartment_number == "4B" + assert member.zip == "12345" + assert member.city == "Springfield" + assert member.country == "USA" + assert member.email == "bob@example.com" + assert member.phone == "+14155551234" + + async def test_member_timestamps(self, async_session): + """Test that created_at and updated_at are set correctly.""" + member = Member(first_name="Charlie") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + assert isinstance(member.created_at, datetime) + assert isinstance(member.updated_at, datetime) + # For new members, created_at should equal updated_at + assert member.created_at == member.updated_at + + async def test_member_repr(self, async_session): + """Test the __repr__ method of Member.""" + member = Member(first_name="David", last_name="Smith") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + repr_str = repr(member) + assert "Member" in repr_str + assert f"id={member.id}" in repr_str + assert "first_name='David'" in repr_str + assert "last_name='Smith'" in repr_str diff --git a/tests/integration/test_member_mutations.py b/tests/integration/test_member_mutations.py new file mode 100644 index 0000000..7e170d6 --- /dev/null +++ b/tests/integration/test_member_mutations.py @@ -0,0 +1,202 @@ +"""Integration tests for GraphQL mutation resolvers.""" + +import pytest +from sqlalchemy import select + +from src.models.member import Member +from src.resolvers.member import MemberNotFoundError, Mutation +from src.schemas.member import CreateMemberInput, UpdateMemberInput +from src.validation import ValidationError + + +class TestCreateMemberMutation: + """Tests for createMember mutation.""" + + async def test_create_member_with_minimal_data(self, async_session, patched_session_maker): + """Test creating a member with only firstName.""" + mutation = Mutation() + input_data = CreateMemberInput(first_name="Bob") + + result = await mutation.create_member(input=input_data) + + assert result.id is not None + assert result.first_name == "Bob" + assert result.last_name is None + assert result.email is None + assert result.phone is None + + # Verify in database + db_result = await async_session.execute( + select(Member).where(Member.id == result.id) + ) + db_member = db_result.scalar_one() + assert db_member.first_name == "Bob" + + async def test_create_member_with_complete_data(self, patched_session_maker): + """Test creating a member with all fields populated.""" + mutation = Mutation() + input_data = CreateMemberInput( + first_name="Alice", + last_name="Johnson", + street="456 Oak Ave", + apartment_number="2A", + zip="54321", + city="Portland", + country="USA", + email="alice@example.com", + phone="+14155559999", + ) + + result = await mutation.create_member(input=input_data) + + assert result.first_name == "Alice" + assert result.last_name == "Johnson" + assert result.street == "456 Oak Ave" + assert result.apartment_number == "2A" + assert result.zip == "54321" + assert result.city == "Portland" + assert result.country == "USA" + assert result.email == "alice@example.com" + assert result.phone == "+14155559999" + + async def test_create_member_with_invalid_email(self, patched_session_maker): + """Test creating a member with invalid email raises ValidationError.""" + mutation = Mutation() + input_data = CreateMemberInput( + first_name="Charlie", + email="not-an-email" + ) + + with pytest.raises(ValidationError, match="Invalid email format"): + await mutation.create_member(input=input_data) + + async def test_create_member_with_invalid_phone(self, patched_session_maker): + """Test creating a member with invalid phone raises ValidationError.""" + mutation = Mutation() + input_data = CreateMemberInput( + first_name="David", + phone="abc123" # Invalid characters + ) + + with pytest.raises(ValidationError, match="Invalid phone format"): + await mutation.create_member(input=input_data) + + async def test_create_member_with_empty_first_name(self, patched_session_maker): + """Test creating a member with empty firstName raises ValidationError.""" + mutation = Mutation() + input_data = CreateMemberInput(first_name="") + + with pytest.raises(ValidationError, match="firstName cannot be empty"): + await mutation.create_member(input=input_data) + + +class TestUpdateMemberMutation: + """Tests for updateMember mutation.""" + + async def test_update_member_successfully(self, async_session, patched_session_maker): + """Test updating a member's fields successfully.""" + # Create a member + member = Member(first_name="Eve", email="old@example.com") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + original_created_at = member.created_at + + # Update the member + mutation = Mutation() + input_data = UpdateMemberInput( + id=member.id, + email="new@example.com", + phone="+14155551111" + ) + + result = await mutation.update_member(input=input_data) + + assert result.id == member.id + assert result.first_name == "Eve" # Unchanged + assert result.email == "new@example.com" # Updated + assert result.phone == "+14155551111" # Updated + + # Verify updated_at is set (can't reliably test it changed due to timing) + assert result.updated_at is not None + assert result.created_at == original_created_at + + async def test_update_member_with_nonexistent_id(self, patched_session_maker): + """Test updating a non-existent member raises MemberNotFoundError.""" + mutation = Mutation() + input_data = UpdateMemberInput(id=999, email="test@example.com") + + with pytest.raises(MemberNotFoundError, match="Member with ID 999 not found"): + await mutation.update_member(input=input_data) + + async def test_update_member_partial_data(self, async_session, patched_session_maker): + """Test updating only some fields leaves others unchanged.""" + # Create a member with complete data + member = Member( + first_name="Frank", + last_name="Miller", + email="frank@example.com", + phone="+14155552222" + ) + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + # Update only email + mutation = Mutation() + input_data = UpdateMemberInput(id=member.id, email="updated@example.com") + + result = await mutation.update_member(input=input_data) + + assert result.first_name == "Frank" # Unchanged + assert result.last_name == "Miller" # Unchanged + assert result.email == "updated@example.com" # Updated + assert result.phone == "+14155552222" # Unchanged + + async def test_update_member_with_invalid_email(self, async_session, patched_session_maker): + """Test updating with invalid email raises ValidationError.""" + # Create a member + member = Member(first_name="Grace") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + # Try to update with invalid email + mutation = Mutation() + input_data = UpdateMemberInput(id=member.id, email="invalid-email") + + with pytest.raises(ValidationError, match="Invalid email format"): + await mutation.update_member(input=input_data) + + +class TestDeleteMemberMutation: + """Tests for deleteMember mutation.""" + + async def test_delete_member_successfully(self, async_session, patched_session_maker): + """Test deleting a member successfully.""" + # Create a member + member = Member(first_name="Henry") + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + member_id = member.id + + # Delete the member + mutation = Mutation() + result = await mutation.delete_member(id=member_id) + + assert result is True + + # Verify member is gone + db_result = await async_session.execute( + select(Member).where(Member.id == member_id) + ) + db_member = db_result.scalar_one_or_none() + assert db_member is None + + async def test_delete_member_with_nonexistent_id(self, patched_session_maker): + """Test deleting a non-existent member raises MemberNotFoundError.""" + mutation = Mutation() + + with pytest.raises(MemberNotFoundError, match="Member with ID 999 not found"): + await mutation.delete_member(id=999) diff --git a/tests/integration/test_member_queries.py b/tests/integration/test_member_queries.py new file mode 100644 index 0000000..e8f3293 --- /dev/null +++ b/tests/integration/test_member_queries.py @@ -0,0 +1,94 @@ +"""Integration tests for GraphQL query resolvers.""" + +import pytest +from sqlalchemy import select + +from src.models.member import Member +from src.resolvers.member import Query + + +class TestMemberQueries: + """Tests for GraphQL query resolvers.""" + + async def test_query_single_member_by_id(self, async_session, patched_session_maker): + """Test querying a single member by ID.""" + # Create a member + member = Member( + first_name="Alice", + last_name="Smith", + email="alice@example.com" + ) + async_session.add(member) + await async_session.commit() + await async_session.refresh(member) + + # Query the member + query = Query() + result = await query.member(id=member.id) + + assert result is not None + assert result.id == member.id + assert result.first_name == "Alice" + assert result.last_name == "Smith" + assert result.email == "alice@example.com" + + async def test_query_member_with_nonexistent_id(self, patched_session_maker): + """Test querying a member with non-existent ID returns None.""" + query = Query() + result = await query.member(id=999) + + assert result is None + + async def test_query_all_members(self, async_session, patched_session_maker): + """Test querying all members.""" + # Create multiple members + members = [ + Member(first_name="Alice", last_name="Smith"), + Member(first_name="Bob", last_name="Johnson"), + Member(first_name="Charlie", last_name="Brown"), + ] + for m in members: + async_session.add(m) + await async_session.commit() + + # Query all members + query = Query() + results = await query.members() + + assert len(results) == 3 + assert results[0].first_name == "Charlie" # Brown comes first + assert results[1].first_name == "Bob" # Johnson + assert results[2].first_name == "Alice" # Smith + + async def test_query_members_empty_database(self, patched_session_maker): + """Test querying members when database is empty returns empty array.""" + query = Query() + results = await query.members() + + assert results == [] + + async def test_members_sorting_nulls_last(self, async_session, patched_session_maker): + """Test that members query sorts by last_name (nulls last), then first_name.""" + # Create members with some having null last names + members = [ + Member(first_name="Zoe", last_name="Anderson"), + Member(first_name="Alice", last_name=None), + Member(first_name="Bob", last_name=None), + Member(first_name="Charlie", last_name="Baker"), + ] + for m in members: + async_session.add(m) + await async_session.commit() + + # Query all members + query = Query() + results = await query.members() + + # Expected order: Anderson, Baker, then nulls (Alice, Bob) + assert len(results) == 4 + assert results[0].last_name == "Anderson" + assert results[1].last_name == "Baker" + assert results[2].first_name == "Alice" + assert results[2].last_name is None + assert results[3].first_name == "Bob" + assert results[3].last_name is None diff --git a/tests/mcp/test_mcp_server.py b/tests/mcp/test_mcp_server.py new file mode 100644 index 0000000..c53ffd5 --- /dev/null +++ b/tests/mcp/test_mcp_server.py @@ -0,0 +1,278 @@ +"""Tests for MCP server tools and functionality.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp.types import TextContent, Tool + +from src.mcp_server import MemberManagementServer + + +class TestMCPServer: + """Tests for MCP server initialization and tools.""" + + def test_mcp_server_initialization(self): + """Test that MCP server initializes correctly.""" + api_url = "http://127.0.0.1:8000/graphql" + server = MemberManagementServer(api_url) + assert server.graphql is not None + + def test_mcp_server_custom_api_url(self): + """Test MCP server with custom API URL.""" + custom_url = "http://localhost:3000/graphql" + server = MemberManagementServer(custom_url) + assert server.graphql is not None + + async def test_list_tools_returns_six_tools(self): + """Test that MCP server registers 6 tools.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + # The server.server object has the registered tools + # We can't directly test the decorator, but we can verify the server was initialized + assert server.server is not None + assert server.server.name == "clubber-mcp-server" + + +class TestMCPToolExecution: + """Tests for MCP tool execution.""" + + @pytest.fixture + def mock_graphql_client(self): + """Create a mock GraphQL client.""" + mock = MagicMock() + mock.query = AsyncMock() + return mock + + async def test_list_members_tool(self, mock_graphql_client): + """Test list_members tool execution.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "members": [ + { + "id": 1, + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.com", + "phone": "+14155551234", + "street": "123 Main St", + "apartmentNumber": None, + "zip": "12345", + "city": "Springfield", + "country": "USA", + "createdAt": "2024-01-01T00:00:00", + "updatedAt": "2024-01-01T00:00:00" + } + ] + } + + result = await server._list_members() + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "Alice Smith" in result[0].text + assert "alice@example.com" in result[0].text + + async def test_get_member_tool(self, mock_graphql_client): + """Test get_member tool execution.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "member": { + "id": 1, + "firstName": "Bob", + "lastName": "Johnson", + "email": "bob@example.com", + "phone": None, + "street": None, + "apartmentNumber": None, + "zip": None, + "city": None, + "country": None, + "createdAt": "2024-01-01T00:00:00", + "updatedAt": "2024-01-01T00:00:00" + } + } + + result = await server._get_member(member_id=1) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "Bob Johnson" in result[0].text + assert "bob@example.com" in result[0].text + + async def test_get_member_not_found(self, mock_graphql_client): + """Test get_member tool with non-existent ID.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response for non-existent member + mock_graphql_client.query.return_value = {"member": None} + + result = await server._get_member(member_id=999) + + assert len(result) == 1 + assert "not found" in result[0].text.lower() + + async def test_create_member_tool(self, mock_graphql_client): + """Test create_member tool execution.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "createMember": { + "id": 1, + "firstName": "Charlie", + "lastName": "Brown", + "email": "charlie@example.com", + "phone": None, + "street": None, + "apartmentNumber": None, + "zip": None, + "city": None, + "country": None, + "createdAt": "2024-01-01T00:00:00", + "updatedAt": "2024-01-01T00:00:00" + } + } + + result = await server._create_member( + {"firstName": "Charlie", "lastName": "Brown", "email": "charlie@example.com"} + ) + + assert len(result) == 1 + assert "created successfully" in result[0].text.lower() + assert "Charlie Brown" in result[0].text + + async def test_update_member_tool(self, mock_graphql_client): + """Test update_member tool execution.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "updateMember": { + "id": 1, + "firstName": "David", + "lastName": "Smith", + "email": "updated@example.com", + "phone": "+14155559999", + "street": None, + "apartmentNumber": None, + "zip": None, + "city": None, + "country": None, + "createdAt": "2024-01-01T00:00:00", + "updatedAt": "2024-01-02T00:00:00" + } + } + + result = await server._update_member( + {"id": 1, "email": "updated@example.com", "phone": "+14155559999"} + ) + + assert len(result) == 1 + assert "updated successfully" in result[0].text.lower() + assert "updated@example.com" in result[0].text + + async def test_get_graphql_schema_tool(self, mock_graphql_client): + """Test get_graphql_schema tool execution.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock introspection response + mock_graphql_client.query.return_value = { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": [ + { + "kind": "OBJECT", + "name": "Member", + "description": None, + "fields": [ + { + "name": "id", + "description": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "ID"}}, + "args": [] + }, + { + "name": "firstName", + "description": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String"}}, + "args": [] + } + ], + "inputFields": None + } + ] + } + } + + result = await server._get_graphql_schema() + + assert len(result) == 1 + assert "GraphQL Schema" in result[0].text + assert "Member" in result[0].text + + async def test_execute_graphql_query_tool(self, mock_graphql_client): + """Test execute_graphql_query tool with simple query.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "members": [ + {"id": 1, "firstName": "Alice"}, + {"id": 2, "firstName": "Bob"} + ] + } + + result = await server._execute_graphql_query( + "{ members { id firstName } }" + ) + + assert len(result) == 1 + assert "Query executed successfully" in result[0].text + # Should contain formatted JSON + assert '"firstName": "Alice"' in result[0].text or "'firstName': 'Alice'" in result[0].text + + async def test_execute_graphql_query_with_variables(self, mock_graphql_client): + """Test execute_graphql_query tool with variables.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock response + mock_graphql_client.query.return_value = { + "member": {"id": 5, "firstName": "Eve", "email": "eve@example.com"} + } + + result = await server._execute_graphql_query( + "query GetMember($id: Int!) { member(id: $id) { id firstName email } }", + {"id": 5} + ) + + assert len(result) == 1 + assert "Query executed successfully" in result[0].text + # Verify the mock was called with variables + mock_graphql_client.query.assert_called_once() + call_args = mock_graphql_client.query.call_args + assert call_args[0][1] == {"id": 5} # Variables were passed + + async def test_error_handling_api_unavailable(self, mock_graphql_client): + """Test error handling when GraphQL API is unavailable.""" + server = MemberManagementServer("http://127.0.0.1:8000/graphql") + server.graphql = mock_graphql_client + + # Mock connection error + mock_graphql_client.query.side_effect = Exception("Connection refused") + + # The error should propagate (the handler doesn't catch it in private methods) + with pytest.raises(Exception, match="Connection refused"): + await server._list_members() diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 0000000..800e88a --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,112 @@ +"""Unit tests for validation logic.""" + +import pytest + +from src.validation import ValidationError, validate_email, validate_first_name, validate_phone + + +class TestFirstNameValidation: + """Tests for validate_first_name function.""" + + def test_valid_first_name(self): + """Test that valid firstName passes validation.""" + validate_first_name("Alice") + # No exception = pass + + def test_empty_string_raises_error(self): + """Test that empty string raises ValidationError.""" + with pytest.raises(ValidationError, match="firstName cannot be empty"): + validate_first_name("") + + def test_whitespace_only_raises_error(self): + """Test that whitespace-only string raises ValidationError.""" + with pytest.raises(ValidationError, match="firstName cannot be empty"): + validate_first_name(" ") + + +class TestEmailValidation: + """Tests for validate_email function.""" + + def test_valid_email(self): + """Test that valid email passes validation.""" + validate_email("test@example.com") + # No exception = pass + + def test_valid_email_with_subdomain(self): + """Test that email with subdomain passes validation.""" + validate_email("user@mail.example.com") + + def test_valid_email_with_plus(self): + """Test that email with plus sign passes validation.""" + validate_email("user+tag@example.com") + + def test_invalid_email_no_at(self): + """Test that email without @ raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid email format"): + validate_email("notanemail") + + def test_invalid_email_no_domain(self): + """Test that email without domain raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid email format"): + validate_email("user@") + + def test_invalid_email_no_tld(self): + """Test that email without TLD raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid email format"): + validate_email("user@example") + + def test_none_is_allowed(self): + """Test that None passes validation (optional field).""" + validate_email(None) + # No exception = pass + + def test_empty_string_is_allowed(self): + """Test that empty string passes validation (optional field).""" + validate_email("") + # No exception = pass + + +class TestPhoneValidation: + """Tests for validate_phone function.""" + + def test_valid_phone_with_plus(self): + """Test that valid E.164 phone with + passes validation.""" + validate_phone("+41791234567") + + def test_valid_phone_without_plus(self): + """Test that valid E.164 phone without + passes validation.""" + validate_phone("41791234567") + + def test_valid_us_phone(self): + """Test that valid US phone passes validation.""" + validate_phone("+14155551234") + + def test_invalid_phone_with_invalid_chars(self): + """Test that phone with invalid characters raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid phone format"): + validate_phone("abc123") + + def test_invalid_phone_too_long(self): + """Test that phone too long raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid phone format"): + validate_phone("+123456789012345678") # More than 15 digits + + def test_invalid_phone_starts_with_zero(self): + """Test that phone starting with 0 raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid phone format"): + validate_phone("+0791234567") + + def test_invalid_phone_has_letters(self): + """Test that phone with letters raises ValidationError.""" + with pytest.raises(ValidationError, match="Invalid phone format"): + validate_phone("+41abc1234567") + + def test_none_is_allowed(self): + """Test that None passes validation (optional field).""" + validate_phone(None) + # No exception = pass + + def test_empty_string_is_allowed(self): + """Test that empty string passes validation (optional field).""" + validate_phone("") + # No exception = pass