Files
grm/tests/unit/test_sync_wiki.py
T
emil 5b05db4e6d GRM-36: feat: implement documentation-as-code with wiki sync and doc-coverage
Add /docs/ directory with user and technical documentation extracted from README, AGENTS.md, and source code. Add scripts/sync_wiki.py to sync docs to Gitea wiki via API. Add scripts/doc_coverage.py to check CLI commands, modules, and CI scripts are documented. Add sync-wiki.yml workflow for auto-sync on merge and release. Slim down README.md to lean entry point. 28 new unit tests, 100% coverage maintained.

Closes GRM-36
2026-06-21 19:45:34 +00:00

187 lines
8.6 KiB
Python

"""Unit tests for scripts/sync_wiki.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from scripts.sync_wiki import (
list_wiki_pages,
load_mapping,
main,
read_doc_content,
sync_page,
)
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.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.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.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.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", "page_name": "Home"},
{"title": "Getting-Started", "page_name": "Getting-Started"},
]
result = list_wiki_pages(client)
assert result == {"Home": "Home", "Getting-Started": "Getting-Started"}
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(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/page"
def test_updates_existing_page(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] == "PUT"
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.sync_wiki.MAPPING_FILE")
@patch("scripts.sync_wiki.DOCS_DIR")
@patch("scripts.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.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.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.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.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.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.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.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.sync_wiki.GiteaClient")
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
"""Test that existing wiki pages are reported."""
with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.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.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.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
with patch("scripts.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
with patch("scripts.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.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.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.sync_wiki.load_mapping", return_value=mapping):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Content"):
with patch("scripts.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