feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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:
249
tests/test_capability_badge_integration.py
Normal file
249
tests/test_capability_badge_integration.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""F.7e — dashboard_sites_view renders capability probe into template context.
|
||||
|
||||
End-to-end-ish test: builds a real DB + site, stubs the plugin's probe,
|
||||
and verifies that ``dashboard_sites_view`` injects a ``capability_probe``
|
||||
context entry with a well-shaped ``fit`` dict. The manage.html template
|
||||
consumes this entry to render the badge; this test only covers the
|
||||
context wiring, not the template output (templates are hit in
|
||||
integration dashboard tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _encryption_key(monkeypatch):
|
||||
key = base64.b64encode(os.urandom(32)).decode()
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||
import core.encryption as enc_mod
|
||||
|
||||
monkeypatch.setattr(enc_mod, "_credential_encryption", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_probe_cache():
|
||||
from core.capability_probe import get_probe_cache
|
||||
|
||||
get_probe_cache()._entries.clear()
|
||||
yield
|
||||
get_probe_cache()._entries.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _db_with_wp_site(tmp_path, monkeypatch):
|
||||
import core.database as db_mod
|
||||
from core.database import initialize_database
|
||||
from core.encryption import get_credential_encryption
|
||||
|
||||
monkeypatch.setattr(db_mod, "_database", None)
|
||||
db = await initialize_database(str(tmp_path / "badge.db"))
|
||||
|
||||
user = await db.create_user(
|
||||
email="badge@example.com",
|
||||
name="Badge",
|
||||
provider="github",
|
||||
provider_id="gh-badge",
|
||||
)
|
||||
enc = get_credential_encryption()
|
||||
creds = enc.encrypt_credentials(
|
||||
{"username": "admin", "app_password": "xxxx xxxx"}, "site-badge-1"
|
||||
)
|
||||
site = await db.create_site(
|
||||
user_id=user["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="bloggy",
|
||||
url="https://wp.example.com",
|
||||
credentials=creds,
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE sites SET id = ?, tool_scope = ? WHERE id = ?",
|
||||
("site-badge-1", "admin", site["id"]),
|
||||
)
|
||||
site = await db.get_site("site-badge-1", user["id"])
|
||||
yield db, user, site
|
||||
await db.close()
|
||||
monkeypatch.setattr(db_mod, "_database", None)
|
||||
|
||||
|
||||
class TestSitesViewInjectsCapabilityProbe:
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_tier_with_admin_role_renders_ok(self, _db_with_wp_site, monkeypatch):
|
||||
_, user, site = _db_with_wp_site
|
||||
|
||||
async def _probe(self: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": ["administrator", "manage_options", "upload_files"],
|
||||
"source": "wordpress_companion",
|
||||
"roles": ["administrator"],
|
||||
}
|
||||
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
|
||||
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_template(_request, template_name, context, **kwargs):
|
||||
captured["template"] = template_name
|
||||
captured["context"] = context
|
||||
|
||||
# Return a minimal response object; the test doesn't render HTML.
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.dashboard.routes import dashboard_sites_view
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": f"/dashboard/sites/{site['id']}",
|
||||
"path_params": {"id": site["id"]},
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
request = Request(scope, receive)
|
||||
|
||||
with (
|
||||
patch("core.dashboard.routes.templates") as mock_templates,
|
||||
patch(
|
||||
"core.dashboard.routes._require_user_session",
|
||||
return_value=({"user_id": user["id"]}, None),
|
||||
),
|
||||
):
|
||||
mock_templates.TemplateResponse = _capture_template
|
||||
await dashboard_sites_view(request)
|
||||
|
||||
assert captured["template"] == "dashboard/sites/manage.html"
|
||||
ctx = captured["context"]
|
||||
assert "capability_probe" in ctx
|
||||
probe = ctx["capability_probe"]
|
||||
assert probe["probe_available"] is True
|
||||
assert probe["fit"]["status"] == "ok"
|
||||
assert probe["fit"]["missing"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_tier_with_editor_role_renders_warning(self, _db_with_wp_site, monkeypatch):
|
||||
_, user, site = _db_with_wp_site
|
||||
|
||||
async def _probe(self: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": ["editor", "edit_posts", "upload_files"],
|
||||
"source": "wordpress_companion",
|
||||
"roles": ["editor"],
|
||||
}
|
||||
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
|
||||
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_template(_request, _name, context, **kwargs):
|
||||
captured["context"] = context
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.dashboard.routes import dashboard_sites_view
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": f"/dashboard/sites/{site['id']}",
|
||||
"path_params": {"id": site["id"]},
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
request = Request(scope, receive)
|
||||
|
||||
with (
|
||||
patch("core.dashboard.routes.templates") as mock_templates,
|
||||
patch(
|
||||
"core.dashboard.routes._require_user_session",
|
||||
return_value=({"user_id": user["id"]}, None),
|
||||
),
|
||||
):
|
||||
mock_templates.TemplateResponse = _capture_template
|
||||
await dashboard_sites_view(request)
|
||||
|
||||
probe = captured["context"]["capability_probe"]
|
||||
assert probe["fit"]["status"] == "warning"
|
||||
assert "manage_options" in probe["fit"]["missing"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_raises_renders_probe_unavailable(self, _db_with_wp_site, monkeypatch):
|
||||
_, user, site = _db_with_wp_site
|
||||
|
||||
async def _probe(self: Any) -> dict[str, Any]:
|
||||
raise RuntimeError("no companion")
|
||||
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
|
||||
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_template(_request, _name, context, **kwargs):
|
||||
captured["context"] = context
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.dashboard.routes import dashboard_sites_view
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": f"/dashboard/sites/{site['id']}",
|
||||
"path_params": {"id": site["id"]},
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
request = Request(scope, receive)
|
||||
|
||||
with (
|
||||
patch("core.dashboard.routes.templates") as mock_templates,
|
||||
patch(
|
||||
"core.dashboard.routes._require_user_session",
|
||||
return_value=({"user_id": user["id"]}, None),
|
||||
),
|
||||
):
|
||||
mock_templates.TemplateResponse = _capture_template
|
||||
await dashboard_sites_view(request)
|
||||
|
||||
probe = captured["context"]["capability_probe"]
|
||||
assert probe["probe_available"] is False
|
||||
assert probe["fit"]["status"] == "probe_unavailable"
|
||||
Reference in New Issue
Block a user