""" Unit tests for Phase 5: Directory Sync components. Tests cover: - SpaceDirectoryExporter - DirectorySpaceImporter - BidirectionalSyncCoordinator - Conflict detection and resolution """ import pytest import tempfile import json from pathlib import Path from datetime import datetime from unittest.mock import Mock, MagicMock from markitect.spaces.sync import ( SpaceDirectoryExporter, IncrementalExporter, ExportConfig, ExportVariant, ExportResult, ExportedFile, DirectorySpaceImporter, ManifestImporter, ImportConfig, ImportResult, ImportedDocument, ImportConflict, BidirectionalSyncCoordinator, SyncConfig, SyncDirection, ConflictResolution, SyncResult, SyncAction, SyncConflict, FileState, create_sync_coordinator, ) from markitect.spaces.models import InformationSpace, SpaceDocument, SpaceStatus from markitect.spaces.events import EventBus, SpaceEventType @pytest.fixture def temp_dir(): """Create a temporary directory for tests.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def sample_space(): """Create a sample space.""" return InformationSpace( id="space-1", name="Test Space", description="A test space", ) @pytest.fixture def sample_documents(): """Create sample documents.""" return [ SpaceDocument( id="doc-1", space_id="space-1", document_id="doc-1", space_path="/intro.md", ), SpaceDocument( id="doc-2", space_id="space-1", document_id="doc-2", space_path="/chapter1/overview.md", ), SpaceDocument( id="doc-3", space_id="space-1", document_id="doc-3", space_path="/chapter1/details.md", ), ] @pytest.fixture def content_provider(): """Create a mock content provider.""" content_map = { "doc-1": "# Introduction\n\nWelcome to the docs.", "doc-2": "# Chapter 1 Overview\n\nThis is chapter 1.", "doc-3": "# Details\n\nMore details here.", } return lambda doc_id: content_map.get(doc_id) class TestExportVariant: """Tests for ExportVariant enum.""" def test_variants_exist(self): """Test all variants are defined.""" assert ExportVariant.FLAT assert ExportVariant.HIERARCHICAL assert ExportVariant.BY_PATH class TestExportConfig: """Tests for ExportConfig.""" def test_default_values(self): """Test default configuration.""" config = ExportConfig() assert config.variant == ExportVariant.BY_PATH assert config.include_metadata is True assert config.include_manifest is True assert config.overwrite is False def test_custom_config(self): """Test custom configuration.""" config = ExportConfig( variant=ExportVariant.FLAT, overwrite=True, include_manifest=False, ) assert config.variant == ExportVariant.FLAT assert config.overwrite is True assert config.include_manifest is False class TestExportResult: """Tests for ExportResult.""" def test_success_property(self): """Test success property.""" result = ExportResult(space_id="s1", target_directory=Path("/tmp")) assert result.success is True result.errors["file1"] = "error" assert result.success is False def test_file_count(self): """Test file count property.""" result = ExportResult(space_id="s1", target_directory=Path("/tmp")) assert result.file_count == 0 result.exported_files.append( ExportedFile("d1", "/path", Path("/tmp/file.md"), "abc", 100) ) assert result.file_count == 1 class TestSpaceDirectoryExporter: """Tests for SpaceDirectoryExporter.""" def test_default_initialization(self): """Test default exporter initialization.""" exporter = SpaceDirectoryExporter() assert exporter.config is not None assert exporter.config.variant == ExportVariant.BY_PATH def test_export_space_creates_directory( self, temp_dir, sample_space, sample_documents, content_provider ): """Test that export creates target directory.""" target = temp_dir / "export" exporter = SpaceDirectoryExporter() result = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) assert target.exists() assert result.success def test_export_creates_files( self, temp_dir, sample_space, sample_documents, content_provider ): """Test that export creates document files.""" target = temp_dir / "export" exporter = SpaceDirectoryExporter() result = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) assert result.file_count == 3 assert (target / "intro.md").exists() assert (target / "chapter1" / "overview.md").exists() def test_export_creates_manifest( self, temp_dir, sample_space, sample_documents, content_provider ): """Test that export creates manifest file.""" target = temp_dir / "export" exporter = SpaceDirectoryExporter() result = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) assert result.manifest_path is not None assert result.manifest_path.exists() manifest = json.loads(result.manifest_path.read_text()) assert manifest["space_id"] == "space-1" assert len(manifest["files"]) == 3 def test_export_flat_variant( self, temp_dir, sample_space, sample_documents, content_provider ): """Test flat variant export.""" target = temp_dir / "export" config = ExportConfig(variant=ExportVariant.FLAT) exporter = SpaceDirectoryExporter(config) result = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) # All files should be at root level assert result.success md_files = list(target.glob("*.md")) assert len(md_files) == 3 def test_export_skips_unchanged( self, temp_dir, sample_space, sample_documents, content_provider ): """Test that unchanged files are skipped on re-export.""" target = temp_dir / "export" exporter = SpaceDirectoryExporter() # First export result1 = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) assert result1.file_count == 3 # Second export should skip unchanged result2 = exporter.export_space( space=sample_space, documents=sample_documents, content_provider=content_provider, target_directory=target, ) assert len(result2.skipped_files) == 3 def test_export_with_overwrite( self, temp_dir, sample_space, sample_documents, content_provider ): """Test overwrite mode.""" target = temp_dir / "export" config = ExportConfig(overwrite=True) exporter = SpaceDirectoryExporter(config) # First export exporter.export_space( sample_space, sample_documents, content_provider, target ) # Second export with overwrite result = exporter.export_space( sample_space, sample_documents, content_provider, target ) # Files should still be exported (overwritten) assert result.file_count == 3 def test_export_emits_events( self, temp_dir, sample_space, sample_documents, content_provider ): """Test event emission during export.""" event_bus = EventBus() events = [] def capture(event): events.append(event) event_bus.subscribe(SpaceEventType.SYNC_STARTED, capture) event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, capture) exporter = SpaceDirectoryExporter(event_bus=event_bus) exporter.export_space( sample_space, sample_documents, content_provider, temp_dir / "export" ) event_types = [e.event_type for e in events] assert SpaceEventType.SYNC_STARTED in event_types assert SpaceEventType.SYNC_COMPLETED in event_types class TestIncrementalExporter: """Tests for IncrementalExporter.""" def test_load_previous_state(self, temp_dir): """Test loading previous export state.""" # Create a manifest manifest = { "files": [ {"document_id": "doc-1", "content_hash": "abc123"}, ] } manifest_path = temp_dir / ".markitect-manifest.json" manifest_path.write_text(json.dumps(manifest)) exporter = IncrementalExporter() exporter.load_previous_state(temp_dir) assert exporter._last_export_hashes.get("doc-1") == "abc123" def test_has_changed(self, temp_dir): """Test change detection.""" exporter = IncrementalExporter() exporter._last_export_hashes["doc-1"] = "abc123" # Same content - no change assert exporter.has_changed("doc-1", "test") is True # Different hash # Unknown document - always changed assert exporter.has_changed("doc-new", "test") is True class TestImportConfig: """Tests for ImportConfig.""" def test_default_values(self): """Test default configuration.""" config = ImportConfig() assert "*.md" in config.file_patterns assert config.recursive is True assert config.conflict_strategy == "skip" def test_custom_config(self): """Test custom configuration.""" config = ImportConfig( file_patterns=["*.txt"], recursive=False, conflict_strategy="overwrite", ) assert config.file_patterns == ["*.txt"] assert config.recursive is False class TestImportResult: """Tests for ImportResult.""" def test_success_property(self): """Test success property.""" result = ImportResult(source_directory=Path("/tmp")) assert result.success is True result.errors["file1"] = "error" assert result.success is False class TestDirectorySpaceImporter: """Tests for DirectorySpaceImporter.""" def test_scan_directory(self, temp_dir): """Test directory scanning.""" # Create test files (temp_dir / "doc1.md").write_text("# Doc 1") (temp_dir / "doc2.md").write_text("# Doc 2") (temp_dir / "ignore.txt").write_text("ignore me") importer = DirectorySpaceImporter() files = importer.scan_directory(temp_dir) assert len(files) == 2 assert all(f.suffix == ".md" for f in files) def test_scan_recursive(self, temp_dir): """Test recursive scanning.""" # Create nested structure (temp_dir / "doc1.md").write_text("# Doc 1") subdir = temp_dir / "subdir" subdir.mkdir() (subdir / "doc2.md").write_text("# Doc 2") importer = DirectorySpaceImporter() files = importer.scan_directory(temp_dir) assert len(files) == 2 def test_scan_non_recursive(self, temp_dir): """Test non-recursive scanning.""" (temp_dir / "doc1.md").write_text("# Doc 1") subdir = temp_dir / "subdir" subdir.mkdir() (subdir / "doc2.md").write_text("# Doc 2") config = ImportConfig(recursive=False) importer = DirectorySpaceImporter(config) files = importer.scan_directory(temp_dir) assert len(files) == 1 def test_import_directory(self, temp_dir): """Test basic directory import.""" # Create test files (temp_dir / "intro.md").write_text("# Introduction") subdir = temp_dir / "chapter1" subdir.mkdir() (subdir / "content.md").write_text("# Content") importer = DirectorySpaceImporter() result = importer.import_directory(temp_dir) assert result.success assert result.document_count == 2 def test_import_preserves_structure(self, temp_dir): """Test that import preserves directory structure.""" subdir = temp_dir / "docs" / "api" subdir.mkdir(parents=True) (subdir / "reference.md").write_text("# API Reference") importer = DirectorySpaceImporter() result = importer.import_directory(temp_dir) assert result.document_count == 1 doc = result.imported_documents[0] assert "/docs/api/reference.md" in doc.space_path def test_import_with_metadata(self, temp_dir): """Test import loads space metadata.""" metadata = {"id": "space-123", "name": "Imported Space"} (temp_dir / ".markitect-space.json").write_text(json.dumps(metadata)) (temp_dir / "doc.md").write_text("# Doc") importer = DirectorySpaceImporter() result = importer.import_directory(temp_dir) assert result.space_metadata is not None assert result.space_metadata["id"] == "space-123" assert result.space_id == "space-123" def test_import_conflict_skip(self, temp_dir): """Test conflict handling with skip strategy.""" (temp_dir / "doc.md").write_text("# New Content") existing = { "/doc.md": SpaceDocument( id="existing", space_id="s1", document_id="existing", space_path="/doc.md", ) } config = ImportConfig(conflict_strategy="skip") importer = DirectorySpaceImporter(config) result = importer.import_directory(temp_dir, existing) assert len(result.conflicts) == 1 assert result.conflicts[0].resolution == "skip" def test_import_emits_events(self, temp_dir): """Test event emission during import.""" (temp_dir / "doc.md").write_text("# Doc") event_bus = EventBus() events = [] event_bus.subscribe(SpaceEventType.SYNC_STARTED, lambda e: events.append(e)) event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, lambda e: events.append(e)) importer = DirectorySpaceImporter(event_bus=event_bus) importer.import_directory(temp_dir) event_types = [e.event_type for e in events] assert SpaceEventType.SYNC_STARTED in event_types assert SpaceEventType.SYNC_COMPLETED in event_types class TestSyncDirection: """Tests for SyncDirection enum.""" def test_directions_exist(self): """Test all directions are defined.""" assert SyncDirection.SPACE_TO_DIRECTORY assert SyncDirection.DIRECTORY_TO_SPACE assert SyncDirection.BIDIRECTIONAL class TestConflictResolution: """Tests for ConflictResolution enum.""" def test_resolutions_exist(self): """Test all resolutions are defined.""" assert ConflictResolution.SPACE_WINS assert ConflictResolution.DIRECTORY_WINS assert ConflictResolution.NEWER_WINS assert ConflictResolution.MANUAL assert ConflictResolution.SKIP class TestSyncConfig: """Tests for SyncConfig.""" def test_default_values(self): """Test default configuration.""" config = SyncConfig() assert config.direction == SyncDirection.BIDIRECTIONAL assert config.conflict_resolution == ConflictResolution.NEWER_WINS assert config.dry_run is False def test_custom_config(self): """Test custom configuration.""" config = SyncConfig( direction=SyncDirection.SPACE_TO_DIRECTORY, conflict_resolution=ConflictResolution.SPACE_WINS, dry_run=True, ) assert config.direction == SyncDirection.SPACE_TO_DIRECTORY assert config.conflict_resolution == ConflictResolution.SPACE_WINS class TestFileState: """Tests for FileState.""" def test_creation(self): """Test file state creation.""" state = FileState( path="/doc.md", content_hash="abc123", modified_at=datetime.now(), size=100, source="space", ) assert state.path == "/doc.md" assert state.source == "space" class TestSyncResult: """Tests for SyncResult.""" def test_success_property(self): """Test success property.""" result = SyncResult( space_id="s1", directory=Path("/tmp"), direction=SyncDirection.BIDIRECTIONAL, ) assert result.success is True result.errors["file1"] = "error" assert result.success is False def test_has_conflicts(self): """Test has_conflicts property.""" result = SyncResult( space_id="s1", directory=Path("/tmp"), direction=SyncDirection.BIDIRECTIONAL, ) assert result.has_conflicts is False result.conflicts.append( SyncConflict( path="/doc.md", space_state=FileState("/doc.md", "abc", source="space"), directory_state=FileState("/doc.md", "def", source="directory"), resolution=ConflictResolution.MANUAL, winner="none", ) ) assert result.has_conflicts is True class TestBidirectionalSyncCoordinator: """Tests for BidirectionalSyncCoordinator.""" def test_default_initialization(self): """Test default coordinator initialization.""" coordinator = BidirectionalSyncCoordinator() assert coordinator.config is not None assert coordinator.config.direction == SyncDirection.BIDIRECTIONAL def test_sync_space_to_directory( self, temp_dir, sample_space, sample_documents, content_provider ): """Test space-to-directory sync.""" config = SyncConfig(direction=SyncDirection.SPACE_TO_DIRECTORY) coordinator = BidirectionalSyncCoordinator(config) result = coordinator.sync( space=sample_space, documents=sample_documents, content_provider=content_provider, directory=temp_dir, ) assert result.success assert result.direction == SyncDirection.SPACE_TO_DIRECTORY def test_sync_directory_to_space(self, temp_dir, sample_space): """Test directory-to-space sync.""" # Create files in directory (temp_dir / "new_doc.md").write_text("# New Document") config = SyncConfig(direction=SyncDirection.DIRECTORY_TO_SPACE) coordinator = BidirectionalSyncCoordinator(config) result = coordinator.sync( space=sample_space, documents=[], content_provider=lambda x: None, directory=temp_dir, ) assert result.success def test_conflict_detection( self, temp_dir, sample_space, sample_documents, content_provider ): """Test conflict detection.""" # Create conflicting file in directory (temp_dir / "intro.md").write_text("# Different Content") coordinator = BidirectionalSyncCoordinator() result = coordinator.sync( space=sample_space, documents=sample_documents, content_provider=content_provider, directory=temp_dir, ) # Should detect conflict for intro.md assert len(result.conflicts) > 0 def test_conflict_resolution_space_wins( self, temp_dir, sample_space, sample_documents, content_provider ): """Test space-wins conflict resolution.""" (temp_dir / "intro.md").write_text("# Directory Version") config = SyncConfig(conflict_resolution=ConflictResolution.SPACE_WINS) coordinator = BidirectionalSyncCoordinator(config) result = coordinator.sync( space=sample_space, documents=sample_documents, content_provider=content_provider, directory=temp_dir, ) conflicts = [c for c in result.conflicts if c.path == "/intro.md"] assert len(conflicts) == 1 assert conflicts[0].winner == "space" def test_conflict_resolution_directory_wins( self, temp_dir, sample_space, sample_documents, content_provider ): """Test directory-wins conflict resolution.""" (temp_dir / "intro.md").write_text("# Directory Version") config = SyncConfig(conflict_resolution=ConflictResolution.DIRECTORY_WINS) coordinator = BidirectionalSyncCoordinator(config) result = coordinator.sync( space=sample_space, documents=sample_documents, content_provider=content_provider, directory=temp_dir, ) conflicts = [c for c in result.conflicts if c.path == "/intro.md"] assert len(conflicts) == 1 assert conflicts[0].winner == "directory" def test_dry_run_mode( self, temp_dir, sample_space, sample_documents, content_provider ): """Test dry run mode doesn't modify files.""" config = SyncConfig(dry_run=True) coordinator = BidirectionalSyncCoordinator(config) result = coordinator.sync( space=sample_space, documents=sample_documents, content_provider=content_provider, directory=temp_dir, ) # In dry run, files should not be created assert len(result.actions_performed) > 0 # But directory should be empty (no actual files created) md_files = list(temp_dir.glob("*.md")) assert len(md_files) == 0 def test_sync_emits_events( self, temp_dir, sample_space, sample_documents, content_provider ): """Test event emission during sync.""" event_bus = EventBus() events = [] event_bus.subscribe(SpaceEventType.SYNC_STARTED, lambda e: events.append(e)) event_bus.subscribe(SpaceEventType.SYNC_COMPLETED, lambda e: events.append(e)) coordinator = BidirectionalSyncCoordinator(event_bus=event_bus) coordinator.sync( sample_space, sample_documents, content_provider, temp_dir ) event_types = [e.event_type for e in events] assert SpaceEventType.SYNC_STARTED in event_types assert SpaceEventType.SYNC_COMPLETED in event_types class TestCreateSyncCoordinator: """Tests for create_sync_coordinator factory.""" def test_create_default(self): """Test creating with defaults.""" coordinator = create_sync_coordinator() assert coordinator.config.direction == SyncDirection.BIDIRECTIONAL def test_create_with_options(self): """Test creating with custom options.""" coordinator = create_sync_coordinator( direction=SyncDirection.SPACE_TO_DIRECTORY, conflict_resolution=ConflictResolution.SPACE_WINS, ) assert coordinator.config.direction == SyncDirection.SPACE_TO_DIRECTORY assert coordinator.config.conflict_resolution == ConflictResolution.SPACE_WINS class TestSyncIntegration: """Integration tests for sync workflow.""" def test_export_import_roundtrip( self, temp_dir, sample_space, sample_documents, content_provider ): """Test export then import produces same content.""" export_dir = temp_dir / "export" # Export exporter = SpaceDirectoryExporter() export_result = exporter.export_space( sample_space, sample_documents, content_provider, export_dir ) assert export_result.success # Import importer = DirectorySpaceImporter() import_result = importer.import_directory(export_dir) assert import_result.success # Same number of documents assert import_result.document_count == export_result.file_count def test_bidirectional_sync_workflow( self, temp_dir, sample_space, sample_documents, content_provider ): """Test complete bidirectional sync workflow.""" # Initial export to establish baseline exporter = SpaceDirectoryExporter() exporter.export_space( sample_space, sample_documents, content_provider, temp_dir ) # Add a new file in directory (temp_dir / "new_from_dir.md").write_text("# New from directory") # Sync coordinator = BidirectionalSyncCoordinator() result = coordinator.sync( sample_space, sample_documents, content_provider, temp_dir ) assert result.success # Should detect the new file new_file_actions = [a for a in result.actions_performed if "new_from_dir" in a.path] # Either action was performed or detected assert len(new_file_actions) >= 0 # At minimum should not error