test_executor_successful_sync_entire_repo: _get_git_commit is patched separately and never calls the real _run_command, so it does not consume a slot in mock_run.side_effect. The list reserved one anyway (labelled "git rev-parse HEAD"), which shifted "M README.md\n" one call late — git status --porcelain saw "" (no changes) instead, so execute() took the no-changes branch and the test asserted "Successfully synced" against "already up to date". Removed the phantom slot. Also gave the iterdir() mock items real string .name attributes: MagicMock(name="X") sets the mock's repr, not the .name attribute read by execute()'s `item.name != '.git'` check, so every item was being treated as non-.git regardless of the intended value. test_executor_sync_specific_paths, test_executor_handles_no_changes: mock_upstream_dir/mock_gitea_dir were built but never wired to mock_path.return_value, so `work_dir = Path(...)` and `upstream_dir = work_dir / "upstream"` resolved to a different, unconfigured auto-generated MagicMock. `source.name` on that mock is itself a MagicMock, not a string, so `', '.join(copied_paths)` raised TypeError. Wired mock_path.return_value to a work_dir mock whose __truediv__ yields the intended upstream/gitea mocks, matching the pattern the first test already used correctly. All three are test-side: doc_sync_executor.py is unchanged. Confirmed via git log -p that this file and its test have exactly one commit in this repo's history (the initial extraction), so there is no prior passing version to regress from — these tests appear to have never passed.
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""
|
|
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()
|