feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

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>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -0,0 +1,224 @@
"""F.18.7 — Tests for wordpress audit-hook tools."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.audit_hook import (
SUPPORTED_EVENTS,
AuditHookHandler,
_validate_configure,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return AuditHookHandler(wp_client)
class TestValidateConfigure:
def test_all_none_ok(self):
assert (
_validate_configure(endpoint_url=None, secret=None, enabled=None, events=None) is None
)
def test_https_ok(self):
assert (
_validate_configure(
endpoint_url="https://mcp.example.com/api/companion-audit",
secret=None,
enabled=None,
events=None,
)
is None
)
def test_non_url_rejected(self):
err = _validate_configure(
endpoint_url="not-a-url",
secret=None,
enabled=None,
events=None,
)
assert err is not None
assert err["error"] == "invalid_endpoint_url"
def test_short_secret_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret="short",
enabled=None,
events=None,
)
assert err is not None
assert err["error"] == "secret_too_short"
def test_empty_secret_allowed_to_clear(self):
assert _validate_configure(endpoint_url=None, secret="", enabled=None, events=None) is None
def test_unknown_event_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled=None,
events=["transition_post_status", "bogus"],
)
assert err is not None
assert err["error"] == "unknown_event"
assert "supported" in err
def test_non_list_events_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled=None,
events="not a list",
)
assert err is not None
assert err["error"] == "invalid_events"
def test_non_bool_enabled_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled="yes",
events=None,
)
assert err is not None
assert err["error"] == "invalid_enabled"
@pytest.mark.asyncio
async def test_status_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"enabled": True,
"endpoint_url": "https://mcp.example.com/api/companion-audit",
"secret_set": True,
"secret_last4": "abcd",
"events": list(SUPPORTED_EVENTS),
"last_push_gmt": "2026-04-15T09:00:00Z",
"failure_count": 0,
"last_error": "",
"plugin_version": "2.7.0",
}
get_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.audit_hook_status())
assert out["ok"] is True
assert out["enabled"] is True
assert out["secret_last4"] == "abcd"
assert out["failure_count"] == 0
get_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_status_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.audit_hook_status())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
@pytest.mark.asyncio
async def test_configure_happy_path(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["endpoint"] = endpoint
captured["body"] = json_data
return {
"enabled": True,
"endpoint_url": "https://mcp.example.com/api/companion-audit",
"secret_set": True,
"secret_last4": "wxyz",
"events": ["transition_post_status"],
"failure_count": 0,
}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
out = json.loads(
await handler.audit_hook_configure(
endpoint_url="https://mcp.example.com/api/companion-audit",
secret="a" * 32,
enabled=True,
events=["transition_post_status"],
)
)
assert out["ok"] is True
assert captured["endpoint"] == "airano-mcp/v1/audit-hook"
assert captured["body"]["endpoint_url"] == "https://mcp.example.com/api/companion-audit"
assert captured["body"]["secret"] == "a" * 32
assert captured["body"]["enabled"] is True
assert captured["body"]["events"] == ["transition_post_status"]
@pytest.mark.asyncio
async def test_configure_rejects_short_secret_without_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.audit_hook_configure(secret="short"))
assert out["ok"] is False
assert out["error"] == "secret_too_short"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_configure_no_fields_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.audit_hook_configure())
assert out["ok"] is False
assert out["error"] == "no_fields"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_disable_calls_delete(handler, wp_client, monkeypatch):
del_mock = AsyncMock(
return_value={
"enabled": False,
"endpoint_url": "",
"secret_set": False,
"cleared": True,
"plugin_version": "2.7.0",
}
)
monkeypatch.setattr(wp_client, "delete", del_mock)
out = json.loads(await handler.audit_hook_disable())
assert out["ok"] is True
assert out["cleared"] is True
del_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_disable_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "delete", AsyncMock(side_effect=RuntimeError("boom")))
out = json.loads(await handler.audit_hook_disable())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
def test_tool_specs_shape():
specs = get_tool_specifications()
assert len(specs) == 3
names = {s["name"] for s in specs}
assert names == {"audit_hook_status", "audit_hook_configure", "audit_hook_disable"}
scopes = {s["name"]: s["scope"] for s in specs}
assert scopes["audit_hook_status"] == "read"
assert scopes["audit_hook_configure"] == "admin"
assert scopes["audit_hook_disable"] == "admin"