"""Unit tests for scripts/ci/sync_wiki.py.""" import base64 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 ( decode_content, encode_content, fetch_page_content, list_wiki_pages, load_mapping, main, read_doc_content, sync_page, verify_wiki_integrity, 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("devx.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("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"): with pytest.raises(FileNotFoundError): load_mapping() def test_non_dict_mapping_raises(self, tmp_path: Path) -> None: """Non-dict mapping.json should raise.""" mapping_file = tmp_path / "mapping.json" mapping_file.write_text('["not", "a", "dict"]') with patch("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_raise(self, tmp_path: Path) -> None: """Non-string values in mapping.json should raise.""" mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"file.md": 123}') with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file): with pytest.raises(click.ClickException, match="must be strings"): 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("devx.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("devx.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 devx.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 devx.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 TestVerifyWikiIntegrity: def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock: """Create a mock client that returns the given pages and contents.""" client = MagicMock() # list_wiki_pages calls GET /wiki/pages page_list = [{"title": t, "sub_url": s} for t, s in pages.items()] # fetch_page_content calls GET /wiki/page/{sub_url} def mock_request(method, path, **kwargs): resp = MagicMock() if path == "/wiki/pages": resp.json.return_value = page_list elif path.startswith("/wiki/page/"): sub_url = path.replace("/wiki/page/", "") content = contents.get(sub_url, "") encoded = base64.b64encode(content.encode()).decode("ascii") if content else "" resp.json.return_value = {"content_base64": encoded} return resp client._request.side_effect = mock_request return client def test_all_good_no_failures(self) -> None: pages = {"Home": "Home", "FAQ": "FAQ"} contents = {"Home": "# Home", "FAQ": "# FAQ"} client = self._make_client(pages, contents) mapping = {"index.md": "Home", "faq.md": "FAQ"} synced = {"Home": "# Home", "FAQ": "# FAQ"} failures = verify_wiki_integrity(client, mapping, synced) assert failures == [] def test_missing_page_detected(self) -> None: pages = {"Home": "Home"} # FAQ missing from wiki contents = {"Home": "# Home"} client = self._make_client(pages, contents) mapping = {"index.md": "Home", "faq.md": "FAQ"} synced = {"Home": "# Home"} failures = verify_wiki_integrity(client, mapping, synced) assert any("Missing page: FAQ" in f for f in failures) def test_stale_page_detected(self) -> None: pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping contents = {"Home": "# Home", "Old-Page": "# Old"} client = self._make_client(pages, contents) mapping = {"index.md": "Home"} synced = {"Home": "# Home"} failures = verify_wiki_integrity(client, mapping, synced) assert any("Stale page" in f and "Old-Page" in f for f in failures) def test_page_count_mismatch_detected(self) -> None: pages = {"Home": "Home", "Extra": "Extra"} contents = {"Home": "# Home", "Extra": "# Extra"} client = self._make_client(pages, contents) mapping = {"index.md": "Home"} synced = {"Home": "# Home"} failures = verify_wiki_integrity(client, mapping, synced) assert any("Page count mismatch" in f for f in failures) def test_empty_content_detected(self) -> None: pages = {"Home": "Home"} contents = {"Home": ""} # Empty content client = self._make_client(pages, contents) mapping = {"index.md": "Home"} synced = {"Home": "# Expected Content"} failures = verify_wiki_integrity(client, mapping, synced) assert any("Empty content: Home" in f for f in failures) def test_content_mismatch_detected(self) -> None: pages = {"Home": "Home"} contents = {"Home": "# Wrong Content"} client = self._make_client(pages, contents) mapping = {"index.md": "Home"} synced = {"Home": "# Correct Content"} failures = verify_wiki_integrity(client, mapping, synced) assert any("Content mismatch: Home" in f for f in failures) def test_multiple_failures_all_reported(self) -> None: pages = {"Home": "Home", "Stale": "Stale"} contents = {"Home": "", "Stale": "# Stale"} client = self._make_client(pages, contents) mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing synced = {"Home": "# Home Content"} failures = verify_wiki_integrity(client, mapping, synced) assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home class TestMain: @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch("devx.ci.sync_wiki.MAPPING_FILE") @patch("devx.ci.sync_wiki.DOCS_DIR") @patch("devx.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("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.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", {"CI_GITEA_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 "CI_GITEA_TOKEN" in result.output @patch.dict( "os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True ) @patch("devx.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("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.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("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: """Test that existing wiki pages are reported.""" with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None: """Test that missing doc files cause an error, not a warning.""" with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): with patch("devx.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError): with patch("devx.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 @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None: """Test that empty doc files cause an error, not a warning.""" with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value=" \n "): with patch("devx.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() @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.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("devx.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("devx.ci.sync_wiki.load_mapping", return_value=mapping): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Content"): with patch("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.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("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.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("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch("devx.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", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.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("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.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 @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_strict_passes(self, mock_client_cls: MagicMock) -> None: """Test that --strict passes when integrity check succeeds.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch("devx.ci.sync_wiki.verify_wiki_integrity", return_value=[]): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo", "--strict"]) assert result.exit_code == 0 assert "Integrity check passed" in result.output @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None: """Test that --strict fails when integrity check finds issues.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): with patch( "devx.ci.sync_wiki.verify_wiki_integrity", return_value=["Missing page: FAQ", "Stale page: Old-Page"], ): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo", "--strict"]) assert result.exit_code == 1 assert "Integrity check FAILED" in result.output assert "Missing page: FAQ" in result.output assert "Stale page: Old-Page" in result.output @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: """Test that --strict verification is skipped during dry-run.""" with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "Integrity check" not in result.output