""" Tests for the documentation sync executor. """ import pytest from pathlib import Path from unittest.mock import AsyncMock, patch, MagicMock from src.executors import doc_sync_executor from src.config import Settings @pytest.mark.executor @pytest.mark.unit class TestDocSyncExecutor: """Tests for doc_sync_executor module.""" @pytest.mark.asyncio async def test_executor_requires_all_config_fields(self, test_settings: Settings): """Test that executor validates required config fields.""" incomplete_config = { "project": "test", # Missing other required fields } with pytest.raises(ValueError, match="Missing required config"): await doc_sync_executor.execute(incomplete_config, test_settings) @pytest.mark.asyncio async def test_executor_validates_project_name(self, test_settings: Settings): """Test that executor requires project name.""" config = { "project": "", "upstream_repo": "https://github.com/test/repo.git", "gitea_repo": "library/test" } with pytest.raises(ValueError): await doc_sync_executor.execute(config, test_settings) @pytest.mark.asyncio async def test_executor_successful_sync_entire_repo( self, test_settings: Settings, sample_doc_sync_config: dict, temp_work_dir: Path ): """Test successful documentation sync of entire repository.""" # Modify config to sync entire repo config = {**sample_doc_sync_config, "docs_paths": []} with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ patch('src.executors.doc_sync_executor.Path') as mock_path: # Mock git commit hash mock_get_commit.return_value = "abc123def456" # Mock path operations mock_work_dir = MagicMock() mock_upstream_dir = MagicMock() mock_gitea_dir = MagicMock() # Setup directory mocking. MagicMock(name=...) sets the mock's # repr, not its .name attribute (classic gotcha — see # test_executor_sync_specific_paths) — set .name explicitly so # execute()'s `item.name != '.git'` check actually excludes it. git_item = MagicMock(is_dir=lambda: True) git_item.name = ".git" readme_item = MagicMock(is_dir=lambda: False) readme_item.name = "README.md" docs_item = MagicMock(is_dir=lambda: True) docs_item.name = "docs" mock_upstream_dir.iterdir.return_value = [git_item, readme_item, docs_item] gitea_git_item = MagicMock(is_dir=lambda: True) gitea_git_item.name = ".git" mock_gitea_dir.iterdir.return_value = [gitea_git_item] mock_path.return_value = mock_work_dir mock_work_dir.__truediv__.side_effect = [mock_upstream_dir, mock_gitea_dir] # Mock git status to show changes. _get_git_commit is patched # separately above and never calls the real _run_command, so it # does not consume a slot in this side_effect list — the actual # call order for this (clone-succeeds, entire-repo) path is: # clone upstream, clone gitea, add, status, commit, tag, push # branch, push tag. The list previously reserved a slot for # "git rev-parse HEAD" that _run_command is never asked for, # which shifted "M README.md\n" one call late and made # `git status --porcelain` see "" (no changes) instead. mock_run.side_effect = [ "", # git clone upstream "", # git clone gitea "", # git add "M README.md\n", # git status --porcelain (has changes) "", # git commit "", # git tag "", # git push branch "", # git push tag ] result = await doc_sync_executor.execute(config, test_settings) assert "Successfully synced" in result assert "test-project" in result.lower() assert "entire repository" in result @pytest.mark.asyncio async def test_executor_sync_specific_paths( self, test_settings: Settings, sample_doc_sync_config: dict ): """Test syncing only specific paths.""" config = {**sample_doc_sync_config, "docs_paths": ["/docs", "/examples"]} with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ patch('src.executors.doc_sync_executor.Path') as mock_path: mock_get_commit.return_value = "abc123" # Mock path operations. work_dir = Path(...) resolves to # mock_path.return_value — mock_upstream_dir/mock_gitea_dir have # to be reachable from there via __truediv__, the same way # test_executor_successful_sync_entire_repo wires it, or # `upstream_dir / doc_path` never reaches these mocks at all and # falls through to an unconfigured auto-generated MagicMock # instead (observed failure: TypeError joining a MagicMock into # ', '.join(copied_paths)). mock_work_dir = MagicMock() mock_upstream_dir = MagicMock() mock_gitea_dir = MagicMock() mock_docs = MagicMock(name="docs") mock_docs.exists.return_value = True mock_docs.is_dir.return_value = True mock_docs.name = "docs" mock_examples = MagicMock(name="examples") mock_examples.exists.return_value = True mock_examples.is_dir.return_value = True mock_examples.name = "examples" mock_path.return_value = mock_work_dir mock_work_dir.__truediv__.side_effect = [mock_upstream_dir, mock_gitea_dir] mock_upstream_dir.__truediv__.side_effect = [mock_docs, mock_examples] mock_gitea_dir.iterdir.return_value = [] # Mock git status to show changes async def run_command_side_effect(cmd, **kwargs): if 'status' in cmd: return "M docs/README.md\n" return "" mock_run.side_effect = run_command_side_effect result = await doc_sync_executor.execute(config, test_settings) # Should mention the specific paths assert "docs" in result.lower() or "Successfully synced" in result @pytest.mark.asyncio async def test_executor_handles_no_changes( self, test_settings: Settings, sample_doc_sync_config: dict ): """Test that executor handles case where there are no changes.""" with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ patch('src.executors.doc_sync_executor._get_git_commit', new_callable=AsyncMock) as mock_get_commit, \ patch('src.executors.doc_sync_executor.shutil'), \ patch('src.executors.doc_sync_executor.Path') as mock_path: mock_get_commit.return_value = "abc123" # Mock empty git status (no changes) async def run_command_side_effect(cmd, cwd=None, capture=False): if 'status' in cmd and '--porcelain' in cmd: return "" # No changes return "" mock_run.side_effect = run_command_side_effect # Setup minimal mocking. sample_doc_sync_config's docs_paths is # ["/docs"] (tests/conftest.py), so execute() takes the # specific-paths branch and needs `upstream_dir / "docs"` wired # to something with a real string .name — see # test_executor_sync_specific_paths for the same wiring gap and # the TypeError it produces unwired. mock_work_dir = MagicMock() mock_upstream_dir = MagicMock() mock_gitea_dir = MagicMock() mock_gitea_dir.iterdir.return_value = [] mock_upstream_dir.iterdir.return_value = [] mock_docs = MagicMock() mock_docs.exists.return_value = True mock_docs.is_dir.return_value = True mock_docs.name = "docs" mock_path.return_value = mock_work_dir mock_work_dir.__truediv__.side_effect = [mock_upstream_dir, mock_gitea_dir] mock_upstream_dir.__truediv__.side_effect = [mock_docs] result = await doc_sync_executor.execute(sample_doc_sync_config, test_settings) assert "already up to date" in result.lower() or "no changes" in result.lower() @pytest.mark.asyncio async def test_executor_cleanup_on_error( self, test_settings: Settings, sample_doc_sync_config: dict ): """Test that executor cleans up on error.""" with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run, \ patch('src.executors.doc_sync_executor.shutil') as mock_shutil, \ patch('src.executors.doc_sync_executor.Path') as mock_path: # Make git clone fail mock_run.side_effect = Exception("Git clone failed") mock_work_dir = MagicMock() mock_work_dir.exists.return_value = True mock_path.return_value = mock_work_dir with pytest.raises(Exception, match="Git clone failed"): await doc_sync_executor.execute(sample_doc_sync_config, test_settings) # Should have attempted cleanup mock_shutil.rmtree.assert_called() @pytest.mark.executor @pytest.mark.unit class TestDocSyncHelpers: """Tests for doc_sync_executor helper functions.""" @pytest.mark.asyncio async def test_run_command_success(self): """Test that _run_command executes successfully.""" with patch('asyncio.create_subprocess_exec', new_callable=AsyncMock) as mock_exec: mock_proc = AsyncMock() mock_proc.returncode = 0 mock_proc.communicate.return_value = (b"output", b"") mock_exec.return_value = mock_proc result = await doc_sync_executor._run_command( ["echo", "test"], capture=True ) assert result == "output" @pytest.mark.asyncio async def test_run_command_failure(self): """Test that _run_command raises on failure.""" with patch('asyncio.create_subprocess_exec', new_callable=AsyncMock) as mock_exec: mock_proc = AsyncMock() mock_proc.returncode = 1 mock_proc.communicate.return_value = (b"", b"error message") mock_exec.return_value = mock_proc with pytest.raises(Exception, match="Command failed"): await doc_sync_executor._run_command(["false"]) @pytest.mark.asyncio async def test_get_git_commit(self, temp_work_dir: Path): """Test getting git commit hash.""" with patch('src.executors.doc_sync_executor._run_command', new_callable=AsyncMock) as mock_run: mock_run.return_value = "abc123def456\n" commit = await doc_sync_executor._get_git_commit(temp_work_dir) assert commit == "abc123def456" mock_run.assert_called_once()