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,387 @@
"""Tests for F.5a.4 AI image generation + upload chain."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from core.database import Database
from plugins.ai_image.providers.base import (
GenerationRequest,
GenerationResult,
ProviderError,
)
from plugins.ai_image.providers.openai import OpenAIProvider
from plugins.ai_image.registry import get_provider, list_providers
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.ai_media import AIMediaHandler
_TEST_KEY_B64 = base64.b64encode(b"0" * 32).decode()
# 1x1 PNG
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
async def db(tmp_path):
database = Database(str(tmp_path / "ai_test.db"))
await database.initialize()
yield database
await database.close()
@pytest.fixture
async def user_row(db):
return await db.create_user(
email="alice@example.com",
name="Alice",
provider="github",
provider_id="gh-ai",
)
# --- Registry ---------------------------------------------------------------
class TestRegistry:
def test_lists_expected_providers(self):
# Registry is the source of truth now that the per-user
# user_provider_keys module has been removed (F.5a.9.x).
# F.5a.9 partial: OpenRouter added.
assert set(list_providers()) == {"openai", "stability", "replicate", "openrouter"}
def test_get_provider_known(self):
assert get_provider("openai").name == "openai"
def test_get_provider_unknown_raises(self):
with pytest.raises(ProviderError) as e:
get_provider("midjourney")
assert e.value.code == "PROVIDER_UNKNOWN"
# Per-user UserProviderKeyManager removed in F.5a.9.x. Per-site equivalents
# live in ``tests/test_site_provider_keys.py``.
# --- OpenAI provider --------------------------------------------------------
def _mock_session_post(responses: list):
"""Build a fake aiohttp.ClientSession whose .post() cycles through responses."""
iterator = iter(responses)
def _post(*_args, **_kwargs):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _post
sess.get = _post # reuse for URL fetches
return sess
def _fake_resp(*, status: int, json_data=None, text_data=None, raw=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or (json.dumps(json_data) if json_data else ""))
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
return resp
class TestOpenAIProvider:
@pytest.mark.asyncio
async def test_dalle_url_fetched_and_returned_as_bytes(self):
provider = OpenAIProvider()
generate_resp = _fake_resp(
status=200,
json_data={
"data": [
{
"url": "https://oaidalle.example.com/img/abc.png",
"revised_prompt": "revised",
}
]
},
)
fetch_resp = _fake_resp(status=200, raw=_PNG_1x1)
sess = _mock_session_post([generate_resp, fetch_resp])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate(
"sk-test",
GenerationRequest(prompt="a cube", size="1024x1024"),
)
assert result.data == _PNG_1x1
assert result.mime == "image/png"
assert result.meta.get("revised_prompt") == "revised"
assert result.cost_usd == 0.040
@pytest.mark.asyncio
async def test_missing_key_raises_auth(self):
provider = OpenAIProvider()
with pytest.raises(ProviderError) as e:
await provider.generate("", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_429_retried_then_succeeds(self):
provider = OpenAIProvider()
r429 = _fake_resp(status=429, text_data="slow down")
ok = _fake_resp(
status=200,
json_data={"data": [{"b64_json": base64.b64encode(_PNG_1x1).decode()}]},
)
sess = _mock_session_post([r429, ok])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
result = await provider.generate(
"sk-test",
GenerationRequest(prompt="x", model="gpt-image-1"),
)
assert result.data == _PNG_1x1
@pytest.mark.asyncio
async def test_persistent_5xx_becomes_unavailable(self):
provider = OpenAIProvider()
r500 = _fake_resp(status=500, text_data="boom")
sess = _mock_session_post([r500, r500, r500])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
with pytest.raises(ProviderError) as e:
await provider.generate("sk", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_UNAVAILABLE"
# --- AIMediaHandler ---------------------------------------------------------
class TestAIMediaHandler:
@pytest.mark.asyncio
async def test_missing_api_key_returns_typed_error(self, wp_client, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id=None)
async def _noop(*_a, **_kw):
return None
# Force the key resolver to return None without needing a real DB.
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value=None))
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "NO_PROVIDER_KEY"
@pytest.mark.asyncio
async def test_happy_path_chains_to_upload(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
fake_result = GenerationResult(
data=_PNG_1x1,
mime="image/png",
filename="ai.png",
meta={"model": "dall-e-3"},
cost_usd=0.04,
)
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(fake_result),
)
upload_mock = AsyncMock(
return_value={
"id": 42,
"title": {"rendered": "ai"},
"source_url": "https://wp.example.com/ai.png",
"mime_type": "image/png",
"media_type": "image",
}
)
monkeypatch.setattr("plugins.wordpress.handlers.ai_media.wp_raw_upload", upload_mock)
# Disable optimize (skip_optimize=True skips Pillow and keeps test hermetic).
out = await handler.generate_and_upload_image(
provider="openai", prompt="x", skip_optimize=True
)
data = json.loads(out)
assert data["id"] == 42
assert data["provider"] == "openai"
assert data["cost_usd"] == 0.04
assert upload_mock.await_count == 1
@pytest.mark.asyncio
async def test_provider_error_surfaced_with_code(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(exc=ProviderError("PROVIDER_QUOTA", "rate")),
)
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "PROVIDER_QUOTA"
@pytest.mark.asyncio
async def test_audit_entry_emitted_on_success(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(
GenerationResult(
data=_PNG_1x1,
mime="image/png",
filename="ai.png",
meta={"model": "dall-e-3"},
cost_usd=0.04,
)
),
)
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.wp_raw_upload",
AsyncMock(
return_value={
"id": 7,
"title": {"rendered": ""},
"source_url": "https://x/img.png",
"mime_type": "image/png",
"media_type": "image",
}
),
)
calls: list[dict] = []
class _FakeAudit:
def log_tool_call(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(
"core.audit_log.get_audit_logger",
lambda: _FakeAudit(),
)
await handler.generate_and_upload_image(provider="openai", prompt="x", skip_optimize=True)
# F.5a.6.4: AI uploads emit BOTH the legacy provider-cost entry and a
# unified media.upload entry. Find each by tool_name.
ai_call = next(c for c in calls if c["tool_name"] == "wordpress_generate_and_upload_image")
assert ai_call["params"]["provider"] == "openai"
assert ai_call["params"]["cost_usd"] == 0.04
assert ai_call["user_id"] == "u1"
media_call = next(c for c in calls if c["tool_name"] == "media.upload")
assert media_call["params"]["source"] == "ai:openai"
assert media_call["params"]["cost_usd"] == 0.04
assert media_call["params"]["media_id"] == 7
class TestResolverSiteScope:
"""F.5a.9.x: ``_resolve_api_key`` reads from the per-site key store when
``site_id`` is set, and falls back to env vars only when it is None."""
@pytest.mark.asyncio
async def test_site_key_wins_over_env(self, wp_client, monkeypatch):
"""With site_id set, the per-site key is returned even if env has one."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-should-not-be-used")
async def _fake_get(site_id, provider):
assert site_id == "site-abc"
assert provider == "openai"
return "sk-site-win"
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
assert await handler._resolve_api_key("openai") == "sk-site-win"
@pytest.mark.asyncio
async def test_site_set_but_no_key_returns_none(self, wp_client, monkeypatch):
"""site_id set + no site key stored + env present → still None.
Env fallback is deliberately not applied on the per-site path."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-ignored")
async def _fake_get(site_id, provider):
return None
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
assert await handler._resolve_api_key("openai") is None
@pytest.mark.asyncio
async def test_no_site_id_falls_back_to_env(self, wp_client, monkeypatch):
"""site_id=None (admin / master-key path): env var is used."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-admin-env")
handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
assert await handler._resolve_api_key("openai") == "sk-admin-env"
@pytest.mark.asyncio
async def test_no_site_id_no_env_returns_none(self, wp_client, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
assert await handler._resolve_api_key("openai") is None
@pytest.mark.asyncio
async def test_missing_key_error_points_to_site_dashboard(self, wp_client, monkeypatch):
"""When site_id is set and no key, error message points at the site
edit page, not the deprecated /dashboard/provider-keys page."""
async def _fake_get(site_id, provider):
return None
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-xyz")
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "NO_PROVIDER_KEY"
assert data["dashboard_url"] == "/dashboard/sites/site-xyz"
assert "/dashboard/provider-keys" not in json.dumps(data)
class _StubProvider:
"""Minimal provider stub used by AIMediaHandler tests."""
name = "stub"
def __init__(
self,
result: GenerationResult | None = None,
exc: ProviderError | None = None,
):
self._result = result
self._exc = exc
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if self._exc is not None:
raise self._exc
assert self._result is not None
return self._result

View File

@@ -0,0 +1,188 @@
"""F.X.fix #7 — AI image upload sends Idempotency-Key to the companion.
Regression: when the MCP client timed out mid-call the companion had
already created the attachment. The client retried and the companion
created a second ``-2.webp`` orphan, leaving admins to clean up by
hand. Fix: generate a stable key per logical call, send it as an
``Idempotency-Key`` header, and let the companion dedupe.
These tests cover the Python side — the key is deterministic for the
same inputs, differs when any input changes, and is actually put on
the wire when ``_companion_upload_and_attach`` fires.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import _media_core
from plugins.wordpress.handlers.ai_media import (
_content_sha,
_idempotency_key_for,
)
class TestIdempotencyKeyBuilder:
def test_matches_companion_regex(self):
key = _idempotency_key_for(
provider="openrouter",
model="google/gemini-2.5-flash-image",
prompt="a cat",
size="1024x1024",
attach_to_post=42,
set_featured=True,
site_url="https://blog.example.com",
user_id="user-1",
content_sha=_content_sha(b"bytes"),
)
# Companion PHP validates ^[A-Za-z0-9_-]{1,128}$ before use.
import re
assert re.match(r"^[A-Za-z0-9_\-]{1,128}$", key)
assert 1 <= len(key) <= 128
def test_deterministic_for_same_inputs(self):
kwargs = {
"provider": "openrouter",
"model": "google/gemini-2.5-flash-image",
"prompt": "a cat",
"size": "1024x1024",
"attach_to_post": 42,
"set_featured": True,
"site_url": "https://blog.example.com",
"user_id": "user-1",
"content_sha": "abc123",
}
assert _idempotency_key_for(**kwargs) == _idempotency_key_for(**kwargs)
def test_differs_when_prompt_changes(self):
base = {
"provider": "openrouter",
"model": "m",
"prompt": "cat",
"size": "1024x1024",
"attach_to_post": 0,
"set_featured": False,
"site_url": "https://x",
"user_id": None,
"content_sha": "a",
}
other = {**base, "prompt": "dog"}
assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
def test_differs_when_attach_to_post_changes(self):
base = {
"provider": "openrouter",
"model": "m",
"prompt": "x",
"size": "1024x1024",
"attach_to_post": None,
"set_featured": False,
"site_url": "https://x",
"user_id": None,
"content_sha": "a",
}
other = {**base, "attach_to_post": 42}
assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
class TestHeaderPropagation:
@pytest.mark.asyncio
async def test_idempotency_key_reaches_companion_route(self):
"""_companion_upload_and_attach must put the key in the header."""
client = WordPressClient("https://example.com", "u", "app_pw")
captured: dict = {}
class _FakeResp:
status = 200
async def text(self):
return "{}"
async def json(self, content_type=None):
return {"id": 99, "source_url": "https://example.com/a.png"}
def _request(url, data=None, headers=None, params=None):
captured["headers"] = headers
captured["params"] = params
return AsyncMock(
__aenter__=AsyncMock(return_value=_FakeResp()),
__aexit__=AsyncMock(return_value=False),
)
session = AsyncMock()
session.post = _request
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
):
await _media_core._companion_upload_and_attach(
client,
b"\x89PNG\r\n\x1a\n",
sniffed="image/png",
disposition='attachment; filename="a.png"',
attach_to_post=42,
set_featured=True,
title=None,
alt_text=None,
caption=None,
description=None,
idempotency_key="mcphub_ai_abc123",
)
assert captured["headers"]["Idempotency-Key"] == "mcphub_ai_abc123"
@pytest.mark.asyncio
async def test_idempotency_key_absent_header_when_none(self):
client = WordPressClient("https://example.com", "u", "app_pw")
captured: dict = {}
class _FakeResp:
status = 200
async def text(self):
return "{}"
async def json(self, content_type=None):
return {"id": 7}
def _request(url, data=None, headers=None, params=None):
captured["headers"] = headers
return AsyncMock(
__aenter__=AsyncMock(return_value=_FakeResp()),
__aexit__=AsyncMock(return_value=False),
)
session = AsyncMock()
session.post = _request
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
):
await _media_core._companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition='attachment; filename="a.png"',
attach_to_post=None,
set_featured=False,
title=None,
alt_text=None,
caption=None,
description=None,
idempotency_key=None,
)
# No Idempotency-Key header when the caller didn't supply one;
# the companion must still handle the request normally.
assert "Idempotency-Key" not in captured["headers"]

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"

View File

@@ -0,0 +1,197 @@
"""F.18.2 — Tests for wordpress_bulk_update_meta."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.bulk_meta import (
MAX_BULK_ITEMS,
BulkMetaHandler,
_validate_updates,
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 BulkMetaHandler(wp_client)
# ---------------------------------------------------------------------------
# Client-side validation: should never hit the network.
# ---------------------------------------------------------------------------
def test_validate_rejects_non_list():
out = _validate_updates("not a list")
assert isinstance(out, dict)
assert out["error"] == "invalid_updates"
def test_validate_rejects_empty_list():
out = _validate_updates([])
assert isinstance(out, dict)
assert out["error"] == "empty_updates"
def test_validate_rejects_too_many_items():
too_many = [{"post_id": i + 1, "meta": {"k": "v"}} for i in range(MAX_BULK_ITEMS + 1)]
out = _validate_updates(too_many)
assert isinstance(out, dict)
assert out["error"] == "too_many_items"
def test_validate_rejects_non_object_item():
out = _validate_updates(["not a dict"])
assert isinstance(out, dict)
assert out["error"] == "invalid_item"
assert out["index"] == 0
def test_validate_rejects_non_positive_post_id():
out = _validate_updates([{"post_id": 0, "meta": {"k": "v"}}])
assert isinstance(out, dict)
assert out["error"] == "invalid_post_id"
def test_validate_rejects_non_dict_meta():
out = _validate_updates([{"post_id": 1, "meta": "not a dict"}])
assert isinstance(out, dict)
assert out["error"] == "invalid_meta"
def test_validate_passes_well_formed_input():
out = _validate_updates(
[
{"post_id": 1, "meta": {"a": 1}},
{"post_id": 2, "meta": {"b": None}},
]
)
assert isinstance(out, list)
assert out == [
{"post_id": 1, "meta": {"a": 1}},
{"post_id": 2, "meta": {"b": None}},
]
# ---------------------------------------------------------------------------
# Handler behaviour: network errors and happy paths.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_client_side_reject_does_not_hit_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out_json = await handler.bulk_update_meta(updates="not a list")
out = json.loads(out_json)
assert out["ok"] is False
assert out["error"] == "invalid_updates"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}]))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "probe_capabilities" in out["hint"]
assert out["total"] == 1
@pytest.mark.asyncio
async def test_happy_path_passes_updates_as_list(handler, wp_client, monkeypatch):
companion_response = {
"total": 2,
"updated": 2,
"failed": 0,
"skipped": 0,
"results": [
{"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
{"index": 1, "post_id": 2, "status": "ok", "updated_keys": ["b"]},
],
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
updates = [
{"post_id": 1, "meta": {"a": "1"}},
{"post_id": 2, "meta": {"b": "2"}},
]
out = json.loads(await handler.bulk_update_meta(updates=updates))
assert out["ok"] is True
assert out["total"] == 2
assert out["updated"] == 2
assert out["failed"] == 0
assert len(out["results"]) == 2
post_mock.assert_called_once_with(
"airano-mcp/v1/bulk-meta",
json_data={"updates": updates},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_mixed_success_and_skip_passed_through(handler, wp_client, monkeypatch):
companion_response = {
"total": 3,
"updated": 1,
"failed": 0,
"skipped": 2,
"results": [
{"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
{"index": 1, "post_id": 999, "status": "not_found", "error": "post_not_found"},
{"index": 2, "post_id": 2, "status": "forbidden", "error": "cannot_edit_post"},
],
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(
await handler.bulk_update_meta(
updates=[
{"post_id": 1, "meta": {"a": "1"}},
{"post_id": 999, "meta": {"b": "2"}},
{"post_id": 2, "meta": {"c": "3"}},
]
)
)
assert out["ok"] is True
assert out["updated"] == 1
assert out["skipped"] == 2
assert out["results"][1]["status"] == "not_found"
assert out["results"][2]["status"] == "forbidden"
@pytest.mark.asyncio
async def test_null_meta_value_survives_payload(handler, wp_client, monkeypatch):
"""null values delete keys in PHP — we must preserve them in the JSON payload."""
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["json_data"] = json_data
return {"total": 1, "updated": 1, "failed": 0, "skipped": 0, "results": []}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"rank_math_title": None}}])
assert captured["json_data"]["updates"][0]["meta"] == {"rank_math_title": None}
def test_tool_spec_is_write_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "bulk_update_meta"
assert specs[0]["scope"] == "write"
assert "updates" in specs[0]["schema"]["properties"]

View File

@@ -0,0 +1,128 @@
"""F.18.4 — Tests for wordpress_cache_purge."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.cache_purge import (
CachePurgeHandler,
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 CachePurgeHandler(wp_client)
@pytest.mark.asyncio
async def test_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"detected": ["wp_rocket", "litespeed"],
"purged": ["wp_rocket_all", "litespeed_all", "object_cache"],
"skipped": [],
"errors": [],
"ok": True,
"plugin_version": "2.4.0",
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
assert out["detected"] == ["wp_rocket", "litespeed"]
assert "object_cache" in out["purged"]
assert out["plugin_version"] == "2.4.0"
post_mock.assert_called_once_with(
"airano-mcp/v1/cache-purge",
json_data={},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_no_cache_plugins_detected(handler, wp_client, monkeypatch):
companion_response = {
"detected": [],
"purged": ["object_cache"],
"skipped": [],
"errors": [],
"ok": True,
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
assert out["detected"] == []
# Object cache still flushed unconditionally.
assert "object_cache" in out["purged"]
@pytest.mark.asyncio
async def test_partial_errors_propagated(handler, wp_client, monkeypatch):
companion_response = {
"detected": ["wp_rocket"],
"purged": ["object_cache"],
"skipped": [],
"errors": [{"plugin": "wp_rocket", "message": "rocket_clean_domain exploded"}],
"ok": False,
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert len(out["errors"]) == 1
assert out["errors"][0]["plugin"] == "wp_rocket"
@pytest.mark.asyncio
async def test_companion_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "manage_options" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="not a dict"))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_ok_inferred_from_empty_errors_if_missing(handler, wp_client, monkeypatch):
"""Older companion builds might not emit an `ok` flag; derive from errors list."""
companion_response = {
"detected": ["w3_total_cache"],
"purged": ["w3_total_cache_all", "object_cache"],
"errors": [],
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
def test_tool_spec_is_admin_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "cache_purge"
assert specs[0]["scope"] == "admin"

View File

@@ -0,0 +1,239 @@
"""F.18.1 — Tests for wordpress_probe_capabilities."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.capabilities import (
_EXPECTED_CAPS,
_EXPECTED_ROUTES,
CapabilitiesHandler,
_CapabilitiesCache,
get_cached_capabilities,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def cache():
return _CapabilitiesCache(ttl=24 * 3600)
@pytest.fixture
def companion_payload():
return {
"plugin_version": "2.1.0",
"companion": True,
"user": {
"id": 1,
"login": "mcphub",
"roles": ["administrator"],
"capabilities": {
"upload_files": True,
"edit_posts": True,
"publish_posts": True,
"edit_others_posts": True,
"delete_posts": True,
"edit_pages": True,
"publish_pages": True,
"manage_categories": True,
"moderate_comments": True,
"manage_options": True,
"edit_users": True,
"list_users": True,
"manage_woocommerce": False,
"edit_shop_orders": False,
"edit_products": False,
},
},
"features": {
"rank_math": True,
"yoast": False,
"woocommerce": False,
"multisite": False,
},
"routes": {
"seo_meta": True,
"upload_limits": True,
"upload_chunk": True,
"capabilities": True,
"bulk_meta": False,
"export": False,
"cache_purge": False,
"transient_flush": False,
"site_health": False,
"audit_hook": False,
},
"wordpress": {
"version": "6.5.3",
"php_version": "8.1.27",
"rest_enabled": True,
},
}
@pytest.mark.asyncio
async def test_companion_endpoint_parses_and_caches(
wp_client, monkeypatch, cache, companion_payload
):
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out_json = await handler.probe_capabilities()
out = json.loads(out_json)
assert out["companion_available"] is True
assert out["plugin_version"] == "2.1.0"
assert out["cached"] is False
assert out["user"]["login"] == "mcphub"
assert out["user"]["capabilities"]["upload_files"] is True
assert out["user"]["capabilities"]["manage_woocommerce"] is False
assert out["routes"]["upload_chunk"] is True
assert out["routes"]["bulk_meta"] is False
assert out["features"]["rank_math"] is True
assert out["wordpress"]["version"] == "6.5.3"
get_mock.assert_called_once_with("airano-mcp/v1/capabilities", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache, companion_payload):
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_capabilities()
out2 = json.loads(await handler.probe_capabilities())
assert out2["cached"] is True
assert get_mock.call_count == 1
@pytest.mark.asyncio
async def test_cache_expires_after_ttl(wp_client, monkeypatch, companion_payload):
cache = _CapabilitiesCache(ttl=0.0)
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_capabilities()
await handler.probe_capabilities()
assert get_mock.call_count == 2
@pytest.mark.asyncio
async def test_companion_missing_returns_false_shape(wp_client, monkeypatch, cache):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is False
assert out["plugin_version"] is None
assert out["user"] is None
assert out["features"] is None
# All routes default to False so downstream code can still index safely.
assert all(v is False for v in out["routes"].values())
assert set(out["routes"].keys()) == set(_EXPECTED_ROUTES)
assert "companion_unreachable" in out["reason"]
@pytest.mark.asyncio
async def test_non_dict_payload_treated_as_missing(wp_client, monkeypatch, cache):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is False
assert out["reason"] == "companion_returned_non_dict"
@pytest.mark.asyncio
async def test_missing_caps_default_to_false(wp_client, monkeypatch, cache):
"""If the plugin drops a cap key (e.g. older plugin build), we fill with False."""
handler = CapabilitiesHandler(wp_client, cache=cache)
partial = {
"plugin_version": "2.1.0",
"user": {
"id": 1,
"login": "x",
"roles": ["editor"],
"capabilities": {"upload_files": True, "edit_posts": True},
},
"routes": {"upload_limits": True, "upload_chunk": True},
"features": {"rank_math": False},
"wordpress": {"version": "6.4"},
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=partial))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is True
caps = out["user"]["capabilities"]
assert caps["upload_files"] is True
assert caps["manage_options"] is False
assert set(caps.keys()) == set(_EXPECTED_CAPS)
routes = out["routes"]
assert routes["upload_limits"] is True
assert routes["bulk_meta"] is False
assert set(routes.keys()) == set(_EXPECTED_ROUTES)
@pytest.mark.asyncio
async def test_extra_caps_preserved_under_extra_key(wp_client, monkeypatch, cache):
"""Caps the plugin sends that aren't in our known list end up under extra_capabilities."""
handler = CapabilitiesHandler(wp_client, cache=cache)
payload = {
"plugin_version": "2.9.0", # future plugin with more caps
"user": {
"id": 1,
"login": "x",
"roles": ["administrator"],
"capabilities": {
"upload_files": True,
"some_future_cap": True,
},
},
"routes": {},
"features": {},
"wordpress": {},
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
out = json.loads(await handler.probe_capabilities())
assert out["user"]["capabilities"]["upload_files"] is True
assert out["user"]["extra_capabilities"] == {"some_future_cap": True}
@pytest.mark.asyncio
async def test_get_cached_capabilities_returns_none_before_probe(wp_client):
cache = _CapabilitiesCache(ttl=24 * 3600)
assert await get_cached_capabilities(wp_client, cache=cache) is None
@pytest.mark.asyncio
async def test_get_cached_capabilities_after_probe(
wp_client, monkeypatch, cache, companion_payload
):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=companion_payload))
await handler.probe_capabilities()
got = await get_cached_capabilities(wp_client, cache=cache)
assert got is not None
assert got["companion_available"] is True
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "probe_capabilities"
assert specs[0]["scope"] == "read"

View File

@@ -0,0 +1,173 @@
"""F.X.fix #3 — WordPressClient fast-fails on unreachable sites.
Regression: tools hung 35-85s on DNS-dead / TCP-refused hosts because
the client did not set a connect timeout. Fix adds connect=5 and maps
every TCP/DNS/SSL failure to SiteUnreachableError with a structured
install_hint. Budget: total wall-clock < 10s per request.
"""
from __future__ import annotations
import socket
import time
from unittest.mock import AsyncMock, patch
import aiohttp
import pytest
from plugins.wordpress.client import (
_CONNECT_TIMEOUT,
_REQUEST_TIMEOUT,
SiteUnreachableError,
WordPressClient,
)
@pytest.fixture
def client() -> WordPressClient:
return WordPressClient("https://dead.example.invalid", "user", "app_pw")
def _patch_session_raising(exc: Exception):
"""Patch aiohttp.ClientSession so ``session.request(...)`` raises ``exc``.
The production code uses ``async with session.request(...) as resp``,
so we mock ``request`` as a plain callable returning an
``AsyncMock`` whose ``__aenter__`` raises — matching the real
``aiohttp.ClientConnector*`` error path.
"""
session = AsyncMock()
def _request(*_args, **_kwargs):
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=exc)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
session.request = _request
return patch(
"plugins.wordpress.client.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
)
def _conn_key(site_url: str) -> aiohttp.client_reqrep.ConnectionKey:
# Minimal ConnectionKey required by aiohttp error constructors.
return aiohttp.client_reqrep.ConnectionKey(
host=site_url.split("://")[-1],
port=443,
is_ssl=site_url.startswith("https"),
ssl=None,
proxy=None,
proxy_auth=None,
proxy_headers_hash=None,
)
class TestConnectTimeoutConfigured:
def test_connect_timeout_is_five_seconds(self):
# Sanity: the module-level constant is what the roadmap asked for.
assert _CONNECT_TIMEOUT == 5
assert _REQUEST_TIMEOUT == 30
class TestSiteUnreachableMapping:
@pytest.mark.asyncio
async def test_dns_error_maps_to_site_unreachable(self, client):
dns_exc = aiohttp.ClientConnectorDNSError(
_conn_key(client.site_url), OSError("Name or service not known")
)
with _patch_session_raising(dns_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.error_code == "SITE_UNREACHABLE"
assert e.value.reason == "site_dns_error"
assert e.value.install_hint is not None
assert e.value.install_hint["companion_min_version"]
assert e.value.install_hint["install_url"].startswith("http")
@pytest.mark.asyncio
async def test_gaierror_via_connector_also_maps_to_dns_error(self, client):
gai = socket.gaierror("Name or service not known")
conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), gai)
with _patch_session_raising(conn_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_dns_error"
@pytest.mark.asyncio
async def test_connection_refused_maps_to_site_unreachable(self, client):
os_err = OSError(111, "Connection refused")
conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), os_err)
with _patch_session_raising(conn_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_connection_refused"
assert e.value.install_hint is not None
@pytest.mark.asyncio
async def test_ssl_error_maps_to_site_unreachable(self, client):
import ssl
ssl_exc = aiohttp.ClientConnectorCertificateError(
_conn_key(client.site_url),
ssl.SSLCertVerificationError("bad cert"),
)
with _patch_session_raising(ssl_exc):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_ssl_error"
@pytest.mark.asyncio
async def test_invalid_url_maps_to_site_unreachable(self):
client = WordPressClient("not-a-url", "u", "p")
invalid = aiohttp.InvalidURL("not-a-url")
with _patch_session_raising(invalid):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_invalid_url"
@pytest.mark.asyncio
async def test_timeout_after_retries_maps_to_site_unreachable(self, client):
with _patch_session_raising(TimeoutError("connect")):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_timeout"
assert e.value.install_hint is not None
class TestFastFailBudget:
"""Guards the 10s assertion promised in the F.X.retest matrix."""
@pytest.mark.asyncio
async def test_dns_error_fails_under_ten_seconds(self, client):
dns_exc = aiohttp.ClientConnectorDNSError(_conn_key(client.site_url), OSError("dead"))
with _patch_session_raising(dns_exc):
start = time.monotonic()
with pytest.raises(SiteUnreachableError):
await client.request("GET", "posts")
elapsed = time.monotonic() - start
# DNS errors are non-retryable, so this is essentially instant;
# give a loose 10s bound to match the F.X.retest assertion shape.
assert elapsed < 10.0
@pytest.mark.asyncio
async def test_timeout_with_retries_fails_under_ten_seconds(self, client):
# Two retries × _CONNECT_TIMEOUT + retry backoff. With mocked
# sleep this should return immediately; real-world budget is
# 3 × 5s = 15s worst-case, but only on the first request; follow-
# ups hit the cache.
with _patch_session_raising(TimeoutError()):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
start = time.monotonic()
with pytest.raises(SiteUnreachableError):
await client.request("GET", "posts")
elapsed = time.monotonic() - start
assert elapsed < 10.0

View File

@@ -0,0 +1,158 @@
"""Tests for the shared ``companion_install_hint`` helper + its use in
companion-backed handlers' ``companion_unreachable`` error payloads.
The helper's job is to give the caller enough information to actually
install / configure the companion plugin rather than just saying
"companion unreachable". Every handler that routes through the
companion now emits this dict alongside the existing human-readable
``hint`` string.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._companion_hint import (
COMPANION_DOWNLOAD_URL,
companion_install_hint,
)
@pytest.fixture
def wp_client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.post = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
c.get = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
return c
# ---------------------------------------------------------------------------
# Helper-level tests
# ---------------------------------------------------------------------------
class TestCompanionInstallHintHelper:
@pytest.mark.unit
def test_download_url_points_at_github_raw(self):
assert COMPANION_DOWNLOAD_URL.startswith("https://github.com/airano-ir/mcphub/raw/main/")
assert COMPANION_DOWNLOAD_URL.endswith("airano-mcp-bridge.zip")
@pytest.mark.unit
def test_returns_required_keys(self):
hint = companion_install_hint(min_version="2.4.0")
assert set(hint.keys()) == {
"install_url",
"install_instructions",
"required_capability",
"companion_min_version",
}
assert hint["companion_min_version"] == "2.4.0"
assert hint["required_capability"] == "manage_options"
@pytest.mark.unit
def test_capability_override(self):
hint = companion_install_hint(min_version="2.3.0", required_capability="edit_posts")
assert hint["required_capability"] == "edit_posts"
assert "edit_posts" in hint["install_instructions"]
@pytest.mark.unit
def test_route_hint_appended_when_provided(self):
hint = companion_install_hint(min_version="2.8.0", route="airano-mcp/v1/foo")
assert hint["route"] == "airano-mcp/v1/foo"
@pytest.mark.unit
def test_install_url_is_stable(self):
hint_a = companion_install_hint(min_version="2.0.0")
hint_b = companion_install_hint(min_version="3.0.0")
# The download URL itself is version-agnostic — it always points
# at the latest zip on main.
assert hint_a["install_url"] == hint_b["install_url"] == COMPANION_DOWNLOAD_URL
# ---------------------------------------------------------------------------
# End-to-end: a handler emits install_hint when the companion is missing.
# ---------------------------------------------------------------------------
class TestHandlerEmitsInstallHint:
@pytest.mark.asyncio
async def test_cache_purge_emits_install_hint_on_failure(self, wp_client):
from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
handler = CachePurgeHandler(wp_client)
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "install_hint" in out
assert out["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL
assert out["install_hint"]["companion_min_version"] == "2.4.0"
assert out["install_hint"]["required_capability"] == "manage_options"
assert out["install_hint"]["route"] == "airano-mcp/v1/cache-purge"
@pytest.mark.asyncio
async def test_regenerate_thumbnails_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.regenerate_thumbnails import (
RegenerateThumbnailsHandler,
)
handler = RegenerateThumbnailsHandler(wp_client)
out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.8.0"
# Writes need upload_files, not manage_options.
assert out["install_hint"]["required_capability"] == "upload_files"
@pytest.mark.asyncio
async def test_bulk_meta_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
handler = BulkMetaHandler(wp_client)
out = json.loads(
await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}])
)
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.2.0"
@pytest.mark.asyncio
async def test_export_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.export import ExportHandler
handler = ExportHandler(wp_client)
out = json.loads(await handler.export_content())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.3.0"
# Export uses edit_posts, not manage_options.
assert out["install_hint"]["required_capability"] == "edit_posts"
@pytest.mark.asyncio
async def test_site_health_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.site_health import SiteHealthHandler
handler = SiteHealthHandler(wp_client)
out = json.loads(await handler.site_health())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.6.0"
@pytest.mark.asyncio
async def test_transient_flush_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
handler = TransientFlushHandler(wp_client)
out = json.loads(await handler.transient_flush())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.5.0"
@pytest.mark.asyncio
async def test_capabilities_probe_embeds_install_hint_when_empty(self, wp_client):
"""``_empty_capabilities_payload`` is the fallback shape every
companion probe returns on failure; the install hint must be in
there too so the UI can link-to-install without a second lookup."""
from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
payload = _empty_capabilities_payload("https://wp.example.com", reason="test")
assert "install_hint" in payload
assert payload["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL

View File

@@ -0,0 +1,263 @@
"""F.18.3 — Tests for wordpress_export_content."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.export import (
EXPORT_DEFAULT_LIMIT,
EXPORT_MAX_LIMIT,
ExportHandler,
_build_query_params,
_normalise_bool,
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 ExportHandler(wp_client)
# ---------------------------------------------------------------------------
# Parameter normalisation.
# ---------------------------------------------------------------------------
class TestNormaliseBool:
def test_none_defaults(self):
assert _normalise_bool(None, True) is True
assert _normalise_bool(None, False) is False
def test_bool_passthrough(self):
assert _normalise_bool(True, False) is True
assert _normalise_bool(False, True) is False
def test_numeric(self):
assert _normalise_bool(1, False) is True
assert _normalise_bool(0, True) is False
def test_strings(self):
assert _normalise_bool("true", False) is True
assert _normalise_bool("False", True) is False
assert _normalise_bool("yes", False) is True
assert _normalise_bool("no", True) is False
assert _normalise_bool("garbage", True) is True # fallback
class TestBuildQueryParams:
def test_defaults(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=None,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["post_type"] == "post"
assert out["status"] == "publish"
assert out["limit"] == EXPORT_DEFAULT_LIMIT
assert out["offset"] == 0
assert out["include_media"] == "true"
assert out["include_terms"] == "true"
assert out["include_meta"] == "true"
assert "since" not in out
def test_limit_clamped_to_max(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=EXPORT_MAX_LIMIT * 10,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["limit"] == EXPORT_MAX_LIMIT
def test_negative_offset_floored_to_zero(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=None,
offset=-5,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["offset"] == 0
def test_since_passed_through(self):
out = _build_query_params(
post_type="post",
status="publish",
since="2026-04-01T00:00:00Z",
limit=10,
offset=0,
include_media=False,
include_terms=True,
include_meta=False,
)
assert out["since"] == "2026-04-01T00:00:00Z"
assert out["include_media"] == "false"
assert out["include_meta"] == "false"
assert out["limit"] == 10
def test_comma_separated_types(self):
out = _build_query_params(
post_type="post,page,product",
status="publish,draft",
since=None,
limit=None,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["post_type"] == "post,page,product"
assert out["status"] == "publish,draft"
# ---------------------------------------------------------------------------
# Handler behaviour.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_happy_path_forwards_to_companion(handler, wp_client, monkeypatch):
companion_response = {
"post_types": ["post"],
"status": ["publish"],
"since": None,
"limit": 100,
"offset": 0,
"returned": 2,
"total_matching": 5,
"has_more": True,
"next_offset": 2,
"include_media": True,
"include_terms": True,
"include_meta": True,
"posts": [
{"id": 1, "title": "First", "post_type": "post"},
{"id": 2, "title": "Second", "post_type": "post"},
],
"media": [],
"exported_at_gmt": "2026-04-15T09:00:00Z",
"plugin_version": "2.3.0",
}
get_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.export_content())
assert out["ok"] is True
assert out["returned"] == 2
assert out["has_more"] is True
assert out["next_offset"] == 2
assert len(out["posts"]) == 2
assert out["plugin_version"] == "2.3.0"
# Verify the endpoint was called with default params.
call_args = get_mock.call_args
assert call_args.args[0] == "airano-mcp/v1/export"
assert call_args.kwargs["use_custom_namespace"] is True
params = call_args.kwargs["params"]
assert params["post_type"] == "post"
assert params["status"] == "publish"
assert params["limit"] == EXPORT_DEFAULT_LIMIT
@pytest.mark.asyncio
async def test_all_params_forwarded(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_get(endpoint, params=None, **kwargs):
captured["endpoint"] = endpoint
captured["params"] = params
return {"posts": [], "media": [], "returned": 0, "total_matching": 0}
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
await handler.export_content(
post_type="post,page",
status="publish,draft",
since="2026-01-01T00:00:00Z",
limit=50,
offset=10,
include_media=False,
include_terms=False,
include_meta=True,
)
assert captured["params"]["post_type"] == "post,page"
assert captured["params"]["status"] == "publish,draft"
assert captured["params"]["since"] == "2026-01-01T00:00:00Z"
assert captured["params"]["limit"] == 50
assert captured["params"]["offset"] == 10
assert captured["params"]["include_media"] == "false"
assert captured["params"]["include_terms"] == "false"
assert captured["params"]["include_meta"] == "true"
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404")))
out = json.loads(await handler.export_content(post_type="post"))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "probe_capabilities" in out["hint"]
assert "params" in out
@pytest.mark.asyncio
async def test_non_dict_companion_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.export_content())
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_limit_clamping_before_network(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_get(endpoint, params=None, **kwargs):
captured["params"] = params
return {"posts": [], "media": []}
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
await handler.export_content(limit=10_000)
assert captured["params"]["limit"] == EXPORT_MAX_LIMIT
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "export_content"
assert specs[0]["scope"] == "read"
props = specs[0]["schema"]["properties"]
for key in (
"post_type",
"status",
"since",
"limit",
"offset",
"include_media",
"include_terms",
"include_meta",
):
assert key in props

View File

@@ -0,0 +1,153 @@
"""F.X.fix #4 — get_post default projection + strict ``fields=`` allow-list.
Regression: ``get_post`` dropped ``featured_media``, ``slug``, and
``featured_media_url`` from its response even when callers asked for
them explicitly via ``fields=``. Fix restores the defaults and makes
the ``fields`` parameter a strict allow-list rather than a subset of a
hard-coded projection.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.posts import PostsHandler
SAMPLE_POST = {
"id": 42,
"slug": "mcp-hub-launch",
"featured_media": 77,
"title": {"rendered": "MCP Hub Launch"},
"content": {"rendered": "<p>Hello world</p>"},
"excerpt": {"rendered": "<p>short</p>"},
"status": "publish",
"date": "2026-04-17T10:00:00",
"modified": "2026-04-17T10:05:00",
"categories": [3],
"tags": [9],
"link": "https://example.com/mcp-hub-launch",
"_embedded": {
"author": [{"name": "Ali"}],
"wp:featuredmedia": [
{
"id": 77,
"source_url": "https://example.com/wp-content/uploads/hero.webp",
}
],
},
}
@pytest.fixture
def handler_with(post_payload):
client = AsyncMock()
client.get = AsyncMock(return_value=post_payload)
return PostsHandler(client), client
class TestDefaultProjection:
@pytest.mark.asyncio
async def test_default_response_includes_featured_media_slug_and_url(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["id"] == 42
assert data["slug"] == "mcp-hub-launch"
assert data["featured_media"] == 77
assert data["featured_media_url"] == "https://example.com/wp-content/uploads/hero.webp"
@pytest.mark.asyncio
async def test_featured_media_url_empty_when_no_embedded_media(self):
post = {**SAMPLE_POST, "featured_media": 0, "_embedded": {"author": [{"name": "x"}]}}
client = AsyncMock()
client.get = AsyncMock(return_value=post)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["featured_media"] == 0
assert data["featured_media_url"] == ""
@pytest.mark.asyncio
async def test_default_still_embeds_metadata(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42)
# ``_embed=true`` must remain in query so featured_media_url can
# be derived from _embedded.wp:featuredmedia.
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
assert params.get("_embed") == "true"
class TestStrictFieldsAllowList:
@pytest.mark.asyncio
async def test_fields_limits_to_requested_plus_id(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,featured_media")
data = json.loads(raw)
# id is always preserved for identification.
assert set(data.keys()) == {"id", "slug", "featured_media"}
@pytest.mark.asyncio
async def test_fields_title_only_excludes_slug_by_design(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="title")
data = json.loads(raw)
# Previous behaviour implicitly included slug even when NOT
# requested — strict allow-list must drop it.
assert "slug" not in data
assert "featured_media" not in data
assert set(data.keys()) == {"id", "title"}
@pytest.mark.asyncio
async def test_fields_featured_media_url_honoured(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="featured_media_url")
data = json.loads(raw)
assert set(data.keys()) == {"id", "featured_media_url"}
assert data["featured_media_url"].startswith("https://")
@pytest.mark.asyncio
async def test_unknown_field_name_is_ignored(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,does_not_exist")
data = json.loads(raw)
# Unknown name silently dropped; requested known names plus id.
assert set(data.keys()) == {"id", "slug"}
@pytest.mark.asyncio
async def test_fields_passes_wp_fields_query_param(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42, fields="featured_media,slug")
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
wp_fields = set((params.get("_fields") or "").split(","))
assert "featured_media" in wp_fields
assert "slug" in wp_fields
assert "id" in wp_fields

View File

@@ -0,0 +1,217 @@
"""Tests for F.5a.3 WooCommerce attachment + featured-image tool."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_attach import (
MediaAttachHandler,
_merge_product_images,
)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
# --- Pure merge logic -------------------------------------------------------
class TestMergeImages:
def test_append_gallery_preserves_existing(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [3, 4], role="gallery", mode="append")
assert [i["id"] for i in out] == [1, 2, 3, 4]
def test_append_gallery_dedupes(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [2, 3], role="gallery", mode="append")
assert [i["id"] for i in out] == [1, 2, 3]
def test_replace_gallery_keeps_main(self):
out = _merge_product_images(
[{"id": 1}, {"id": 2}, {"id": 3}], [9], role="gallery", mode="replace"
)
assert [i["id"] for i in out] == [1, 9]
def test_replace_main_wipes_all(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="replace")
assert [i["id"] for i in out] == [9]
def test_append_main_promotes_new_to_index0(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="append")
assert out[0]["id"] == 9
# Existing images demoted but preserved
assert {i["id"] for i in out} == {9, 1, 2}
# --- Handler integration ----------------------------------------------------
class TestMediaAttachHandler:
@pytest.mark.asyncio
async def test_attach_happy_path(self):
handler = MediaAttachHandler(_client())
with (
patch.object(handler.client, "get", new=AsyncMock()) as mock_get,
patch.object(handler.client, "put", new=AsyncMock()) as mock_put,
):
# media validation GETs + product GET
mock_get.side_effect = [
{"id": 10}, # media/10 exists
{"id": 11}, # media/11 exists
{"id": 50, "images": [{"id": 1}]}, # products/50
]
mock_put.return_value = {
"id": 50,
"images": [{"id": 1, "src": "a"}, {"id": 10, "src": "b"}, {"id": 11, "src": "c"}],
}
out = await handler.attach_media_to_product(50, [10, 11], role="gallery", mode="append")
parsed = json.loads(out)
assert parsed["product_id"] == 50
assert [i["id"] for i in parsed["images"]] == [1, 10, 11]
# Verify PUT body
put_call = mock_put.await_args
assert put_call.kwargs["json_data"]["images"] == [{"id": 1}, {"id": 10}, {"id": 11}]
assert put_call.kwargs["use_woocommerce"] is True
@pytest.mark.asyncio
async def test_attach_rejects_missing_media(self):
handler = MediaAttachHandler(_client())
with patch.object(handler.client, "get", new=AsyncMock(side_effect=Exception("404"))):
out = await handler.attach_media_to_product(50, [999])
assert json.loads(out)["error_code"] == "MEDIA_NOT_FOUND"
@pytest.mark.asyncio
async def test_attach_rejects_bad_role(self):
handler = MediaAttachHandler(_client())
out = await handler.attach_media_to_product(50, [1], role="invalid")
assert json.loads(out)["error_code"] == "BAD_ROLE"
@pytest.mark.asyncio
async def test_attach_rejects_empty_media_ids(self):
handler = MediaAttachHandler(_client())
out = await handler.attach_media_to_product(50, [])
assert json.loads(out)["error_code"] == "MISSING_FIELD"
@pytest.mark.asyncio
async def test_upload_and_attach_base64(self):
handler = MediaAttachHandler(_client())
wp_media = {
"id": 77,
"title": {"rendered": "x"},
"source_url": "https://wp.example.com/x.png",
"mime_type": "image/png",
"media_type": "image",
}
with (
patch(
"plugins.wordpress.handlers.media_attach.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media_attach.wp_update_media_metadata",
new=AsyncMock(),
),
patch.object(
handler.client, "get", new=AsyncMock(return_value={"id": 77, "images": []})
),
patch.object(
handler.client,
"put",
new=AsyncMock(return_value={"id": 50, "images": [{"id": 77, "src": "s"}]}),
),
):
out = await handler.upload_and_attach_to_product(
product_id=50,
source="base64",
filename="x.png",
data=base64.b64encode(_PNG_1x1).decode(),
role="main",
)
parsed = json.loads(out)
assert parsed["media_id"] == 77
assert parsed["product_id"] == 50
@pytest.mark.asyncio
async def test_set_featured_image_post(self):
"""F.X.fix-pass5 — auto-detects "this is not a WC product",
falls through to /wp/v2/posts/{id} for regular posts/pages."""
handler = MediaAttachHandler(_client())
async def _fake_get(path, **kwargs):
if kwargs.get("use_woocommerce"):
raise Exception("404 — not a product")
return {"id": 9} # media exists
with (
patch.object(handler.client, "get", side_effect=_fake_get),
patch.object(
handler.client,
"post",
new=AsyncMock(return_value={"id": 100, "featured_media": 9}),
) as mock_post,
):
out = await handler.set_featured_image(post_id=100, media_id=9)
parsed = json.loads(out)
assert parsed["post_id"] == 100
assert parsed["featured_media"] == 9
assert parsed["context"] == "post"
mock_post.assert_awaited_once_with("posts/100", json_data={"featured_media": 9})
@pytest.mark.asyncio
async def test_set_featured_image_wc_product(self):
"""F.X.fix-pass5 — when post_id is a WC product, route through
WC API (PUT /wc/v3/products/{id} with images[]) instead of
/wp/v2/posts/{id} which 404s for products."""
handler = MediaAttachHandler(_client())
async def _fake_get(path, **kwargs):
if kwargs.get("use_woocommerce") and path == "products/77":
return {"id": 77, "images": [{"id": 5}, {"id": 6}]}
return {"id": 9} # media exists
with (
patch.object(handler.client, "get", side_effect=_fake_get),
patch.object(
handler.client,
"put",
new=AsyncMock(return_value={"id": 77, "images": [{"id": 9}, {"id": 5}, {"id": 6}]}),
) as mock_put,
patch.object(handler.client, "post", new=AsyncMock()) as mock_post,
):
out = await handler.set_featured_image(post_id=77, media_id=9)
parsed = json.loads(out)
assert parsed["post_id"] == 77
assert parsed["featured_media"] == 9
assert parsed["context"] == "product"
# WC PUT was used, NOT /wp/v2/posts.
mock_put.assert_awaited_once()
assert mock_put.await_args.kwargs["use_woocommerce"] is True
assert mock_post.await_count == 0
@pytest.mark.asyncio
async def test_upload_and_attach_missing_data(self):
handler = MediaAttachHandler(_client())
out = await handler.upload_and_attach_to_product(
product_id=1, source="base64", filename="x.png"
)
assert json.loads(out)["error_code"] == "MISSING_FIELD"
@pytest.mark.asyncio
async def test_upload_and_attach_ssrf_blocked(self):
handler = MediaAttachHandler(_client())
out = await handler.upload_and_attach_to_product(
product_id=1,
source="url",
filename="x.png",
url="http://127.0.0.1/x.png",
)
assert json.loads(out)["error_code"] == "SSRF"

View File

@@ -0,0 +1,161 @@
"""F.5a.6.4 — media.upload audit-log emission tests."""
from __future__ import annotations
import base64
import json
from unittest.mock import MagicMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media import MediaHandler
# 1x1 PNG — passes magic-byte sniff and size validation.
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def fake_audit(monkeypatch):
"""Replace the singleton audit logger with a MagicMock."""
fake = MagicMock()
fake.log_tool_call = MagicMock()
monkeypatch.setattr("core.audit_log.get_audit_logger", lambda: fake)
return fake
def _patch_wp_upload(monkeypatch, *, media_id=42, mime="image/png"):
"""Stub wp_raw_upload at its import site inside media.py."""
async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
return {
"id": media_id,
"mime_type": mime,
"media_type": "image",
"source_url": f"https://wp.example.com/wp-content/uploads/{filename}",
"title": {"rendered": filename},
"media_details": {"filesize": len(data)},
}
monkeypatch.setattr("plugins.wordpress.handlers.media.wp_raw_upload", fake_upload)
@pytest.mark.asyncio
async def test_base64_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
_patch_wp_upload(monkeypatch)
handler = MediaHandler(wp_client, user_id="alice")
out = json.loads(
await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="x.png",
skip_optimize=True,
)
)
assert out["id"] == 42
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["tool_name"] == "media.upload"
assert call.kwargs["user_id"] == "alice"
p = call.kwargs["params"]
assert p["source"] == "base64"
assert p["mime"] == "image/png"
assert p["media_id"] == 42
assert p["size_bytes"] == len(_PNG_1x1)
assert p["site"] == "https://wp.example.com"
assert "cost_usd" not in p
@pytest.mark.asyncio
async def test_base64_failure_writes_no_audit_entry(wp_client, fake_audit, monkeypatch):
"""Decode failure short-circuits before upload — no audit entry."""
handler = MediaHandler(wp_client, user_id="alice")
out = json.loads(
await handler.upload_media_from_base64(
data="!!! not valid base64 !!!",
filename="x.png",
)
)
assert out["error_code"] == "BAD_BASE64"
assert fake_audit.log_tool_call.call_count == 0
@pytest.mark.asyncio
async def test_url_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
_patch_wp_upload(monkeypatch)
handler = MediaHandler(wp_client, user_id=None) # admin/env
# Bypass SSRF + network: stub fetch_url_bytes.
async def fake_fetch(url, **kw):
return _PNG_1x1, "image/png", "remote.png"
monkeypatch.setattr("plugins.wordpress.handlers.media.fetch_url_bytes", fake_fetch)
monkeypatch.setattr(
"plugins.wordpress.handlers.media.ssrf_check",
lambda url: type("S", (), {"allowed": True, "reason": None})(),
)
out = json.loads(
await handler.upload_media_from_url(url="https://cdn.example.com/x.png", skip_optimize=True)
)
assert out["id"] == 42
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["params"]["source"] == "url"
assert call.kwargs["user_id"] is None # admin
@pytest.mark.asyncio
async def test_chunked_finish_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
"""Chunked finish should emit a media.upload entry with source='chunked'."""
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
_patch_wp_upload(monkeypatch)
# Stub the chunked store's finalize() to return assembled bytes.
class _FakeSession:
filename = "big.png"
mime = "image/png"
class _FakeStore:
async def finalize(self, sid):
return _FakeSession(), _PNG_1x1
monkeypatch.setattr(
"plugins.wordpress.handlers.media_chunked.wp_raw_upload",
lambda *a, **k: _patch_wp_upload, # not used; media.wp_raw_upload is patched
)
# The chunked handler calls wp_raw_upload imported into media_chunked.py
async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
return {
"id": 99,
"mime_type": "image/png",
"media_type": "image",
"source_url": "https://wp.example.com/wp-content/uploads/big.png",
"title": {"rendered": "big.png"},
"media_details": {"filesize": len(data)},
}
monkeypatch.setattr("plugins.wordpress.handlers.media_chunked.wp_raw_upload", fake_upload)
handler = MediaChunkedHandler(wp_client, user_id="bob", store=_FakeStore())
out = json.loads(
await handler.upload_media_chunked_finish(session_id="sid", skip_optimize=True)
)
assert out["id"] == 99
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["params"]["source"] == "chunked"
assert call.kwargs["params"]["media_id"] == 99
assert call.kwargs["user_id"] == "bob"

View File

@@ -0,0 +1,216 @@
"""F.5a.8.3 — Tests for bulk_delete_media + bulk_reassign_media."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_bulk import (
MediaBulkHandler,
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 MediaBulkHandler(wp_client)
# ---------------------------------------------------------------------------
# Spec
# ---------------------------------------------------------------------------
def test_specs_expose_both_tools():
specs = get_tool_specifications()
names = {s["name"] for s in specs}
assert names == {"bulk_delete_media", "bulk_reassign_media"}
delete_spec = next(s for s in specs if s["name"] == "bulk_delete_media")
assert delete_spec["scope"] == "admin"
assert delete_spec["schema"]["properties"]["media_ids"]["maxItems"] == 100
reassign_spec = next(s for s in specs if s["name"] == "bulk_reassign_media")
assert reassign_spec["scope"] == "write"
assert "target_post" in reassign_spec["schema"]["required"]
# ---------------------------------------------------------------------------
# Input normalization
# ---------------------------------------------------------------------------
def test_normalize_ids_dedupes_and_caps(handler):
huge = [5, 5, 5] + list(range(1, 200))
out = handler._normalize_ids(huge)
assert len(out) == 100
assert len(out) == len(set(out))
def test_normalize_ids_drops_non_positive_and_garbage(handler):
out = handler._normalize_ids([1, 0, -5, "abc", None, 2, 3])
assert out == [1, 2, 3]
# ---------------------------------------------------------------------------
# bulk_delete_media
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_rejects_empty_list(handler, wp_client, monkeypatch):
delete_mock = AsyncMock()
monkeypatch.setattr(wp_client, "delete", delete_mock)
out = json.loads(await handler.bulk_delete_media([]))
assert out["ok"] is False
assert out["error"] == "invalid_request"
delete_mock.assert_not_called()
@pytest.mark.asyncio
async def test_delete_happy_path(handler, wp_client, monkeypatch):
calls: list[tuple[str, dict]] = []
async def _delete(path, params=None):
calls.append((path, params or {}))
return {"deleted": True}
monkeypatch.setattr(wp_client, "delete", _delete)
out = json.loads(await handler.bulk_delete_media([10, 20, 30], force=True))
assert out["ok"] is True
assert out["total"] == 3
assert out["processed"] == 3
assert out["errors"] == []
assert out["force"] is True
assert len(calls) == 3
# force flag propagated as string
assert all(c[1].get("force") == "true" for c in calls)
paths = sorted(c[0] for c in calls)
assert paths == ["media/10", "media/20", "media/30"]
@pytest.mark.asyncio
async def test_delete_partial_failure(handler, wp_client, monkeypatch):
async def _delete(path, params=None):
if path == "media/20":
raise RuntimeError("HTTP 404 not found")
return {"deleted": True}
monkeypatch.setattr(wp_client, "delete", _delete)
out = json.loads(await handler.bulk_delete_media([10, 20, 30]))
assert out["ok"] is False
assert out["total"] == 3
assert out["processed"] == 2
assert len(out["errors"]) == 1
assert out["errors"][0]["id"] == 20
assert "404" in out["errors"][0]["error"]
@pytest.mark.asyncio
async def test_delete_force_false_moves_to_trash(handler, wp_client, monkeypatch):
captured: list[dict] = []
async def _delete(path, params=None):
captured.append(params or {})
return {}
monkeypatch.setattr(wp_client, "delete", _delete)
await handler.bulk_delete_media([1], force=False)
assert captured[0]["force"] == "false"
# ---------------------------------------------------------------------------
# bulk_reassign_media
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reassign_rejects_empty_ids(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([], target_post=5))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_rejects_negative_target(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([1], target_post=-1))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_rejects_non_integer_target(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([1], target_post="abc")) # type: ignore[arg-type]
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_happy_path(handler, wp_client, monkeypatch):
calls: list[tuple[str, dict]] = []
async def _post(path, json_data=None, **kw):
calls.append((path, json_data or {}))
return {"id": 1, "post": 42}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([1, 2, 3], target_post=42))
assert out["ok"] is True
assert out["processed"] == 3
assert out["total"] == 3
assert out["target_post"] == 42
assert len(calls) == 3
assert all(c[1] == {"post": 42} for c in calls)
@pytest.mark.asyncio
async def test_reassign_detach_target_zero(handler, wp_client, monkeypatch):
calls: list[dict] = []
async def _post(path, json_data=None, **kw):
calls.append(json_data or {})
return {}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([1, 2], target_post=0))
assert out["ok"] is True
assert all(c == {"post": 0} for c in calls)
@pytest.mark.asyncio
async def test_reassign_partial_failure_surfaces_errors(handler, wp_client, monkeypatch):
async def _post(path, json_data=None, **kw):
if path == "media/7":
raise RuntimeError("permission denied")
return {}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([6, 7, 8], target_post=100))
assert out["ok"] is False
assert out["processed"] == 2
assert len(out["errors"]) == 1
assert out["errors"][0]["id"] == 7

View File

@@ -0,0 +1,264 @@
"""Tests for F.5a.5 chunked media upload (session store + WP handler)."""
from __future__ import annotations
import asyncio
import base64 as _b64
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from core.database import Database
from core.upload_sessions import (
CleanupTask,
UploadSessionError,
UploadSessionStore,
make_session_id,
set_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
_PNG_1x1 = _b64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client() -> WordPressClient:
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
async def db(tmp_path: Path):
d = Database(str(tmp_path / "test.db"))
await d.initialize()
try:
yield d
finally:
await d.close()
@pytest.fixture
async def store(tmp_path: Path, db: Database) -> UploadSessionStore:
s = UploadSessionStore(db=db, spill_dir=tmp_path / "spill")
set_upload_session_store(s)
try:
yield s
finally:
set_upload_session_store(None)
@pytest.fixture
def handler(store: UploadSessionStore) -> MediaChunkedHandler:
return MediaChunkedHandler(_client(), user_id="alice", store=store)
def _wp_response_mock(wp_response: dict):
mock_resp = AsyncMock()
mock_resp.status = 201
mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
mock_resp.json = AsyncMock(return_value=wp_response)
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
cls_mock = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
return cls_mock
class TestSessionStoreBasics:
@pytest.mark.asyncio
async def test_deterministic_session_id(self):
a = make_session_id("u1", "a.bin", 100, None, None)
b = make_session_id("u1", "a.bin", 100, None, None)
c = make_session_id("u2", "a.bin", 100, None, None)
assert a == b
assert a != c
@pytest.mark.asyncio
async def test_start_creates_row_and_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert sess.status == "open"
assert sess.spill_path.exists()
assert sess.received_bytes == 0
@pytest.mark.asyncio
async def test_start_is_idempotent_for_same_tuple(self, store: UploadSessionStore):
s1 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
s2 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert s1.id == s2.id
@pytest.mark.asyncio
async def test_hard_cap_rejects(self, store: UploadSessionStore):
store.max_session_bytes = 1024
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="alice", filename="big.bin", total_bytes=2048)
assert e.value.code == "SESSION_TOO_LARGE"
class TestAppendAndFinalize:
@pytest.mark.asyncio
async def test_round_trip_two_chunks(self, store: UploadSessionStore):
payload = b"abcdefghij" # 10 bytes
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, payload[:6])
await store.append_chunk(sess.id, 1, payload[6:])
sess2, data = await store.finalize(sess.id)
assert data == payload
assert not sess2.spill_path.exists()
@pytest.mark.asyncio
async def test_chunk_out_of_order(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, b"abcde")
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 2, b"fgh")
assert e.value.code == "CHUNK_ORDER"
@pytest.mark.asyncio
async def test_chunk_overflow(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=5)
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 0, b"toolongdata")
assert e.value.code == "CHUNK_OVERFLOW"
@pytest.mark.asyncio
async def test_finalize_sha_mismatch_keeps_session(self, store: UploadSessionStore):
payload = b"hello world"
wrong = hashlib.sha256(b"other").hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=wrong,
)
await store.append_chunk(sess.id, 0, payload)
with pytest.raises(UploadSessionError) as e:
await store.finalize(sess.id)
assert e.value.code == "CHECKSUM_MISMATCH"
# Session still present — spill file still on disk
again = await store.get(sess.id)
assert again is not None
assert again.spill_path.exists()
@pytest.mark.asyncio
async def test_finalize_sha_match_passes(self, store: UploadSessionStore):
payload = b"hello world"
digest = hashlib.sha256(payload).hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=digest,
)
await store.append_chunk(sess.id, 0, payload)
_, data = await store.finalize(sess.id)
assert data == payload
@pytest.mark.asyncio
async def test_abort_removes_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
assert spill.exists()
assert await store.abort(sess.id) is True
assert not spill.exists()
assert await store.get(sess.id) is None
class TestQuotaAndCleanup:
@pytest.mark.asyncio
async def test_quota_rejects_11th_session(self, store: UploadSessionStore):
store.max_concurrent_per_user = 10
ids = set()
for i in range(10):
s = await store.start(user_id="bob", filename=f"f{i}.bin", total_bytes=10)
ids.add(s.id)
assert len(ids) == 10
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="bob", filename="f10.bin", total_bytes=10)
assert e.value.code == "QUOTA_EXCEEDED"
@pytest.mark.asyncio
async def test_cleanup_reaps_expired(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
# Force-expire via DB update
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
reaped = await store.cleanup_expired()
assert reaped == 1
assert not spill.exists()
assert await store.get(sess.id) is None
@pytest.mark.asyncio
async def test_cleanup_task_runs(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
task = CleanupTask(store=store, interval_seconds=60)
await task.start()
# Give the loop a tick to run its first iteration.
for _ in range(20):
if await store.get(sess.id) is None:
break
await asyncio.sleep(0.05)
await task.stop()
assert await store.get(sess.id) is None
class TestHandlerIntegration:
@pytest.mark.asyncio
async def test_handler_round_trip_calls_wp_once(self, handler: MediaChunkedHandler):
payload = _PNG_1x1
# Start
start_out = json.loads(
await handler.upload_media_chunked_start(filename="photo.png", total_bytes=len(payload))
)
sid = start_out["session_id"]
half = len(payload) // 2
c0 = _b64.b64encode(payload[:half]).decode()
c1 = _b64.b64encode(payload[half:]).decode()
await handler.upload_media_chunked_chunk(sid, 0, c0)
await handler.upload_media_chunked_chunk(sid, 1, c1)
wp_response = {
"id": 999,
"title": {"rendered": "photo"},
"source_url": "https://wp.example.com/photo.png",
"mime_type": "image/png",
"media_type": "image",
}
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = _wp_response_mock(wp_response)
out = json.loads(await handler.upload_media_chunked_finish(session_id=sid))
# No metadata/attach args → only the raw-upload ClientSession() call
assert mock_cls.call_count == 1
assert out["id"] == 999
assert out["source"] == "chunked"
@pytest.mark.asyncio
async def test_handler_abort(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
out = json.loads(await handler.upload_media_chunked_abort(sid))
assert out["aborted"] is True
@pytest.mark.asyncio
async def test_handler_chunk_order_returns_error_json(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
chunk_b64 = _b64.b64encode(b"abc").decode()
out = json.loads(await handler.upload_media_chunked_chunk(sid, 5, chunk_b64))
assert out["error_code"] == "CHUNK_ORDER"

View File

@@ -0,0 +1,129 @@
"""F.5a.8.4 — resumable chunked upload status tool.
Verifies the new ``upload_media_chunked_status`` tool returns the
expected ``received_bytes`` / ``next_chunk`` for active sessions and
yields a ``NOT_FOUND`` error for unknown / finalised / aborted ones.
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from core.database import Database
from core.upload_sessions import (
UploadSessionStore,
set_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_chunked import (
MediaChunkedHandler,
get_tool_specifications,
)
@pytest.fixture
async def _db(tmp_path: Path):
db = Database(str(tmp_path / "status.db"))
await db.initialize()
yield db
await db.close()
@pytest.fixture
async def _store(tmp_path: Path, _db: Database):
s = UploadSessionStore(db=_db, spill_dir=tmp_path / "spill")
set_upload_session_store(s)
try:
yield s
finally:
set_upload_session_store(None)
@pytest.fixture
def _handler(_store: UploadSessionStore) -> MediaChunkedHandler:
client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
return MediaChunkedHandler(client, user_id="alice", store=_store)
class TestStatusToolSpec:
@pytest.mark.unit
def test_status_tool_is_registered(self):
specs = get_tool_specifications()
names = [s["name"] for s in specs]
assert "upload_media_chunked_status" in names
@pytest.mark.unit
def test_status_tool_is_read_scope(self):
specs = get_tool_specifications()
status = next(s for s in specs if s["name"] == "upload_media_chunked_status")
assert status["scope"] == "read"
assert status["schema"]["required"] == ["session_id"]
class TestStatusLookup:
@pytest.mark.asyncio
async def test_unknown_session_returns_no_session(self, _handler):
out = json.loads(await _handler.upload_media_chunked_status(session_id="does-not-exist"))
# NO_SESSION is the documented taxonomy code used by the rest of
# the chunked-session surface for exactly this case.
assert out["error_code"] == "NO_SESSION"
assert out["session_id"] == "does-not-exist"
@pytest.mark.asyncio
async def test_fresh_session_reports_zero_received(self, _handler):
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=1_000_000)
)
sid = started["session_id"]
out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
assert out["session_id"] == sid
assert out["received_bytes"] == 0
assert out["next_chunk"] == 0
assert out["total_bytes"] == 1_000_000
@pytest.mark.asyncio
async def test_partial_session_reports_progress(self, _handler):
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=100)
)
sid = started["session_id"]
chunk = base64.b64encode(b"A" * 40).decode()
await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=chunk)
out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
assert out["received_bytes"] == 40
assert out["next_chunk"] == 1
# Still open for more chunks.
assert out["status"] == "open"
@pytest.mark.asyncio
async def test_resume_flow_via_status_then_chunk(self, _handler):
"""End-to-end resume: start → chunk → (simulate disconnect) →
status → chunk from next_chunk → progress should advance."""
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.bin", total_bytes=60)
)
sid = started["session_id"]
first = base64.b64encode(b"x" * 20).decode()
await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=first)
# Simulate client coming back from a disconnect.
status = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
resume_idx = status["next_chunk"]
assert resume_idx == 1
second = base64.b64encode(b"y" * 25).decode()
progressed = json.loads(
await _handler.upload_media_chunked_chunk(
session_id=sid, index=resume_idx, data_b64=second
)
)
assert progressed["received_bytes"] == 45
assert progressed["next_chunk"] == 2

View File

@@ -0,0 +1,235 @@
"""F.5a.7 — Companion upload route selection tests.
Validates `_media_core.wp_raw_upload` behaviour when the
`airano-mcp/v1/upload-limits` probe advertises the companion plugin:
- helper-present + size > advertised limit → POST to companion upload-chunk
- helper-present + size < advertised limit → POST to standard /wp/v2/media
- helper-absent → always POST to /wp/v2/media (no probe = no companion)
- companion 4xx → fall back to /wp/v2/media (never regress default path)
"""
from __future__ import annotations
import base64
import json
from contextlib import asynccontextmanager
from unittest.mock import patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import _media_core as media_core
from plugins.wordpress.handlers._media_core import wp_raw_upload
from plugins.wordpress.handlers.media_probe import _ProbeCache
# A minimal valid 1x1 PNG.
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client() -> WordPressClient:
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
class _FakeResponse:
def __init__(self, status: int, payload: dict | None, *, text: str | None = None) -> None:
self.status = status
self._payload = payload
self._text = text if text is not None else json.dumps(payload or {})
async def text(self) -> str:
return self._text
async def json(self, content_type=None): # noqa: D401, ANN001
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, *exc): # noqa: ANN002
return False
class _RecordingSession:
"""aiohttp.ClientSession stand-in that records every POST target."""
def __init__(self, responses_by_url: dict[str, _FakeResponse]) -> None:
self._responses_by_url = responses_by_url
self.posts: list[str] = []
def post(self, url, data=None, headers=None): # noqa: ANN001
self.posts.append(url)
resp = self._responses_by_url.get(url)
if resp is None:
raise AssertionError(f"unexpected POST to {url!r}")
return resp
async def __aenter__(self):
return self
async def __aexit__(self, *exc): # noqa: ANN002
return False
@asynccontextmanager
async def _patched_session(session: _RecordingSession):
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as cls:
cls.return_value = session
yield cls
# WP attachment JSON shape (identical across /wp/v2/media and companion route).
_WP_MEDIA_JSON = {
"id": 101,
"title": {"rendered": "photo"},
"mime_type": "image/png",
"media_type": "image",
"source_url": "https://wp.example.com/wp-content/uploads/photo.png",
}
def _rest_url() -> str:
return "https://wp.example.com/wp-json/wp/v2/media"
def _companion_url() -> str:
return "https://wp.example.com/wp-json/airano-mcp/v1/upload-chunk"
async def _seed_probe_cache(
client: WordPressClient,
*,
companion: bool,
upload_max_filesize_bytes: int | None = None,
) -> _ProbeCache:
"""Inject a cached probe result so wp_raw_upload sees it synchronously."""
cache = _ProbeCache(ttl=3600)
limits = {
"upload_max_filesize": None,
"post_max_size": None,
"memory_limit": None,
"max_input_time": None,
"wp_max_upload_size": None,
}
if upload_max_filesize_bytes is not None:
limits["upload_max_filesize"] = upload_max_filesize_bytes # already bytes
payload = {
"site_url": client.site_url,
"source": "companion" if companion else "rest_index",
"companion_available": companion,
"limits": limits,
"limits_bytes": {
"upload_max_filesize": upload_max_filesize_bytes,
"post_max_size": None,
"wp_max_upload_size": None,
"effective_ceiling": upload_max_filesize_bytes,
},
}
await cache.set((client.site_url, client.username), payload)
return cache
@pytest.mark.asyncio
async def test_companion_preferred_when_size_exceeds_limit(monkeypatch):
client = _client()
cache = await _seed_probe_cache(
client, companion=True, upload_max_filesize_bytes=4 # 4 bytes → our 1x1 PNG is larger
)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_companion_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_companion_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "companion"
@pytest.mark.asyncio
async def test_rest_used_when_size_under_limit(monkeypatch):
client = _client()
# Advertise a huge limit so the PNG slips under it.
cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=10 * 1024**2)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "rest"
@pytest.mark.asyncio
async def test_rest_used_when_companion_absent(monkeypatch):
client = _client()
cache = await _seed_probe_cache(client, companion=False)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
@pytest.mark.asyncio
async def test_rest_used_when_cache_empty(monkeypatch):
"""Cold cache = no companion hint available = take the standard route."""
client = _client()
empty_cache = _ProbeCache(ttl=3600)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", empty_cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
@pytest.mark.asyncio
async def test_companion_4xx_falls_back_to_rest(monkeypatch):
client = _client()
cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=4)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{
_companion_url(): _FakeResponse(500, None, text='{"code":"sideload_failed"}'),
_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON),
}
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
# Companion tried first, then the REST fallback.
assert session.posts == [_companion_url(), _rest_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "rest"
@pytest.mark.asyncio
async def test_should_use_companion_is_defensive(monkeypatch):
"""If the probe lookup raises, we must not crash the upload path."""
client = _client()
async def boom(_client):
raise RuntimeError("probe blew up")
monkeypatch.setattr(
"plugins.wordpress.handlers.media_probe.get_cached_limits",
boom,
)
assert await media_core._should_use_companion(client, 10 * 1024**2) is False

View File

@@ -0,0 +1,120 @@
"""F.5a.6.2 — Error-code taxonomy stability test.
Walks every raise-site in the media stack, extracts the literal error
code, and asserts each one is in the documented
:data:`core.media_error_codes.MEDIA_ERROR_CODES` set (or matches the
dynamic ``WP_<status>`` pattern).
If this test fails, you're either:
- Introducing a new code — add it to ``MEDIA_ERROR_CODES`` *and*
``docs/media-error-codes.md``, or
- Removing a documented code — make sure it's actually unused and drop
it from both the set and the docs.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from core.media_error_codes import MEDIA_ERROR_CODES, is_valid_code
REPO_ROOT = Path(__file__).resolve().parents[3]
# Files to scan for error-code literals.
SCANNED_FILES = [
"plugins/wordpress/handlers/_media_security.py",
"plugins/wordpress/handlers/_media_core.py",
"plugins/wordpress/handlers/media.py",
"plugins/wordpress/handlers/media_attach.py",
"plugins/wordpress/handlers/media_chunked.py",
"plugins/wordpress/handlers/ai_media.py",
"core/upload_sessions.py",
"core/tool_rate_limiter.py",
"plugins/ai_image/providers/base.py",
"plugins/ai_image/providers/openai.py",
"plugins/ai_image/providers/stability.py",
"plugins/ai_image/providers/replicate.py",
"plugins/ai_image/registry.py",
]
# Literal raise-site patterns. Multiline to span wrapped calls.
_RAISE_RE = re.compile(
r'raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*"([A-Z][A-Z0-9_]+)"',
re.MULTILINE,
)
# Formatted (f-string) codes like f"WP_{response.status}" — we assert the
# pattern prefix is a valid dynamic code.
_FSTRING_RE = re.compile(
r"raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*" r'f"([A-Z_]+)\{',
re.MULTILINE,
)
# Hard-coded error_code dict literals like {"error_code": "INTERNAL", ...}
_ERROR_CODE_RE = re.compile(r'"error_code"\s*:\s*"([A-Z][A-Z0-9_]+)"')
def _collect_codes() -> tuple[set[str], set[str]]:
literal_codes: set[str] = set()
fstring_prefixes: set[str] = set()
for rel in SCANNED_FILES:
path = REPO_ROOT / rel
text = path.read_text(encoding="utf-8")
literal_codes.update(_RAISE_RE.findall(text))
literal_codes.update(_ERROR_CODE_RE.findall(text))
fstring_prefixes.update(_FSTRING_RE.findall(text))
return literal_codes, fstring_prefixes
def test_every_raise_site_uses_documented_code():
literal_codes, fstring_prefixes = _collect_codes()
assert literal_codes, "taxonomy test found no raise sites — scanner broken?"
undocumented = {c for c in literal_codes if not is_valid_code(c)}
assert not undocumented, (
f"Undocumented error codes in raise sites: {sorted(undocumented)}. "
f"Add them to core.media_error_codes.MEDIA_ERROR_CODES and "
f"docs/media-error-codes.md, or remove the raise site."
)
# f-string codes must have a documented prefix (e.g. "WP_" for WP_{status}
# or "COMPANION_" for COMPANION_{status} from the F.5a.7 upload-chunk route).
allowed_prefixes = {"WP_", "COMPANION_"}
for prefix in fstring_prefixes:
assert prefix in allowed_prefixes, (
f"Unknown dynamic error-code prefix '{prefix}'. "
f"Allowed prefixes: {sorted(allowed_prefixes)} (see is_valid_code)."
)
def test_documented_codes_are_all_used():
"""Stability guard: every documented code has at least one raise/use site.
Prevents the documented set from drifting with dead entries. A small
allow-list exists for codes that are reserved for upcoming work.
"""
literal_codes, _ = _collect_codes()
reserved: set[str] = set() # None currently reserved.
unused = MEDIA_ERROR_CODES - literal_codes - reserved
# WP_<status> is dynamic; its fixed siblings (WP_413, WP_AUTH, WP_BAD_RESPONSE)
# must be explicitly raised.
assert not unused, (
f"Documented codes with no raise site: {sorted(unused)}. "
"Either wire them up, move to the 'reserved' set, or remove them."
)
@pytest.mark.parametrize("code", sorted(MEDIA_ERROR_CODES))
def test_is_valid_code_accepts_documented(code):
assert is_valid_code(code)
@pytest.mark.parametrize("code", ["WP_400", "WP_500", "WP_418"])
def test_is_valid_code_accepts_dynamic_wp_status(code):
assert is_valid_code(code)
@pytest.mark.parametrize("code", ["UNKNOWN", "wp_413", "WP_", "WP_X", "WP_41"])
def test_is_valid_code_rejects_others(code):
assert not is_valid_code(code)

View File

@@ -0,0 +1,180 @@
"""Tests for F.5a.2 image optimization pipeline."""
from __future__ import annotations
import io
import pytest
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
from plugins.wordpress.handlers._media_optimize import optimize # noqa: E402
def _make_png(w: int, h: int, mode: str = "RGB") -> bytes:
import os as _os
img = Image.new(mode, (w, h))
# Fill with noise so solid-color PNG compression doesn't undercut JPEG
img.frombytes(_os.urandom(w * h * len(mode)))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _make_jpeg(w: int, h: int, quality: int = 95) -> bytes:
img = Image.new("RGB", (w, h), color=(200, 100, 50))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def test_large_jpeg_is_resized():
data = _make_jpeg(4000, 3000)
new_data, new_mime = optimize(data, "image/jpeg", max_edge=1024)
assert new_mime == "image/jpeg"
img = Image.open(io.BytesIO(new_data))
assert max(img.size) == 1024
assert len(new_data) < len(data)
def test_opaque_png_converted_to_jpeg():
data = _make_png(800, 600, mode="RGB")
new_data, new_mime = optimize(data, "image/png", max_edge=2560)
assert new_mime == "image/jpeg"
assert len(new_data) < len(data)
def test_transparent_png_stays_png():
data = _make_png(800, 600, mode="RGBA")
new_data, new_mime = optimize(data, "image/png", max_edge=2560)
assert new_mime == "image/png"
def test_small_image_returns_original_bytes():
# Tiny image that cannot be made smaller
data = _make_jpeg(8, 8, quality=50)
new_data, new_mime = optimize(data, "image/jpeg", max_edge=2560)
# Either unchanged or still JPEG
assert new_mime in (None, "image/jpeg")
assert len(new_data) <= len(data) + 1024
def test_pdf_passthrough():
data = b"%PDF-1.4\n%garbage\n%%EOF"
new_data, new_mime = optimize(data, "application/pdf")
assert new_data == data
assert new_mime == "application/pdf"
def test_video_passthrough():
data = b"\x00" * 32
new_data, new_mime = optimize(data, "video/mp4")
assert new_data == data
assert new_mime == "video/mp4"
def test_unknown_mime_tries_anyway_but_fails_gracefully():
# Garbage bytes with no PIL-recognizable format → passthrough
data = b"not an image at all"
new_data, new_mime = optimize(data, None)
assert new_data == data
# ---------------------------------------------------------------------------
# F.5a.8.1 — convert_to override (WebP / AVIF)
# ---------------------------------------------------------------------------
def _peek_format(data: bytes) -> str:
return (Image.open(io.BytesIO(data)).format or "").upper()
def test_convert_png_to_webp():
data = _make_png(64, 64, mode="RGB")
new_data, new_mime = optimize(data, "image/png", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_jpeg_to_webp():
data = _make_jpeg(64, 64)
new_data, new_mime = optimize(data, "image/jpeg", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_preserves_alpha_on_webp():
data = _make_png(64, 64, mode="RGBA")
new_data, new_mime = optimize(data, "image/png", convert_to="webp")
assert new_mime == "image/webp"
img = Image.open(io.BytesIO(new_data))
# Either RGBA or palette-with-alpha is acceptable
assert "A" in img.mode or "A" in "".join(img.getbands())
def test_convert_avif_falls_back_to_webp_when_unsupported():
from plugins.wordpress.handlers._media_optimize import _avif_supported
data = _make_png(32, 32, mode="RGB")
new_data, new_mime = optimize(data, "image/png", convert_to="avif")
if _avif_supported():
assert new_mime == "image/avif"
assert _peek_format(new_data) == "AVIF"
else:
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_to_env_default(monkeypatch):
import plugins.wordpress.handlers._media_optimize as opt_mod
monkeypatch.setattr(opt_mod, "_DEFAULT_CONVERT_TO", "webp")
data = _make_png(48, 48, mode="RGB")
new_data, new_mime = optimize(data, "image/png")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_wins_over_size_guard():
# 1×1 images re-encode to formats that carry larger container overhead.
# The explicit convert_to request MUST still win — the size guard only
# applies to the implicit recompression path.
tiny = _make_png(1, 1, mode="RGB")
new_data, new_mime = optimize(tiny, "image/png", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_unknown_value_falls_through_to_heuristic():
# "jpg" isn't in the convert_to map → optimizer uses source-format heuristic.
data = _make_jpeg(64, 64)
_, new_mime = optimize(data, "image/jpeg", convert_to="jpg")
assert new_mime == "image/jpeg"
def test_convert_to_does_not_touch_pdf():
data = b"%PDF-1.4\n%fake\n"
new_data, new_mime = optimize(data, "application/pdf", convert_to="webp")
assert new_data == data
assert new_mime == "application/pdf"
def test_maybe_optimize_forwards_convert_to():
from plugins.wordpress.handlers.media import _maybe_optimize
out, mime = _maybe_optimize(
_make_png(48, 48, mode="RGB"), "image/png", skip=False, convert_to="webp"
)
assert mime == "image/webp"
assert _peek_format(out) == "WEBP"
def test_maybe_optimize_skip_overrides_convert_to():
from plugins.wordpress.handlers.media import _maybe_optimize
src = _make_png(48, 48, mode="RGB")
out, mime = _maybe_optimize(src, "image/png", skip=True, convert_to="webp")
assert out == src
assert mime == "image/png"

View File

@@ -0,0 +1,96 @@
"""F.X.fix #6 — get_media / list_media expose post_parent.
Regression: neither endpoint returned the parent post, so MCP clients
had no way to verify "is media X attached to post Y" without a second
round trip. Fix adds ``post_parent`` (int, 0 when unattached).
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.media import MediaHandler
def _make_media(media_id: int, *, post: int = 0) -> dict:
return {
"id": media_id,
"title": {"rendered": f"media-{media_id}"},
"mime_type": "image/webp",
"media_type": "image",
"source_url": f"https://example.com/uploads/{media_id}.webp",
"date": "2026-04-17T10:00:00",
"alt_text": "",
"link": f"https://example.com/?attachment_id={media_id}",
"post": post,
"caption": {"rendered": ""},
"description": {"rendered": ""},
"media_details": {},
}
@pytest.fixture
def handler():
client = AsyncMock()
return MediaHandler(client), client
class TestGetMediaPostParent:
@pytest.mark.asyncio
async def test_get_media_returns_attached_parent(self, handler):
h, client = handler
client.get = AsyncMock(return_value=_make_media(77, post=42))
raw = await h.get_media(media_id=77)
data = json.loads(raw)
assert data["post_parent"] == 42
@pytest.mark.asyncio
async def test_get_media_returns_zero_when_unattached(self, handler):
h, client = handler
client.get = AsyncMock(return_value=_make_media(78, post=0))
raw = await h.get_media(media_id=78)
data = json.loads(raw)
# WP's own REST returns 0 for unattached; we preserve that.
assert data["post_parent"] == 0
@pytest.mark.asyncio
async def test_get_media_handles_missing_post_key(self, handler):
h, client = handler
payload = _make_media(79, post=0)
del payload["post"] # older WP installs or partial fields=
client.get = AsyncMock(return_value=payload)
raw = await h.get_media(media_id=79)
data = json.loads(raw)
assert data["post_parent"] == 0
class TestListMediaPostParent:
@pytest.mark.asyncio
async def test_list_media_shape_includes_post_parent(self, handler):
h, client = handler
client.get = AsyncMock(
return_value=[
_make_media(1, post=42),
_make_media(2, post=0),
_make_media(3, post=99),
]
)
raw = await h.list_media()
data = json.loads(raw)
parents = [m["post_parent"] for m in data["media"]]
assert parents == [42, 0, 99]
@pytest.mark.asyncio
async def test_list_media_unattached_reports_zero_not_null(self, handler):
h, client = handler
item = _make_media(5, post=0)
item["post"] = None # Some older WP payloads send null
client.get = AsyncMock(return_value=[item])
raw = await h.list_media()
data = json.loads(raw)
# MCP consumers expect an int they can compare against; None
# makes type-strict callers crash. Normalise to 0.
assert data["media"][0]["post_parent"] == 0

View File

@@ -0,0 +1,177 @@
"""F.5a.6.3 — Tests for wordpress_probe_upload_limits."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_probe import ProbeHandler, _ProbeCache
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def cache():
return _ProbeCache(ttl=24 * 3600)
@pytest.mark.asyncio
async def test_companion_endpoint_parses_and_caches(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
payload = {
"upload_max_filesize": "64M",
"post_max_size": "128M",
"memory_limit": "256M",
"max_input_time": "300",
"wp_max_upload_size": 67108864,
}
get_mock = AsyncMock(return_value=payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out_json = await handler.probe_upload_limits()
out = json.loads(out_json)
assert out["source"] == "companion"
assert out["limits"] == payload
assert out["cached"] is False
# F.5a.7: companion payload exposes byte-parsed limits + companion flag.
assert out["companion_available"] is True
assert out["limits_bytes"]["upload_max_filesize"] == 64 * 1024**2
assert out["limits_bytes"]["post_max_size"] == 128 * 1024**2
assert out["limits_bytes"]["wp_max_upload_size"] == 67108864
# The effective ceiling is the smallest of the byte-valued keys.
assert out["limits_bytes"]["effective_ceiling"] == 64 * 1024**2
# First call hit the network exactly once.
assert get_mock.call_count == 1
get_mock.assert_called_with("airano-mcp/v1/upload-limits", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
payload = {"upload_max_filesize": "64M"}
get_mock = AsyncMock(return_value=payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_upload_limits()
out2 = json.loads(await handler.probe_upload_limits())
assert out2["cached"] is True
# Network only hit once across two probe calls.
assert get_mock.call_count == 1
@pytest.mark.asyncio
async def test_cache_expires_after_ttl(wp_client, monkeypatch):
cache = _ProbeCache(ttl=0.0) # immediate expiry
handler = ProbeHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value={"upload_max_filesize": "64M"})
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_upload_limits()
await handler.probe_upload_limits()
# ttl=0 → both calls re-fetch.
assert get_mock.call_count == 2
@pytest.mark.asyncio
async def test_companion_failure_falls_back_to_rest_index(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
async def fake_get(endpoint, **kwargs):
if endpoint == "airano-mcp/v1/upload-limits":
raise RuntimeError("404")
if endpoint == "":
return {"wp_max_upload_size": 4 * 1024 * 1024}
raise AssertionError(f"unexpected endpoint: {endpoint}")
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
out = json.loads(await handler.probe_upload_limits())
assert out["source"] == "rest_index"
assert out["limits"]["wp_max_upload_size"] == 4 * 1024 * 1024
@pytest.mark.asyncio
async def test_total_failure_returns_empty_limits(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("totally offline")))
out = json.loads(await handler.probe_upload_limits())
assert out["source"] == "unknown"
assert all(v is None for v in out["limits"].values())
def test_tool_spec_is_read_scope():
from plugins.wordpress.handlers.media_probe import get_tool_specifications
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "probe_upload_limits"
assert specs[0]["scope"] == "read"
# F.5a.7: byte parser helpers ------------------------------------------------
class TestParsePhpSize:
@staticmethod
def _parse(v):
from plugins.wordpress.handlers.media_probe import parse_php_size
return parse_php_size(v)
def test_none(self):
assert self._parse(None) is None
def test_empty_string(self):
assert self._parse("") is None
def test_bare_integer_string(self):
assert self._parse("1024") == 1024
def test_megabytes_upper(self):
assert self._parse("64M") == 64 * 1024**2
def test_megabytes_lower(self):
assert self._parse("8m") == 8 * 1024**2
def test_gigabytes(self):
assert self._parse("2G") == 2 * 1024**3
def test_numeric_int(self):
assert self._parse(4096) == 4096
def test_unlimited_minus_one(self):
# PHP "-1" means no limit; treat as unknown.
assert self._parse("-1") is None
assert self._parse(-1) is None
def test_invalid(self):
assert self._parse("garbage") is None
class TestEffectiveCeiling:
def test_returns_min_of_populated(self):
from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
limits = {
"upload_max_filesize": "8M",
"post_max_size": "64M",
"memory_limit": "256M",
"max_input_time": "60",
"wp_max_upload_size": 8388608,
}
# min(8MB, 64MB, 8388608 bytes) = 8388608 bytes
assert effective_upload_ceiling(limits) == 8 * 1024**2
def test_none_when_empty(self):
from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
assert effective_upload_ceiling({}) is None
assert effective_upload_ceiling(None) is None

View File

@@ -0,0 +1,362 @@
"""Tests for F.5a.1 media upload primitives and handler."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import (
wp_raw_upload,
wp_set_featured_media,
wp_update_media_metadata,
)
from plugins.wordpress.handlers._media_security import (
ALLOWED_MIMES,
UploadError,
content_disposition,
safe_filename,
sniff_mime,
ssrf_check,
validate_mime,
validate_size,
)
from plugins.wordpress.handlers.media import MediaHandler
# A minimal valid PNG (1x1, transparent)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
# --- Security primitives ----------------------------------------------------
class TestSecurity:
def test_sniff_mime_detects_png(self):
assert sniff_mime(_PNG_1x1) == "image/png"
def test_sniff_mime_falls_back_to_hint(self):
# Random ASCII with no image/video/audio magic signature. Different
# libmagic builds disagree on whether short human-readable bytes are
# "application/octet-stream" (conservative) or "text/plain" (aggressive).
# Either is acceptable; what matters is that the function doesn't crash
# and returns *something* usable when the hint can't be trusted.
result = sniff_mime(b"not a real file", hint="image/png")
assert result in {
"image/png",
"application/octet-stream",
"text/plain",
}
def test_validate_size_empty(self):
with pytest.raises(UploadError) as e:
validate_size(b"")
assert e.value.code == "EMPTY_FILE"
def test_validate_size_too_large(self):
with pytest.raises(UploadError) as e:
validate_size(b"x" * 10, max_bytes=5)
assert e.value.code == "TOO_LARGE"
def test_validate_mime_rejects_unknown(self):
with pytest.raises(UploadError) as e:
validate_mime("application/x-msdownload")
assert e.value.code == "MIME_REJECTED"
def test_validate_mime_allows_jpeg(self):
validate_mime("image/jpeg") # no raise
def test_safe_filename_ascii_preserved(self):
name, encoded = safe_filename("photo.jpg", mime="image/jpeg")
assert name == "photo.jpg"
assert encoded is None
def test_safe_filename_sanitizes_path(self):
name, _ = safe_filename("../../etc/passwd.jpg", mime="image/jpeg")
assert "/" not in name and ".." not in name
assert name.endswith(".jpg")
def test_safe_filename_unicode(self):
name, encoded = safe_filename("عکس.jpg", mime="image/jpeg")
assert encoded is not None
assert encoded.startswith("UTF-8''")
# ASCII filename still has extension
assert name.endswith(".jpg")
def test_safe_filename_adds_extension_from_mime(self):
name, _ = safe_filename("noext", mime="image/png")
assert name.endswith(".png")
def test_content_disposition_ascii_only(self):
h = content_disposition("a.jpg", None)
assert h == 'attachment; filename="a.jpg"'
def test_content_disposition_rfc5987(self):
h = content_disposition("a.jpg", "UTF-8''%D8%B9.jpg")
assert "filename*=UTF-8''" in h
class TestSSRF:
def test_blocks_localhost(self):
r = ssrf_check("http://127.0.0.1/x", allow_http=True)
assert not r.allowed
def test_blocks_metadata_host(self):
r = ssrf_check("http://169.254.169.254/latest/meta-data", allow_http=True)
assert not r.allowed
def test_blocks_http_by_default(self):
r = ssrf_check("http://example.com/x")
assert not r.allowed
def test_blocks_bad_scheme(self):
r = ssrf_check("file:///etc/passwd")
assert not r.allowed
def test_blocks_metadata_hostname(self):
r = ssrf_check("https://metadata.google.internal/")
assert not r.allowed
def test_blocks_private_range(self):
# 10.x resolves without DNS via literal IP
r = ssrf_check("https://10.0.0.1/x")
assert not r.allowed
# --- wp_raw_upload ----------------------------------------------------------
class TestRawUpload:
@pytest.mark.asyncio
async def test_happy_path_png(self):
client = _client()
wp_response = {
"id": 123,
"title": {"rendered": "photo"},
"source_url": "https://wp.example.com/wp-content/uploads/photo.png",
"mime_type": "image/png",
"media_type": "image",
}
mock_resp = AsyncMock()
mock_resp.status = 201
mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
mock_resp.json = AsyncMock(return_value=wp_response)
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert result["id"] == 123
@pytest.mark.asyncio
async def test_413_raises_typed_error(self):
client = _client()
mock_resp = AsyncMock()
mock_resp.status = 413
mock_resp.text = AsyncMock(return_value="Payload Too Large")
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(UploadError) as e:
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert e.value.code == "WP_413"
@pytest.mark.asyncio
async def test_401_raises_auth_error(self):
client = _client()
mock_resp = AsyncMock()
mock_resp.status = 401
mock_resp.text = AsyncMock(return_value='{"code":"invalid_auth"}')
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(UploadError) as e:
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert e.value.code == "WP_AUTH"
@pytest.mark.asyncio
async def test_rejects_disallowed_mime(self):
client = _client()
# Binary that will be sniffed as something unusual; enforce via hint list
with pytest.raises(UploadError) as e:
await wp_raw_upload(
client,
b"MZ\x90\x00" + b"\x00" * 100,
filename="evil.exe",
mime_hint="application/x-msdownload",
allowed_mimes={"image/png"},
)
assert e.value.code == "MIME_REJECTED"
# --- MediaHandler integration ----------------------------------------------
class TestMediaHandler:
@pytest.mark.asyncio
async def test_upload_from_base64_happy_path(self):
handler = MediaHandler(_client())
wp_media = {
"id": 42,
"title": {"rendered": "hi"},
"source_url": "https://wp.example.com/wp-content/uploads/hi.png",
"mime_type": "image/png",
"media_type": "image",
}
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
):
out = await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="hi.png",
alt_text="hi",
)
parsed = json.loads(out)
assert parsed["id"] == 42
assert parsed["mime_type"] == "image/png"
@pytest.mark.asyncio
async def test_upload_from_base64_rejects_bad_payload(self):
handler = MediaHandler(_client())
out = await handler.upload_media_from_base64(data="!!!!not-base64", filename="x.png")
parsed = json.loads(out)
assert parsed["error_code"] == "BAD_BASE64"
@pytest.mark.asyncio
async def test_upload_from_base64_strips_data_url_prefix(self):
handler = MediaHandler(_client())
wp_media = {
"id": 9,
"title": {"rendered": "x"},
"source_url": "https://wp.example.com/x.png",
"mime_type": "image/png",
"media_type": "image",
}
prefixed = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
):
out = await handler.upload_media_from_base64(data=prefixed, filename="x.png")
assert json.loads(out)["id"] == 9
@pytest.mark.asyncio
async def test_upload_from_url_ssrf_blocked(self):
handler = MediaHandler(_client())
out = await handler.upload_media_from_url(url="http://127.0.0.1/image.png")
parsed = json.loads(out)
assert parsed["error_code"] == "SSRF"
@pytest.mark.asyncio
async def test_upload_sets_featured_when_attached(self):
handler = MediaHandler(_client())
wp_media = {
"id": 7,
"title": {"rendered": "hi"},
"source_url": "https://wp.example.com/hi.png",
"mime_type": "image/png",
"media_type": "image",
}
featured_mock = AsyncMock(return_value={"id": 100, "featured_media": 7})
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
new=featured_mock,
),
):
await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="hi.png",
attach_to_post=100,
set_featured=True,
)
featured_mock.assert_awaited_once()
args = featured_mock.await_args
assert args.args[1] == 100 # post_id
assert args.args[2] == 7 # media_id
# --- metadata helpers -------------------------------------------------------
class TestMetadataHelpers:
@pytest.mark.asyncio
async def test_update_media_metadata_skips_when_nothing_to_update(self):
client = _client()
with patch.object(client, "post", new=AsyncMock()) as mock_post:
result = await wp_update_media_metadata(client, 1)
assert result == {}
mock_post.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_media_metadata_sends_only_set_fields(self):
client = _client()
with patch.object(client, "post", new=AsyncMock(return_value={"id": 1})) as mock_post:
await wp_update_media_metadata(client, 1, alt_text="hi")
mock_post.assert_awaited_once()
call = mock_post.await_args
assert call.kwargs["json_data"] == {"alt_text": "hi"}
@pytest.mark.asyncio
async def test_set_featured_media(self):
client = _client()
with patch.object(client, "post", new=AsyncMock(return_value={"id": 5})) as mock_post:
await wp_set_featured_media(client, 5, 9)
mock_post.assert_awaited_once_with("posts/5", json_data={"featured_media": 9})
def test_allowed_mimes_includes_common_types():
for m in ("image/jpeg", "image/png", "image/webp", "application/pdf"):
assert m in ALLOWED_MIMES

View File

@@ -0,0 +1,192 @@
"""F.X.fix #12 — OpenRouter /v1/models discovery + 1h cache."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers import openrouter as or_mod
from plugins.ai_image.providers.openrouter import OpenRouterProvider
def _fake_resp(*, status, json_data=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.json = AsyncMock(return_value=json_data or {})
resp.headers = headers or {}
return resp
def _mock_session(responses):
iterator = iter(responses)
def _request(*_a, **_kw):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.get = _request
sess.post = _request
return sess
def _reset_cache():
or_mod._models_cache["fetched_at"] = 0.0
or_mod._models_cache["payload"] = None
@pytest.fixture(autouse=True)
def reset_cache():
_reset_cache()
yield
_reset_cache()
CATALOG_PAYLOAD = {
"data": [
{
"id": "google/gemini-2.5-flash-image",
"name": "Gemini 2.5 Flash Image",
"description": "Google's GA image model",
"context_length": 1000000,
"architecture": {
"input_modalities": ["text", "image"],
"output_modalities": ["image", "text"],
"modality": "text+image->image+text",
},
},
{
"id": "google/gemini-2.5-flash-image-preview",
"name": "Gemini 2.5 Flash Image (Preview)",
"architecture": {
"output_modalities": ["image"],
},
},
{
"id": "openai/gpt-4o",
"name": "GPT-4o",
"architecture": {
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
},
},
{
"id": "openai/dall-e-3",
"name": "DALL-E 3",
"architecture": {"output_modalities": ["image"]},
},
{
"id": "meta/llama-3",
"name": "Llama 3",
"description": "chat only",
"architecture": {"output_modalities": ["text"]},
},
]
}
class TestListImageModels:
@pytest.mark.asyncio
async def test_filters_to_image_output_only(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
models = await provider.list_image_models()
ids = {m["id"] for m in models}
# Kept — image output
assert "google/gemini-2.5-flash-image" in ids
assert "openai/dall-e-3" in ids
# Dropped — text-only
assert "openai/gpt-4o" not in ids
assert "meta/llama-3" not in ids
# Dropped — deprecated alias (we refuse to surface it)
assert "google/gemini-2.5-flash-image-preview" not in ids
@pytest.mark.asyncio
async def test_shape_includes_pricing_and_modalities(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
models = await provider.list_image_models()
gemini = next(m for m in models if m["id"] == "google/gemini-2.5-flash-image")
assert gemini["name"]
assert "image" in gemini["output_modalities"]
# F.X.fix #11 integration — catalog exposes price per image for
# UI display alongside the model list.
assert gemini["price_per_image_usd"] is not None
@pytest.mark.asyncio
async def test_cache_hits_second_call(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp]) # only ONE response
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
first = await provider.list_image_models()
second = await provider.list_image_models() # cache hit
assert first == second
# Second call should not have tried a second HTTP request (mock
# session would raise StopIteration if it did).
@pytest.mark.asyncio
async def test_force_bypasses_cache(self):
provider = OpenRouterProvider()
resp1 = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
resp2 = _fake_resp(status=200, json_data={"data": []})
sess = _mock_session([resp1, resp2])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
first = await provider.list_image_models()
second = await provider.list_image_models(force=True)
assert len(first) > 0
assert second == []
@pytest.mark.asyncio
async def test_upstream_error_returns_previous_cache(self):
provider = OpenRouterProvider()
ok = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
err = _fake_resp(status=500, json_data={"error": "boom"})
sess = _mock_session([ok, err])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
first = await provider.list_image_models()
second = await provider.list_image_models(force=True)
# First populated cache, second upstream failed — we serve the
# cached snapshot rather than an empty list.
assert second == first
@pytest.mark.asyncio
async def test_upstream_error_with_empty_cache_returns_empty(self):
provider = OpenRouterProvider()
err = _fake_resp(status=500, json_data={})
sess = _mock_session([err])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
models = await provider.list_image_models()
assert models == []

View File

@@ -0,0 +1,157 @@
"""F.X.fix #11 — OpenRouter pricing table for cost_usd attribution."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.providers.openrouter import (
_MODEL_PRICING,
OpenRouterProvider,
_cost_for,
_pricing_table,
)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _fake_resp(*, status, json_data=None, text_data="", raw=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or "")
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
resp.headers = headers or {}
return resp
def _mock_session(responses):
iterator = iter(responses)
def _request(*_a, **_kw):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _request
sess.get = _request
return sess
class TestPricingTable:
def test_known_model_maps_to_non_null_cost(self):
assert _cost_for("google/gemini-2.5-flash-image") is not None
assert _cost_for("google/gemini-2.5-flash-image") > 0
def test_unknown_model_returns_none(self, caplog):
assert _cost_for("nonexistent/model-xyz") is None
def test_gemini_preview_still_priced(self):
# Preview alias is deprecated but still has a price entry so if
# an admin call bypasses the generate-time guard the cost column
# is still usable for audit.
assert _cost_for("google/gemini-2.5-flash-image-preview") is not None
def test_pricing_table_contains_expected_providers(self):
ids = set(_MODEL_PRICING.keys())
assert any(m.startswith("google/gemini-") for m in ids)
assert any(m.startswith("openai/dall-e") for m in ids)
assert any(m.startswith("black-forest-labs/flux") for m in ids)
def test_env_override_merges_and_wins(self, monkeypatch):
monkeypatch.setenv(
"OPENROUTER_PRICING_OVERRIDE",
json.dumps({"google/gemini-2.5-flash-image": 0.001, "custom/model": 0.5}),
)
table = _pricing_table()
assert table["google/gemini-2.5-flash-image"] == 0.001
assert table["custom/model"] == 0.5
# Untouched entries stay from the hard-coded table.
assert "openai/dall-e-3" in table
def test_env_override_bad_json_is_ignored(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_PRICING_OVERRIDE", "not-json{")
# Falls back to the default table, no exception.
assert (
_pricing_table()["google/gemini-2.5-flash-image"]
== _MODEL_PRICING["google/gemini-2.5-flash-image"]
)
class TestGenerateAttributesCost:
@pytest.mark.asyncio
async def test_cost_usd_populated_for_known_model(self):
provider = OpenRouterProvider()
b64 = base64.b64encode(_PNG_1x1).decode()
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
},
)
sess = _mock_session([ok])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate("k", GenerationRequest(prompt="x"))
assert result.cost_usd == _MODEL_PRICING["google/gemini-2.5-flash-image"]
@pytest.mark.asyncio
async def test_cost_usd_null_for_unknown_model(self):
provider = OpenRouterProvider()
b64 = base64.b64encode(_PNG_1x1).decode()
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
},
)
sess = _mock_session([ok])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate(
"k", GenerationRequest(prompt="x", model="weird/unreleased-model")
)
assert result.cost_usd is None
class TestDeprecatedModelGuard:
@pytest.mark.asyncio
async def test_preview_model_raises_model_deprecated(self):
provider = OpenRouterProvider()
with pytest.raises(ProviderError) as e:
await provider.generate(
"k",
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
)
assert e.value.code == "PROVIDER_MODEL_DEPRECATED"
assert "google/gemini-2.5-flash-image" in str(e.value.message)
assert e.value.details.get("replacement_model") == "google/gemini-2.5-flash-image"

View File

@@ -0,0 +1,314 @@
"""F.5a.9 partial — Tests for the OpenRouter image-generation provider."""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.providers.openrouter import (
OpenRouterProvider,
_extract_image_url,
_image_url_from_entry,
)
from plugins.ai_image.registry import get_provider, list_providers
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _mock_session_post(responses: list):
"""Build a fake aiohttp.ClientSession whose .post/.get cycle responses."""
iterator = iter(responses)
def _request(*_args, **_kwargs):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _request
sess.get = _request
return sess
def _fake_resp(*, status: int, json_data=None, text_data="", raw=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or "")
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
resp.headers = headers or {}
return resp
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestRegistry:
def test_openrouter_is_registered(self):
assert "openrouter" in list_providers()
def test_registry_lookup_returns_singleton(self):
p = get_provider("openrouter")
assert p.name == "openrouter"
assert p is get_provider("openrouter") # same instance
# ---------------------------------------------------------------------------
# Response-shape parsers (pure, no network)
# ---------------------------------------------------------------------------
class TestImageUrlExtraction:
def test_image_url_from_dict_url(self):
entry = {"image_url": {"url": "data:image/png;base64,AAAA"}}
assert _image_url_from_entry(entry) == "data:image/png;base64,AAAA"
def test_image_url_from_string(self):
entry = {"image_url": "https://x.example/y.png"}
assert _image_url_from_entry(entry) == "https://x.example/y.png"
def test_image_url_bare_url_field(self):
assert (
_image_url_from_entry({"url": "https://z.example/a.png"}) == "https://z.example/a.png"
)
def test_image_url_from_junk(self):
assert _image_url_from_entry(None) is None
assert _image_url_from_entry({"image_url": 42}) is None
def test_extract_gemini_shape_message_images(self):
body = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,Zm9v"}}]}}
]
}
assert _extract_image_url(body) == "data:image/png;base64,Zm9v"
def test_extract_multimodal_content_parts(self):
body = {
"choices": [
{
"message": {
"content": [
{"type": "text", "text": "here's your image"},
{"type": "image_url", "image_url": {"url": "https://x/y.png"}},
]
}
}
]
}
assert _extract_image_url(body) == "https://x/y.png"
def test_extract_no_image_returns_none(self):
body = {"choices": [{"message": {"content": "just text"}}]}
assert _extract_image_url(body) is None
def test_extract_empty_choices(self):
assert _extract_image_url({"choices": []}) is None
assert _extract_image_url({}) is None
# ---------------------------------------------------------------------------
# Provider.generate — auth / request shape
# ---------------------------------------------------------------------------
class TestOpenRouterProvider:
@pytest.mark.asyncio
async def test_missing_api_key_raises_auth(self):
provider = OpenRouterProvider()
with pytest.raises(ProviderError) as e:
await provider.generate("", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_happy_path_gemini_data_url(self):
provider = OpenRouterProvider()
b64 = base64.b64encode(_PNG_1x1).decode()
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
],
"usage": {"total_tokens": 128},
},
)
sess = _mock_session_post([ok])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate(
"or-test",
GenerationRequest(prompt="a cat"),
)
assert result.data == _PNG_1x1
assert result.mime == "image/png"
assert "gemini" in result.filename
assert result.meta.get("total_tokens") == 128
# F.X.fix #11: known model → non-null cost_usd from _MODEL_PRICING.
assert result.cost_usd is not None
assert result.cost_usd > 0
@pytest.mark.asyncio
async def test_http_url_is_fetched(self):
provider = OpenRouterProvider()
gen_resp = _fake_resp(
status=200,
json_data={
"choices": [
{"message": {"images": [{"image_url": {"url": "https://cdn.example/out.png"}}]}}
]
},
)
fetch_resp = _fake_resp(
status=200,
raw=_PNG_1x1,
headers={"Content-Type": "image/png"},
)
sess = _mock_session_post([gen_resp, fetch_resp])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate("or-test", GenerationRequest(prompt="x"))
assert result.data == _PNG_1x1
assert result.mime == "image/png"
@pytest.mark.asyncio
async def test_no_image_raises_bad_response(self):
provider = OpenRouterProvider()
empty = _fake_resp(
status=200,
json_data={"choices": [{"message": {"content": "no image for you"}}]},
)
sess = _mock_session_post([empty])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ProviderError) as e:
await provider.generate("or-test", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_BAD_RESPONSE"
@pytest.mark.asyncio
async def test_401_maps_to_provider_auth(self):
provider = OpenRouterProvider()
r401 = _fake_resp(status=401, text_data="invalid key")
sess = _mock_session_post([r401])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ProviderError) as e:
await provider.generate("bad-key", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_400_maps_to_provider_bad_request(self):
provider = OpenRouterProvider()
r400 = _fake_resp(status=400, text_data="model not found")
sess = _mock_session_post([r400])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(ProviderError) as e:
await provider.generate(
"or-test",
GenerationRequest(prompt="x", model="nonexistent/model"),
)
assert e.value.code == "PROVIDER_BAD_REQUEST"
@pytest.mark.asyncio
async def test_429_retried_then_succeeds(self):
provider = OpenRouterProvider()
b64 = base64.b64encode(_PNG_1x1).decode()
r429 = _fake_resp(status=429, text_data="slow down")
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
},
)
sess = _mock_session_post([r429, ok])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
result = await provider.generate("or-test", GenerationRequest(prompt="x"))
assert result.data == _PNG_1x1
@pytest.mark.asyncio
async def test_persistent_5xx_raises_unavailable(self):
provider = OpenRouterProvider()
r500 = _fake_resp(status=500, text_data="boom")
sess = _mock_session_post([r500, r500, r500])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
with pytest.raises(ProviderError) as e:
await provider.generate("or-test", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_UNAVAILABLE"
@pytest.mark.asyncio
async def test_negative_prompt_appended_to_user_content(self, monkeypatch):
"""Negative prompt is inlined into the user message as 'Avoid: ...'."""
provider = OpenRouterProvider()
captured: dict = {}
async def _mock_post_with_retry(self, api_key, payload):
captured["payload"] = payload
b64 = base64.b64encode(_PNG_1x1).decode()
return {
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
}
monkeypatch.setattr(
OpenRouterProvider, "_post_with_retry", _mock_post_with_retry, raising=True
)
await provider.generate(
"or-test",
GenerationRequest(prompt="a cat", negative_prompt="no dogs"),
)
user_msg = captured["payload"]["messages"][0]["content"]
assert "a cat" in user_msg
assert "Avoid: no dogs" in user_msg

View File

@@ -0,0 +1,222 @@
"""F.5a.8.2 — Tests for wordpress_regenerate_thumbnails."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.regenerate_thumbnails import (
RegenerateThumbnailsHandler,
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 RegenerateThumbnailsHandler(wp_client)
# ---------------------------------------------------------------------------
# Spec
# ---------------------------------------------------------------------------
def test_spec_shape():
specs = get_tool_specifications()
assert len(specs) == 1
s = specs[0]
assert s["name"] == "regenerate_thumbnails"
assert s["scope"] == "write"
props = s["schema"]["properties"]
assert {"ids", "all", "offset", "limit"} <= set(props.keys())
# ---------------------------------------------------------------------------
# Validation (no round-trip)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_neither_ids_nor_all_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails())
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_both_ids_and_all_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails(ids=[1], all=True))
assert out["ok"] is False
assert "mutually exclusive" in out["message"]
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_empty_ids_list_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails(ids=["abc", 0, -1]))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
# ---------------------------------------------------------------------------
# Happy path — ids mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ids_mode_dedupes_and_caps(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["route"] = route
captured["body"] = json_data
captured["ns"] = use_custom_namespace
return {
"mode": "ids",
"attempted": 3,
"processed": 3,
"skipped": [],
"errors": [],
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _post)
# 60 ids with duplicates — should be deduped and capped at 50
ids = [5, 5, 10, 10] + list(range(1, 57))
out = json.loads(await handler.regenerate_thumbnails(ids=ids))
assert out["ok"] is True
assert out["mode"] == "ids"
assert out["processed"] == 3
assert out["plugin_version"] == "2.8.0"
assert captured["route"] == "airano-mcp/v1/regenerate-thumbnails"
assert captured["ns"] is True
assert "all" not in captured["body"]
sent = captured["body"]["ids"]
assert len(sent) <= 50
assert len(sent) == len(set(sent)) # deduped
@pytest.mark.asyncio
async def test_all_mode_forwards_offset_and_limit(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {
"mode": "all",
"attempted": 10,
"processed": 10,
"skipped": [],
"errors": [],
"offset": 100,
"limit": 10,
"has_more": True,
"next_offset": 110,
"total": 200,
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.regenerate_thumbnails(all=True, offset=100, limit=10))
assert captured["body"] == {"all": True, "offset": 100, "limit": 10}
assert out["mode"] == "all"
assert out["has_more"] is True
assert out["next_offset"] == 110
assert out["total"] == 200
@pytest.mark.asyncio
async def test_all_mode_limit_capped_server_side(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {"mode": "all", "attempted": 0, "processed": 0}
monkeypatch.setattr(wp_client, "post", _post)
await handler.regenerate_thumbnails(all=True, limit=9999)
assert captured["body"]["limit"] == 50
@pytest.mark.asyncio
async def test_all_mode_negative_offset_clamped(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {"mode": "all", "attempted": 0, "processed": 0}
monkeypatch.setattr(wp_client, "post", _post)
await handler.regenerate_thumbnails(all=True, offset=-5)
assert captured["body"]["offset"] == 0
# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
async def _boom(*_a, **_kw):
raise RuntimeError("404 - rest_no_route")
monkeypatch.setattr(wp_client, "post", _boom)
out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "v2.8.0" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response_is_rejected(handler, wp_client, monkeypatch):
async def _weird(*_a, **_kw):
return ["not", "a", "dict"]
monkeypatch.setattr(wp_client, "post", _weird)
out = json.loads(await handler.regenerate_thumbnails(ids=[1]))
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_errors_from_companion_surface_through(handler, wp_client, monkeypatch):
async def _errs(*_a, **_kw):
return {
"mode": "ids",
"attempted": 2,
"processed": 0,
"skipped": [{"id": 42, "reason": "not_image"}],
"errors": [{"id": 99, "error": "file_missing"}],
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _errs)
out = json.loads(await handler.regenerate_thumbnails(ids=[42, 99]))
# processed=0 with at least one error → not ok
assert out["ok"] is False
assert out["errors"] == [{"id": 99, "error": "file_missing"}]
assert out["skipped"] == [{"id": 42, "reason": "not_image"}]

View File

@@ -0,0 +1,122 @@
"""F.X.fix #2 — Rank Math SEO roundtrip: write → read → assert equality.
Protects against the regression where ``update_post_seo`` wrote
``rank_math_seo_title`` (wrong key) while ``get_post_seo`` read back
from the same wrong key, so a full write→read cycle looked successful
but the WordPress frontend showed no SEO title because Rank Math never
saw the value.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.seo import SEOHandler
@pytest.fixture
def fake_wp_state():
"""In-memory meta store shared across fake client get/post calls.
Rank Math is considered active when at least one canonical
rank_math_* key exists in the store, which mirrors the real
_check_seo_plugins detection heuristic.
"""
return {
"meta": {
# Seeded so _check_seo_plugins detects Rank Math as active.
"rank_math_focus_keyword": "",
"rank_math_title": "",
"rank_math_description": "",
}
}
@pytest.fixture
def fake_client(fake_wp_state):
client = AsyncMock()
async def _get(endpoint, params=None, use_custom_namespace=False, use_woocommerce=False):
if endpoint.startswith("posts/"):
return {
"id": 42,
"title": {"rendered": "Test Post"},
"meta": dict(fake_wp_state["meta"]),
}
if endpoint == "posts":
return [
{
"id": 42,
"title": {"rendered": "Test Post"},
"meta": dict(fake_wp_state["meta"]),
}
]
raise AssertionError(f"unexpected GET {endpoint}")
async def _post(endpoint, json_data=None, **_):
if endpoint.startswith("posts/") and json_data and "meta" in json_data:
fake_wp_state["meta"].update(json_data["meta"])
return {"id": 42, "meta": dict(fake_wp_state["meta"])}
raise AssertionError(f"unexpected POST {endpoint}")
client.get = _get
client.post = _post
return client
class TestRankMathRoundtrip:
@pytest.mark.asyncio
async def test_update_writes_canonical_rank_math_title(self, fake_client, fake_wp_state):
handler = SEOHandler(fake_client)
# Force the bridge-status fallback path to detect Rank Math via
# meta heuristic (seeded in fixture).
raw = await handler.update_post_seo(
post_id=42,
focus_keyword="mcp hub",
seo_title="MCP Hub — unified MCP server",
meta_description="Self-hosted MCP platform",
)
resp = json.loads(raw)
assert "rank_math_title" in resp["updated_fields"]
# The wrong key must NOT be written — guards against silent
# regression of the rank_math_seo_title bug.
assert "rank_math_seo_title" not in resp["updated_fields"]
assert fake_wp_state["meta"]["rank_math_title"] == "MCP Hub — unified MCP server"
assert fake_wp_state["meta"]["rank_math_focus_keyword"] == "mcp hub"
assert fake_wp_state["meta"]["rank_math_description"] == "Self-hosted MCP platform"
@pytest.mark.asyncio
async def test_get_reads_canonical_rank_math_title(self, fake_client, fake_wp_state):
fake_wp_state["meta"].update(
{
"rank_math_focus_keyword": "mcp hub",
"rank_math_title": "Readback Title",
"rank_math_description": "Readback Desc",
}
)
handler = SEOHandler(fake_client)
raw = await handler.get_post_seo(post_id=42)
data = json.loads(raw)
assert data["plugin_detected"] == "rank_math"
assert data["seo_title"] == "Readback Title"
assert data["focus_keyword"] == "mcp hub"
assert data["meta_description"] == "Readback Desc"
@pytest.mark.asyncio
async def test_roundtrip_write_then_read_returns_non_empty(self, fake_client, fake_wp_state):
handler = SEOHandler(fake_client)
await handler.update_post_seo(
post_id=42,
focus_keyword="kw",
seo_title="Title A",
meta_description="Desc A",
)
raw = await handler.get_post_seo(post_id=42)
data = json.loads(raw)
# Every field we just wrote must come back non-empty.
assert data["focus_keyword"] == "kw"
assert data["seo_title"] == "Title A"
assert data["meta_description"] == "Desc A"

View File

@@ -0,0 +1,176 @@
"""F.18.6 — Tests for wordpress_site_health."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.site_health import (
SiteHealthHandler,
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 SiteHealthHandler(wp_client)
@pytest.fixture
def companion_payload():
return {
"ok": True,
"wordpress": {
"version": "6.5.3",
"multisite": False,
"home_url": "https://wp.example.com",
"site_url": "https://wp.example.com",
"language": "en_US",
"timezone": "UTC",
"rest_enabled": True,
"application_passwords_available": True,
"debug_mode": False,
"https_home": True,
},
"php": {
"version": "8.1.27",
"memory_limit": "256M",
"max_execution_time": "30",
"upload_max_filesize": "64M",
"post_max_size": "128M",
"max_input_vars": 1000,
"extensions": ["curl", "gd", "json", "mbstring", "xml"],
"has_mbstring": True,
"has_curl": True,
"has_gd": True,
"has_imagick": False,
"has_intl": True,
},
"server": {
"software": "nginx/1.25.3",
"disk_free_bytes": 123456789,
"disk_total_bytes": 999999999,
},
"database": {
"version": "10.5.15",
"server_info": "10.5.15-MariaDB",
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci",
"prefix": "wp_",
},
"plugins": {
"total_count": 20,
"active_count": 12,
"active": [
{
"file": "airano-mcp-bridge/airano-mcp-bridge.php",
"name": "Airano MCP Bridge for WordPress",
"version": "2.6.0",
}
],
"must_use_count": 2,
},
"theme": {
"active": {
"name": "Twenty Twenty-Four",
"version": "1.1",
"stylesheet": "twentytwentyfour",
},
"parent": None,
"total_count": 3,
},
"checks": {
"writable_wp_content": True,
"writable_uploads": True,
"ssl_enabled": True,
},
"companion": {
"plugin_version": "2.6.0",
"routes": {
"seo_meta": True,
"site_health": True,
"audit_hook": False,
},
"features": {
"rank_math": True,
"yoast": False,
"woocommerce": False,
},
},
"generated_at_gmt": "2026-04-15T09:00:00Z",
"plugin_version": "2.6.0",
}
@pytest.mark.asyncio
async def test_happy_path(handler, wp_client, monkeypatch, companion_payload):
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.site_health())
assert out["ok"] is True
assert out["companion_available"] is True
assert out["wordpress"]["version"] == "6.5.3"
assert out["php"]["version"] == "8.1.27"
assert out["database"]["charset"] == "utf8mb4"
assert out["plugins"]["active_count"] == 12
assert out["theme"]["active"]["name"] == "Twenty Twenty-Four"
assert out["checks"]["writable_uploads"] is True
assert out["companion"]["plugin_version"] == "2.6.0"
get_mock.assert_called_once_with(
"airano-mcp/v1/site-health",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_companion_unreachable_falls_back(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.site_health())
assert out["ok"] is False
assert out["companion_available"] is False
assert out["error"] == "companion_unreachable"
assert "wordpress_get_site_health" in out["hint"]
assert "manage_options" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.site_health())
assert out["ok"] is False
assert out["error"] == "invalid_response"
assert out["companion_available"] is False
@pytest.mark.asyncio
async def test_ok_inferred_when_payload_omits_it(handler, wp_client, monkeypatch):
"""Older plugin builds might not set `ok`; derive it from presence/no-error."""
payload = {
"wordpress": {"version": "6.4"},
"php": {"version": "8.0"},
"plugin_version": "2.6.0",
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
out = json.loads(await handler.site_health())
assert out["ok"] is True
assert out["companion_available"] is True
assert out["wordpress"]["version"] == "6.4"
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "site_health"
assert specs[0]["scope"] == "read"

View File

@@ -0,0 +1,98 @@
"""F.5a.6.1 — Per-tool rate limit tests."""
from __future__ import annotations
import json
import pytest
from core.tool_rate_limiter import (
PerToolRateLimiter,
ToolRateLimitError,
set_tool_rate_limiter,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.ai_media import AIMediaHandler
@pytest.fixture(autouse=True)
def _fresh_limiter():
set_tool_rate_limiter(PerToolRateLimiter())
yield
set_tool_rate_limiter(None)
def test_admin_exempt():
limiter = PerToolRateLimiter({"tool_x": 2})
for _ in range(10):
limiter.check("tool_x", None) # None = admin/env
limiter.check("tool_x", "")
def test_per_user_limit_enforced():
limiter = PerToolRateLimiter({"tool_x": 3})
for _ in range(3):
limiter.check("tool_x", "alice")
with pytest.raises(ToolRateLimitError) as exc:
limiter.check("tool_x", "alice")
assert exc.value.tool_name == "tool_x"
assert exc.value.limit_per_hour == 3
d = exc.value.to_dict()
assert d["error_code"] == "TOOL_RATE_LIMITED"
assert d["details"]["tool"] == "tool_x"
def test_different_users_isolated():
limiter = PerToolRateLimiter({"tool_x": 2})
limiter.check("tool_x", "alice")
limiter.check("tool_x", "alice")
with pytest.raises(ToolRateLimitError):
limiter.check("tool_x", "alice")
# Bob still has full quota
limiter.check("tool_x", "bob")
limiter.check("tool_x", "bob")
def test_unknown_tool_is_unlimited():
limiter = PerToolRateLimiter({})
for _ in range(100):
limiter.check("whatever", "alice")
@pytest.mark.asyncio
async def test_ai_generate_returns_typed_error_on_11th_call():
"""With cap of 10/hr/user, the 11th AI call yields TOOL_RATE_LIMITED."""
set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 10}))
client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
handler = AIMediaHandler(client, user_id="alice")
# Pre-consume 10 tokens for alice
from core.tool_rate_limiter import get_tool_rate_limiter
limiter = get_tool_rate_limiter()
for _ in range(10):
limiter.check("wordpress_generate_and_upload_image", "alice")
# 11th call via handler should short-circuit with typed error and not
# even attempt to call the provider.
result_json = await handler.generate_and_upload_image(provider="openai", prompt="a cat")
result = json.loads(result_json)
assert result["error_code"] == "TOOL_RATE_LIMITED"
assert result["details"]["tool"] == "wordpress_generate_and_upload_image"
@pytest.mark.asyncio
async def test_ai_generate_another_user_not_rate_limited():
set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 2}))
from core.tool_rate_limiter import get_tool_rate_limiter
limiter = get_tool_rate_limiter()
# Burn alice's quota
for _ in range(2):
limiter.check("wordpress_generate_and_upload_image", "alice")
with pytest.raises(ToolRateLimitError):
limiter.check("wordpress_generate_and_upload_image", "alice")
# Bob's check still passes (handler isn't invoked — we verify the
# limiter path, actual provider call is out of scope for this unit).
limiter.check("wordpress_generate_and_upload_image", "bob")

View File

@@ -0,0 +1,193 @@
"""F.18.5 — Tests for wordpress_transient_flush."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.transient_flush import (
TransientFlushHandler,
_validate,
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 TransientFlushHandler(wp_client)
# ---------------------------------------------------------------------------
# Client-side validation.
# ---------------------------------------------------------------------------
def test_validate_defaults_to_expired():
assert _validate(None, None) is None
def test_validate_expired_ok():
assert _validate("expired", None) is None
def test_validate_all_ok():
assert _validate("all", None) is None
def test_validate_pattern_requires_pattern():
err = _validate("pattern", None)
assert err is not None
assert err["error"] == "pattern_required"
def test_validate_pattern_with_glob_ok():
assert _validate("pattern", "rank_math_*") is None
def test_validate_rejects_unknown_scope():
err = _validate("demolish", None)
assert err is not None
assert err["error"] == "invalid_scope"
# ---------------------------------------------------------------------------
# Handler behaviour.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invalid_scope_never_hits_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush(scope="demolish"))
assert out["ok"] is False
assert out["error"] == "invalid_scope"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_pattern_without_pattern_rejected_client_side(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush(scope="pattern"))
assert out["ok"] is False
assert out["error"] == "pattern_required"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_expired_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"ok": True,
"scope": "expired",
"pattern": None,
"include_site_transients": True,
"deleted_count": 42,
"deleted_sample": ["foo", "bar"],
"plugin_version": "2.5.0",
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush()) # default scope
assert out["ok"] is True
assert out["scope"] == "expired"
assert out["deleted_count"] == 42
call_args = post_mock.call_args
assert call_args.args[0] == "airano-mcp/v1/transient-flush"
body = call_args.kwargs["json_data"]
assert body["scope"] == "expired"
assert body["include_site_transients"] is True
assert "pattern" not in body
@pytest.mark.asyncio
async def test_pattern_passes_pattern_through(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["body"] = json_data
return {
"ok": True,
"scope": "pattern",
"pattern": "rank_math_*",
"include_site_transients": False,
"deleted_count": 3,
"deleted_sample": ["rank_math_a", "rank_math_b", "rank_math_c"],
"plugin_version": "2.5.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
out = json.loads(
await handler.transient_flush(
scope="pattern",
pattern="rank_math_*",
include_site_transients=False,
)
)
assert out["ok"] is True
assert out["pattern"] == "rank_math_*"
assert out["deleted_count"] == 3
assert captured["body"]["pattern"] == "rank_math_*"
assert captured["body"]["include_site_transients"] is False
@pytest.mark.asyncio
async def test_include_site_transients_bool_coercion(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["body"] = json_data
return {"ok": True, "scope": "all", "deleted_count": 0}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
# String "false" should coerce to False.
await handler.transient_flush(scope="all", include_site_transients="false")
assert captured["body"]["include_site_transients"] is False
# Integer 1 should coerce to True.
await handler.transient_flush(scope="all", include_site_transients=1)
assert captured["body"]["include_site_transients"] is True
# None / default → True.
await handler.transient_flush(scope="all", include_site_transients=None)
assert captured["body"]["include_site_transients"] is True
@pytest.mark.asyncio
async def test_companion_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.transient_flush(scope="expired"))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="bad"))
out = json.loads(await handler.transient_flush(scope="expired"))
assert out["ok"] is False
assert out["error"] == "invalid_response"
def test_tool_spec_is_admin_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "transient_flush"
assert specs[0]["scope"] == "admin"
assert set(specs[0]["schema"]["properties"]["scope"]["enum"]) == {
"expired",
"all",
"pattern",
}

View File

@@ -0,0 +1,437 @@
"""F.5a.8.5 — unified upload+metadata+attach+featured via companion.
Tests the routing layer in ``_media_core.wp_raw_upload``:
* When the cached capability probe advertises ``upload_and_attach`` AND
the caller supplies metadata/attach intent, we POST to
``/airano-mcp/v1/upload-and-attach`` and ``_apply_metadata_and_attach``
becomes a no-op.
* When the probe doesn't advertise the route, we fall through to the
legacy 3-step path (upload + PATCH metadata + PATCH featured).
* When the companion call errors, we fall back to the legacy path and
metadata gets reapplied via the REST calls.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import (
_companion_has_upload_and_attach,
_companion_upload_and_attach,
wp_raw_upload,
)
from plugins.wordpress.handlers._media_security import UploadError
from plugins.wordpress.handlers.media import _apply_metadata_and_attach
_PNG_1x1 = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010806000000"
"1f15c4890000000a49444154789c6300010000000500010a2db4b70000"
"000049454e44ae426082"
)
@pytest.fixture
def client():
return WordPressClient(site_url="https://wp.example.com", username="admin", app_password="xxxx")
# ---------------------------------------------------------------------------
# Route advertisement check
# ---------------------------------------------------------------------------
class TestCompanionAdvertiseCheck:
@pytest.mark.asyncio
async def test_returns_true_when_route_advertised(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(
return_value={
"companion_available": True,
"routes": {"upload_and_attach": True, "upload_chunk": True},
}
),
):
assert await _companion_has_upload_and_attach(client) is True
@pytest.mark.asyncio
async def test_returns_false_when_route_absent(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(
return_value={
"companion_available": True,
"routes": {"upload_chunk": True}, # no upload_and_attach
}
),
):
assert await _companion_has_upload_and_attach(client) is False
@pytest.mark.asyncio
async def test_returns_false_when_companion_missing(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(return_value={"companion_available": False}),
):
assert await _companion_has_upload_and_attach(client) is False
@pytest.mark.asyncio
async def test_returns_false_on_cold_cache(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(return_value=None),
):
assert await _companion_has_upload_and_attach(client) is False
# ---------------------------------------------------------------------------
# wp_raw_upload routing: unified path when advertised + metadata supplied
# ---------------------------------------------------------------------------
class TestWpRawUploadRouting:
@pytest.mark.asyncio
async def test_uses_unified_path_when_advertised_and_metadata_supplied(self, client):
captured: dict = {}
async def _fake_unified(
_client,
data,
*,
sniffed,
disposition,
attach_to_post,
set_featured,
title,
alt_text,
caption,
description,
idempotency_key=None,
):
captured["params"] = {
"attach_to_post": attach_to_post,
"set_featured": set_featured,
"title": title,
"alt_text": alt_text,
"caption": caption,
"description": description,
}
captured["called"] = True
return {
"id": 123,
"source_url": "https://wp.example.com/out.png",
"mime_type": "image/png",
"_upload_and_attach": {
"attach_to_post": attach_to_post,
"set_featured": set_featured,
},
}
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_fake_unified,
),
):
media = await wp_raw_upload(
client,
_PNG_1x1,
filename="hero.png",
mime_hint="image/png",
attach_to_post=42,
set_featured=True,
title="Hero",
alt_text="A hero image",
caption="caption here",
)
assert captured.get("called") is True
assert captured["params"]["attach_to_post"] == 42
assert captured["params"]["set_featured"] is True
assert captured["params"]["title"] == "Hero"
assert media["_upload_route"] == "companion_unified"
assert media["id"] == 123
@pytest.mark.asyncio
async def test_skips_unified_when_no_metadata_intent(self, client):
"""Upload with zero metadata → no reason to use unified route
even if advertised; fall through to legacy path."""
unified_called: dict = {"hit": False}
async def _fake_unified(*_a, **_kw):
unified_called["hit"] = True
return {}
legacy_mock = AsyncMock(return_value={"id": 1, "source_url": "x", "mime_type": "image/png"})
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_fake_unified,
),
patch(
"plugins.wordpress.handlers._media_core._should_use_companion",
AsyncMock(return_value=False),
),
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
):
# Bare /wp/v2/media session mock.
resp = AsyncMock()
resp.status = 201
resp.text = AsyncMock(return_value="{}")
resp.json = AsyncMock(
return_value={"id": 1, "source_url": "x", "mime_type": "image/png"}
)
sess = AsyncMock()
sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
_ = legacy_mock
media = await wp_raw_upload(client, _PNG_1x1, filename="x.png", mime_hint="image/png")
assert unified_called["hit"] is False
assert media["_upload_route"] == "rest"
@pytest.mark.asyncio
async def test_falls_back_when_unified_errors(self, client):
"""Unified call raising UploadError → fall through to legacy
path, and the caller's metadata gets applied via the REST calls."""
async def _boom(*_a, **_kw):
raise UploadError("COMPANION_500", "simulated")
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_boom,
),
patch(
"plugins.wordpress.handlers._media_core._should_use_companion",
AsyncMock(return_value=False),
),
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
):
resp = AsyncMock()
resp.status = 201
resp.text = AsyncMock(return_value="{}")
resp.json = AsyncMock(
return_value={"id": 2, "source_url": "x", "mime_type": "image/png"}
)
sess = AsyncMock()
sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
media = await wp_raw_upload(
client,
_PNG_1x1,
filename="x.png",
mime_hint="image/png",
attach_to_post=7,
)
# Legacy path marker — metadata will be applied via separate calls.
assert media["_upload_route"] == "rest"
assert "_upload_and_attach" not in media
# ---------------------------------------------------------------------------
# _apply_metadata_and_attach: skip when unified route marker present
# ---------------------------------------------------------------------------
class TestApplyMetadataSkip:
@pytest.mark.asyncio
async def test_unified_marker_short_circuits(self, client):
# Media response carries the unified marker → no REST calls.
media = {"id": 55, "_upload_route": "companion_unified"}
update_mock = AsyncMock()
featured_mock = AsyncMock()
with (
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
update_mock,
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
featured_mock,
),
):
await _apply_metadata_and_attach(
client,
media,
title="t",
alt_text="a",
caption="c",
attach_to_post=7,
set_featured=True,
)
update_mock.assert_not_called()
featured_mock.assert_not_called()
@pytest.mark.asyncio
async def test_legacy_marker_runs_metadata_calls(self, client):
media = {"id": 55, "_upload_route": "rest"}
update_mock = AsyncMock()
featured_mock = AsyncMock()
with (
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
update_mock,
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
featured_mock,
),
):
await _apply_metadata_and_attach(
client,
media,
title="t",
alt_text="a",
caption="c",
attach_to_post=7,
set_featured=True,
)
update_mock.assert_awaited_once()
featured_mock.assert_awaited_once()
# ---------------------------------------------------------------------------
# _companion_upload_and_attach: query params + error mapping
# ---------------------------------------------------------------------------
def _build_fake_session(status: int, body_text: str):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=body_text)
try:
_parsed = json.loads(body_text) if body_text else {}
except json.JSONDecodeError:
_parsed = {}
resp.json = AsyncMock(return_value=_parsed)
captured: dict = {}
def _post(url, **kwargs):
captured["url"] = url
captured["params"] = kwargs.get("params")
captured["headers"] = kwargs.get("headers")
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _post
sess_cm = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
return sess_cm, captured
class TestCompanionUploadAndAttachClient:
@pytest.mark.asyncio
async def test_query_params_encoded_from_metadata(self, client):
cm, captured = _build_fake_session(
200,
json.dumps({"id": 7, "source_url": "x", "mime_type": "image/png"}),
)
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
out = await _companion_upload_and_attach(
client,
b"irrelevant",
sniffed="image/png",
disposition='attachment; filename="x.png"',
attach_to_post=42,
set_featured=True,
title="T",
alt_text="A",
caption="C",
description="D",
)
assert out["id"] == 7
p = captured["params"]
assert p["attach_to_post"] == "42"
assert p["set_featured"] == "true"
assert p["title"] == "T"
assert p["alt_text"] == "A"
assert p["caption"] == "C"
assert p["description"] == "D"
assert "airano-mcp/v1/upload-and-attach" in captured["url"]
@pytest.mark.asyncio
async def test_set_featured_ignored_without_attach_target(self, client):
cm, captured = _build_fake_session(
200, json.dumps({"id": 1, "source_url": "x", "mime_type": "image/png"})
)
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
await _companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition="attachment",
attach_to_post=None,
set_featured=True, # ignored when no attach_to_post
title=None,
alt_text=None,
caption=None,
description=None,
)
assert "set_featured" not in captured["params"]
assert "attach_to_post" not in captured["params"]
@pytest.mark.asyncio
async def test_500_maps_to_companion_status_error(self, client):
cm, _ = _build_fake_session(500, "internal error")
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
with pytest.raises(UploadError) as e:
await _companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition="attachment",
attach_to_post=1,
set_featured=False,
title=None,
alt_text=None,
caption=None,
description=None,
)
assert e.value.code == "COMPANION_500"