Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""F.17 — Gitea ergonomics: batch files, tree, search, compare, releases, fork.
|
|
|
|
Tests cover both the client-level additions and the handler wrappers.
|
|
All network calls are mocked through ``client.request`` so the tests
|
|
exercise validation, error shaping, and payload construction without
|
|
hitting a real Gitea instance.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from plugins.gitea.client import GiteaClient
|
|
from plugins.gitea.handlers import repositories as repo_handlers
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""A GiteaClient with its ``request`` method replaced by an AsyncMock."""
|
|
c = GiteaClient(site_url="https://git.example.com", token="ghs_test")
|
|
c.request = AsyncMock() # type: ignore[assignment]
|
|
return c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _normalise_file_content: error messages + round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormaliseFileContent:
|
|
@pytest.mark.unit
|
|
def test_plaintext_roundtrips(self):
|
|
out = GiteaClient._normalise_file_content("hello world", False)
|
|
assert base64.b64decode(out) == b"hello world"
|
|
|
|
@pytest.mark.unit
|
|
def test_bytes_input_accepted(self):
|
|
out = GiteaClient._normalise_file_content(b"\x00\x01\x02", False)
|
|
assert base64.b64decode(out) == b"\x00\x01\x02"
|
|
|
|
@pytest.mark.unit
|
|
def test_base64_input_validates_roundtrip(self):
|
|
b64 = base64.b64encode(b"precise").decode()
|
|
out = GiteaClient._normalise_file_content(b64, True)
|
|
assert base64.b64decode(out) == b"precise"
|
|
|
|
@pytest.mark.unit
|
|
def test_invalid_base64_raises_actionable_error(self):
|
|
# F.17 feedback #10: make the error message tell the user how to
|
|
# recover rather than just echoing the decoder's byte offset.
|
|
with pytest.raises(ValueError) as exc:
|
|
GiteaClient._normalise_file_content("###not base64###", True)
|
|
msg = str(exc.value)
|
|
assert "not valid base64" in msg
|
|
assert "content_is_base64=False" in msg
|
|
|
|
@pytest.mark.unit
|
|
def test_data_url_prefix_rejected_with_hint(self):
|
|
with pytest.raises(ValueError) as exc:
|
|
GiteaClient._normalise_file_content("data:text/plain;base64,aGVsbG8=", True)
|
|
assert "data:" in str(exc.value)
|
|
assert "Strip" in str(exc.value)
|
|
|
|
@pytest.mark.unit
|
|
def test_non_string_non_bytes_rejected(self):
|
|
with pytest.raises(ValueError):
|
|
GiteaClient._normalise_file_content(12345, False) # type: ignore[arg-type]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client-level endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClientEndpoints:
|
|
@pytest.mark.asyncio
|
|
async def test_change_files_forwards_to_batch_endpoint(self, client):
|
|
client.request.return_value = {"commit": {"sha": "abc"}}
|
|
payload = {"message": "m", "files": [{"operation": "create", "path": "x"}]}
|
|
await client.change_files("o", "r", payload)
|
|
client.request.assert_called_once_with("POST", "repos/o/r/contents", json_data=payload)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tree_passes_recursive_flag(self, client):
|
|
client.request.return_value = {"tree": []}
|
|
await client.get_tree("o", "r", "main", recursive=True, page=2, per_page=50)
|
|
call = client.request.call_args
|
|
assert call.args == ("GET", "repos/o/r/git/trees/main")
|
|
assert call.kwargs["params"] == {"page": 2, "per_page": 50, "recursive": "true"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tree_omits_recursive_when_false(self, client):
|
|
client.request.return_value = {"tree": []}
|
|
await client.get_tree("o", "r")
|
|
params = client.request.call_args.kwargs["params"]
|
|
assert "recursive" not in params
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_code_scoped_to_repo(self, client):
|
|
client.request.return_value = {"ok": True, "data": []}
|
|
await client.search_code(keyword="func", owner="o", repo="r")
|
|
assert client.request.call_args.args == ("GET", "repos/o/r/search/code")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_code_instance_wide(self, client):
|
|
client.request.return_value = {"ok": True, "data": []}
|
|
await client.search_code(keyword="func")
|
|
assert client.request.call_args.args == ("GET", "repos/search/code")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compare_uses_triple_dot_separator(self, client):
|
|
client.request.return_value = {"commits": []}
|
|
await client.compare("o", "r", "main", "feature-x")
|
|
assert client.request.call_args.args == ("GET", "repos/o/r/compare/main...feature-x")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_release_forwards_payload(self, client):
|
|
client.request.return_value = {"id": 1}
|
|
await client.create_release("o", "r", {"tag_name": "v1.0", "draft": False})
|
|
assert client.request.call_args.args == ("POST", "repos/o/r/releases")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fork_payload_omits_none_fields(self, client):
|
|
client.request.return_value = {"id": 1}
|
|
await client.fork_repository("o", "r")
|
|
# Empty payload — no organization, no name.
|
|
assert client.request.call_args.kwargs["json_data"] == {}
|
|
|
|
client.request.reset_mock()
|
|
await client.fork_repository("o", "r", organization="new-org", name="new-name")
|
|
assert client.request.call_args.kwargs["json_data"] == {
|
|
"organization": "new-org",
|
|
"name": "new-name",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Handlers: validation + JSON shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateFilesHandler:
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_batches_operations(self, client):
|
|
client.request.return_value = {"commit": {"sha": "abc"}}
|
|
out = json.loads(
|
|
await repo_handlers.create_files(
|
|
client,
|
|
owner="o",
|
|
repo="r",
|
|
files=[
|
|
{"operation": "create", "path": "a.txt", "content": "hi"},
|
|
{
|
|
"operation": "update",
|
|
"path": "b.txt",
|
|
"content": "bye",
|
|
"sha": "deadbeef",
|
|
},
|
|
{"operation": "delete", "path": "c.txt", "sha": "cafebabe"},
|
|
],
|
|
message="batch commit",
|
|
branch="main",
|
|
)
|
|
)
|
|
assert out["success"] is True
|
|
assert "Batched 3 file" in out["message"]
|
|
# Client got a single batch call.
|
|
body = client.request.call_args.kwargs["json_data"]
|
|
assert body["branch"] == "main"
|
|
assert body["message"] == "batch commit"
|
|
assert len(body["files"]) == 3
|
|
# content is base64-encoded on the way out.
|
|
for f in body["files"][:2]:
|
|
assert base64.b64decode(f["content"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_invalid_operation(self, client):
|
|
out = json.loads(
|
|
await repo_handlers.create_files(
|
|
client,
|
|
owner="o",
|
|
repo="r",
|
|
files=[{"operation": "rename", "path": "a.txt", "content": "x"}],
|
|
message="m",
|
|
)
|
|
)
|
|
assert out["success"] is False
|
|
assert out["errors"][0]["error"].startswith("invalid_operation")
|
|
client.request.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_requires_sha(self, client):
|
|
out = json.loads(
|
|
await repo_handlers.create_files(
|
|
client,
|
|
owner="o",
|
|
repo="r",
|
|
files=[{"operation": "update", "path": "a.txt", "content": "x"}],
|
|
message="m",
|
|
)
|
|
)
|
|
assert out["success"] is False
|
|
assert out["errors"][0]["error"] == "missing_sha_for_update"
|
|
client.request.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_requires_sha(self, client):
|
|
out = json.loads(
|
|
await repo_handlers.create_files(
|
|
client,
|
|
owner="o",
|
|
repo="r",
|
|
files=[{"operation": "delete", "path": "a.txt"}],
|
|
message="m",
|
|
)
|
|
)
|
|
assert out["success"] is False
|
|
assert out["errors"][0]["error"] == "missing_sha_for_delete"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bad_base64_surfaces_actionable_error(self, client):
|
|
out = json.loads(
|
|
await repo_handlers.create_files(
|
|
client,
|
|
owner="o",
|
|
repo="r",
|
|
files=[
|
|
{
|
|
"operation": "create",
|
|
"path": "a.txt",
|
|
"content": "###garbage###",
|
|
"content_is_base64": True,
|
|
}
|
|
],
|
|
message="m",
|
|
)
|
|
)
|
|
assert out["success"] is False
|
|
assert "content_is_base64=False" in out["errors"][0]["error"]
|
|
|
|
|
|
class TestTreeAndSearchHandlers:
|
|
@pytest.mark.asyncio
|
|
async def test_get_tree_passthrough(self, client):
|
|
client.request.return_value = {"tree": [{"path": "x"}]}
|
|
out = json.loads(await repo_handlers.get_tree(client, "o", "r", sha="main", recursive=True))
|
|
assert out["success"] is True
|
|
assert out["tree"]["tree"][0]["path"] == "x"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_code_passthrough(self, client):
|
|
client.request.return_value = {"ok": True, "data": [{"path": "hit"}]}
|
|
out = json.loads(
|
|
await repo_handlers.search_code(client, keyword="foo", owner="o", repo="r")
|
|
)
|
|
assert out["success"] is True
|
|
assert out["result"]["data"][0]["path"] == "hit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compare_passthrough(self, client):
|
|
client.request.return_value = {"commits": [{"sha": "c1"}]}
|
|
out = json.loads(await repo_handlers.compare(client, "o", "r", "main", "x"))
|
|
assert out["success"] is True
|
|
assert out["compare"]["commits"][0]["sha"] == "c1"
|
|
|
|
|
|
class TestReleaseAndForkHandlers:
|
|
@pytest.mark.asyncio
|
|
async def test_list_releases_passthrough(self, client):
|
|
client.request.return_value = [{"tag_name": "v1"}]
|
|
out = json.loads(await repo_handlers.list_releases(client, "o", "r"))
|
|
assert out["success"] is True
|
|
assert out["releases"][0]["tag_name"] == "v1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_release_forwards_optional_fields(self, client):
|
|
client.request.return_value = {"id": 42}
|
|
await repo_handlers.create_release(
|
|
client, "o", "r", tag_name="v1.0", name="1.0", body="hi", prerelease=True
|
|
)
|
|
body = client.request.call_args.kwargs["json_data"]
|
|
assert body["tag_name"] == "v1.0"
|
|
assert body["name"] == "1.0"
|
|
assert body["body"] == "hi"
|
|
assert body["prerelease"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_release_without_optional_fields(self, client):
|
|
client.request.return_value = {"id": 42}
|
|
await repo_handlers.create_release(client, "o", "r", tag_name="v1.0")
|
|
body = client.request.call_args.kwargs["json_data"]
|
|
assert body["tag_name"] == "v1.0"
|
|
assert "name" not in body
|
|
assert "body" not in body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fork_repository_with_org(self, client):
|
|
client.request.return_value = {"full_name": "neworg/r"}
|
|
out = json.loads(
|
|
await repo_handlers.fork_repository(client, "o", "r", organization="neworg")
|
|
)
|
|
assert out["success"] is True
|
|
assert out["fork"]["full_name"] == "neworg/r"
|