"""Unit tests for scripts/ci/sync_wiki.py.""" import base64 import json from pathlib import Path from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner from scripts.ci.sync_wiki import ( decode_content, encode_content, fetch_page_content, list_wiki_pages, load_mapping, main, read_doc_content, sync_page, verify_wiki_page, ) class TestEncodeContent: def test_encodes_utf8_to_base64(self) -> None: result = encode_content("# Hello World") assert result == base64.b64encode(b"# Hello World").decode("ascii") def test_encodes_empty_string(self) -> None: assert encode_content("") == "" def test_encodes_unicode(self) -> None: result = encode_content("# Café — résumé") decoded = base64.b64decode(result).decode("utf-8") assert decoded == "# Café — résumé" class TestDecodeContent: def test_decodes_base64_to_utf8(self) -> None: encoded = base64.b64encode(b"# Hello").decode("ascii") assert decode_content(encoded) == "# Hello" def test_empty_string_returns_empty(self) -> None: assert decode_content("") == "" def test_roundtrip(self) -> None: original = "# Wiki Page\n\nContent with **markdown**." encoded = encode_content(original) assert decode_content(encoded) == original class TestLoadMapping: def test_loads_mapping(self, tmp_path: Path) -> None: mapping_file = tmp_path / "mapping.json" mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"})) with patch("scripts.ci.sync_wiki.MAPPING_FILE", mapping_file): result = load_mapping() assert result == {"user/getting-started.md": "Getting-Started"} def test_missing_mapping_raises(self, tmp_path: Path) -> None: with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"): with pytest.raises(FileNotFoundError): load_mapping() class TestReadDocContent: def test_reads_file(self, tmp_path: Path) -> None: docs_dir = tmp_path / "docs" docs_dir.mkdir() (docs_dir / "test.md").write_text("# Test\n\nContent") with patch("scripts.ci.sync_wiki.DOCS_DIR", docs_dir): content = read_doc_content("test.md") assert content == "# Test\n\nContent" def test_missing_file_raises(self, tmp_path: Path) -> None: with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path): with pytest.raises(FileNotFoundError): read_doc_content("nonexistent.md") class TestListWikiPages: def test_returns_empty_on_api_error(self) -> None: from gitea_runner_manager.exceptions import APIError client = MagicMock() client._request.side_effect = APIError(404, "not found") result = list_wiki_pages(client) assert result == {} def test_returns_page_dict(self) -> None: client = MagicMock() client._request.return_value.json.return_value = [ {"title": "Home", "sub_url": "Home"}, {"title": "Getting-Started", "sub_url": "Getting-Started.-"}, ] result = list_wiki_pages(client) assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"} class TestFetchPageContent: def test_fetches_and_decodes_content(self) -> None: client = MagicMock() encoded = base64.b64encode(b"# Hello Wiki").decode("ascii") client._request.return_value.json.return_value = {"content_base64": encoded} result = fetch_page_content(client, "Home") assert result == "# Hello Wiki" def test_returns_empty_on_api_error(self) -> None: from gitea_runner_manager.exceptions import APIError client = MagicMock() client._request.side_effect = APIError(404, "not found") assert fetch_page_content(client, "Missing") == "" def test_returns_empty_for_empty_content(self) -> None: client = MagicMock() client._request.return_value.json.return_value = {"content_base64": ""} assert fetch_page_content(client, "Home") == "" class TestSyncPage: def test_dry_run_skips(self) -> None: client = MagicMock() result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True) assert result == "skipped" client._request.assert_not_called() def test_creates_new_page_with_base64(self) -> None: client = MagicMock() result = sync_page(client, "New-Page", "# Content", {}, dry_run=False) assert result == "created" client._request.assert_called_once() call_args = client._request.call_args assert call_args.args[0] == "POST" assert call_args.args[1] == "/wiki/new" # Verify content_base64 is used, not content payload = call_args.kwargs["json"] assert "content_base64" in payload assert "content" not in payload assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content" def test_updates_existing_page_with_base64(self) -> None: client = MagicMock() existing = {"Existing-Page": "Existing-Page.-"} result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False) assert result == "updated" client._request.assert_called_once() call_args = client._request.call_args assert call_args.args[0] == "PATCH" assert "/wiki/page/Existing-Page.-" in call_args.args[1] # Verify content_base64 is used payload = call_args.kwargs["json"] assert "content_base64" in payload assert "content" not in payload assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated" class TestVerifyWikiPage: def test_verifies_matching_content(self) -> None: client = MagicMock() encoded = base64.b64encode(b"# Hello Wiki").decode("ascii") client._request.return_value.json.return_value = {"content_base64": encoded} existing = {"Home": "Home"} assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True def test_fails_on_mismatch(self) -> None: client = MagicMock() encoded = base64.b64encode(b"# Old Content").decode("ascii") client._request.return_value.json.return_value = {"content_base64": encoded} existing = {"Home": "Home"} assert verify_wiki_page(client, "Home", "# New Content", existing) is False def test_fails_on_empty_wiki_content(self) -> None: client = MagicMock() client._request.return_value.json.return_value = {"content_base64": ""} existing = {"Home": "Home"} assert verify_wiki_page(client, "Home", "# Expected", existing) is False def test_fails_when_page_not_in_existing(self) -> None: client = MagicMock() assert verify_wiki_page(client, "Missing", "# Content", {}) is False class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.sync_wiki.MAPPING_FILE") @patch("scripts.ci.sync_wiki.DOCS_DIR") @patch("scripts.ci.sync_wiki.GiteaClient") def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None: mock_mapping_file.exists.return_value = True mock_mapping_file.__str__ = lambda _: "/docs/mapping.json" with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "dry-run" in result.output @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) assert result.exit_code == 1 assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None: """Test that repo is auto-detected from env vars when --repo is not passed.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 mock_client_cls.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None: """Test that missing mapping.json exits with error.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = False runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) assert result.exit_code == 1 assert "mapping.json" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: """Test that existing wiki pages are reported.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "existing wiki pages" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None: """Test that missing doc files are skipped with a warning.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "not found" in result.output assert "Skipped: 1" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None: """Test that empty doc files are skipped with a warning.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "empty" in result.output.lower() assert "Skipped: 1" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_create_and_update(self, mock_client_cls: MagicMock) -> None: """Test that pages are created and updated correctly (non-dry-run).""" mock_client = MagicMock() mock_client_cls.return_value = mock_client with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"} with patch("scripts.ci.sync_wiki.load_mapping", return_value=mapping): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) assert result.exit_code == 0 assert "Created: 1" in result.output assert "Updated: 1" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_verify_passes(self, mock_client_cls: MagicMock) -> None: """Test that --verify passes when content matches.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client encoded = base64.b64encode(b"# Home Content").decode("ascii") # list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content mock_client._request.return_value.json.return_value = {"content_base64": encoded} with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=True): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo", "--verify"]) assert result.exit_code == 0 assert "Verification passed" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None: """Test that --verify fails when wiki pages have empty content.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=False): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo", "--verify"]) assert result.exit_code == 1 assert "FAIL" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch("scripts.ci.sync_wiki.GiteaClient") def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: """Test that --verify is skipped during dry-run.""" with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "Verification" not in result.output