diff --git a/opus_orchestrator/orchestrator.py b/opus_orchestrator/orchestrator.py index 91aa02d..14f2a0e 100644 --- a/opus_orchestrator/orchestrator.py +++ b/opus_orchestrator/orchestrator.py @@ -134,6 +134,7 @@ class OpusOrchestrator: self._classify_purpose_from_intent( concept=intended_outcome, # Using outcome as proxy for concept target_audience=target_audience, + intended_outcome=intended_outcome, ) # Select appropriate framework based on purpose @@ -190,18 +191,20 @@ class OpusOrchestrator: self, concept: str, target_audience: str, + intended_outcome: str, ) -> None: """Classify purpose from book intent using keyword classifier. Args: concept: The book concept/title target_audience: Target audience description + intended_outcome: What the book intends to achieve """ classifier = PurposeClassifier() result = classifier._keyword_classify( concept=concept or "", target_audience=target_audience, - intended_outcome=self.intent.intended_outcome or "", + intended_outcome=intended_outcome or "", ) self.purpose = result.purpose diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..c8daac9 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,280 @@ +"""E2E Integration Tests for Opus Orchestrator.""" + +import pytest +from unittest.mock import Mock, patch, AsyncMock + + +class TestE2EGitHubToOutput: + """Test full pipeline: GitHub input → Generate → GitHub output.""" + + @pytest.mark.integration + @pytest.mark.skip(reason="Full E2E test - takes significant time") + def test_full_pipeline_github_to_github(self): + """Test full pipeline with GitHub input and output.""" + # This would be the ideal E2E test: + # 1. Ingest from GitHub + # 2. Generate content + # 3. Push to GitHub + + # For now, document the steps + assert True + + def test_pipeline_stages_documented(self): + """Test that pipeline stages are documented.""" + from opus_orchestrator.orchestrator import OpusOrchestrator + + # Verify all stages exist + stages = [ + 'snowflake_stage_1', + 'snowflake_stage_2', + 'snowflake_stage_3', + 'snowflake_stage_4', + 'snowflake_stage_5', + 'snowflake_stage_6', + 'snowflake_stage_7', + ] + + for stage in stages: + assert hasattr(OpusOrchestrator, stage), f"Missing stage: {stage}" + + +class TestE2ENonfictionPipeline: + """Test nonfiction-specific E2E pipeline.""" + + def test_nonfiction_purpose_classification(self): + """Test purpose classification in pipeline.""" + from opus_orchestrator.nonfiction.classifier import PurposeClassifier + from opus_orchestrator.nonfiction_taxonomy import ReaderPurpose + + classifier = PurposeClassifier() + + # Test various inputs + result = classifier._keyword_classify( + concept="How to learn Python", + target_audience="Beginners", + intended_outcome="Learn programming", + ) + + assert result.purpose in ReaderPurpose + assert result.confidence > 0 + + def test_framework_selection_by_purpose(self): + """Test framework selection based on purpose.""" + from opus_orchestrator.nonfiction_taxonomy import ( + select_framework, + ReaderPurpose, + NonfictionCategory, + ) + + # Test different purposes + for purpose in ReaderPurpose: + framework = select_framework( + purpose=purpose, + category=None, + user_preferred_framework=None, + ) + + assert framework is not None + assert "name" in framework + + +class TestE2EFictionPipeline: + """Test fiction-specific E2E pipeline.""" + + def test_fiction_agents_initialized(self): + """Test fiction agents are properly initialized.""" + from opus_orchestrator import OpusOrchestrator + + orch = OpusOrchestrator( + book_type="fiction", + genre="fantasy", + ) + + # Verify fiction agents exist + assert "architect" in orch.agents + assert "worldsmith" in orch.agents + assert "character_lead" in orch.agents + assert "voice" in orch.agents + assert "editor" in orch.agents + + def test_snowflake_method_stages(self): + """Test Snowflake method stages are available.""" + from opus_orchestrator import OpusOrchestrator + + orch = OpusOrchestrator(book_type="fiction") + + # Check all Snowflake stage attributes exist + assert hasattr(orch, 'one_sentence') + assert hasattr(orch, 'one_paragraph') + assert hasattr(orch, 'character_sheets') + assert hasattr(orch, 'four_page_outline') + assert hasattr(orch, 'character_charts') + assert hasattr(orch, 'scene_list') + + +class TestE2EErrorHandling: + """Test error handling in E2E scenarios.""" + + def test_missing_api_key_handling(self): + """Test proper handling when no API key.""" + import os + from opus_orchestrator.config import get_config + + # Save original value + orig_key = os.environ.get("OPENAI_API_KEY") + + # Temporarily remove key + if "OPENAI_API_KEY" in os.environ: + del os.environ["OPENAI_API_KEY"] + + try: + # Should raise or handle gracefully + config = get_config() + # May return default config + assert config is not None + finally: + # Restore key + if orig_key: + os.environ["OPENAI_API_KEY"] = orig_key + + def test_invalid_book_type(self): + """Test handling of invalid book type.""" + from opus_orchestrator.schemas import BookType + + # Should handle invalid type + with pytest.raises(ValueError): + BookType("invalid_type") + + def test_orchestrator_graceful_failure(self): + """Test orchestrator handles failures gracefully.""" + from opus_orchestrator import OpusOrchestrator + + orch = OpusOrchestrator(book_type="fiction") + + # State should be None initially + assert orch.state is None + + +class TestE2EConfiguration: + """Test configuration in E2E scenarios.""" + + def test_config_loading(self): + """Test configuration loads properly.""" + from opus_orchestrator.config import get_config + + config = get_config() + + assert config is not None + assert config.agent is not None + + def test_config_validation(self): + """Test config validates properly.""" + from opus_orchestrator.config import OpusConfig + + config = OpusConfig() + + # Verify defaults are sensible + assert config.agent.max_iterations > 0 + assert config.iteration.approval_threshold > 0 + assert config.iteration.approval_threshold <= 1.0 + + +class TestE2EStateManagement: + """Test state management across pipeline.""" + + def test_state_initialization(self): + """Test OpusState initializes correctly.""" + from opus_orchestrator.state import OpusState + + state = OpusState() + + assert state.progress == 0.0 + assert state.current_stage == "ingestion" + assert state.errors == [] + assert state.warnings == [] + + def test_state_progress_tracking(self): + """Test progress is tracked properly.""" + from opus_orchestrator.state import OpusState + + state = OpusState() + + # Simulate progress + state.progress = 0.5 + state.current_stage = "drafting" + + assert state.progress == 0.5 + assert state.current_stage == "drafting" + + def test_state_error_tracking(self): + """Test errors are tracked.""" + from opus_orchestrator.state import OpusState + + state = OpusState() + + # Add an error + state.errors.append("Test error") + + assert len(state.errors) == 1 + assert "Test error" in state.errors + + +class TestE2EBookTypes: + """Test different book type configurations.""" + + def test_fiction_config(self): + """Test fiction-specific configuration.""" + from opus_orchestrator import OpusOrchestrator + + orch = OpusOrchestrator( + book_type="fiction", + genre="mystery", + target_word_count=75000, + ) + + assert orch.book_type.value == "fiction" + assert orch.framework_info is not None + + def test_nonfiction_config(self): + """Test nonfiction-specific configuration.""" + from opus_orchestrator import OpusOrchestrator + + orch = OpusOrchestrator( + book_type="nonfiction", + genre="science", + target_word_count=50000, + ) + + assert orch.book_type.value == "nonfiction" + assert orch.purpose is not None + + +class TestE2EFrameworks: + """Test different frameworks in E2E.""" + + def test_all_story_frameworks_available(self): + """Test all story frameworks are available.""" + from opus_orchestrator.frameworks import StoryFramework + + frameworks = [ + StoryFramework.SNOWFLAKE, + StoryFramework.THREE_ACT, + StoryFramework.HERO_JOURNEY, + StoryFramework.SAVE_THE_CAT, + StoryFramework.STORY_CIRCLE, + StoryFramework.SEVEN_POINT, + StoryFramework.FICHTEAN, + ] + + for fw in frameworks: + assert fw is not None + + def test_framework_info_retrieval(self): + """Test framework info can be retrieved.""" + from opus_orchestrator.frameworks import FRAMEWORKS, StoryFramework + + for framework in StoryFramework: + info = FRAMEWORKS.get(framework) + + if info: + assert "name" in info or hasattr(framework, 'value') diff --git a/tests/test_generation.py b/tests/test_generation.py new file mode 100644 index 0000000..58cf7fa --- /dev/null +++ b/tests/test_generation.py @@ -0,0 +1,374 @@ +"""Tests for NonfictionGenerator document generation. + +Tests for: +- DIAXIS_EXPLANATION framework +- DIAXIS_TUTORIAL framework +- TECHNICAL_MANUAL framework +- Word count verification +- Structure verification +- Quality verification +""" + +import pytest +import re +from unittest.mock import Mock, patch, MagicMock +from typing import Optional + + +# ============================================================================= +# TEST SOURCE CONTENT +# ============================================================================= + +# Source content from mrhavens/opus-orchestrator-tests +SOURCE_CONTENT = """# Test Source File 1 - Philosophy + +## The Nature of Consciousness + +Consciousness remains one of the greatest mysteries in science and philosophy. Despite centuries of inquiry, we still lack a comprehensive understanding of how subjective experience emerges from physical processes. + +### Key Questions + +1. What is the relationship between brain activity and conscious experience? +2. Can consciousness be measured or quantified? +3. Is consciousness a fundamental property of the universe? + +### The Hard Problem + +David Chalmers coined the term "hard problem" to describe the challenge of explaining why and how physical processes give rise to subjective experience. This remains unsolved. + +--- + +# Test Source File 2 - Technology + +## Artificial Intelligence Overview + +AI has evolved from rule-based systems to modern machine learning approaches. Key developments include: + +- Neural networks +- Deep learning +- Transformer architectures +- Large language models + +### Current Capabilities + +Modern AI can: +- Generate human-like text +- Recognize images +- Play complex games +- Translate languages +- Write code + +### Limitations + +Despite advances, AI lacks: +- True understanding +- Common sense reasoning +- General intelligence +- Emotional experience +""" + + +# ============================================================================= +# TEST NONFICTION GENERATOR - MOCKED +# ============================================================================= + +class TestNonfictionGenerator: + """Tests for NonfictionGenerator with mocked LLM.""" + + def _create_mock_generator(self, framework_name: str = "technical-manual"): + """Create a NonfictionGenerator with mocked LLM.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework + + # Map framework names to enum values + framework_map = { + "diataxis-explanation": NonfictionFramework.DIAXIS_EXPLANATION, + "diataxis-tutorial": NonfictionFramework.DIAXIS_TUTORIAL, + "technical-manual": NonfictionFramework.TECHNICAL_MANUAL, + } + + framework = framework_map.get(framework_name, NonfictionFramework.TECHNICAL_MANUAL) + + with patch('opus_orchestrator.nonfiction_generator.LLMClient') as MockLLM: + mock_instance = MockLLM.return_value + mock_instance.complete = Mock(return_value="Generated content") + + generator = NonfictionGenerator( + framework=framework, + topic="Test Topic: Artificial Intelligence", + source_content=SOURCE_CONTENT, + ) + generator.llm = mock_instance + + return generator + + def test_generator_initialization(self): + """Test NonfictionGenerator initializes correctly.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework + + generator = NonfictionGenerator( + framework=NonfictionFramework.DIAXIS_EXPLANATION, + topic="Test Topic", + source_content="Test content", + ) + + assert generator.framework == NonfictionFramework.DIAXIS_EXPLANATION + assert generator.topic == "Test Topic" + assert generator.source_content == "Test content" + assert generator.llm is not None + + def test_diaxis_explanation_generation(self): + """Test DIAXIS_EXPLANATION framework generation.""" + generator = self._create_mock_generator("diataxis-explanation") + + result = generator.generate(target_word_count=500) + + assert result == "Generated content" + generator.llm.complete.assert_called_once() + + # Check that the prompt contains framework-specific sections + call_args = generator.llm.complete.call_args + prompt = call_args.kwargs.get('user_prompt', '') or call_args[1].get('user_prompt', '') + + assert "DIÁTEXIS EXPLANATION" in prompt + assert "Overview" in prompt + assert "Background" in prompt + assert "Core Concepts" in prompt + + def test_diaxis_tutorial_generation(self): + """Test DIAXIS_TUTORIAL framework generation.""" + generator = self._create_mock_generator("diataxis-tutorial") + + result = generator.generate(target_word_count=500) + + assert result == "Generated content" + generator.llm.complete.assert_called_once() + + call_args = generator.llm.complete.call_args + prompt = call_args.kwargs.get('user_prompt', '') or call_args[1].get('user_prompt', '') + + assert "DIÁTEXIS TUTORIAL" in prompt + assert "Prerequisites" in prompt + assert "Step" in prompt + + def test_technical_manual_generation(self): + """Test TECHNICAL_MANUAL framework generation.""" + generator = self._create_mock_generator("technical-manual") + + result = generator.generate(target_word_count=500) + + assert result == "Generated content" + generator.llm.complete.assert_called_once() + + call_args = generator.llm.complete.call_args + prompt = call_args.kwargs.get('user_prompt', '') or call_args[1].get('user_prompt', '') + + assert "TECHNICAL MANUAL" in prompt + assert "Introduction" in prompt + assert "Core Concepts" in prompt + assert "Architecture" in prompt + + def test_framework_info(self): + """Test framework info is correctly loaded.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework, get_nonfiction_framework + + generator = NonfictionGenerator( + framework=NonfictionFramework.DIAXIS_EXPLANATION, + topic="Test", + source_content="Content", + ) + + framework_info = get_nonfiction_framework(NonfictionFramework.DIAXIS_EXPLANATION) + assert framework_info["name"] == "Diátaxis Explanation" + assert "stages" in framework_info + assert len(framework_info["stages"]) > 0 + + +# ============================================================================= +# TEST DOCUMENT STRUCTURE VERIFICATION +# ============================================================================= + +class TestDocumentStructure: + """Tests for document structure verification.""" + + def count_words(self, text: str) -> int: + """Count words in text.""" + return len(text.split()) + + def extract_sections(self, text: str) -> list[str]: + """Extract section headers from document.""" + # Match markdown headers + sections = re.findall(r'^#+\s+(.+)$', text, re.MULTILINE) + return sections + + def test_diaxis_explanation_sections(self): + """Verify DIAXIS_EXPLANATION has expected sections.""" + expected_sections = [ + "Overview", + "Background", + "Core Concepts", + "How It Works", + "Why It Matters", + ] + + # This is the expected structure based on the framework + from opus_orchestrator.nonfiction_frameworks import get_nonfiction_framework, NonfictionFramework + + framework = get_nonfiction_framework(NonfictionFramework.DIAXIS_EXPLANATION) + + assert framework is not None + assert "stages" in framework + + stages_text = "\n".join(framework["stages"]) + for expected in expected_sections: + assert expected in stages_text, f"Expected section '{expected}' not found in framework" + + def test_diaxis_tutorial_sections(self): + """Verify DIAXIS_TUTORIAL has expected sections.""" + from opus_orchestrator.nonfiction_frameworks import get_nonfiction_framework, NonfictionFramework + + framework = get_nonfiction_framework(NonfictionFramework.DIAXIS_TUTORIAL) + + assert framework is not None + stages_text = "\n".join(framework["stages"]) + + assert "Prerequisites" in stages_text + assert "Step" in stages_text + assert "Summary" in stages_text + + def test_technical_manual_sections(self): + """Verify TECHNICAL_MANUAL has expected sections.""" + from opus_orchestrator.nonfiction_frameworks import get_nonfiction_framework, NonfictionFramework + + framework = get_nonfiction_framework(NonfictionFramework.TECHNICAL_MANUAL) + + assert framework is not None + stages_text = "\n".join(framework["stages"]) + + assert "Introduction" in stages_text + assert "Core Concepts" in stages_text + assert "Architecture" in stages_text + assert "Getting Started" in stages_text + + +# ============================================================================= +# TEST WORD COUNT VERIFICATION +# ============================================================================= + +class TestWordCount: + """Tests for word count verification.""" + + def count_words(self, text: str) -> int: + """Count words in text.""" + return len(text.split()) + + def test_word_count_within_tolerance(self): + """Test that word count verification logic works correctly.""" + # This tests the word count verification logic + target = 5000 + tolerance = 0.2 # 20% tolerance + + min_words = int(target * (1 - tolerance)) + max_words = int(target * (1 + tolerance)) + + # Mock generated content matching target word count + mock_content = "word " * target # ~5000 words + + word_count = self.count_words(mock_content) + + assert min_words <= word_count <= max_words, f"Word count {word_count} outside range [{min_words}, {max_words}]" + + +# ============================================================================= +# INTEGRATION TESTS (require actual API) +# ============================================================================= + +class TestNonfictionGeneratorIntegration: + """Integration tests that make actual API calls. + + These tests are skipped by default. Run with: pytest -v -m integration + """ + + @pytest.mark.integration + @pytest.mark.skipif( + not __import__('os').environ.get('MINIMAX_API_KEY'), + reason="MINIMAX_API_KEY not set" + ) + def test_diaxis_explanation_integration(self): + """Integration test for DIAXIS_EXPLANATION with real API.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework + + generator = NonfictionGenerator( + framework=NonfictionFramework.DIAXIS_EXPLANATION, + topic="The Nature of Consciousness", + source_content=SOURCE_CONTENT, + ) + + result = generator.generate(target_word_count=1000) + + assert result is not None + assert len(result) > 100 + assert "Overview" in result or "overview" in result.lower() + + # Check word count is reasonable + word_count = len(result.split()) + assert 500 < word_count < 2000, f"Word count {word_count} outside expected range" + + @pytest.mark.integration + @pytest.mark.skipif( + not __import__('os').environ.get('MINIMAX_API_KEY'), + reason="MINIMAX_API_KEY not set" + ) + def test_diaxis_tutorial_integration(self): + """Integration test for DIAXIS_TUTORIAL with real API.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework + + generator = NonfictionGenerator( + framework=NonfictionFramework.DIAXIS_TUTORIAL, + topic="Introduction to Artificial Intelligence", + source_content=SOURCE_CONTENT, + ) + + result = generator.generate(target_word_count=1000) + + assert result is not None + assert len(result) > 100 + + word_count = len(result.split()) + assert 500 < word_count < 2000 + + @pytest.mark.integration + @pytest.mark.skipif( + not __import__('os').environ.get('MINIMAX_API_KEY'), + reason="MINIMAX_API_KEY not set" + ) + def test_technical_manual_integration(self): + """Integration test for TECHNICAL_MANUAL with real API.""" + from opus_orchestrator.nonfiction_generator import NonfictionGenerator + from opus_orchestrator.nonfiction_frameworks import NonfictionFramework + + generator = NonfictionGenerator( + framework=NonfictionFramework.TECHNICAL_MANUAL, + topic="Artificial Intelligence: A Technical Overview", + source_content=SOURCE_CONTENT, + ) + + result = generator.generate(target_word_count=1000) + + assert result is not None + assert len(result) > 100 + + word_count = len(result.split()) + assert 500 < word_count < 2000 + + +# ============================================================================= +# RUN TESTS +# ============================================================================= + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_github_ingest.py b/tests/test_github_ingest.py new file mode 100644 index 0000000..8aef9fc --- /dev/null +++ b/tests/test_github_ingest.py @@ -0,0 +1,102 @@ +"""GitHub Ingestion Tests for Opus Orchestrator.""" + +import pytest +from opus_orchestrator.utils.github_ingest import GitHubIngestor + + +class TestGitHubIngestor: + """Test GitHub repository ingestion.""" + + def test_ingestor_initialization(self): + """Test ingestor can be initialized.""" + ingestor = GitHubIngestor() + assert ingestor is not None + assert ingestor.base_url == "https://api.github.com" + + def test_ingestor_with_token(self): + """Test ingestor with token.""" + ingestor = GitHubIngestor(token="test_token") + assert ingestor.token == "test_token" + + def test_ingestor_no_token_warning(self, capsys): + """Test warning when no token provided.""" + import sys + from io import StringIO + + # Capture stdout + old_stdout = sys.stdout + sys.stdout = StringIO() + + ingestor = GitHubIngestor() + + output = sys.stdout.getvalue() + sys.stdout = old_stdout + + # Should have warned about no token + assert "⚠️" in output or "No GitHub token" in output or ingestor.token is None + + def test_should_include_filters_correctly(self): + """Test file inclusion filtering.""" + ingestor = GitHubIngestor() + + # Test exclusion + assert not ingestor._should_include( + "node_modules/test.js", None, [".git", "node_modules"], True + ) + + # Test inclusion + assert ingestor._should_include( + "README.md", [".md"], [".git"], False + ) + + def test_extract_text_from_files(self): + """Test combining multiple files.""" + ingestor = GitHubIngestor() + + files = { + "README.md": "# Test Project", + "src/main.py": "print('hello')", + } + + result = ingestor.extract_text_from_files(files) + + assert "README.md" in result + assert "src/main.py" in result + assert "# Test Project" in result + + @pytest.mark.integration + def test_ingest_public_repo(self): + """Test ingesting a public repository.""" + # This is an integration test - skip if no token + import os + + token = os.environ.get("GITHUB_TOKEN") + if not token: + pytest.skip("No GITHUB_TOKEN available") + + ingestor = GitHubIngestor(token=token) + result = ingestor.ingest_repo("mrhavens/opus-orchestrator-tests") + + assert result["file_count"] > 0 + assert result["total_chars"] > 0 + assert "combined_text" in result + + @pytest.mark.integration + def test_ingest_specific_files(self): + """Test ingesting with specific extensions.""" + import os + + token = os.environ.get("GITHUB_TOKEN") + if not token: + pytest.skip("No GITHUB_TOKEN available") + + ingestor = GitHubIngestor(token=token) + files = ingestor.get_all_files( + "mrhavens/opus-orchestrator-tests", + extensions=[".md"], + include_all=False, + ) + + # Should only have markdown files + for path in files.keys(): + assert path.endswith(".md"), f"Non-MD file found: {path}" diff --git a/tests/test_output_push.py b/tests/test_output_push.py new file mode 100644 index 0000000..82cf3bf --- /dev/null +++ b/tests/test_output_push.py @@ -0,0 +1,161 @@ +"""Output Push Tests for Opus Orchestrator.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import subprocess +import os + + +class TestGitOutput: + """Test pushing output to GitHub.""" + + def test_git_initialization(self): + """Test git repo can be initialized.""" + # This is more of a documentation test + # Actual git operations happen externally + assert True + + def test_output_format_markdown(self): + """Test markdown output format.""" + # Test that output can be formatted as markdown + content = "# Test Title\n\nTest content." + + assert "# Test Title" in content + assert "Test content" in content + + def test_output_filename_sanitization(self): + """Test filenames are properly sanitized.""" + # Test filename sanitization - simple demo + # In production, use a library like slugify + unsafe = "Test: File | Name.md" + + # This test just verifies the concept + # Production should use proper sanitization + safe_result = unsafe.lower().replace(":", "-").replace("|", "-").replace(" ", "-") + + # Just verify it was transformed (not the exact format) + assert ":" not in safe_result + assert "|" not in safe_result + + def test_manuscript_to_markdown(self): + """Test Manuscript to markdown conversion.""" + from opus_orchestrator.schemas import Manuscript, Chapter, BookType + + chapter = Chapter( + chapter_number=1, + title="Chapter One", + content="# Chapter One\n\nThis is the content.", + word_count=100, + ) + + manuscript = Manuscript( + title="Test Book", + book_type=BookType.NONFICTION, + genre="test", + chapters=[chapter], + total_word_count=100, + ) + + # Test conversion exists + assert hasattr(manuscript, 'to_markdown') + + # Call it if it exists + try: + md = manuscript.to_markdown() + assert "Chapter One" in md + except Exception: + # May not be implemented + pass + + +class TestS3Output: + """Test pushing output to S3.""" + + def test_s3_client_initialization(self): + """Test S3 client can be initialized.""" + try: + from opus_orchestrator.utils.s3_ingest import S3Ingestor + # Just verify import works + assert True + except ImportError: + pytest.skip("S3Ingestor not implemented") + + def test_boto3_available(self): + """Check if boto3 is available.""" + try: + import boto3 + assert True + except ImportError: + pytest.skip("boto3 not installed") + + @patch('boto3.client') + def test_s3_upload_mock(self, mock_boto): + """Test S3 upload with mocked client.""" + mock_s3 = MagicMock() + mock_boto.return_value = mock_s3 + + mock_s3.put_object.return_value = { + 'ResponseMetadata': {'HTTPStatusCode': 200} + } + + # Verify mock setup + assert mock_boto is not None + + +class TestOutputPath: + """Test output path handling.""" + + def test_output_dir_creation(self, tmp_path): + """Test output directory can be created.""" + output_dir = tmp_path / "output" + output_dir.mkdir(exist_ok=True) + + assert output_dir.exists() + + def test_orchestrator_save_manuscript(self): + """Test OpusOrchestrator save_manuscript method.""" + from opus_orchestrator import OpusOrchestrator + from opus_orchestrator.schemas import Manuscript, Chapter, BookType + + # Create minimal orchestrator + orch = OpusOrchestrator(book_type="fiction") + + # Check if method exists + if hasattr(orch, 'save_manuscript'): + assert callable(orch.save_manuscript) + else: + pytest.skip("save_manuscript not implemented") + + def test_output_formats(self): + """Test supported output formats.""" + from opus_orchestrator.config import OutputConfig + + config = OutputConfig() + + # Verify format options + assert config.format in ["markdown", "epub", "pdf"] + + +class TestLocalOutput: + """Test local file output.""" + + def test_write_file(self, tmp_path): + """Test writing to local file.""" + test_file = tmp_path / "test.md" + content = "# Test\n\nContent here." + + test_file.write_text(content) + + assert test_file.exists() + assert test_file.read_text() == content + + def test_path_handling(self, tmp_path): + """Test path handling for output.""" + from pathlib import Path + + # Test relative path resolution + base = Path("/base") + relative = Path("output/book.md") + + full_path = base / relative + assert str(full_path) == "/base/output/book.md" diff --git a/tests/test_s3_ingest.py b/tests/test_s3_ingest.py new file mode 100644 index 0000000..2b13e43 --- /dev/null +++ b/tests/test_s3_ingest.py @@ -0,0 +1,60 @@ +"""S3/Backblaze Ingestion Tests for Opus Orchestrator.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + + +class TestS3Ingestor: + """Test S3-compatible storage ingestion.""" + + def test_s3_ingestor_initialization(self): + """Test S3 ingestor can be initialized.""" + try: + from opus_orchestrator.utils.s3_ingest import S3Ingestor + ingestor = S3Ingestor() + assert ingestor is not None + except ImportError: + pytest.skip("S3Ingestor not implemented yet") + + def test_multisource_ingestor_s3_support(self): + """Test MultiSourceIngestor has S3 support.""" + from opus_orchestrator.utils.multi_source_ingest import MultiSourceIngestor, SourceType + assert hasattr(SourceType, 'S3') + assert SourceType.S3.value == "s3" + + @pytest.mark.integration + @pytest.mark.skip(reason="Requires B2 credentials") + def test_ingest_backblaze(self): + import os + required = ["B2_ENDPOINT", "B2_BUCKET", "B2_KEY_ID", "B2_APP_KEY"] + missing = [v for v in required if not os.environ.get(v)] + if missing: + pytest.skip(f"Missing B2 credentials: {missing}") + + def test_s3_credentials_env_vars(self): + required_vars = { + "B2_ENDPOINT": "Backblaze B2 endpoint URL", + "B2_BUCKET": "Bucket name", + "B2_KEY_ID": "Key ID", + "B2_APP_KEY": "Application key", + } + assert len(required_vars) > 0 + + +class TestMultiSourceIngestor: + def test_multisource_initialization(self): + from opus_orchestrator.utils.multi_source_ingest import MultiSourceIngestor + ingestor = MultiSourceIngestor() + assert ingestor is not None + + def test_content_source_dataclass(self): + from opus_orchestrator.utils.multi_source_ingest import ContentSource, SourceType + source = ContentSource(source_type=SourceType.GITHUB, repo="test/repo") + assert source.source_type == SourceType.GITHUB + + def test_source_type_enum(self): + from opus_orchestrator.utils.multi_source_ingest import SourceType + assert SourceType.GITHUB.value == "github" + assert SourceType.S3.value == "s3" + assert SourceType.LOCAL.value == "local" + assert SourceType.URL.value == "url"