Public Access
DEVX-118: refactor: rewrite sync_wiki.py to use git-based approach
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 28s
Post-merge / release (push) Successful in 42s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 31s
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 28s
Post-merge / release (push) Successful in 42s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 31s
Replace the unreliable Gitea wiki API with direct Git operations:
- Clone {repo}.wiki.git, copy docs with link transformation, push
- Faster: single git push vs N API calls
- More reliable: no API timeouts or rate limits
- Atomic: all pages sync in one commit
- Auto-pruning: stale wiki pages removed automatically
- Link transformation: [text](file.md) → [text](file) for wiki format
- 36 new tests covering transform_links, clone, sync_files, commit, verify
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
f28ba432ce
commit
0a5625b70b
+406
-552
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for scripts/ci/sync_wiki.py."""
|
||||
"""Unit tests for devx.ci.sync_wiki (git-based approach)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -10,593 +11,446 @@ import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.sync_wiki import (
|
||||
decode_content,
|
||||
encode_content,
|
||||
fetch_page_content,
|
||||
list_wiki_pages,
|
||||
clone_wiki,
|
||||
commit_and_push,
|
||||
get_wiki_clone_url,
|
||||
init_wiki,
|
||||
load_mapping,
|
||||
main,
|
||||
read_doc_content,
|
||||
sync_page,
|
||||
verify_wiki_integrity,
|
||||
verify_wiki_page,
|
||||
sync_files,
|
||||
transform_links,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestEncodeContent:
|
||||
def test_encodes_utf8_to_base64(self) -> None:
|
||||
result = encode_content("# Hello World")
|
||||
assert result == base64.b64encode(b"# Hello World").decode("ascii")
|
||||
class TestTransformLinks:
|
||||
def test_removes_md_extension(self) -> None:
|
||||
result = transform_links("[link](page.md)")
|
||||
assert result == "[link](page)"
|
||||
|
||||
def test_encodes_empty_string(self) -> None:
|
||||
assert encode_content("") == ""
|
||||
def test_removes_directory_prefix(self) -> None:
|
||||
result = transform_links("[link](docs/page.md)")
|
||||
assert result == "[link](page)"
|
||||
|
||||
def test_encodes_unicode(self) -> None:
|
||||
result = encode_content("# Café — résumé")
|
||||
decoded = base64.b64decode(result).decode("utf-8")
|
||||
assert decoded == "# Café — résumé"
|
||||
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)"
|
||||
|
||||
class TestDecodeContent:
|
||||
def test_decodes_base64_to_utf8(self) -> None:
|
||||
encoded = base64.b64encode(b"# Hello").decode("ascii")
|
||||
assert decode_content(encoded) == "# Hello"
|
||||
def test_preserves_http_links(self) -> None:
|
||||
result = transform_links("[link](http://example.com)")
|
||||
assert result == "[link](http://example.com)"
|
||||
|
||||
def test_empty_string_returns_empty(self) -> None:
|
||||
assert decode_content("") == ""
|
||||
def test_preserves_mailto(self) -> None:
|
||||
result = transform_links("[email](mailto:test@example.com)")
|
||||
assert result == "[email](mailto:test@example.com)"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = "# Wiki Page\n\nContent with **markdown**."
|
||||
encoded = encode_content(original)
|
||||
assert decode_content(encoded) == original
|
||||
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) -> 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_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_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"
|
||||
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"]')
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
with pytest.raises(click.ClickException, match="must be a dict"):
|
||||
load_mapping()
|
||||
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_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()
|
||||
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 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 TestGetWikiCloneUrl:
|
||||
def test_builds_url(self) -> None:
|
||||
url = get_wiki_clone_url("owner", "repo", "token")
|
||||
assert "owner/repo.wiki.git" in url
|
||||
|
||||
|
||||
class TestListWikiPages:
|
||||
def test_raises_on_api_error(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(404, "not found")
|
||||
with pytest.raises(APIError):
|
||||
list_wiki_pages(client)
|
||||
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
|
||||
|
||||
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.-"},
|
||||
@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 = list_wiki_pages(client)
|
||||
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
|
||||
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 commit
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # git push
|
||||
]
|
||||
result = commit_and_push(tmp_path, "url", dry_run=False)
|
||||
assert result is True
|
||||
|
||||
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"
|
||||
|
||||
def test_create_falls_back_to_update_on_already_exists(self) -> None:
|
||||
"""When create fails with 400 'already exists', re-list and update."""
|
||||
client = MagicMock()
|
||||
# First call: POST /wiki/new → 400 already exists
|
||||
# Second call: PATCH /wiki/page/{sub_url} → success
|
||||
create_error = APIError(400, "wiki page already exists [title: Test-Page]")
|
||||
client._request.side_effect = [create_error, MagicMock()]
|
||||
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", return_value={"Test-Page": "Test-Page.-"}):
|
||||
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||
assert result == "updated"
|
||||
# Verify PATCH was called (second call)
|
||||
patch_call = client._request.call_args_list[1]
|
||||
assert patch_call.args[0] == "PATCH"
|
||||
assert "/wiki/page/Test-Page.-" in patch_call.args[1]
|
||||
|
||||
def test_create_raises_non_400_error(self) -> None:
|
||||
"""Non-400 errors from create should propagate, not trigger fallback."""
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(500, "server error")
|
||||
with pytest.raises(APIError):
|
||||
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||
|
||||
def test_create_raises_400_not_already_exists(self) -> None:
|
||||
"""400 errors that don't mention 'already exists' should propagate."""
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(400, "invalid title")
|
||||
with pytest.raises(APIError):
|
||||
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_transient_api_failure_returns_empty(self) -> None:
|
||||
"""When the wiki API is unavailable after retries, integrity check
|
||||
should return no failures (sync already succeeded)."""
|
||||
client = MagicMock()
|
||||
|
||||
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
|
||||
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
|
||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert failures == []
|
||||
|
||||
def test_transient_api_failure_recovers_on_retry(self) -> None:
|
||||
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
|
||||
client = MagicMock()
|
||||
pages = {"Home": "Home", "FAQ": "FAQ"}
|
||||
contents = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||
|
||||
def mock_request(method, path, **kwargs):
|
||||
resp = MagicMock()
|
||||
if path == "/wiki/pages":
|
||||
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
|
||||
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
|
||||
|
||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert failures == []
|
||||
@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 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.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:
|
||||
def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
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
|
||||
def test_no_mapping_raises(self, 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.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"])
|
||||
@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,
|
||||
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 "existing wiki pages" in result.output
|
||||
assert "dry-run" in result.output
|
||||
mock_push.assert_not_called()
|
||||
|
||||
@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"])
|
||||
@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,
|
||||
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.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,
|
||||
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.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,
|
||||
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.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,
|
||||
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 "not found" in result.output
|
||||
assert "could not clone" 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"])
|
||||
@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,
|
||||
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 "empty" in result.output.lower()
|
||||
assert "page(s) missing" in result.output
|
||||
|
||||
@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"])
|
||||
@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,
|
||||
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
|
||||
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
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When the initial page list fails after retries, sync aborts to avoid duplicate pages."""
|
||||
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_with_retry", side_effect=APIError(0, "timeout")):
|
||||
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to list existing wiki pages" in result.output
|
||||
assert "Aborting" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
# Initial list succeeds, but verify re-fetch fails
|
||||
list_side_effect = [{"Home": "Home"}, APIError(0, "timeout")]
|
||||
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_with_retry", side_effect=list_side_effect):
|
||||
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||
assert result.exit_code == 0
|
||||
assert "Skipping content verification" in result.output
|
||||
|
||||
Reference in New Issue
Block a user