Files
scheduler/tests/test_doc_sync_executor.py
jpmschweitzer 874f9f9711 fix(tests): repair three mock-wiring bugs in doc_sync_executor tests
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.
2026-08-18 15:49:20 +02:00

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()