Files
markitect-main/tests/unit/spaces/test_sync.py
tegwick 535b83996b feat(spaces): implement Phase 5 Directory Sync Mode
Implements directory synchronization for Information Spaces:

- SpaceDirectoryExporter: Export space to directory structure
  - Multiple variants: flat, hierarchical, by_path
  - Manifest generation for reimport
  - Incremental export (skip unchanged files)
  - Metadata file export
  - IncrementalExporter for change detection

- DirectorySpaceImporter: Import directory content into space
  - Recursive directory scanning
  - Multiple file pattern support
  - Conflict detection with strategies (skip/overwrite/rename)
  - Manifest-based import for intelligent reimport
  - Structure preservation in space paths

- BidirectionalSyncCoordinator: Two-way sync with conflict detection
  - Sync directions: space-to-directory, directory-to-space, bidirectional
  - Conflict resolution strategies: space_wins, directory_wins, newer_wins, manual, skip
  - Dry-run mode for preview
  - Orphan cleanup option
  - Event emission for progress tracking

45 unit tests covering all sync components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 12:11:37 +01:00

795 lines
25 KiB
Python

"""
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