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