344 lines
16 KiB
Python
344 lines
16 KiB
Python
"""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
|