""" Comprehensive tests for GraphQL mutations (Issue #10). Tests all aspects of the GraphQL write interface including: - Mutation schema validation - Markdown file CRUD operations - Schema CRUD operations - Error handling - CLI integration """ import pytest import json import sqlite3 import tempfile import os from datetime import datetime from pathlib import Path from unittest.mock import Mock, patch from markitect.graphql.schema import schema from markitect.database import DatabaseManager @pytest.fixture def temp_db_path(): """Create temporary database for testing.""" with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: db_path = f.name # Initialize database with test data db_manager = DatabaseManager(db_path) db_manager.initialize_database() yield db_path # Cleanup os.unlink(db_path) @pytest.fixture def populated_db_path(): """Create temporary database with some test data.""" with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: db_path = f.name # Initialize database with test data db_manager = DatabaseManager(db_path) db_manager.initialize_database() # Add sample data conn = sqlite3.connect(db_path) cursor = conn.cursor() # Sample markdown file cursor.execute(""" INSERT INTO markdown_files (filename, content, front_matter, created_at) VALUES (?, ?, ?, ?) """, ( 'existing.md', '# Existing Document\n\nThis document already exists.', '{"title": "Existing Document"}', datetime.now().isoformat() )) # Sample schema cursor.execute(""" INSERT INTO schemas (filename, title, description, schema_content, created_at) VALUES (?, ?, ?, ?, ?) """, ( 'existing-schema.json', 'Existing Schema', 'A schema that already exists', '{"type": "object", "properties": {"name": {"type": "string"}}}', datetime.now().isoformat() )) conn.commit() conn.close() yield db_path # Cleanup os.unlink(db_path) class TestGraphQLMutationSchema: """Test GraphQL mutation schema definition and validation.""" def test_schema_has_mutations(self): """Test that the GraphQL schema has mutations.""" result = schema.execute(''' { __schema { mutationType { name fields { name description } } } } ''') assert result.errors is None mutation_type = result.data['__schema']['mutationType'] assert mutation_type is not None assert mutation_type['name'] == 'Mutation' field_names = [field['name'] for field in mutation_type['fields']] assert 'addMarkdownFile' in field_names assert 'updateMarkdownFile' in field_names assert 'addSchema' in field_names assert 'updateSchema' in field_names assert 'deleteSchema' in field_names def test_add_markdown_file_mutation_signature(self): """Test addMarkdownFile mutation has correct signature.""" result = schema.execute(''' { __schema { mutationType { fields { name args { name type { name } } } } } } ''') mutation_fields = result.data['__schema']['mutationType']['fields'] add_file_field = next(f for f in mutation_fields if f['name'] == 'addMarkdownFile') arg_names = [arg['name'] for arg in add_file_field['args']] assert 'filename' in arg_names assert 'content' in arg_names def test_mutation_payload_types(self): """Test that mutation payload types have correct structure.""" result = schema.execute(''' { __schema { types { name fields { name type { name } } } } } ''') types = {t['name']: t for t in result.data['__schema']['types']} # Check AddMarkdownFilePayload payload = types.get('AddMarkdownFilePayload') assert payload is not None field_names = [f['name'] for f in payload['fields']] assert 'markdownFile' in field_names assert 'success' in field_names assert 'errors' in field_names class TestMarkdownFileMutations: """Test markdown file CRUD mutations.""" def test_add_markdown_file_success(self, temp_db_path): """Test successful markdown file creation.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { addMarkdownFile( filename: "new-file.md" content: "# New File\\n\\nThis is new content." ) { success markdownFile { id filename content wordCount } errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['addMarkdownFile'] assert data['success'] is True assert len(data['errors']) == 0 assert data['markdownFile'] is not None assert data['markdownFile']['filename'] == 'new-file.md' assert 'New File' in data['markdownFile']['content'] assert data['markdownFile']['wordCount'] > 0 def test_add_markdown_file_with_front_matter(self, temp_db_path): """Test markdown file creation with front matter.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): content_with_frontmatter = '''--- title: "Test Document" author: "Test Author" tags: ["test", "markdown"] --- # Test Document This is a test document with front matter. ''' mutation = ''' mutation { addMarkdownFile( filename: "with-frontmatter.md" content: "%s" ) { success markdownFile { id filename hasFrontMatter frontMatter { key value } } errors } } ''' % content_with_frontmatter.replace('\n', '\\n').replace('"', '\\"') result = schema.execute(mutation) assert result.errors is None data = result.data['addMarkdownFile'] assert data['success'] is True assert data['markdownFile']['hasFrontMatter'] is True front_matter_keys = [fm['key'] for fm in data['markdownFile']['frontMatter']] assert 'title' in front_matter_keys assert 'author' in front_matter_keys def test_add_markdown_file_duplicate_filename(self, populated_db_path): """Test adding a file with duplicate filename (should succeed as update).""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path): mutation = ''' mutation { addMarkdownFile( filename: "existing.md" content: "# Updated Content\\n\\nThis content replaces the existing." ) { success markdownFile { filename content } errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['addMarkdownFile'] assert data['success'] is True assert 'Updated Content' in data['markdownFile']['content'] def test_update_markdown_file_success(self, populated_db_path): """Test successful markdown file update.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path): mutation = ''' mutation { updateMarkdownFile( id: 1 content: "# Updated Title\\n\\nThis content has been updated." ) { success markdownFile { id content wordCount } errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['updateMarkdownFile'] assert data['success'] is True assert len(data['errors']) == 0 assert 'Updated Title' in data['markdownFile']['content'] def test_update_markdown_file_not_found(self, temp_db_path): """Test updating non-existent markdown file.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { updateMarkdownFile( id: 999 content: "# This should fail" ) { success markdownFile { id } errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['updateMarkdownFile'] assert data['success'] is False assert data['markdownFile'] is None assert len(data['errors']) > 0 assert 'not found' in data['errors'][0].lower() def test_update_markdown_file_no_content(self, populated_db_path): """Test updating markdown file without providing content.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path): mutation = ''' mutation { updateMarkdownFile(id: 1) { success errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['updateMarkdownFile'] assert data['success'] is False assert 'required' in data['errors'][0].lower() class TestSchemaMutations: """Test JSON schema CRUD mutations.""" def test_add_schema_success(self, temp_db_path): """Test successful schema creation.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): schema_content = { "type": "object", "title": "User Schema", "description": "Schema for user objects", "properties": { "name": {"type": "string"}, "age": {"type": "integer", "minimum": 0} }, "required": ["name"] } mutation = ''' mutation { addSchema( filename: "user-schema.json" schemaContent: "%s" ) { success schema { id filename title description propertyCount } errors } } ''' % json.dumps(schema_content).replace('"', '\\"') result = schema.execute(mutation) assert result.errors is None data = result.data['addSchema'] assert data['success'] is True assert len(data['errors']) == 0 assert data['schema']['filename'] == 'user-schema.json' assert data['schema']['title'] == 'User Schema' assert data['schema']['propertyCount'] == 2 def test_add_schema_invalid_json(self, temp_db_path): """Test adding schema with invalid JSON.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { addSchema( filename: "invalid-schema.json" schemaContent: "{ invalid json }" ) { success schema { id } errors } } ''' result = schema.execute(mutation) # GraphQL should reject invalid JSON at the schema validation level assert result.errors is not None assert len(result.errors) > 0 assert "Badly formed JSONString" in str(result.errors[0]) def test_update_schema_success(self, populated_db_path): """Test successful schema update.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path): new_schema = { "type": "object", "title": "Updated Schema", "properties": { "name": {"type": "string"}, "email": {"type": "string", "format": "email"} } } mutation = ''' mutation { updateSchema( id: 1 schemaContent: "%s" ) { success schema { title propertyCount } errors } } ''' % json.dumps(new_schema).replace('"', '\\"') result = schema.execute(mutation) assert result.errors is None data = result.data['updateSchema'] assert data['success'] is True assert data['schema']['title'] == 'Updated Schema' assert data['schema']['propertyCount'] == 2 def test_update_schema_not_found(self, temp_db_path): """Test updating non-existent schema.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { updateSchema( id: 999 schemaContent: "{\\"type\\": \\"object\\"}" ) { success errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['updateSchema'] assert data['success'] is False assert 'not found' in data['errors'][0].lower() def test_delete_schema_success(self, populated_db_path): """Test successful schema deletion.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path): mutation = ''' mutation { deleteSchema(filename: "existing-schema.json") { success deletedFilename errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['deleteSchema'] assert data['success'] is True assert data['deletedFilename'] == 'existing-schema.json' assert len(data['errors']) == 0 def test_delete_schema_not_found(self, temp_db_path): """Test deleting non-existent schema.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { deleteSchema(filename: "nonexistent.json") { success deletedFilename errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['deleteSchema'] assert data['success'] is False assert data['deletedFilename'] is None class TestMutationErrorHandling: """Test error handling in mutations.""" def test_database_error_handling(self, temp_db_path): """Test mutation behavior when database is unavailable.""" # Use a non-existent database path with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'): mutation = ''' mutation { addMarkdownFile( filename: "test.md" content: "# Test" ) { success errors } } ''' result = schema.execute(mutation) assert result.errors is None data = result.data['addMarkdownFile'] assert data['success'] is False assert len(data['errors']) > 0 def test_invalid_mutation_syntax(self): """Test handling of invalid mutation syntax.""" mutation = ''' mutation { addMarkdownFile(filename: "test.md") { success } } ''' result = schema.execute(mutation) # Should have errors due to missing required 'content' argument assert result.errors is not None def test_missing_required_arguments(self): """Test mutations with missing required arguments.""" mutation = ''' mutation { addSchema(filename: "test.json") { success errors } } ''' result = schema.execute(mutation) # Should have errors due to missing required 'schemaContent' argument assert result.errors is not None class TestMutationIntegration: """Test full integration of mutations with database.""" def test_crud_workflow(self, temp_db_path): """Test complete CRUD workflow for markdown files.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): # 1. Create a file create_mutation = ''' mutation { addMarkdownFile( filename: "workflow-test.md" content: "# Original Content\\n\\nOriginal text." ) { success markdownFile { id filename content } } } ''' result = schema.execute(create_mutation) assert result.data['addMarkdownFile']['success'] is True file_id = result.data['addMarkdownFile']['markdownFile']['id'] # 2. Update the file update_mutation = ''' mutation { updateMarkdownFile( id: %d content: "# Updated Content\\n\\nUpdated text." ) { success markdownFile { content } } } ''' % file_id result = schema.execute(update_mutation) assert result.data['updateMarkdownFile']['success'] is True assert 'Updated Content' in result.data['updateMarkdownFile']['markdownFile']['content'] def test_schema_crud_workflow(self, temp_db_path): """Test complete CRUD workflow for schemas.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): # 1. Create a schema create_mutation = ''' mutation { addSchema( filename: "workflow-schema.json" schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Original\\"}" ) { success schema { id title } } } ''' result = schema.execute(create_mutation) assert result.data['addSchema']['success'] is True schema_id = result.data['addSchema']['schema']['id'] # 2. Update the schema update_mutation = ''' mutation { updateSchema( id: %d schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Updated\\"}" ) { success schema { title } } } ''' % schema_id result = schema.execute(update_mutation) assert result.data['updateSchema']['success'] is True assert result.data['updateSchema']['schema']['title'] == 'Updated' # 3. Delete the schema delete_mutation = ''' mutation { deleteSchema(filename: "workflow-schema.json") { success deletedFilename } } ''' result = schema.execute(delete_mutation) assert result.data['deleteSchema']['success'] is True assert result.data['deleteSchema']['deletedFilename'] == 'workflow-schema.json' class TestMutationCLI: """Test CLI integration for mutations.""" def test_graphql_mutate_command_available(self): """Test that graphql-mutate command is available.""" import subprocess import sys result = subprocess.run( [sys.executable, "-m", "markitect.cli", "graphql-mutate", "--help"], capture_output=True, text=True ) assert result.returncode == 0 assert "Execute GraphQL mutations" in result.stdout assert "--local" in result.stdout assert "--variables" in result.stdout def test_mutation_examples_in_help(self): """Test that mutation examples are included in help.""" import subprocess import sys result = subprocess.run( [sys.executable, "-m", "markitect.cli", "graphql-examples"], capture_output=True, text=True ) assert result.returncode == 0 assert "Mutation Examples" in result.stdout assert "addMarkdownFile" in result.stdout assert "updateMarkdownFile" in result.stdout assert "addSchema" in result.stdout assert "deleteSchema" in result.stdout class TestMutationPayloads: """Test mutation payload structures.""" def test_add_markdown_file_payload_structure(self, temp_db_path): """Test AddMarkdownFilePayload has correct structure.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path): mutation = ''' mutation { addMarkdownFile( filename: "payload-test.md" content: "# Payload Test" ) { success markdownFile { id filename content wordCount lineCount hasFrontMatter createdAt } errors } } ''' result = schema.execute(mutation) assert result.errors is None payload = result.data['addMarkdownFile'] # Check payload structure assert isinstance(payload['success'], bool) assert isinstance(payload['errors'], list) if payload['success']: md_file = payload['markdownFile'] assert md_file is not None assert isinstance(md_file['id'], int) assert isinstance(md_file['filename'], str) assert isinstance(md_file['wordCount'], int) assert isinstance(md_file['lineCount'], int) assert isinstance(md_file['hasFrontMatter'], bool) def test_error_payload_structure(self, temp_db_path): """Test error payloads have correct structure.""" with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'): mutation = ''' mutation { addMarkdownFile( filename: "error-test.md" content: "# Error Test" ) { success markdownFile { id } errors } } ''' result = schema.execute(mutation) assert result.errors is None payload = result.data['addMarkdownFile'] assert payload['success'] is False assert payload['markdownFile'] is None assert isinstance(payload['errors'], list) assert len(payload['errors']) > 0 assert all(isinstance(error, str) for error in payload['errors'])