Files
devx/tests/unit/test_sync_wiki.py
T
emil ddfbdec956
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m0s
DEVX-136: feat: add fix_pr_title module and update_pr API method
2026-07-13 23:55:11 +00:00

526 lines
20 KiB
Python

"""Unit tests for devx.ci.sync_wiki (git-based approach)."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.sync_wiki import (
clone_wiki,
commit_and_push,
get_wiki_clone_url,
init_wiki,
load_mapping,
main,
sync_files,
transform_links,
wiki_filename,
)
class TestTransformLinks:
def test_removes_md_extension(self) -> None:
result = transform_links("[link](page.md)")
assert result == "[link](page)"
def test_removes_directory_prefix(self) -> None:
result = transform_links("[link](docs/page.md)")
assert result == "[link](page)"
def test_removes_parent_dir_prefix(self) -> None:
result = transform_links("[link](../page.md)")
assert result == "[link](page)"
def test_preserves_external_links(self) -> None:
result = transform_links("[link](https://example.com)")
assert result == "[link](https://example.com)"
def test_preserves_http_links(self) -> None:
result = transform_links("[link](http://example.com)")
assert result == "[link](http://example.com)"
def test_preserves_mailto(self) -> None:
result = transform_links("[email](mailto:test@example.com)")
assert result == "[email](mailto:test@example.com)"
def test_preserves_anchor_only(self) -> None:
result = transform_links("[section](#section)")
assert result == "[section](#section)"
def test_preserves_anchor_with_path(self) -> None:
result = transform_links("[section](page.md#section)")
assert result == "[section](page#section)"
def test_no_links_unchanged(self) -> None:
text = "# Title\n\nSome text without links.\n"
assert transform_links(text) == text
def test_multiple_links(self) -> None:
result = transform_links("[a](one.md) and [b](two.md)")
assert result == "[a](one) and [b](two)"
class TestLoadMapping:
def test_loads_mapping(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mapping_file = tmp_path / "docs" / "mapping.json"
mapping_file.parent.mkdir()
mapping_file.write_text(json.dumps({"index.md": "Home", "guide.md": "Guide"}))
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
mapping = load_mapping()
assert mapping == {"index.md": "Home", "guide.md": "Guide"}
def test_non_dict_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mapping_file = tmp_path / "docs" / "mapping.json"
mapping_file.parent.mkdir()
mapping_file.write_text('["not", "a", "dict"]')
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
with pytest.raises(click.ClickException, match="must be a dict"):
load_mapping()
def test_non_string_values_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mapping_file = tmp_path / "docs" / "mapping.json"
mapping_file.parent.mkdir()
mapping_file.write_text(json.dumps({"key": 123}))
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
with pytest.raises(click.ClickException, match="must be strings"):
load_mapping()
class TestGetWikiCloneUrl:
def test_builds_url(self) -> None:
url = get_wiki_clone_url("owner", "repo", "token")
assert "owner/repo.wiki.git" in url
class TestWikiFilename:
def test_no_dashes(self) -> None:
assert wiki_filename("Home") == "Home.md"
def test_single_word(self) -> None:
assert wiki_filename("Architecture") == "Architecture.md"
def test_with_dashes_adds_marker(self) -> None:
assert wiki_filename("Getting-Started") == "Getting-Started.-.md"
def test_spaces_become_dashes_with_marker(self) -> None:
# "Getting Started" → "Getting-Started" (has dash) → marker added
assert wiki_filename("Getting Started") == "Getting-Started.-.md"
def test_spaces_no_dashes_no_marker(self) -> None:
# "Foo Bar" → "Foo-Bar" (has dash) → marker added
assert wiki_filename("Foo Bar") == "Foo-Bar.-.md"
def test_single_word_with_spaces_no_dash(self) -> None:
# No dash at all after conversion → no marker
assert wiki_filename("HelloWorld") == "HelloWorld.md"
class TestCloneWiki:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_clone_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = clone_wiki("https://example.com/repo.wiki.git", tmp_path / "wiki")
assert result is True
@patch("devx.ci.sync_wiki.subprocess.run")
def test_clone_failure_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not found")
result = clone_wiki("https://example.com/repo.wiki.git", tmp_path / "wiki")
assert result is False
class TestInitWiki:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_init_calls_git(self, mock_run: MagicMock, tmp_path: Path) -> None:
wiki_dir = tmp_path / "wiki"
init_wiki(wiki_dir)
assert wiki_dir.exists()
calls = [c.args[0] for c in mock_run.call_args_list]
assert ["git", "init"] in calls
assert ["git", "config", "user.email", "ci@oblachno.fyi"] in calls
class TestSyncFiles:
def test_syncs_files(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
(docs / "page.md").write_text("# Page\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"index.md": "Home", "page.md": "Page"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 2
assert pruned == 0
assert (wiki / "Home.md").exists()
assert (wiki / "Page.md").exists()
# Check link transformation
content = (wiki / "Home.md").read_text()
assert "[link](page)" in content
def test_prunes_stale(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
(wiki / "OldPage.md").write_text("# Old\n")
(wiki / "Home.md").write_text("# Old Home\n")
mapping = {"index.md": "Home"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 1
assert pruned == 1 # OldPage.md pruned, Home.md overwritten
assert not (wiki / "OldPage.md").exists()
assert (wiki / "Home.md").exists()
def test_dry_run_no_writes(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"index.md": "Home"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=True)
assert synced == 1
assert pruned == 0
assert not (wiki / "Home.md").exists()
def test_missing_file_warns(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"missing.md": "Missing"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 0
def test_empty_file_warns(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "empty.md").write_text("")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"empty.md": "Empty"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 0
class TestCommitAndPush:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_dry_run_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
result = commit_and_push(tmp_path, "url", dry_run=True)
assert result is False
mock_run.assert_not_called()
@patch("devx.ci.sync_wiki.subprocess.run")
def test_no_changes_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
# git add succeeds, git diff --cached --quiet returns 0 (no changes)
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=0), # git diff --cached --quiet (no changes)
]
result = commit_and_push(tmp_path, "url", dry_run=False)
assert result is False
@patch("devx.ci.sync_wiki.subprocess.run")
def test_pushes_changes(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=1), # git diff --cached --quiet (has changes)
MagicMock(returncode=0), # git config user.email
MagicMock(returncode=0), # git config user.name
MagicMock(returncode=0), # git commit
MagicMock(returncode=0, stdout="", stderr=""), # git push
]
result = commit_and_push(tmp_path, "url", dry_run=False)
assert result is True
@patch("devx.ci.sync_wiki.subprocess.run")
def test_push_failure_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=1), # git diff --cached --quiet (has changes)
MagicMock(returncode=0), # git config user.email
MagicMock(returncode=0), # git config user.name
MagicMock(returncode=0), # git commit
MagicMock(returncode=1, stdout="", stderr="push failed"), # git push
]
result = commit_and_push(tmp_path, "url", dry_run=False)
assert result is False
class TestMain:
@patch("devx.ci.sync_wiki.commit_and_push")
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki")
def test_no_token_raises(
self,
mock_clone: MagicMock,
mock_init: MagicMock,
mock_commit: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner()
result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.ci.sync_wiki.commit_and_push")
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki")
def test_no_mapping_raises(
self,
mock_clone: MagicMock,
mock_init: MagicMock,
mock_commit: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json")
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code != 0
assert "mapping.json" in result.output
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_dry_run(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "dry-run" in result.output
mock_push.assert_not_called()
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(2, 0))
def test_full_sync(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
(docs / "page.md").write_text("# Page\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home", "page.md": "Page"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "Synced" in result.output
mock_push.assert_called_once()
@patch("devx.ci.sync_wiki.clone_wiki", return_value=False)
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_init_fresh_wiki(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_init: MagicMock,
mock_clone: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
mock_init.assert_called_once()
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_verify(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
# Mock clone_wiki to create the wiki dir with the expected file
def fake_clone(url: str, dest: Path) -> bool:
dest.mkdir(parents=True, exist_ok=True)
(dest / "Home.md").write_text("# Home\n")
return True
mock_clone.side_effect = fake_clone
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" in result.output
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.commit_and_push", return_value=False)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_push_failed_message(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "No push needed" in result.output
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki", side_effect=[True, False])
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_verify_clone_fails(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code != 0
assert "could not clone" in result.output
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_verify_missing_page(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
# Mock clone_wiki to create the wiki dir WITHOUT the expected file
def fake_clone(url: str, dest: Path) -> bool:
dest.mkdir(parents=True, exist_ok=True)
return True
mock_clone.side_effect = fake_clone
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code != 0
assert "page(s) missing" in result.output
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_auto_detect_repo(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_init: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0