feat(error-handling): add network error differentiation, retry, and REST API detection (C.5)
WordPress client improvements: - Add ConnectionError exception for network-level failures - Differentiate DNS, SSL, timeout, and connection refused errors - Add retry with exponential backoff for transient errors (502, 503, 504, 429) - Auth/config errors are never retried - Add request timeout (30s default) Health check improvements: - Detect REST API disabled (403/404 on /wp-json) - Classify errors by type: dns_failure, ssl_error, timeout, connection_refused Bare except cleanup: - Replaced all bare except: with except Exception: across 12 files Tests: 303 passed (13 new tests for error handling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -553,6 +553,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
||||||
"""Basic HTTP health check as a last-resort fallback."""
|
"""Basic HTTP health check as a last-resort fallback."""
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
@@ -568,8 +569,27 @@ class HealthMonitor:
|
|||||||
"status_code": resp.status,
|
"status_code": resp.status,
|
||||||
"message": f"HTTP {resp.status} from {url}",
|
"message": f"HTTP {resp.status} from {url}",
|
||||||
}
|
}
|
||||||
|
except TimeoutError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "timeout",
|
||||||
|
"message": f"Site at {url} did not respond within 10 seconds.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorDNSError:
|
||||||
|
host = url.split("://")[-1].split("/")[0]
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "dns_failure",
|
||||||
|
"message": f"DNS resolution failed for '{host}'. Check the URL.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "connection_refused",
|
||||||
|
"message": f"Cannot connect to {url}. Server unreachable.",
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"healthy": False, "message": f"Connection failed: {e}"}
|
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
|
||||||
|
|
||||||
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -241,8 +241,8 @@ class TokenManager:
|
|||||||
try:
|
try:
|
||||||
old_payload = self.validate_access_token(token_data.access_token)
|
old_payload = self.validate_access_token(token_data.access_token)
|
||||||
scope = old_payload.get("scope", "read")
|
scope = old_payload.get("scope", "read")
|
||||||
except:
|
except Exception:
|
||||||
pass # Old token might be expired, use default
|
pass # Old token might be expired, use default scope
|
||||||
|
|
||||||
# Generate new tokens
|
# Generate new tokens
|
||||||
new_access_token = self.generate_access_token(
|
new_access_token = self.generate_access_token(
|
||||||
|
|||||||
@@ -448,12 +448,15 @@ class ToolGenerator:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Import custom exceptions for better error handling
|
# Import custom exceptions for better error handling
|
||||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
from plugins.wordpress.client import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
|
|
||||||
if isinstance(e, ConfigurationError):
|
if isinstance(e, ConfigurationError):
|
||||||
# Configuration error - likely missing env vars
|
|
||||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||||
return (
|
return (
|
||||||
f"Configuration Error: {str(e)}\n\n"
|
f"Configuration Error: {str(e)}\n\n"
|
||||||
@@ -464,12 +467,14 @@ class ToolGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(e, AuthenticationError):
|
elif isinstance(e, AuthenticationError):
|
||||||
# Authentication error - 401/403
|
|
||||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||||
return f"Authentication Error: {str(e)}"
|
return f"Authentication Error: {str(e)}"
|
||||||
|
|
||||||
|
elif isinstance(e, ConnectionError):
|
||||||
|
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
|
||||||
|
return f"Connection Error: {str(e)}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unexpected error
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
try:
|
try:
|
||||||
health = await client.health_check()
|
health = await client.health_check()
|
||||||
info["health"] = health
|
info["health"] = health
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get current user to verify connectivity
|
# Get current user to verify connectivity
|
||||||
@@ -123,7 +123,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
"email": user.get("email"),
|
"email": user.get("email"),
|
||||||
"role": user.get("role"),
|
"role": user.get("role"),
|
||||||
}
|
}
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
info["instance_url"] = client.site_url
|
info["instance_url"] = client.site_url
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
|||||||
# Variable exists, update it
|
# Variable exists, update it
|
||||||
await client.update_variable(key, value)
|
await client.update_variable(key, value)
|
||||||
updated.append(key)
|
updated.append(key)
|
||||||
except:
|
except Exception:
|
||||||
# Variable doesn't exist, create it
|
# Variable doesn't exist, create it
|
||||||
await client.create_variable(key, value)
|
await client.create_variable(key, value)
|
||||||
created.append(key)
|
created.append(key)
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ async def get_profile(client: OpenPanelClient, project_id: str, profile_id: str)
|
|||||||
indent=2,
|
indent=2,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass # Profile event fetch is optional; fall through to generic response
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -161,21 +161,21 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
try:
|
try:
|
||||||
tables = await client.list_tables()
|
tables = await client.list_tables()
|
||||||
stats["tables"] = len(tables) if isinstance(tables, list) else 0
|
stats["tables"] = len(tables) if isinstance(tables, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get schema count
|
# Get schema count
|
||||||
try:
|
try:
|
||||||
schemas = await client.list_schemas()
|
schemas = await client.list_schemas()
|
||||||
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
|
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get extension count
|
# Get extension count
|
||||||
try:
|
try:
|
||||||
extensions = await client.list_extensions()
|
extensions = await client.list_extensions()
|
||||||
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
|
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get database size and version
|
# Get database size and version
|
||||||
@@ -186,7 +186,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
if isinstance(size_result, list) and len(size_result) > 0:
|
if isinstance(size_result, list) and len(size_result) > 0:
|
||||||
stats["size"] = size_result[0].get("size", "unknown")
|
stats["size"] = size_result[0].get("size", "unknown")
|
||||||
stats["version"] = size_result[0].get("version", "unknown")
|
stats["version"] = size_result[0].get("version", "unknown")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get connection info
|
# Get connection info
|
||||||
@@ -196,7 +196,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
)
|
)
|
||||||
if isinstance(conn_result, list) and len(conn_result) > 0:
|
if isinstance(conn_result, list) and len(conn_result) > 0:
|
||||||
stats["active_connections"] = conn_result[0].get("connections", 0)
|
stats["active_connections"] = conn_result[0].get("connections", 0)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
|
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
|
||||||
@@ -237,7 +237,7 @@ async def get_storage_stats(client: SupabaseClient) -> str:
|
|||||||
if isinstance(files, list):
|
if isinstance(files, list):
|
||||||
bucket_info["file_count"] = len(files)
|
bucket_info["file_count"] = len(files)
|
||||||
stats["total_files"] += len(files)
|
stats["total_files"] += len(files)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
stats["bucket_details"].append(bucket_info)
|
stats["bucket_details"].append(bucket_info)
|
||||||
@@ -333,7 +333,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
|||||||
await client.list_schemas()
|
await client.list_schemas()
|
||||||
|
|
||||||
info["services_available"].append(service)
|
info["services_available"].append(service)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try to get PostgreSQL version
|
# Try to get PostgreSQL version
|
||||||
@@ -341,7 +341,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
|||||||
version_result = await client.execute_sql("SELECT version()")
|
version_result = await client.execute_sql("SELECT version()")
|
||||||
if isinstance(version_result, list) and len(version_result) > 0:
|
if isinstance(version_result, list) and len(version_result) > 0:
|
||||||
info["postgres_version"] = version_result[0].get("version", "unknown")
|
info["postgres_version"] = version_result[0].get("version", "unknown")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)
|
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ Handles all HTTP communication with WordPress REST API.
|
|||||||
Separates API communication from business logic.
|
Separates API communication from business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -25,6 +27,23 @@ class AuthenticationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionError(Exception):
|
||||||
|
"""Raised when a network connection to the site fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Transient HTTP status codes that are worth retrying
|
||||||
|
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
|
||||||
|
|
||||||
|
# Default request timeout in seconds
|
||||||
|
_REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Retry configuration
|
||||||
|
_MAX_RETRIES = 2
|
||||||
|
_RETRY_BACKOFF_BASE = 1.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
class WordPressClient:
|
class WordPressClient:
|
||||||
"""
|
"""
|
||||||
WordPress REST API client for HTTP communication.
|
WordPress REST API client for HTTP communication.
|
||||||
@@ -140,37 +159,124 @@ class WordPressClient:
|
|||||||
if json_data:
|
if json_data:
|
||||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||||
|
|
||||||
# Make request
|
# Make request with retry for transient errors
|
||||||
async with (
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
aiohttp.ClientSession() as session,
|
last_exception = None
|
||||||
session.request(
|
|
||||||
method, url, params=params, json=json_data, data=data, headers=headers
|
|
||||||
) as response,
|
|
||||||
):
|
|
||||||
# Handle errors with structured error messages
|
|
||||||
if response.status >= 400:
|
|
||||||
error_text = await response.text()
|
|
||||||
|
|
||||||
# Parse structured error response
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
error_info = self._parse_error_response(
|
try:
|
||||||
response.status, error_text, use_woocommerce
|
async with (
|
||||||
|
aiohttp.ClientSession(timeout=timeout) as session,
|
||||||
|
session.request(
|
||||||
|
method, url, params=params, json=json_data, data=data, headers=headers
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
# Handle errors with structured error messages
|
||||||
|
if response.status >= 400:
|
||||||
|
error_text = await response.text()
|
||||||
|
|
||||||
|
# Retry on transient server errors (502, 503, 504, 429)
|
||||||
|
if response.status in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"Transient error {response.status} from {url}, "
|
||||||
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse structured error response
|
||||||
|
error_info = self._parse_error_response(
|
||||||
|
response.status, error_text, use_woocommerce
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the error for debugging
|
||||||
|
self.logger.error(
|
||||||
|
f"API error: {error_info['error_code']} - {error_info['message']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Raise appropriate exception
|
||||||
|
if response.status in (401, 403):
|
||||||
|
raise AuthenticationError(
|
||||||
|
f"[{error_info['error_code']}] {error_info['message']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception(f"[{error_info['error_code']}] {error_info['message']}")
|
||||||
|
|
||||||
|
# Return JSON response
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
except (AuthenticationError, ConfigurationError):
|
||||||
|
raise # Never retry auth/config errors
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
last_exception = ConnectionError(
|
||||||
|
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||||
|
f"The site at {self.site_url} is not responding. "
|
||||||
|
"Possible causes: site is overloaded, network is slow, "
|
||||||
|
"or the server is down."
|
||||||
)
|
)
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
# Log the error for debugging
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
self.logger.error(
|
self.logger.warning(
|
||||||
f"API error: {error_info['error_code']} - {error_info['message']}"
|
f"Timeout connecting to {url}, "
|
||||||
)
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
|
||||||
# Raise appropriate exception
|
|
||||||
if response.status in (401, 403):
|
|
||||||
raise AuthenticationError(
|
|
||||||
f"[{error_info['error_code']}] {error_info['message']}"
|
|
||||||
)
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
raise Exception(f"[{error_info['error_code']}] {error_info['message']}")
|
except aiohttp.ClientConnectorCertificateError as e:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"SSL certificate error for {self.site_url}. "
|
||||||
|
"The site's SSL certificate is invalid or expired. "
|
||||||
|
f"Details: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
# Return JSON response
|
except aiohttp.ClientConnectorDNSError as e:
|
||||||
return await response.json()
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
raise ConnectionError(
|
||||||
|
f"DNS resolution failed for '{host}'. "
|
||||||
|
"The domain name could not be found. "
|
||||||
|
"Please check that the URL is correct."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError as e:
|
||||||
|
os_error = getattr(e, "os_error", None)
|
||||||
|
if isinstance(os_error, socket.gaierror):
|
||||||
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
raise ConnectionError(
|
||||||
|
f"DNS resolution failed for '{host}'. "
|
||||||
|
"The domain name could not be found. "
|
||||||
|
"Please check that the URL is correct."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Cannot connect to {self.site_url}. "
|
||||||
|
"The server is unreachable. Possible causes: "
|
||||||
|
"wrong URL, server is down, firewall blocking, or wrong port."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except aiohttp.InvalidURL:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Invalid URL: {self.site_url}. "
|
||||||
|
"Please provide a valid URL starting with https:// or http://."
|
||||||
|
)
|
||||||
|
|
||||||
|
except (aiohttp.ClientError, OSError) as e:
|
||||||
|
last_exception = ConnectionError(
|
||||||
|
f"Network error connecting to {self.site_url}: {e}"
|
||||||
|
)
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"Network error for {url}: {e}, "
|
||||||
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
raise last_exception # type: ignore[misc]
|
||||||
|
|
||||||
def _parse_error_response(
|
def _parse_error_response(
|
||||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||||
@@ -408,24 +514,29 @@ class WordPressClient:
|
|||||||
Dict with 'available' bool and version info
|
Dict with 'available' bool and version info
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try to access WooCommerce system status endpoint
|
|
||||||
response = await self.get("system_status", use_woocommerce=True)
|
response = await self.get("system_status", use_woocommerce=True)
|
||||||
return {
|
return {
|
||||||
"available": True,
|
"available": True,
|
||||||
"version": response.get("environment", {}).get("version", "unknown"),
|
"version": response.get("environment", {}).get("version", "unknown"),
|
||||||
}
|
}
|
||||||
except Exception:
|
except AuthenticationError:
|
||||||
return {"available": False, "version": None}
|
return {"available": False, "version": None, "reason": "authentication_failed"}
|
||||||
|
except ConnectionError as e:
|
||||||
|
return {"available": False, "version": None, "reason": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"WooCommerce check failed: {e}")
|
||||||
|
return {"available": False, "version": None, "reason": str(e)}
|
||||||
|
|
||||||
async def check_site_health(self) -> dict[str, Any]:
|
async def check_site_health(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Check WordPress site health and accessibility.
|
Check WordPress site health and accessibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with health status information
|
Dict with health status information including specific error diagnosis.
|
||||||
"""
|
"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.get(f"{self.site_url}/wp-json") as response:
|
async with session.get(f"{self.site_url}/wp-json") as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
@@ -437,15 +548,88 @@ class WordPressClient:
|
|||||||
"url": data.get("url", self.site_url),
|
"url": data.get("url", self.site_url),
|
||||||
"routes": len(data.get("routes", {})),
|
"routes": len(data.get("routes", {})),
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
|
# Detect REST API disabled (common with security plugins)
|
||||||
|
if response.status in (403, 404):
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
"accessible": False,
|
"accessible": True,
|
||||||
"message": f"Site returned status {response.status}",
|
"error_type": "rest_api_disabled",
|
||||||
|
"message": (
|
||||||
|
f"WordPress REST API returned {response.status}. "
|
||||||
|
"The REST API may be disabled by a security plugin "
|
||||||
|
"(e.g., Wordfence, iThemes Security, Disable REST API). "
|
||||||
|
"Please ensure the REST API is enabled for MCP Hub to work."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
|
||||||
|
if response.status == 401:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": True,
|
||||||
|
"error_type": "auth_required",
|
||||||
|
"message": (
|
||||||
|
"WordPress REST API requires authentication even for discovery. "
|
||||||
|
"This may be caused by a security plugin restricting public access."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": True,
|
||||||
|
"error_type": "unexpected_status",
|
||||||
|
"message": f"Site returned HTTP {response.status}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
"accessible": False,
|
"accessible": False,
|
||||||
"message": f"Health check failed: {str(e)}",
|
"error_type": "timeout",
|
||||||
|
"message": (
|
||||||
|
f"Site at {self.site_url} did not respond within 10 seconds. "
|
||||||
|
"The server may be overloaded or down."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorDNSError:
|
||||||
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "dns_failure",
|
||||||
|
"message": (
|
||||||
|
f"DNS resolution failed for '{host}'. " "Please check that the URL is correct."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorCertificateError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "ssl_error",
|
||||||
|
"message": (
|
||||||
|
f"SSL certificate error for {self.site_url}. "
|
||||||
|
"The certificate may be expired or invalid."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "connection_refused",
|
||||||
|
"message": (
|
||||||
|
f"Cannot connect to {self.site_url}. "
|
||||||
|
"The server is unreachable — check URL, firewall, or server status."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Health check failed with unexpected error: {e}")
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "unknown",
|
||||||
|
"message": f"Health check failed: {e}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class MenusHandler:
|
|||||||
# Try custom endpoint first, fallback to standard if not available
|
# Try custom endpoint first, fallback to standard if not available
|
||||||
try:
|
try:
|
||||||
menus = await self.client.get("menus", use_custom_namespace=True)
|
menus = await self.client.get("menus", use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
|
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
|
||||||
menus = await self.client.get("navigation")
|
menus = await self.client.get("navigation")
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ class MenusHandler:
|
|||||||
# Get menu details
|
# Get menu details
|
||||||
try:
|
try:
|
||||||
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
|
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
menu = await self.client.get(f"navigation/{menu_id}")
|
menu = await self.client.get(f"navigation/{menu_id}")
|
||||||
|
|
||||||
# Get menu items
|
# Get menu items
|
||||||
@@ -256,7 +256,7 @@ class MenusHandler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
|
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
# Try navigation endpoint
|
# Try navigation endpoint
|
||||||
menu = await self.client.post("navigation", json_data=data)
|
menu = await self.client.post("navigation", json_data=data)
|
||||||
|
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ class WPCLIManager:
|
|||||||
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||||
else:
|
else:
|
||||||
size_str = "unknown"
|
size_str = "unknown"
|
||||||
except:
|
except (ValueError, IndexError, OSError):
|
||||||
size_str = "unknown"
|
size_str = "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1198,7 +1198,7 @@ class WPCLIManager:
|
|||||||
site_cmd = "core version"
|
site_cmd = "core version"
|
||||||
version_result = await self._execute_wp_cli(site_cmd)
|
version_result = await self._execute_wp_cli(site_cmd)
|
||||||
current_version = version_result.get("message", "unknown").strip()
|
current_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
current_version = "unknown"
|
current_version = "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1220,7 +1220,7 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
version_result = await self._execute_wp_cli("core version")
|
version_result = await self._execute_wp_cli("core version")
|
||||||
old_version = version_result.get("message", "unknown").strip()
|
old_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
old_version = "unknown"
|
old_version = "unknown"
|
||||||
|
|
||||||
# Execute update (long timeout for downloads)
|
# Execute update (long timeout for downloads)
|
||||||
@@ -1230,7 +1230,7 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
version_result = await self._execute_wp_cli("core version")
|
version_result = await self._execute_wp_cli("core version")
|
||||||
new_version = version_result.get("message", "unknown").strip()
|
new_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
new_version = "unknown"
|
new_version = "unknown"
|
||||||
|
|
||||||
# Determine update type
|
# Determine update type
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ class SystemHandler:
|
|||||||
try:
|
try:
|
||||||
db_result = await self.wp_cli.execute_command(db_cmd)
|
db_result = await self.wp_cli.execute_command(db_cmd)
|
||||||
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
||||||
except:
|
except (ValueError, KeyError, Exception):
|
||||||
sizes["database_size_mb"] = 0.0
|
sizes["database_size_mb"] = 0.0
|
||||||
|
|
||||||
# Calculate total
|
# Calculate total
|
||||||
@@ -456,7 +456,7 @@ class SystemHandler:
|
|||||||
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
||||||
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
||||||
schedules = {s.get("name"): s for s in schedules_data}
|
schedules = {s.get("name"): s for s in schedules_data}
|
||||||
except:
|
except (json.JSONDecodeError, KeyError, Exception):
|
||||||
schedules = {}
|
schedules = {}
|
||||||
|
|
||||||
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
||||||
@@ -514,7 +514,7 @@ class SystemHandler:
|
|||||||
size_result = await self.wp_cli.execute_command(size_cmd)
|
size_result = await self.wp_cli.execute_command(size_cmd)
|
||||||
log_size_bytes = int(size_result.get("output", "0").strip())
|
log_size_bytes = int(size_result.get("output", "0").strip())
|
||||||
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
||||||
except:
|
except (ValueError, KeyError, Exception):
|
||||||
log_size_mb = 0.0
|
log_size_mb = 0.0
|
||||||
|
|
||||||
# Read log file (last N lines)
|
# Read log file (last N lines)
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ Integration tests covering plugin initialization, configuration validation,
|
|||||||
tool specifications, handler delegation, client behavior, and health checks.
|
tool specifications, handler delegation, client behavior, and health checks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plugins.base import BasePlugin, PluginRegistry
|
from plugins.base import BasePlugin, PluginRegistry
|
||||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError, WordPressClient
|
from plugins.wordpress.client import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ConnectionError,
|
||||||
|
WordPressClient,
|
||||||
|
)
|
||||||
from plugins.wordpress.plugin import WordPressPlugin
|
from plugins.wordpress.plugin import WordPressPlugin
|
||||||
|
|
||||||
# --- WordPressClient Tests ---
|
# --- WordPressClient Tests ---
|
||||||
@@ -282,6 +289,282 @@ class TestWordPressClientRequest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Connection Error Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestWordPressClientConnectionErrors:
|
||||||
|
"""Test network error differentiation and retry logic."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return WordPressClient(
|
||||||
|
site_url="https://example.com",
|
||||||
|
username="admin",
|
||||||
|
app_password="xxxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_error_raises_connection_error(self, client):
|
||||||
|
"""DNS failure should raise ConnectionError with helpful message."""
|
||||||
|
dns_error = aiohttp.ClientConnectorDNSError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Name resolution failed")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=dns_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="DNS resolution failed"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssl_error_raises_connection_error(self, client):
|
||||||
|
"""SSL certificate error should raise ConnectionError with helpful message."""
|
||||||
|
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||||
|
connection_key=MagicMock(), certificate_error=Exception("cert expired")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=ssl_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="SSL certificate error"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_refused_raises_connection_error(self, client):
|
||||||
|
"""Connection refused should raise ConnectionError with helpful message."""
|
||||||
|
conn_error = aiohttp.ClientConnectorError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=conn_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="Cannot connect"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_retries_then_raises(self, client):
|
||||||
|
"""Timeout should retry then raise ConnectionError."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=TimeoutError())
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
with pytest.raises(ConnectionError, match="timed out"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_url_raises_connection_error(self, client):
|
||||||
|
"""Invalid URL should raise ConnectionError."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=aiohttp.InvalidURL("bad url"))
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="Invalid URL"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_error_not_retried(self, client):
|
||||||
|
"""Auth errors should NOT be retried."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(
|
||||||
|
return_value=json.dumps({"code": "invalid_auth", "message": "Bad"})
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
# Should be called only once (no retry)
|
||||||
|
assert mock_session.request.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_502_retried_then_raises(self, client):
|
||||||
|
"""502 errors should be retried before raising."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 502
|
||||||
|
mock_response.text = AsyncMock(return_value="Bad Gateway")
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
with pytest.raises(Exception, match="BAD_GATEWAY"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
# Should be called 3 times (1 + 2 retries)
|
||||||
|
assert mock_session.request.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestWordPressClientHealthCheck:
|
||||||
|
"""Test site health check with error differentiation."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return WordPressClient(
|
||||||
|
site_url="https://example.com",
|
||||||
|
username="admin",
|
||||||
|
app_password="xxxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_healthy_site(self, client):
|
||||||
|
"""Healthy site should return proper status."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"name": "My Blog",
|
||||||
|
"description": "A blog",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"routes": {"a": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is True
|
||||||
|
assert result["name"] == "My Blog"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rest_api_disabled_detected(self, client):
|
||||||
|
"""403/404 on /wp-json should detect REST API disabled."""
|
||||||
|
for status in (403, 404):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = status
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["accessible"] is True
|
||||||
|
assert result["error_type"] == "rest_api_disabled"
|
||||||
|
assert "REST API" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_failure_in_health_check(self, client):
|
||||||
|
"""DNS failure in health check should be detected."""
|
||||||
|
dns_error = aiohttp.ClientConnectorDNSError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("DNS failed")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=dns_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "dns_failure"
|
||||||
|
assert "DNS" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_in_health_check(self, client):
|
||||||
|
"""Timeout in health check should be detected."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=TimeoutError())
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "timeout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssl_error_in_health_check(self, client):
|
||||||
|
"""SSL error in health check should be detected."""
|
||||||
|
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||||
|
connection_key=MagicMock(), certificate_error=Exception("expired")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=ssl_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "ssl_error"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_refused_in_health_check(self, client):
|
||||||
|
"""Connection refused in health check should be detected."""
|
||||||
|
conn_error = aiohttp.ClientConnectorError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=conn_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "connection_refused"
|
||||||
|
|
||||||
|
|
||||||
# --- WordPressPlugin Tests ---
|
# --- WordPressPlugin Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user