Files
mcphub/tests/plugins/wordpress/test_client_fast_fail.py
airano-ir f203ca88de
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
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

174 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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