- Use app.run_stdio_async() instead of deprecated stdio_server import - Aligns with FastMCP 2.11.3 API - Server now starts correctly with uv run mcp-office-tools - Maintains all MCPMixin functionality and tool registration
395 lines
17 KiB
Python
395 lines
17 KiB
Python
"""Focused tests for UniversalMixin functionality.
|
|
|
|
This module tests the UniversalMixin in isolation, focusing on:
|
|
- Tool registration and functionality
|
|
- Error handling patterns
|
|
- Mocking strategies for file operations
|
|
- Async behavior validation
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
|
|
from pathlib import Path
|
|
|
|
from fastmcp import FastMCP
|
|
# FastMCP testing - using direct tool access
|
|
|
|
from mcp_office_tools.mixins.universal import UniversalMixin
|
|
from mcp_office_tools.utils import OfficeFileError
|
|
|
|
|
|
class TestUniversalMixinRegistration:
|
|
"""Test tool registration and basic setup."""
|
|
|
|
def test_mixin_initialization(self):
|
|
"""Test UniversalMixin initializes correctly."""
|
|
app = FastMCP("Test Universal")
|
|
mixin = UniversalMixin(app)
|
|
|
|
assert mixin.app == app
|
|
assert len(app._tools) == 6 # 6 universal tools
|
|
|
|
def test_tool_names_registered(self):
|
|
"""Test that all expected tool names are registered."""
|
|
app = FastMCP("Test Universal")
|
|
UniversalMixin(app)
|
|
|
|
expected_tools = {
|
|
"extract_text",
|
|
"extract_images",
|
|
"extract_metadata",
|
|
"detect_office_format",
|
|
"analyze_document_health",
|
|
"get_supported_formats"
|
|
}
|
|
|
|
registered_tools = set(app._tools.keys())
|
|
assert expected_tools.issubset(registered_tools)
|
|
|
|
|
|
class TestExtractText:
|
|
"""Test extract_text tool functionality."""
|
|
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
"""Create UniversalMixin for testing."""
|
|
app = FastMCP("Test")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_text_nonexistent_file(self, mixin):
|
|
"""Test extract_text with nonexistent file raises OfficeFileError."""
|
|
with pytest.raises(OfficeFileError):
|
|
await mixin.extract_text("/nonexistent/file.docx")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
async def test_extract_text_validation_failure(self, mock_detect, mock_validate, mock_resolve, mixin):
|
|
"""Test extract_text with validation failure."""
|
|
mock_resolve.return_value = "/test.docx"
|
|
mock_validate.return_value = {
|
|
"is_valid": False,
|
|
"errors": ["File is corrupted"]
|
|
}
|
|
|
|
with pytest.raises(OfficeFileError, match="Invalid file: File is corrupted"):
|
|
await mixin.extract_text("/test.docx")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
async def test_extract_text_csv_success(self, mock_detect, mock_validate, mock_resolve, mixin):
|
|
"""Test successful CSV text extraction."""
|
|
# Setup mocks
|
|
mock_resolve.return_value = "/test.csv"
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {
|
|
"category": "data",
|
|
"extension": ".csv",
|
|
"format_name": "CSV"
|
|
}
|
|
|
|
# Mock internal methods
|
|
with patch.object(mixin, '_extract_text_by_category') as mock_extract:
|
|
mock_extract.return_value = {
|
|
"text": "Name,Age\nJohn,30\nJane,25",
|
|
"method_used": "pandas",
|
|
"methods_tried": ["pandas"]
|
|
}
|
|
|
|
with patch.object(mixin, '_extract_basic_metadata') as mock_metadata:
|
|
mock_metadata.return_value = {"file_size": 1024, "rows": 3}
|
|
|
|
result = await mixin.extract_text("/test.csv")
|
|
|
|
# Verify structure
|
|
assert "text" in result
|
|
assert "metadata" in result
|
|
assert "document_metadata" in result
|
|
|
|
# Verify content
|
|
assert "John" in result["text"]
|
|
assert result["metadata"]["extraction_method"] == "pandas"
|
|
assert result["metadata"]["format"] == "CSV"
|
|
assert result["document_metadata"]["file_size"] == 1024
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_text_parameter_handling(self, mixin):
|
|
"""Test extract_text parameter validation and handling."""
|
|
# Mock all dependencies for parameter testing
|
|
with patch('mcp_office_tools.utils.validation.resolve_office_file_path') as mock_resolve:
|
|
with patch('mcp_office_tools.utils.validation.validate_office_file') as mock_validate:
|
|
with patch('mcp_office_tools.utils.file_detection.detect_format') as mock_detect:
|
|
mock_resolve.return_value = "/test.docx"
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {"category": "word", "extension": ".docx", "format_name": "Word"}
|
|
|
|
with patch.object(mixin, '_extract_text_by_category') as mock_extract:
|
|
mock_extract.return_value = {"text": "test", "method_used": "docx"}
|
|
|
|
with patch.object(mixin, '_extract_basic_metadata') as mock_metadata:
|
|
mock_metadata.return_value = {}
|
|
|
|
# Test with different parameters
|
|
result = await mixin.extract_text(
|
|
file_path="/test.docx",
|
|
preserve_formatting=True,
|
|
include_metadata=False,
|
|
method="primary"
|
|
)
|
|
|
|
# Verify the call was made with correct parameters
|
|
mock_extract.assert_called_once()
|
|
args = mock_extract.call_args[0]
|
|
assert args[2] == "word" # category
|
|
assert args[4] == True # preserve_formatting
|
|
assert args[5] == "primary" # method
|
|
|
|
|
|
class TestExtractImages:
|
|
"""Test extract_images tool functionality."""
|
|
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
"""Create UniversalMixin for testing."""
|
|
app = FastMCP("Test")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_images_nonexistent_file(self, mixin):
|
|
"""Test extract_images with nonexistent file."""
|
|
with pytest.raises(OfficeFileError):
|
|
await mixin.extract_images("/nonexistent/file.docx")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
async def test_extract_images_unsupported_format(self, mock_detect, mock_validate, mock_resolve, mixin):
|
|
"""Test extract_images with unsupported format (CSV)."""
|
|
mock_resolve.return_value = "/test.csv"
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {"category": "data", "extension": ".csv", "format_name": "CSV"}
|
|
|
|
with pytest.raises(OfficeFileError, match="Image extraction not supported for data files"):
|
|
await mixin.extract_images("/test.csv")
|
|
|
|
|
|
class TestGetSupportedFormats:
|
|
"""Test get_supported_formats tool functionality."""
|
|
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
"""Create UniversalMixin for testing."""
|
|
app = FastMCP("Test")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_supported_formats_structure(self, mixin):
|
|
"""Test get_supported_formats returns correct structure."""
|
|
result = await mixin.get_supported_formats()
|
|
|
|
# Verify top-level structure
|
|
assert isinstance(result, dict)
|
|
required_keys = {"supported_extensions", "format_details", "categories", "total_formats"}
|
|
assert required_keys.issubset(result.keys())
|
|
|
|
# Verify supported extensions include common formats
|
|
extensions = result["supported_extensions"]
|
|
assert isinstance(extensions, list)
|
|
expected_extensions = {".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt", ".csv"}
|
|
assert expected_extensions.issubset(set(extensions))
|
|
|
|
# Verify categories
|
|
categories = result["categories"]
|
|
assert isinstance(categories, dict)
|
|
expected_categories = {"word", "excel", "powerpoint", "data"}
|
|
assert expected_categories.issubset(categories.keys())
|
|
|
|
# Verify total_formats is correct
|
|
assert result["total_formats"] == len(extensions)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_supported_formats_details(self, mixin):
|
|
"""Test get_supported_formats includes detailed format information."""
|
|
result = await mixin.get_supported_formats()
|
|
|
|
format_details = result["format_details"]
|
|
assert isinstance(format_details, dict)
|
|
|
|
# Check that .docx details are present and complete
|
|
if ".docx" in format_details:
|
|
docx_details = format_details[".docx"]
|
|
expected_docx_keys = {"name", "category", "description", "features_supported"}
|
|
assert expected_docx_keys.issubset(docx_details.keys())
|
|
|
|
|
|
class TestDocumentHealth:
|
|
"""Test analyze_document_health tool functionality."""
|
|
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
"""Create UniversalMixin for testing."""
|
|
app = FastMCP("Test")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
async def test_analyze_document_health_success(self, mock_detect, mock_validate, mock_resolve, mixin):
|
|
"""Test successful document health analysis."""
|
|
mock_resolve.return_value = "/test.docx"
|
|
mock_validate.return_value = {
|
|
"is_valid": True,
|
|
"errors": [],
|
|
"warnings": [],
|
|
"password_protected": False
|
|
}
|
|
mock_detect.return_value = {
|
|
"category": "word",
|
|
"extension": ".docx",
|
|
"format_name": "Word Document",
|
|
"is_legacy": False,
|
|
"structure": {"estimated_complexity": "simple"}
|
|
}
|
|
|
|
with patch.object(mixin, '_calculate_health_score') as mock_score:
|
|
with patch.object(mixin, '_get_health_recommendations') as mock_recommendations:
|
|
mock_score.return_value = 9
|
|
mock_recommendations.return_value = ["Document appears healthy"]
|
|
|
|
result = await mixin.analyze_document_health("/test.docx")
|
|
|
|
# Verify structure
|
|
assert "health_score" in result
|
|
assert "analysis" in result
|
|
assert "recommendations" in result
|
|
assert "format_info" in result
|
|
|
|
# Verify content
|
|
assert result["health_score"] == 9
|
|
assert len(result["recommendations"]) > 0
|
|
|
|
|
|
class TestDirectToolAccess:
|
|
"""Test mixin integration with direct tool access."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_execution_direct(self):
|
|
"""Test tool execution through direct tool access."""
|
|
app = FastMCP("Test App")
|
|
UniversalMixin(app)
|
|
|
|
# Test get_supported_formats via direct access
|
|
get_supported_formats_tool = app._tools["get_supported_formats"]
|
|
result = await get_supported_formats_tool()
|
|
|
|
assert "supported_extensions" in result
|
|
assert "format_details" in result
|
|
assert isinstance(result["supported_extensions"], list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_error_direct(self):
|
|
"""Test tool error handling via direct access."""
|
|
app = FastMCP("Test App")
|
|
UniversalMixin(app)
|
|
|
|
# Test error handling via direct access
|
|
extract_text_tool = app._tools["extract_text"]
|
|
with pytest.raises(OfficeFileError):
|
|
await extract_text_tool(file_path="/nonexistent/file.docx")
|
|
|
|
|
|
class TestMockingPatterns:
|
|
"""Demonstrate various mocking patterns for file operations."""
|
|
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
"""Create UniversalMixin for testing."""
|
|
app = FastMCP("Test")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_comprehensive_mocking_pattern(self, mixin):
|
|
"""Demonstrate comprehensive mocking for complex tool testing."""
|
|
# Mock all external dependencies
|
|
with patch('mcp_office_tools.utils.validation.resolve_office_file_path') as mock_resolve:
|
|
with patch('mcp_office_tools.utils.validation.validate_office_file') as mock_validate:
|
|
with patch('mcp_office_tools.utils.file_detection.detect_format') as mock_detect:
|
|
|
|
# Setup realistic mock responses
|
|
mock_resolve.return_value = "/realistic/path/document.docx"
|
|
mock_validate.return_value = {
|
|
"is_valid": True,
|
|
"errors": [],
|
|
"warnings": ["File is large"],
|
|
"password_protected": False,
|
|
"file_size": 1048576 # 1MB
|
|
}
|
|
mock_detect.return_value = {
|
|
"category": "word",
|
|
"extension": ".docx",
|
|
"format_name": "Microsoft Word Document",
|
|
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"is_legacy": False,
|
|
"structure": {
|
|
"estimated_complexity": "moderate",
|
|
"has_images": True,
|
|
"has_tables": True
|
|
}
|
|
}
|
|
|
|
# Mock internal processing methods
|
|
with patch.object(mixin, '_extract_text_by_category') as mock_extract:
|
|
mock_extract.return_value = {
|
|
"text": "This is comprehensive test content with multiple paragraphs.\n\nIncluding headers and formatting.",
|
|
"method_used": "python-docx",
|
|
"methods_tried": ["python-docx"],
|
|
"formatted_sections": [
|
|
{"type": "heading", "text": "Document Title", "level": 1},
|
|
{"type": "paragraph", "text": "This is comprehensive test content..."}
|
|
]
|
|
}
|
|
|
|
with patch.object(mixin, '_extract_basic_metadata') as mock_metadata:
|
|
mock_metadata.return_value = {
|
|
"title": "Test Document",
|
|
"author": "Test Author",
|
|
"created": "2024-01-01T10:00:00Z",
|
|
"modified": "2024-01-15T14:30:00Z",
|
|
"word_count": 1247,
|
|
"page_count": 3
|
|
}
|
|
|
|
# Execute with realistic parameters
|
|
result = await mixin.extract_text(
|
|
file_path="/test/document.docx",
|
|
preserve_formatting=True,
|
|
include_metadata=True,
|
|
method="auto"
|
|
)
|
|
|
|
# Comprehensive assertions
|
|
assert result["text"] == "This is comprehensive test content with multiple paragraphs.\n\nIncluding headers and formatting."
|
|
assert result["metadata"]["extraction_method"] == "python-docx"
|
|
assert result["metadata"]["format"] == "Microsoft Word Document"
|
|
assert "extraction_time" in result["metadata"]
|
|
assert result["document_metadata"]["author"] == "Test Author"
|
|
assert "structure" in result # Because preserve_formatting=True
|
|
|
|
# Verify all mocks were called appropriately
|
|
mock_resolve.assert_called_once_with("/test/document.docx")
|
|
mock_validate.assert_called_once_with("/realistic/path/document.docx")
|
|
mock_detect.assert_called_once_with("/realistic/path/document.docx")
|
|
mock_extract.assert_called_once()
|
|
mock_metadata.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"]) |