diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 5633d43..25dab8b 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -15,6 +15,7 @@ """ from hyperping._async_client import AsyncHyperpingClient +from hyperping._async_mcp_client import AsyncHyperpingMcpClient from hyperping._version import __version__ from hyperping.client import ( CircuitBreaker, @@ -40,7 +41,7 @@ from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, - AlertNotification, + AlertHistory, DnsRecordType, EscalationPolicy, Healthcheck, @@ -64,10 +65,13 @@ MonitorCreate, MonitorFrequency, MonitorListResponse, + MonitorMetricsSummary, MonitorProtocol, MonitorReport, MonitorTimeout, MonitorUpdate, + MttaReport, + MttrReport, NotificationOption, OnCallSchedule, Outage, @@ -77,14 +81,18 @@ OutageTimeline, OutageTimelineEvent, ProbeLog, + ProbeLogResponse, Region, ReportPeriod, RequestHeader, + ResponseTimeReport, StatusPage, StatusPageCreate, StatusPageSubscriber, StatusPageUpdate, StatusSummary, + TeamMember, + TimeGroup, ) __all__ = [ @@ -92,6 +100,7 @@ "__version__", # Clients "AsyncHyperpingClient", + "AsyncHyperpingMcpClient", "HyperpingClient", "HyperpingMcpClient", # MCP @@ -156,14 +165,21 @@ # Observability "MonitorAnomaly", "ProbeLog", - "AlertNotification", + "ProbeLogResponse", # On-call "OnCallSchedule", "EscalationPolicy", + "TeamMember", # Integrations "Integration", # Reporting "StatusSummary", + "TimeGroup", + "ResponseTimeReport", + "AlertHistory", + "MonitorMetricsSummary", + "MttrReport", + "MttaReport", # Healthchecks "Healthcheck", "HealthcheckCreate", diff --git a/src/hyperping/_async_mcp_client.py b/src/hyperping/_async_mcp_client.py new file mode 100644 index 0000000..fab127c --- /dev/null +++ b/src/hyperping/_async_mcp_client.py @@ -0,0 +1,249 @@ +"""Async high-level typed MCP client for the Hyperping MCP server. + +Wraps :class:`~hyperping._async_mcp_transport.AsyncMcpTransport` with typed +convenience methods that mirror the MCP tool names exposed by the server. + +Example:: + + from hyperping import AsyncHyperpingMcpClient + + async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp: + summary = await mcp.get_status_summary() + print(summary.total, summary.up, summary.down) +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import SecretStr + +from hyperping._async_mcp_transport import AsyncMcpTransport +from hyperping.endpoints import MCP_URL +from hyperping.models._integration_models import Integration +from hyperping.models._monitor_models import Monitor +from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember +from hyperping.models._outage_models import OutageTimeline +from hyperping.models._reporting_models import ( + AlertHistory, + MttaReport, + MttrReport, + ResponseTimeReport, + StatusSummary, +) + + +class AsyncHyperpingMcpClient: + """Async high-level client for Hyperping MCP server tools. + + Provides typed convenience methods for every MCP tool. Methods return + Pydantic models matching the verified API response shapes. + + Supports the same ``api_key`` formats (``str`` or ``SecretStr``) and + async context-manager pattern as + :class:`~hyperping._async_client.AsyncHyperpingClient`. + """ + + def __init__( + self, + api_key: str | SecretStr, + base_url: str = MCP_URL, + timeout: float = 30.0, + ) -> None: + self._transport = AsyncMcpTransport( + api_key=api_key, + base_url=base_url, + timeout=timeout, + ) + + # ==================== Internal ==================== + + async def _call(self, tool: str, args: dict[str, Any] | None = None) -> Any: + """Call an MCP tool via the transport.""" + return await self._transport.call_tool(tool, args or {}) + + # ==================== Context Manager ==================== + + async def close(self) -> None: + """Close the underlying HTTP transport.""" + await self._transport.close() + + async def __aenter__(self) -> AsyncHyperpingMcpClient: + return self + + async def __aexit__(self, *args: object) -> None: + await self.close() + + # ==================== Status & Reporting ==================== + + async def get_status_summary(self) -> StatusSummary: + """Get aggregate monitor status counts.""" + return StatusSummary.model_validate(await self._call("get_status_summary")) + + async def get_monitor_response_time( + self, + monitor_uuid: str, + **kwargs: Any, + ) -> ResponseTimeReport: + """Get response time metrics for a monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + return ResponseTimeReport.model_validate( + await self._call("get_monitor_response_time", {"uuid": monitor_uuid, **kwargs}) + ) + + async def get_monitor_mtta( + self, + monitor_uuid: str | None = None, + **kwargs: Any, + ) -> MttaReport: + """Get mean time to acknowledge metrics. + + Args: + monitor_uuid: Optional monitor UUID to scope the query. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + args: dict[str, Any] = {**kwargs} + if monitor_uuid is not None: + args["uuid"] = monitor_uuid + return MttaReport.model_validate(await self._call("get_monitor_mtta", args)) + + async def get_monitor_mttr( + self, + monitor_uuid: str | None = None, + **kwargs: Any, + ) -> MttrReport: + """Get mean time to resolve metrics. + + Args: + monitor_uuid: Optional monitor UUID to scope the query. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + args: dict[str, Any] = {**kwargs} + if monitor_uuid is not None: + args["uuid"] = monitor_uuid + return MttrReport.model_validate(await self._call("get_monitor_mttr", args)) + + # ==================== Observability ==================== + + async def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: + """Get anomalies detected for a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + data = await self._call("get_monitor_anomalies", {"uuid": monitor_uuid}) + raw = data.get("anomalies", []) if isinstance(data, dict) else [] + return [MonitorAnomaly.model_validate(a) for a in raw] + + async def get_monitor_http_logs( + self, + monitor_uuid: str, + **kwargs: Any, + ) -> ProbeLogResponse: + """Get HTTP probe logs for a monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + data = await self._call("get_monitor_http_logs", {"uuid": monitor_uuid, **kwargs}) + return ProbeLogResponse.model_validate(data) + + # ==================== Alerts ==================== + + async def list_recent_alerts(self, **kwargs: Any) -> AlertHistory: + """List recent alert notifications. + + Args: + **kwargs: Additional arguments forwarded to the MCP tool. + """ + return AlertHistory.model_validate(await self._call("list_recent_alerts", {**kwargs})) + + # ==================== On-Call ==================== + + async def list_on_call_schedules(self) -> list[OnCallSchedule]: + """List all on-call schedules.""" + data = await self._call("list_on_call_schedules") + raw = data.get("schedules", []) if isinstance(data, dict) else [] + return [OnCallSchedule.model_validate(s) for s in raw] + + async def get_on_call_schedule(self, uuid: str) -> OnCallSchedule: + """Get a single on-call schedule by UUID. + + Args: + uuid: Schedule UUID. + """ + return OnCallSchedule.model_validate( + await self._call("get_on_call_schedule", {"uuid": uuid}) + ) + + # ==================== Escalation Policies ==================== + + async def list_escalation_policies(self) -> list[EscalationPolicy]: + """List all escalation policies.""" + data = await self._call("list_escalation_policies") + raw = data if isinstance(data, list) else [] + return [EscalationPolicy.model_validate(p) for p in raw] + + async def get_escalation_policy(self, uuid: str) -> EscalationPolicy: + """Get a single escalation policy by UUID. + + Args: + uuid: Escalation policy UUID. + """ + return EscalationPolicy.model_validate( + await self._call("get_escalation_policy", {"uuid": uuid}) + ) + + # ==================== Team ==================== + + async def list_team_members(self) -> list[TeamMember]: + """List all team members.""" + data = await self._call("list_team_members") + raw = data if isinstance(data, list) else [] + return [TeamMember.model_validate(m) for m in raw] + + # ==================== Integrations ==================== + + async def list_integrations(self) -> list[Integration]: + """List all notification channel integrations.""" + data = await self._call("list_integrations") + raw = data if isinstance(data, list) else [] + return [Integration.model_validate(i) for i in raw] + + async def get_integration(self, uuid: str) -> Integration: + """Get a single integration by UUID. + + Args: + uuid: Integration UUID. + """ + return Integration.model_validate(await self._call("get_integration", {"uuid": uuid})) + + # ==================== Outages ==================== + + async def get_outage_timeline(self, outage_uuid: str) -> OutageTimeline: + """Get the lifecycle timeline for an outage. + + Args: + outage_uuid: Outage UUID. + """ + return OutageTimeline.model_validate( + await self._call("get_outage_timeline", {"uuid": outage_uuid}) + ) + + # ==================== Monitors ==================== + + async def search_monitors_by_name(self, query: str) -> list[Monitor]: + """Search monitors by name. + + Args: + query: Search string to match against monitor names. + """ + data = await self._call("search_monitors_by_name", {"query": query}) + raw = data if isinstance(data, list) else [] + return [Monitor.model_validate(m) for m in raw] diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py new file mode 100644 index 0000000..72792e4 --- /dev/null +++ b/src/hyperping/_async_mcp_transport.py @@ -0,0 +1,199 @@ +"""Async JSON-RPC 2.0 transport for the Hyperping MCP server.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +from pydantic import SecretStr + +from hyperping._version import __version__ +from hyperping.endpoints import MCP_URL +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingAuthError, + HyperpingNotFoundError, + HyperpingRateLimitError, + HyperpingValidationError, +) + +_PROTOCOL_VERSION = "2025-03-26" + + +class AsyncMcpTransport: + """Async low-level JSON-RPC 2.0 client for the Hyperping MCP server. + + The MCP server exposes tools not available via the REST API: on-call + schedules, anomalies, alerts, integrations, probe logs, and more. + + Uses the same Bearer token API key as the REST client. + """ + + def __init__( + self, + api_key: str | SecretStr, + base_url: str = MCP_URL, + timeout: float = 30.0, + max_retries: int = 2, + ) -> None: + token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key + self._url = base_url.rstrip("/") + self._client = httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + timeout=timeout, + ) + self._initialized = False + self._request_id = 0 + self._lock = asyncio.Lock() + self._max_retries = max_retries + + async def _next_id(self) -> int: + async with self._lock: + self._request_id += 1 + return self._request_id + + async def _send_rpc( + self, + method: str, + params: dict[str, Any] | None = None, + *, + is_notification: bool = False, + ) -> dict[str, Any] | None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + if not is_notification: + payload["id"] = await self._next_id() + + resp = await self._client.post(self._url, content=json.dumps(payload)) + + if resp.status_code in (401, 403): + raise HyperpingAuthError("Invalid or expired API key") + if resp.status_code == 202: + return None # Notification accepted + if resp.status_code == 404: + raise HyperpingNotFoundError( + "Resource not found", + status_code=404, + ) + if resp.status_code == 429: + retry_after = None + raw_retry = resp.headers.get("retry-after") + if raw_retry: + try: + retry_after = int(raw_retry) + except ValueError: + pass + raise HyperpingRateLimitError( + "Rate limit exceeded", + retry_after=retry_after, + status_code=429, + ) + if resp.status_code in (400, 422): + raise HyperpingValidationError( + f"Validation error: HTTP {resp.status_code}", + status_code=resp.status_code, + ) + if resp.status_code != 200: + raise HyperpingAPIError( + f"MCP server returned HTTP {resp.status_code}", + status_code=resp.status_code, + response_body={"raw": resp.text[:500]}, + ) + if is_notification: + return None + + data = resp.json() + if "error" in data: + err = data["error"] + raise HyperpingAPIError( + f"MCP error {err.get('code', '?')}: {err.get('message', 'unknown')}", + status_code=resp.status_code, + response_body=err, + ) + return data # type: ignore[no-any-return] + + async def initialize(self) -> dict[str, Any]: + """Perform MCP handshake. Called automatically on first tool call.""" + result = await self._send_rpc( + "initialize", + { + "protocolVersion": _PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "hyperping-python", "version": __version__}, + }, + ) + await self._send_rpc("notifications/initialized", is_notification=True) + async with self._lock: + self._initialized = True + return result.get("result", {}) if result else {} + + async def call_tool( + self, + tool_name: str, + arguments: dict[str, Any] | None = None, + ) -> Any: + """Call an MCP tool and return parsed response data. + + Auto-initializes on first call. Extracts and parses the JSON + string from ``result.content[0].text``. + + Retries automatically on transient server errors (HTTP 500, 502, + 503, 504) up to ``max_retries`` times with exponential back-off. + """ + async with self._lock: + needs_init = not self._initialized + if needs_init: + await self.initialize() + + last_exc: Exception | None = None + for attempt in range(self._max_retries + 1): + try: + result = await self._send_rpc( + "tools/call", + {"name": tool_name, "arguments": arguments or {}}, + ) + break + except HyperpingAPIError as exc: + if exc.status_code and exc.status_code in (500, 502, 503, 504): + last_exc = exc + if attempt < self._max_retries: + await asyncio.sleep(min(2**attempt, 10)) + continue + raise + else: + raise last_exc # type: ignore[misc] + if result is None: + return None + + content = result.get("result", {}).get("content", []) + if not content: + return None + + text = content[0].get("text", "") + if not text: + return None + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise HyperpingAPIError( + f"Failed to parse MCP tool response: {exc}", + status_code=200, + response_body={"raw": text[:500]}, + ) from exc + + async def close(self) -> None: + await self._client.aclose() + + async def __aenter__(self) -> AsyncMcpTransport: + return self + + async def __aexit__(self, *args: object) -> None: + await self.close() diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py index 47d3955..15ff959 100644 --- a/src/hyperping/_mcp_transport.py +++ b/src/hyperping/_mcp_transport.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import threading +import time from typing import Any import httpx @@ -10,7 +12,13 @@ from hyperping._version import __version__ from hyperping.endpoints import MCP_URL -from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingAuthError, + HyperpingNotFoundError, + HyperpingRateLimitError, + HyperpingValidationError, +) _PROTOCOL_VERSION = "2025-03-26" @@ -29,6 +37,7 @@ def __init__( api_key: str | SecretStr, base_url: str = MCP_URL, timeout: float = 30.0, + max_retries: int = 2, ) -> None: token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key self._url = base_url.rstrip("/") @@ -42,10 +51,13 @@ def __init__( ) self._initialized = False self._request_id = 0 + self._lock = threading.Lock() + self._max_retries = max_retries def _next_id(self) -> int: - self._request_id += 1 - return self._request_id + with self._lock: + self._request_id += 1 + return self._request_id def _send_rpc( self, @@ -66,6 +78,29 @@ def _send_rpc( raise HyperpingAuthError("Invalid or expired API key") if resp.status_code == 202: return None # Notification accepted + if resp.status_code == 404: + raise HyperpingNotFoundError( + "Resource not found", + status_code=404, + ) + if resp.status_code == 429: + retry_after = None + raw = resp.headers.get("retry-after") + if raw: + try: + retry_after = int(raw) + except ValueError: + pass + raise HyperpingRateLimitError( + "Rate limit exceeded", + retry_after=retry_after, + status_code=429, + ) + if resp.status_code in (400, 422): + raise HyperpingValidationError( + f"Validation error: HTTP {resp.status_code}", + status_code=resp.status_code, + ) if resp.status_code != 200: raise HyperpingAPIError( f"MCP server returned HTTP {resp.status_code}", @@ -96,7 +131,8 @@ def initialize(self) -> dict[str, Any]: }, ) self._send_rpc("notifications/initialized", is_notification=True) - self._initialized = True + with self._lock: + self._initialized = True return result.get("result", {}) if result else {} def call_tool( @@ -108,14 +144,33 @@ def call_tool( Auto-initializes on first call. Extracts and parses the JSON string from ``result.content[0].text``. + + Retries automatically on transient server errors (HTTP 500, 502, + 503, 504) up to ``max_retries`` times with exponential back-off. """ - if not self._initialized: + with self._lock: + needs_init = not self._initialized + if needs_init: self.initialize() - result = self._send_rpc( - "tools/call", - {"name": tool_name, "arguments": arguments or {}}, - ) + last_exc: Exception | None = None + for attempt in range(self._max_retries + 1): + try: + result = self._send_rpc( + "tools/call", + {"name": tool_name, "arguments": arguments or {}}, + ) + break + except HyperpingAPIError as exc: + if exc.status_code and exc.status_code in (500, 502, 503, 504): + last_exc = exc + if attempt < self._max_retries: + time.sleep(min(2**attempt, 10)) + continue + raise + else: + raise last_exc # type: ignore[misc] + if result is None: return None diff --git a/src/hyperping/mcp_client.py b/src/hyperping/mcp_client.py index 2b181f8..852707a 100644 --- a/src/hyperping/mcp_client.py +++ b/src/hyperping/mcp_client.py @@ -9,7 +9,7 @@ with HyperpingMcpClient(api_key="sk_...") as mcp: summary = mcp.get_status_summary() - schedules = mcp.list_on_call_schedules() + print(summary.total, summary.up, summary.down) """ from __future__ import annotations @@ -20,14 +20,25 @@ from hyperping._mcp_transport import McpTransport from hyperping.endpoints import MCP_URL +from hyperping.models._integration_models import Integration +from hyperping.models._monitor_models import Monitor +from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember +from hyperping.models._outage_models import OutageTimeline +from hyperping.models._reporting_models import ( + AlertHistory, + MttaReport, + MttrReport, + ResponseTimeReport, + StatusSummary, +) class HyperpingMcpClient: """High-level client for Hyperping MCP server tools. - Provides typed convenience methods for every MCP tool. All methods - return plain dicts or lists of dicts; callers may validate further - with Pydantic models if desired. + Provides typed convenience methods for every MCP tool. Methods return + Pydantic models matching the verified API response shapes. Supports the same ``api_key`` formats (``str`` or ``SecretStr``) and context-manager pattern as :class:`~hyperping.client.HyperpingClient`. @@ -65,31 +76,30 @@ def __exit__(self, *args: object) -> None: # ==================== Status & Reporting ==================== - def get_status_summary(self) -> Any: + def get_status_summary(self) -> StatusSummary: """Get aggregate monitor status counts.""" - return self._call("get_status_summary", {}) + return StatusSummary.model_validate(self._call("get_status_summary")) def get_monitor_response_time( self, monitor_uuid: str, **kwargs: Any, - ) -> Any: + ) -> ResponseTimeReport: """Get response time metrics for a monitor. Args: monitor_uuid: Monitor UUID. **kwargs: Additional arguments forwarded to the MCP tool. """ - return self._call( - "get_monitor_response_time", - {"uuid": monitor_uuid, **kwargs}, + return ResponseTimeReport.model_validate( + self._call("get_monitor_response_time", {"uuid": monitor_uuid, **kwargs}) ) def get_monitor_mtta( self, monitor_uuid: str | None = None, **kwargs: Any, - ) -> Any: + ) -> MttaReport: """Get mean time to acknowledge metrics. Args: @@ -99,13 +109,13 @@ def get_monitor_mtta( args: dict[str, Any] = {**kwargs} if monitor_uuid is not None: args["uuid"] = monitor_uuid - return self._call("get_monitor_mtta", args) + return MttaReport.model_validate(self._call("get_monitor_mtta", args)) def get_monitor_mttr( self, monitor_uuid: str | None = None, **kwargs: Any, - ) -> Any: + ) -> MttrReport: """Get mean time to resolve metrics. Args: @@ -115,108 +125,120 @@ def get_monitor_mttr( args: dict[str, Any] = {**kwargs} if monitor_uuid is not None: args["uuid"] = monitor_uuid - return self._call("get_monitor_mttr", args) + return MttrReport.model_validate(self._call("get_monitor_mttr", args)) # ==================== Observability ==================== - def get_monitor_anomalies(self, monitor_uuid: str) -> Any: + def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: """Get anomalies detected for a monitor. Args: monitor_uuid: Monitor UUID. """ - return self._call("get_monitor_anomalies", {"uuid": monitor_uuid}) + data = self._call("get_monitor_anomalies", {"uuid": monitor_uuid}) + raw = data.get("anomalies", []) if isinstance(data, dict) else [] + return [MonitorAnomaly.model_validate(a) for a in raw] def get_monitor_http_logs( self, monitor_uuid: str, **kwargs: Any, - ) -> Any: + ) -> ProbeLogResponse: """Get HTTP probe logs for a monitor. Args: monitor_uuid: Monitor UUID. **kwargs: Additional arguments forwarded to the MCP tool. """ - return self._call( - "get_monitor_http_logs", - {"uuid": monitor_uuid, **kwargs}, - ) + data = self._call("get_monitor_http_logs", {"uuid": monitor_uuid, **kwargs}) + return ProbeLogResponse.model_validate(data) # ==================== Alerts ==================== - def list_recent_alerts(self, **kwargs: Any) -> Any: + def list_recent_alerts(self, **kwargs: Any) -> AlertHistory: """List recent alert notifications. Args: **kwargs: Additional arguments forwarded to the MCP tool. """ - return self._call("list_recent_alerts", {**kwargs}) + return AlertHistory.model_validate(self._call("list_recent_alerts", {**kwargs})) # ==================== On-Call ==================== - def list_on_call_schedules(self) -> Any: + def list_on_call_schedules(self) -> list[OnCallSchedule]: """List all on-call schedules.""" - return self._call("list_on_call_schedules", {}) + data = self._call("list_on_call_schedules") + raw = data.get("schedules", []) if isinstance(data, dict) else [] + return [OnCallSchedule.model_validate(s) for s in raw] - def get_on_call_schedule(self, uuid: str) -> Any: + def get_on_call_schedule(self, uuid: str) -> OnCallSchedule: """Get a single on-call schedule by UUID. Args: uuid: Schedule UUID. """ - return self._call("get_on_call_schedule", {"uuid": uuid}) + return OnCallSchedule.model_validate(self._call("get_on_call_schedule", {"uuid": uuid})) # ==================== Escalation Policies ==================== - def list_escalation_policies(self) -> Any: + def list_escalation_policies(self) -> list[EscalationPolicy]: """List all escalation policies.""" - return self._call("list_escalation_policies", {}) + data = self._call("list_escalation_policies") + raw = data if isinstance(data, list) else [] + return [EscalationPolicy.model_validate(p) for p in raw] - def get_escalation_policy(self, uuid: str) -> Any: + def get_escalation_policy(self, uuid: str) -> EscalationPolicy: """Get a single escalation policy by UUID. Args: uuid: Escalation policy UUID. """ - return self._call("get_escalation_policy", {"uuid": uuid}) + return EscalationPolicy.model_validate(self._call("get_escalation_policy", {"uuid": uuid})) # ==================== Team ==================== - def list_team_members(self) -> Any: + def list_team_members(self) -> list[TeamMember]: """List all team members.""" - return self._call("list_team_members", {}) + data = self._call("list_team_members") + raw = data if isinstance(data, list) else [] + return [TeamMember.model_validate(m) for m in raw] # ==================== Integrations ==================== - def list_integrations(self) -> Any: + def list_integrations(self) -> list[Integration]: """List all notification channel integrations.""" - return self._call("list_integrations", {}) + data = self._call("list_integrations") + raw = data if isinstance(data, list) else [] + return [Integration.model_validate(i) for i in raw] - def get_integration(self, uuid: str) -> Any: + def get_integration(self, uuid: str) -> Integration: """Get a single integration by UUID. Args: uuid: Integration UUID. """ - return self._call("get_integration", {"uuid": uuid}) + return Integration.model_validate(self._call("get_integration", {"uuid": uuid})) # ==================== Outages ==================== - def get_outage_timeline(self, outage_uuid: str) -> Any: + def get_outage_timeline(self, outage_uuid: str) -> OutageTimeline: """Get the lifecycle timeline for an outage. Args: outage_uuid: Outage UUID. """ - return self._call("get_outage_timeline", {"uuid": outage_uuid}) + return OutageTimeline.model_validate( + self._call("get_outage_timeline", {"uuid": outage_uuid}) + ) # ==================== Monitors ==================== - def search_monitors_by_name(self, query: str) -> Any: + def search_monitors_by_name(self, query: str) -> list[Monitor]: """Search monitors by name. Args: query: Search string to match against monitor names. """ - return self._call("search_monitors_by_name", {"query": query}) + data = self._call("search_monitors_by_name", {"query": query}) + raw = data if isinstance(data, list) else [] + return [Monitor.model_validate(m) for m in raw] diff --git a/src/hyperping/models/__init__.py b/src/hyperping/models/__init__.py index c66fef8..d693bf3 100644 --- a/src/hyperping/models/__init__.py +++ b/src/hyperping/models/__init__.py @@ -52,18 +52,27 @@ RequestHeader, ) from hyperping.models._observability_models import ( - AlertNotification, MonitorAnomaly, ProbeLog, + ProbeLogResponse, ) -from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember from hyperping.models._outage_models import ( Outage, OutageAction, + OutageMonitorSummary, OutageTimeline, OutageTimelineEvent, ) -from hyperping.models._reporting_models import StatusSummary +from hyperping.models._reporting_models import ( + AlertHistory, + MonitorMetricsSummary, + MttaReport, + MttrReport, + ResponseTimeReport, + StatusSummary, + TimeGroup, +) from hyperping.models._statuspage_models import ( StatusPage, StatusPageCreate, @@ -115,19 +124,27 @@ # Outage models "Outage", "OutageAction", + "OutageMonitorSummary", "OutageTimeline", "OutageTimelineEvent", # Observability models "MonitorAnomaly", "ProbeLog", - "AlertNotification", + "ProbeLogResponse", # On-call models "OnCallSchedule", "EscalationPolicy", + "TeamMember", # Integration models "Integration", # Reporting models "StatusSummary", + "TimeGroup", + "ResponseTimeReport", + "AlertHistory", + "MonitorMetricsSummary", + "MttrReport", + "MttaReport", # Healthcheck models "Healthcheck", "HealthcheckCreate", diff --git a/src/hyperping/models/_healthcheck_models.py b/src/hyperping/models/_healthcheck_models.py index dcd29aa..06d73e8 100644 --- a/src/hyperping/models/_healthcheck_models.py +++ b/src/hyperping/models/_healthcheck_models.py @@ -56,11 +56,11 @@ class Healthcheck(HealthcheckBase): API: GET /v2/healthchecks, GET /v2/healthchecks/{uuid} - Uses ``extra="ignore"`` to tolerate additional API fields and - ``frozen=True`` for immutability (D-06: is_paused not paused). + Uses ``extra="allow"`` so new API fields are preserved instead of silently + dropped, and ``frozen=True`` for immutability (D-06: is_paused not paused). """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Healthcheck unique identifier") is_paused: bool = Field(default=False, description="Whether the healthcheck is paused") diff --git a/src/hyperping/models/_incident_models.py b/src/hyperping/models/_incident_models.py index 940951a..5ac706e 100644 --- a/src/hyperping/models/_incident_models.py +++ b/src/hyperping/models/_incident_models.py @@ -29,7 +29,7 @@ class IncidentUpdateType(StrEnum): class IncidentUpdate(BaseModel): """Model for an incident update from v3 API.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Update UUID") date: str = Field(..., description="Update timestamp ISO 8601") @@ -99,7 +99,7 @@ class Incident(BaseModel): API: GET /v3/incidents, GET /v3/incidents/{uuid} """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Incident UUID (inci_xxx)") date: str | None = Field(default=None, description="Incident date ISO 8601") diff --git a/src/hyperping/models/_integration_models.py b/src/hyperping/models/_integration_models.py index e44c047..e38f712 100644 --- a/src/hyperping/models/_integration_models.py +++ b/src/hyperping/models/_integration_models.py @@ -6,7 +6,7 @@ class Integration(BaseModel): """Configured notification integration (Slack, Teams, PagerDuty, etc.).""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Integration UUID") name: str = Field(..., description="Integration display name") diff --git a/src/hyperping/models/_maintenance_models.py b/src/hyperping/models/_maintenance_models.py index 6a0d639..77ddee2 100644 --- a/src/hyperping/models/_maintenance_models.py +++ b/src/hyperping/models/_maintenance_models.py @@ -84,7 +84,7 @@ class Maintenance(BaseModel): API: GET /v1/maintenance-windows, GET /v1/maintenance-windows/{uuid} """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Maintenance UUID (mw_xxx)") name: str = Field(..., description="Internal name") diff --git a/src/hyperping/models/_monitor_models.py b/src/hyperping/models/_monitor_models.py index 405e086..92fb8e0 100644 --- a/src/hyperping/models/_monitor_models.py +++ b/src/hyperping/models/_monitor_models.py @@ -337,7 +337,7 @@ class MonitorUpdate(BaseModel): class Monitor(MonitorBase): """Model for a monitor response from Hyperping API.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Monitor unique identifier (mon_xxx)") project_uuid: str | None = Field(default=None, alias="projectUuid") @@ -398,7 +398,7 @@ class OutageDetail(BaseModel): start_date: str = Field(..., alias="startDate") end_date: str = Field(..., alias="endDate") - model_config = ConfigDict(populate_by_name=True, frozen=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, frozen=True, extra="allow") class OutageStats(BaseModel): @@ -428,7 +428,7 @@ class MonitorReport(BaseModel): API: GET /v2/reporting/monitor-reports?period=30d """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Monitor UUID") name: str = Field(..., description="Monitor name") @@ -445,7 +445,7 @@ class MonitorReport(BaseModel): class MonitorListResponse(BaseModel): """Response model for list monitors endpoint.""" - model_config = ConfigDict(extra="ignore", frozen=True) + model_config = ConfigDict(extra="allow", frozen=True) monitors: list[Monitor] = Field(default_factory=list) total: int = Field(default=0) @@ -458,7 +458,7 @@ class APIErrorResponse(BaseModel): who parse error JSON manually. Consider private if unused by callers. """ - model_config = ConfigDict(extra="ignore", frozen=True) + model_config = ConfigDict(extra="allow", frozen=True) error: str = Field(default="Unknown error") message: str | None = None diff --git a/src/hyperping/models/_observability_models.py b/src/hyperping/models/_observability_models.py index ebcb318..63c0d70 100644 --- a/src/hyperping/models/_observability_models.py +++ b/src/hyperping/models/_observability_models.py @@ -1,46 +1,56 @@ -"""Observability models: anomalies, probe logs, and alert notifications.""" +"""Observability models: anomalies and probe logs.""" + +from __future__ import annotations + +from typing import Any from pydantic import BaseModel, ConfigDict, Field class MonitorAnomaly(BaseModel): - """Anomaly detected on a monitor (flapping, latency spike, etc.).""" + """Anomaly detected on a monitor.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - anomaly_type: str = Field(..., alias="anomalyType", description="Anomaly category") - started_at: str | None = Field( - default=None, alias="startedAt", description="Start time ISO 8601" + id: int = Field(..., description="Anomaly ID") + timestamp: str = Field(..., description="Detection time ISO 8601") + end_timestamp: str | None = Field( + default=None, alias="endTimestamp", description="End time ISO 8601" + ) + type: str = Field(..., description="Anomaly type (error_rate, latency, etc.)") + region: str | None = Field(default=None, description="Affected regions") + value: float = Field(default=0, description="Measured value") + baseline: float = Field(default=0, description="Expected baseline value") + score: float = Field(default=0, description="Anomaly score (0-1)") + consecutive_count: int = Field(default=0, alias="consecutiveCount") + resolved: int = Field(default=0, description="1 if resolved, 0 if ongoing") + outage_uuid: str | None = Field( + default=None, alias="outageUuid", description="Linked outage UUID" ) - ended_at: str | None = Field(default=None, alias="endedAt", description="End time ISO 8601") - severity: str = Field(default="info", description="Anomaly severity") class ProbeLog(BaseModel): - """HTTP probe log entry from a monitor check.""" + """HTTP probe log entry.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - status: int | None = Field(default=None, description="HTTP status code") - location: str | None = Field(default=None, description="Probe region") - response_time_ms: float | None = Field( - default=None, alias="responseTimeMs", description="Response time in ms" - ) - level: str | None = Field(default=None, description="Log level") - timestamp: str | None = Field(default=None, description="Timestamp ISO 8601") + id: str = Field(default="", description="Log entry ID") + status_code: int = Field(default=0, alias="statusCode", description="HTTP status code") + elapsed_time: int = Field(default=0, alias="elapsedTime", description="Response time in ms") + location: str = Field(default="", description="Probe region") + date: str = Field(default="", description="Timestamp ISO 8601") + is_error: int = Field(default=0, alias="isError", description="1 if error, 0 if success") + continent: str = Field(default="", description="Continent code") + bytes: int = Field(default=0, description="Response body size") + headers: str = Field(default="", description="Response headers") -class AlertNotification(BaseModel): - """Alert notification record from notification history.""" +class ProbeLogResponse(BaseModel): + """Wrapper for probe log list responses.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - uuid: str = Field(..., description="Alert UUID") - monitor_uuid: str | None = Field( - default=None, alias="monitorUuid", description="Affected monitor UUID" - ) - channel: str = Field(default="unknown", description="Notification channel") - sent_at: str | None = Field(default=None, alias="sentAt", description="Send time ISO 8601") - resolved_at: str | None = Field( - default=None, alias="resolvedAt", description="Resolution time ISO 8601" - ) + pings: list[ProbeLog] = Field(default_factory=list, description="Probe log entries") + anomalies: list[dict[str, Any]] = Field(default_factory=list) + pagination: dict[str, Any] = Field(default_factory=dict) + totals: dict[str, Any] = Field(default_factory=dict) diff --git a/src/hyperping/models/_oncall_models.py b/src/hyperping/models/_oncall_models.py index a682dd9..d497058 100644 --- a/src/hyperping/models/_oncall_models.py +++ b/src/hyperping/models/_oncall_models.py @@ -8,7 +8,7 @@ class OnCallSchedule(BaseModel): """On-call rotation schedule.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Schedule UUID") name: str = Field(..., description="Schedule name") @@ -20,8 +20,23 @@ class OnCallSchedule(BaseModel): class EscalationPolicy(BaseModel): """Escalation policy with step chain.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Policy UUID") name: str = Field(..., description="Policy name") steps: list[dict[str, Any]] = Field(default_factory=list, description="Escalation steps") + + +class TeamMember(BaseModel): + """Team member from list_team_members.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="User UUID") + email: str = Field(default="", description="Email address") + name: str = Field(default="", description="Display name") + phone: str | None = Field(default=None, description="Phone number") + profile_picture_url: str | None = Field( + default=None, alias="profilePictureUrl", description="Profile picture URL" + ) + account_role: str = Field(default="", alias="accountRole", description="Role in project") diff --git a/src/hyperping/models/_outage_models.py b/src/hyperping/models/_outage_models.py index 8ba800a..85ea8fd 100644 --- a/src/hyperping/models/_outage_models.py +++ b/src/hyperping/models/_outage_models.py @@ -15,11 +15,11 @@ class OutageAction(BaseModel): - POST /v2/outages/{uuid}/resolve - POST /v2/outages/{uuid}/escalate - Uses ``extra="ignore"`` to tolerate additional fields the API may return - and ``frozen=True`` for immutability. + Uses ``extra="allow"`` so new API fields are preserved instead of silently + dropped, and ``frozen=True`` for immutability. """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) status: str = Field( ..., description='Action result, e.g. "acknowledged", "resolved", "escalated"' @@ -39,11 +39,11 @@ class Outage(BaseModel): API: GET /v2/outages - Uses ``extra="ignore"`` and ``frozen=True`` to tolerate undocumented fields - and ensure immutability. + Uses ``extra="allow"`` so new API fields are preserved instead of silently + dropped, and ``frozen=True`` for immutability. """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Outage UUID") monitor_uuid: str | None = Field( @@ -81,25 +81,36 @@ def from_raw(cls, data: dict[str, Any]) -> Outage: class OutageTimelineEvent(BaseModel): - """Single event in an outage lifecycle timeline. + """Single event in an outage lifecycle timeline.""" - Events include detection, cross-region verification, alert dispatch, - acknowledgement, and resolution. - """ - - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - event_type: str = Field(..., alias="eventType", description="Event category") + type: str = Field(..., description="Event type (anomaly_detected, outage_detected, etc.)") timestamp: str = Field(..., description="Event time ISO 8601") - detail: str | None = Field(default=None, description="Event detail text") + data: dict[str, Any] = Field(default_factory=dict, description="Event-specific payload") + + +class OutageMonitorSummary(BaseModel): + """Monitor summary embedded in outage timeline responses.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Monitor UUID") + name: str = Field(default="", description="Monitor name") + url: str = Field(default="", description="Monitor URL") + protocol: str = Field(default="", description="Monitor protocol") class OutageTimeline(BaseModel): - """Full lifecycle timeline for an outage.""" + """Full outage timeline from get_outage_timeline.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - outage_uuid: str = Field(..., alias="outageUuid", description="Outage UUID") - events: list[OutageTimelineEvent] = Field( - default_factory=list, description="Chronological list of events" + outage: Outage = Field(..., description="Outage details") + monitor: OutageMonitorSummary = Field(..., description="Monitor details") + escalation_policy: dict[str, Any] | None = Field( + default=None, alias="escalationPolicy", description="Linked escalation policy" + ) + timeline: list[OutageTimelineEvent] = Field( + default_factory=list, description="Chronological event list" ) diff --git a/src/hyperping/models/_reporting_models.py b/src/hyperping/models/_reporting_models.py index 9558a39..ea44edc 100644 --- a/src/hyperping/models/_reporting_models.py +++ b/src/hyperping/models/_reporting_models.py @@ -1,4 +1,6 @@ -"""Reporting models: status summary and aggregated metrics.""" +"""Reporting models: status summary, response time, MTTA, MTTR.""" + +from __future__ import annotations from typing import Any @@ -6,12 +8,87 @@ class StatusSummary(BaseModel): - """Aggregate status summary from a single API call.""" + """Aggregate monitor status counts from get_status_summary.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + total: int = Field(default=0, description="Total monitor count") + up: int = Field(default=0, description="Monitors currently up") + down: int = Field(default=0, description="Monitors currently down") + paused: int = Field(default=0, description="Monitors currently paused") + unknown: int = Field(default=0, description="Monitors in unknown state") + down_monitors: list[dict[str, Any]] = Field( + default_factory=list, alias="down_monitors", description="Details of down monitors" + ) + paused_monitors: list[dict[str, Any]] = Field( + default_factory=list, alias="paused_monitors", description="Details of paused monitors" + ) + + +class TimeGroup(BaseModel): + """Single time bucket in a time-series report.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + time: str = Field(..., description="Time bucket label (date string)") + count: int = Field(default=0, description="Number of data points") + avg_response_time: int | None = Field( + default=None, alias="avgResponseTime", description="Average response time in ms" + ) + + +class ResponseTimeReport(BaseModel): + """Response time report from get_monitor_response_time.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + time_groups: list[TimeGroup] = Field( + default_factory=list, alias="timeGroups", description="Time-bucketed response data" + ) + + +class AlertHistory(BaseModel): + """Alert notification history from list_recent_alerts.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + time_groups: list[TimeGroup] = Field( + default_factory=list, alias="timeGroups", description="Time-bucketed alert counts" + ) + + +class MonitorMetricsSummary(BaseModel): + """Per-monitor metrics entry in MTTA/MTTR reports.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Monitor UUID") + name: str = Field(default="", description="Monitor name") + protocol: str = Field(default="", description="Monitor protocol") + outage_count: int = Field(default=0, alias="outageCount") + total_downtime: int = Field(default=0, alias="totalDowntime", description="Seconds") + mttr: int = Field(default=0, description="Mean time to resolve in seconds") + mtta: int = Field(default=0, description="Mean time to acknowledge in seconds") + longest_outage: int = Field(default=0, alias="longestOutage", description="Seconds") + + +class MttrReport(BaseModel): + """MTTR report from get_monitor_mttr.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + monitors: list[MonitorMetricsSummary] = Field(default_factory=list) + total_outages: int = Field(default=0, alias="totalOutages") + total_outages_length: int = Field(default=0, alias="totalOutagesLength", description="Seconds") + mttr: int = Field(default=0, description="Aggregate MTTR in seconds") + mtta: int = Field(default=0, description="Aggregate MTTA in seconds") + + +class MttaReport(BaseModel): + """MTTA report from get_monitor_mtta.""" - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) - total_monitors: int = Field(default=0, alias="totalMonitors") - up_count: int = Field(default=0, alias="upCount") - down_count: int = Field(default=0, alias="downCount") - paused_count: int = Field(default=0, alias="pausedCount") - down_monitors: list[dict[str, Any]] = Field(default_factory=list, alias="downMonitors") + monitors: list[MonitorMetricsSummary] = Field(default_factory=list) + total_acknowledged: int = Field(default=0, alias="totalAcknowledged") + mtta: int = Field(default=0, description="Aggregate MTTA in seconds") diff --git a/src/hyperping/models/_statuspage_models.py b/src/hyperping/models/_statuspage_models.py index b1ce509..cd048ed 100644 --- a/src/hyperping/models/_statuspage_models.py +++ b/src/hyperping/models/_statuspage_models.py @@ -11,7 +11,7 @@ class StatusPage(BaseModel): API: GET /v2/statuspages, GET /v2/statuspages/{uuid} """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) uuid: str = Field(..., description="Status page UUID") name: str = Field(..., description="Status page display name") @@ -65,7 +65,7 @@ class StatusPageSubscriber(BaseModel): API: GET /v2/statuspages/{uuid}/subscribers """ - model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) id: str = Field(..., description="Subscriber ID") email: str = Field(..., description="Subscriber email address") diff --git a/tests/unit/test_async_mcp_client.py b/tests/unit/test_async_mcp_client.py new file mode 100644 index 0000000..6ab4111 --- /dev/null +++ b/tests/unit/test_async_mcp_client.py @@ -0,0 +1,284 @@ +"""Tests for the async high-level MCP client.""" + +from unittest.mock import AsyncMock + +import pytest + +from hyperping._async_mcp_client import AsyncHyperpingMcpClient +from hyperping.models._integration_models import Integration +from hyperping.models._monitor_models import Monitor +from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember +from hyperping.models._outage_models import OutageTimeline +from hyperping.models._reporting_models import ( + AlertHistory, + MttaReport, + MttrReport, + ResponseTimeReport, + StatusSummary, +) + + +def make_client() -> AsyncHyperpingMcpClient: + client = AsyncHyperpingMcpClient(api_key="sk_test") + client._transport = AsyncMock() + return client + + +@pytest.mark.asyncio +async def test_get_status_summary(): + client = make_client() + client._transport.call_tool.return_value = { + "total": 5, + "up": 3, + "down": 1, + "paused": 1, + "unknown": 0, + } + result = await client.get_status_summary() + assert isinstance(result, StatusSummary) + assert result.total == 5 + client._transport.call_tool.assert_called_once_with("get_status_summary", {}) + + +@pytest.mark.asyncio +async def test_list_on_call_schedules(): + client = make_client() + client._transport.call_tool.return_value = { + "schedules": [{"uuid": "s1", "name": "Primary"}], + } + result = await client.list_on_call_schedules() + assert len(result) == 1 + assert isinstance(result[0], OnCallSchedule) + client._transport.call_tool.assert_called_once_with("list_on_call_schedules", {}) + + +@pytest.mark.asyncio +async def test_list_team_members_bare_array(): + client = make_client() + client._transport.call_tool.return_value = [ + {"uuid": "u1", "email": "a@b.com", "name": "A"}, + ] + result = await client.list_team_members() + assert len(result) == 1 + assert isinstance(result[0], TeamMember) + assert result[0].email == "a@b.com" + client._transport.call_tool.assert_called_once_with("list_team_members", {}) + + +@pytest.mark.asyncio +async def test_search_monitors(): + client = make_client() + client._transport.call_tool.return_value = [ + { + "uuid": "m1", + "name": "API", + "url": "https://api.example.com", + "protocol": "http", + }, + ] + result = await client.search_monitors_by_name("API") + assert len(result) == 1 + assert isinstance(result[0], Monitor) + client._transport.call_tool.assert_called_once_with("search_monitors_by_name", {"query": "API"}) + + +@pytest.mark.asyncio +async def test_get_outage_timeline(): + client = make_client() + client._transport.call_tool.return_value = { + "outage": {"uuid": "o1"}, + "monitor": {"uuid": "m1"}, + "escalationPolicy": None, + "timeline": [{"type": "detected", "timestamp": "2026-01-01T00:00:00Z"}], + } + result = await client.get_outage_timeline("outage_123") + assert isinstance(result, OutageTimeline) + assert len(result.timeline) == 1 + client._transport.call_tool.assert_called_once_with( + "get_outage_timeline", {"uuid": "outage_123"} + ) + + +@pytest.mark.asyncio +async def test_get_monitor_anomalies(): + client = make_client() + client._transport.call_tool.return_value = { + "anomalies": [ + { + "id": 1, + "timestamp": "2026-01-01", + "type": "flapping", + "value": 0.5, + "baseline": 0, + "score": 0.5, + } + ], + } + result = await client.get_monitor_anomalies("mon_123") + assert len(result) == 1 + assert isinstance(result[0], MonitorAnomaly) + client._transport.call_tool.assert_called_once_with( + "get_monitor_anomalies", {"uuid": "mon_123"} + ) + + +@pytest.mark.asyncio +async def test_context_manager(): + async with AsyncHyperpingMcpClient(api_key="sk_test") as client: + assert client is not None + + +@pytest.mark.asyncio +async def test_get_monitor_response_time(): + client = make_client() + client._transport.call_tool.return_value = { + "timeGroups": [{"time": "2026-01-01", "avgResponseTime": 120, "count": 10}], + } + result = await client.get_monitor_response_time("mon_1") + assert isinstance(result, ResponseTimeReport) + assert result.time_groups[0].avg_response_time == 120 + client._transport.call_tool.assert_called_once_with( + "get_monitor_response_time", {"uuid": "mon_1"} + ) + + +@pytest.mark.asyncio +async def test_get_monitor_mtta_with_uuid(): + client = make_client() + client._transport.call_tool.return_value = { + "monitors": [], + "totalAcknowledged": 0, + "mtta": 45, + } + result = await client.get_monitor_mtta(monitor_uuid="mon_1") + assert isinstance(result, MttaReport) + assert result.mtta == 45 + client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {"uuid": "mon_1"}) + + +@pytest.mark.asyncio +async def test_get_monitor_mtta_without_uuid(): + client = make_client() + client._transport.call_tool.return_value = { + "monitors": [], + "totalAcknowledged": 0, + "mtta": 60, + } + result = await client.get_monitor_mtta() + assert isinstance(result, MttaReport) + client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {}) + + +@pytest.mark.asyncio +async def test_get_monitor_mttr(): + client = make_client() + client._transport.call_tool.return_value = { + "monitors": [], + "totalOutages": 1, + "totalOutagesLength": 90, + "mttr": 90, + "mtta": 0, + } + result = await client.get_monitor_mttr(monitor_uuid="mon_1") + assert isinstance(result, MttrReport) + assert result.mttr == 90 + client._transport.call_tool.assert_called_once_with("get_monitor_mttr", {"uuid": "mon_1"}) + + +@pytest.mark.asyncio +async def test_get_monitor_http_logs(): + client = make_client() + client._transport.call_tool.return_value = { + "pings": [ + { + "statusCode": 200, + "elapsedTime": 5, + "location": "nyc", + "date": "2026-01-01", + "isError": 0, + "continent": "na", + "bytes": 100, + "headers": "", + "id": "log1", + } + ], + "anomalies": [], + "pagination": {}, + "totals": {}, + } + result = await client.get_monitor_http_logs("mon_1") + assert isinstance(result, ProbeLogResponse) + assert result.pings[0].status_code == 200 + client._transport.call_tool.assert_called_once_with("get_monitor_http_logs", {"uuid": "mon_1"}) + + +@pytest.mark.asyncio +async def test_list_recent_alerts(): + client = make_client() + client._transport.call_tool.return_value = { + "timeGroups": [{"time": "2026-01-01", "count": 3}], + } + result = await client.list_recent_alerts() + assert isinstance(result, AlertHistory) + client._transport.call_tool.assert_called_once_with("list_recent_alerts", {}) + + +@pytest.mark.asyncio +async def test_get_on_call_schedule(): + client = make_client() + client._transport.call_tool.return_value = {"uuid": "s1", "name": "Primary"} + result = await client.get_on_call_schedule("s1") + assert isinstance(result, OnCallSchedule) + client._transport.call_tool.assert_called_once_with("get_on_call_schedule", {"uuid": "s1"}) + + +@pytest.mark.asyncio +async def test_list_escalation_policies(): + client = make_client() + client._transport.call_tool.return_value = [ + {"uuid": "ep1", "name": "Default", "steps": []}, + ] + result = await client.list_escalation_policies() + assert len(result) == 1 + assert isinstance(result[0], EscalationPolicy) + client._transport.call_tool.assert_called_once_with("list_escalation_policies", {}) + + +@pytest.mark.asyncio +async def test_get_escalation_policy(): + client = make_client() + client._transport.call_tool.return_value = { + "uuid": "ep1", + "name": "Default", + "steps": [], + } + result = await client.get_escalation_policy("ep1") + assert isinstance(result, EscalationPolicy) + client._transport.call_tool.assert_called_once_with("get_escalation_policy", {"uuid": "ep1"}) + + +@pytest.mark.asyncio +async def test_list_integrations(): + client = make_client() + client._transport.call_tool.return_value = [ + {"uuid": "int1", "name": "Slack", "type": "slack", "active": True}, + ] + result = await client.list_integrations() + assert len(result) == 1 + assert isinstance(result[0], Integration) + client._transport.call_tool.assert_called_once_with("list_integrations", {}) + + +@pytest.mark.asyncio +async def test_get_integration(): + client = make_client() + client._transport.call_tool.return_value = { + "uuid": "int1", + "name": "Slack", + "type": "slack", + "active": True, + } + result = await client.get_integration("int1") + assert isinstance(result, Integration) + client._transport.call_tool.assert_called_once_with("get_integration", {"uuid": "int1"}) diff --git a/tests/unit/test_async_mcp_transport.py b/tests/unit/test_async_mcp_transport.py new file mode 100644 index 0000000..b7404bc --- /dev/null +++ b/tests/unit/test_async_mcp_transport.py @@ -0,0 +1,302 @@ +"""Tests for the async MCP JSON-RPC 2.0 transport layer.""" + +import json + +import httpx +import pytest +import respx +from pydantic import SecretStr + +from hyperping._async_mcp_transport import MCP_URL, AsyncMcpTransport +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingAuthError, + HyperpingNotFoundError, + HyperpingRateLimitError, + HyperpingValidationError, +) + +# -- Helpers ------------------------------------------------------------------ + +INIT_RESPONSE = httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "hyperping"}, + }, + }, +) + +NOTIFICATION_ACCEPTED = httpx.Response(202) + + +def _tool_response(data: dict, *, req_id: int = 2) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": json.dumps(data)}]}, + }, + ) + + +# -- Tests -------------------------------------------------------------------- + + +@respx.mock +async def test_initialize(): + respx.post(MCP_URL).mock( + side_effect=[INIT_RESPONSE, NOTIFICATION_ACCEPTED], + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + result = await transport.initialize() + assert result["protocolVersion"] == "2025-03-26" + await transport.close() + + +@respx.mock +async def test_call_tool_auto_initializes(): + respx.post(MCP_URL).mock( + side_effect=[ + INIT_RESPONSE, + NOTIFICATION_ACCEPTED, + _tool_response({"schedules": []}), + ], + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + result = await transport.call_tool("list_on_call_schedules") + assert result == {"schedules": []} + await transport.close() + + +@respx.mock +async def test_call_tool_http_401(): + respx.post(MCP_URL).mock(return_value=httpx.Response(401, text="Invalid Token")) + transport = AsyncMcpTransport(api_key="sk_bad", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAuthError): + await transport.call_tool("list_team_members") + await transport.close() + + +@respx.mock +async def test_call_tool_http_403(): + respx.post(MCP_URL).mock(return_value=httpx.Response(403, text="Forbidden")) + transport = AsyncMcpTransport(api_key="sk_bad", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAuthError): + await transport.call_tool("list_team_members") + await transport.close() + + +@respx.mock +async def test_call_tool_http_404(): + respx.post(MCP_URL).mock(return_value=httpx.Response(404, text="Not Found")) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingNotFoundError): + await transport.call_tool("get_monitor", {"id": "nonexistent"}) + await transport.close() + + +@respx.mock +async def test_call_tool_http_429(): + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 429, + text="Rate limit exceeded", + headers={"retry-after": "30"}, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingRateLimitError) as exc_info: + await transport.call_tool("list_monitors") + assert exc_info.value.retry_after == 30 + await transport.close() + + +@respx.mock +async def test_call_tool_http_400(): + respx.post(MCP_URL).mock(return_value=httpx.Response(400, text="Bad Request")) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingValidationError): + await transport.call_tool("create_monitor", {"invalid": "params"}) + await transport.close() + + +@respx.mock +async def test_call_tool_http_500(): + respx.post(MCP_URL).mock(return_value=httpx.Response(500, text="Internal Server Error")) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=0) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="HTTP 500"): + await transport.call_tool("some_tool") + await transport.close() + + +@respx.mock +async def test_call_tool_jsonrpc_error(): + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32601, "message": "Method not found"}, + }, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="Method not found"): + await transport.call_tool("nonexistent_tool") + await transport.close() + + +@respx.mock +async def test_call_tool_empty_content(): + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": []}, + }, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = await transport.call_tool("some_tool") + assert result is None + await transport.close() + + +@respx.mock +async def test_call_tool_invalid_json(): + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"type": "text", "text": "not valid json{"}]}, + }, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="Failed to parse"): + await transport.call_tool("some_tool") + await transport.close() + + +@respx.mock +async def test_call_tool_retry_on_500(): + """Verify that a transient 500 is retried and the second attempt succeeds.""" + respx.post(MCP_URL).mock( + side_effect=[ + httpx.Response(500, text="Internal Server Error"), + _tool_response({"ok": True}, req_id=2), + ], + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=1) + transport._initialized = True + result = await transport.call_tool("flaky_tool") + assert result == {"ok": True} + await transport.close() + + +async def test_context_manager(): + async with AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) as transport: + assert transport is not None + + +async def test_secretstr_api_key(): + transport = AsyncMcpTransport(api_key=SecretStr("sk_secret"), base_url=MCP_URL) + assert transport._client.headers["Authorization"] == "Bearer sk_secret" + await transport.close() + + +@respx.mock +async def test_call_tool_http_429_non_integer_retry_after(): + """Non-integer retry-after header should be ignored (retry_after=None).""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 429, + text="Rate limit exceeded", + headers={"retry-after": "not-a-number"}, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingRateLimitError) as exc_info: + await transport.call_tool("list_monitors") + assert exc_info.value.retry_after is None + await transport.close() + + +@respx.mock +async def test_call_tool_retry_exhausted(): + """All retries exhausted on 500 should raise the last exception.""" + respx.post(MCP_URL).mock( + side_effect=[ + httpx.Response(500, text="Internal Server Error"), + httpx.Response(502, text="Bad Gateway"), + ], + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=1) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="HTTP 502"): + await transport.call_tool("failing_tool") + await transport.close() + + +@respx.mock +async def test_call_tool_empty_text(): + """Content with empty text string should return None.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"type": "text", "text": ""}]}, + }, + ), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = await transport.call_tool("some_tool") + assert result is None + await transport.close() + + +@respx.mock +async def test_send_rpc_notification_returns_none(): + """A notification that gets 200 instead of 202 should still return None.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response(200, json={"jsonrpc": "2.0"}), + ) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + result = await transport._send_rpc("notifications/initialized", is_notification=True) + assert result is None + await transport.close() + + +@respx.mock +async def test_call_tool_result_none_from_202(): + """If the tool call response is 202 (accepted), call_tool returns None.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(202)) + transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = await transport.call_tool("some_tool") + assert result is None + await transport.close() diff --git a/tests/unit/test_async_preexisting.py b/tests/unit/test_async_preexisting.py index f52606e..0ef9573 100644 --- a/tests/unit/test_async_preexisting.py +++ b/tests/unit/test_async_preexisting.py @@ -77,6 +77,67 @@ async def test_create_healthcheck(self, async_client): result = await async_client.create_healthcheck(hc) assert result.uuid == "hc_new" + @respx.mock + @pytest.mark.asyncio + async def test_list_healthchecks_from_dict_key(self, async_client): + """Handles API response wrapped in a 'healthchecks' key.""" + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response( + 200, + json={ + "healthchecks": [ + {"uuid": "hc_d1", "name": "Dict HC", "period": 300, "grace": 60} + ] + }, + ) + ) + result = await async_client.list_healthchecks() + assert len(result) == 1 + assert result[0].uuid == "hc_d1" + + @respx.mock + @pytest.mark.asyncio + async def test_list_healthchecks_unexpected_shape_returns_empty(self, async_client): + """Returns empty list when response is neither list nor dict with key.""" + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response(200, json={"other_key": "value"}) + ) + result = await async_client.list_healthchecks() + assert result == [] + + @respx.mock + @pytest.mark.asyncio + async def test_list_healthchecks_404_returns_empty(self, async_client): + """Returns empty list on 404 (HyperpingNotFoundError is caught).""" + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = await async_client.list_healthchecks() + assert result == [] + + @respx.mock + @pytest.mark.asyncio + async def test_update_healthcheck(self, async_client): + """update_healthcheck sends PUT and returns updated Healthcheck.""" + from hyperping.models import HealthcheckUpdate + + respx.put(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "hc_1", + "name": "Updated HC", + "period": 600, + "grace": 120, + }, + ) + ) + update = HealthcheckUpdate(name="Updated HC", period=600) + result = await async_client.update_healthcheck("hc_1", update) + assert result.uuid == "hc_1" + assert result.name == "Updated HC" + assert result.period == 600 + @respx.mock @pytest.mark.asyncio async def test_delete_healthcheck(self, async_client): @@ -233,6 +294,247 @@ async def test_update_maintenance(self, async_client): result = await async_client.update_maintenance("mw_1", MaintenanceUpdate(name="New Name")) assert result.name == "New Name" + @respx.mock + @pytest.mark.asyncio + async def test_list_maintenance_with_status_filter(self, async_client): + """list_maintenance passes status query param when provided (covers line 42).""" + route = respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenanceWindows": [ + { + "uuid": "mw_active", + "name": "Active MW", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-12-31T23:59:59Z", + "monitors": [], + } + ] + }, + ) + ) + result = await async_client.list_maintenance(status="active") + assert len(result) == 1 + assert result[0].uuid == "mw_active" + assert route.calls[0].request.url.params.get("status") == "active" + + @respx.mock + @pytest.mark.asyncio + async def test_list_maintenance_fallback_maintenance_key(self, async_client): + """list_maintenance uses 'maintenance' key as fallback (covers line 48).""" + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenance": [ + { + "uuid": "mw_fb", + "name": "Fallback MW", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": [], + } + ] + }, + ) + ) + result = await async_client.list_maintenance() + assert len(result) == 1 + assert result[0].uuid == "mw_fb" + + @respx.mock + @pytest.mark.asyncio + async def test_create_maintenance_uuid_only_response(self, async_client): + """create_maintenance re-fetches when API returns uuid-only (covers line 91).""" + from hyperping.models import MaintenanceCreate + + respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response(201, json={"uuid": "mw_refetch"}) + ) + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_refetch").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_refetch", + "name": "Refetched MW", + "start_date": "2026-03-01T00:00:00Z", + "end_date": "2026-03-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + mw = MaintenanceCreate( + name="Refetched MW", + start_date="2026-03-01T00:00:00Z", + end_date="2026-03-01T02:00:00Z", + monitors=["mon_1"], + ) + result = await async_client.create_maintenance(mw) + assert result.uuid == "mw_refetch" + assert result.name == "Refetched MW" + + @respx.mock + @pytest.mark.asyncio + async def test_get_active_maintenance(self, async_client): + """get_active_maintenance filters to currently active windows (covers lines 153-155).""" + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenanceWindows": [ + { + "uuid": "mw_active", + "name": "Active Now", + "start_date": "2020-01-01T00:00:00Z", + "end_date": "2030-12-31T23:59:59Z", + "monitors": ["mon_1"], + }, + { + "uuid": "mw_past", + "name": "Past MW", + "start_date": "2020-01-01T00:00:00Z", + "end_date": "2020-01-02T00:00:00Z", + "monitors": ["mon_2"], + }, + ] + }, + ) + ) + result = await async_client.get_active_maintenance() + assert len(result) == 1 + assert result[0].uuid == "mw_active" + + @respx.mock + @pytest.mark.asyncio + async def test_is_monitor_in_maintenance(self, async_client): + """is_monitor_in_maintenance returns True for monitored monitor (covers lines 170-171).""" + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenanceWindows": [ + { + "uuid": "mw_active", + "name": "Active MW", + "start_date": "2020-01-01T00:00:00Z", + "end_date": "2030-12-31T23:59:59Z", + "monitors": ["mon_in_mw"], + } + ] + }, + ) + ) + assert await async_client.is_monitor_in_maintenance("mon_in_mw") is True + + @respx.mock + @pytest.mark.asyncio + async def test_is_monitor_not_in_maintenance(self, async_client): + """is_monitor_in_maintenance returns False for unaffected monitor.""" + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={"maintenanceWindows": []}, + ) + ) + assert await async_client.is_monitor_in_maintenance("mon_other") is False + + +# ==================== Async Outages ==================== + + +class TestAsyncOutages: + @respx.mock + @pytest.mark.asyncio + async def test_list_outages_404_returns_empty(self, async_client): + """list_outages returns empty list on 404 (covers line 69 area).""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = await async_client.list_outages() + assert result == [] + + @respx.mock + @pytest.mark.asyncio + async def test_list_outages_with_type_filter(self, async_client): + """list_outages passes type query param when outage_type is not 'all' (covers line 69).""" + route = respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 200, + json={ + "outages": [ + { + "uuid": "out_manual", + "monitorUuid": "mon_1", + "acknowledged": False, + "resolved": False, + } + ] + }, + ) + ) + result = await async_client.list_outages(page=0, outage_type="manual") + assert len(result) == 1 + assert result[0].uuid == "out_manual" + assert route.calls[0].request.url.params.get("type") == "manual" + + @respx.mock + @pytest.mark.asyncio + async def test_unacknowledge_outage(self, async_client): + """unacknowledge_outage returns OutageAction (covers lines 160-162).""" + respx.post(f"{API_BASE}{Endpoint.OUTAGES}/out_1/unacknowledge").mock( + return_value=httpx.Response(200, json={"status": "unacknowledged"}) + ) + result = await async_client.unacknowledge_outage("out_1") + assert result.status == "unacknowledged" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_outage(self, async_client): + """delete_outage sends DELETE request (covers lines 173-174).""" + respx.delete(f"{API_BASE}{Endpoint.OUTAGES}/out_1").mock(return_value=httpx.Response(204)) + await async_client.delete_outage("out_1") + + @respx.mock + @pytest.mark.asyncio + async def test_create_outage(self, async_client): + """create_outage sends POST and returns Outage (covers lines 189-191).""" + respx.post(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 201, + json={ + "uuid": "out_new", + "monitorUuid": "mon_1", + "startedAt": "2026-04-17T10:00:00Z", + "acknowledged": False, + "resolved": False, + }, + ) + ) + result = await async_client.create_outage("mon_1") + assert result.uuid == "out_new" + assert result.monitor_uuid == "mon_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_outage(self, async_client): + """get_outage fetches single outage by ID (covers lines 205-207).""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "out_1", + "monitorUuid": "mon_1", + "startedAt": "2026-04-17T10:00:00Z", + "acknowledged": True, + "resolved": False, + }, + ) + ) + result = await async_client.get_outage("out_1") + assert result.uuid == "out_1" + assert result.acknowledged is True + # ==================== Async Incidents ==================== @@ -279,6 +581,95 @@ async def test_get_incident(self, async_client): result = await async_client.get_incident("inc_1") assert result.title_en == "Outage" + @respx.mock + @pytest.mark.asyncio + async def test_list_incidents_with_status_filter(self, async_client): + """list_incidents passes status query param when provided.""" + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(return_value=httpx.Response(200, json=[])) + result = await async_client.list_incidents(status="investigating") + assert result == [] + + @respx.mock + @pytest.mark.asyncio + async def test_create_incident(self, async_client): + """create_incident POSTs and re-fetches the full incident when API returns uuid.""" + from hyperping.models import IncidentCreate, IncidentType, LocalizedText + + create_response = {"message": "Incident created", "uuid": "inc_new"} + full_response = { + "uuid": "inc_new", + "title": {"en": "New Incident"}, + "type": "incident", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + } + respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response(201, json=create_response) + ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_new").mock( + return_value=httpx.Response(200, json=full_response) + ) + incident = IncidentCreate( + title=LocalizedText(en="New Incident"), + text=LocalizedText(en="Body"), + type=IncidentType.INCIDENT, + statuspages=["sp_1"], + ) + result = await async_client.create_incident(incident) + assert result.uuid == "inc_new" + assert result.title_en == "New Incident" + + @respx.mock + @pytest.mark.asyncio + async def test_create_incident_full_response(self, async_client): + """create_incident returns directly when API returns full incident object.""" + from hyperping.models import IncidentCreate, IncidentType, LocalizedText + + full_response = { + "uuid": "inc_full", + "title": {"en": "Full Incident"}, + "type": "incident", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + } + respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response(201, json=full_response) + ) + incident = IncidentCreate( + title=LocalizedText(en="Full Incident"), + text=LocalizedText(en="Body"), + type=IncidentType.INCIDENT, + statuspages=["sp_1"], + ) + result = await async_client.create_incident(incident) + assert result.uuid == "inc_full" + + @respx.mock + @pytest.mark.asyncio + async def test_update_incident(self, async_client): + """update_incident sends PUT and returns updated Incident.""" + from hyperping.models import IncidentUpdateRequest, LocalizedText + + updated_response = { + "uuid": "inc_upd", + "title": {"en": "Updated Title"}, + "type": "incident", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + } + respx.put(f"{API_BASE}{Endpoint.INCIDENTS}/inc_upd").mock( + return_value=httpx.Response(200, json=updated_response) + ) + result = await async_client.update_incident( + "inc_upd", + IncidentUpdateRequest(title=LocalizedText(en="Updated Title")), + ) + assert result.uuid == "inc_upd" + assert result.title_en == "Updated Title" + @respx.mock @pytest.mark.asyncio async def test_resolve_incident(self, async_client): diff --git a/tests/unit/test_client_coverage.py b/tests/unit/test_client_coverage.py new file mode 100644 index 0000000..3dd75e7 --- /dev/null +++ b/tests/unit/test_client_coverage.py @@ -0,0 +1,277 @@ +"""Additional coverage tests for HyperpingClient (client.py).""" + +from unittest.mock import patch + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient, RetryConfig +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingAuthError, +) + + +class TestClientRepr: + """Tests for __repr__ method.""" + + def test_repr_contains_class_name(self) -> None: + """__repr__ returns a string containing HyperpingClient.""" + c = HyperpingClient(api_key="sk_test") + r = repr(c) + assert "HyperpingClient" in r + assert API_BASE in r + c.close() + + +class TestValidateConnection: + """Tests for ping / validate_connection behavior.""" + + @respx.mock + def test_ping_success(self) -> None: + """ping() returns True when list_monitors succeeds.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + c = HyperpingClient(api_key="sk_test") + assert c.ping() is True + c.close() + + @respx.mock + def test_ping_auth_failure_reraises(self) -> None: + """ping() re-raises HyperpingAuthError directly.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(401, json={"error": "Unauthorized"}) + ) + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=0), + ) + with pytest.raises(HyperpingAuthError): + c.ping() + c.close() + + @respx.mock + def test_ping_api_error_wraps(self) -> None: + """ping() wraps HyperpingAPIError with connectivity message.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(500, json={"error": "Server error"}) + ) + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=0), + ) + with pytest.raises(HyperpingAPIError, match="connectivity test failed"): + c.ping() + c.close() + + @respx.mock + def test_ping_timeout_wraps(self) -> None: + """ping() wraps httpx.TimeoutException.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + side_effect=httpx.TimeoutException("timed out") + ) + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=0), + ) + with pytest.raises(HyperpingAPIError, match="connectivity test failed"): + c.ping() + c.close() + + @respx.mock + def test_ping_request_error_wraps(self) -> None: + """ping() wraps httpx.RequestError.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + side_effect=httpx.ConnectError("connection refused") + ) + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=0), + ) + with pytest.raises(HyperpingAPIError, match="connectivity test failed"): + c.ping() + c.close() + + +class TestCircuitBreakerDisabled: + """Tests for circuit breaker disabled path.""" + + def test_empty_api_key_raises(self) -> None: + """Empty api_key raises ValueError (line 109).""" + with pytest.raises(ValueError, match="non-empty"): + HyperpingClient(api_key="") + + def test_whitespace_api_key_raises(self) -> None: + """Whitespace-only api_key raises ValueError (line 109).""" + with pytest.raises(ValueError, match="non-empty"): + HyperpingClient(api_key=" ") + + +class TestRetryAfterParsing: + """Tests for _parse_retry_after with non-integer values.""" + + def test_parse_retry_after_non_integer(self) -> None: + """Non-integer Retry-After returns None (lines 158-159/172-173).""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response( + 429, + headers={"Retry-After": "Thu, 01 Dec 2025 16:00:00 GMT"}, + ) + result = c._parse_retry_after(response) + assert result is None + c.close() + + def test_parse_retry_after_integer(self) -> None: + """Integer Retry-After is parsed correctly.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response(429, headers={"Retry-After": "60"}) + result = c._parse_retry_after(response) + assert result == 60 + c.close() + + def test_parse_retry_after_absent(self) -> None: + """Missing Retry-After returns None.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response(429) + result = c._parse_retry_after(response) + assert result is None + c.close() + + +class TestNonJsonErrorBody: + """Tests for _parse_error_body with non-JSON responses.""" + + def test_parse_error_body_non_json(self) -> None: + """Non-JSON body returns plain-text envelope (lines 172-173).""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response( + 500, + text="Internal Server Error", + headers={"content-type": "text/plain"}, + ) + result = c._parse_error_body(response) + assert result == {"error": "Internal Server Error"} + c.close() + + def test_parse_error_body_json(self) -> None: + """JSON body is returned as-is.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response( + 400, + json={"error": "Bad request", "details": []}, + ) + result = c._parse_error_body(response) + assert result["error"] == "Bad request" + c.close() + + +class TestRetrySleepBackoff: + """Tests for _compute_sleep_time with backoff (lines 259-262).""" + + def test_compute_sleep_time_429_with_retry_after(self) -> None: + """429 with valid Retry-After uses server value.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response(429, headers={"Retry-After": "45"}) + sleep_time = c._compute_sleep_time(response, delay=1.0) + assert sleep_time == 45.0 + c.close() + + def test_compute_sleep_time_429_non_numeric_retry_after(self) -> None: + """429 with non-numeric Retry-After falls back to backoff.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response( + 429, + headers={"Retry-After": "Thu, 01 Dec 2025 16:00:00 GMT"}, + ) + sleep_time = c._compute_sleep_time(response, delay=2.0) + # Should fall through to backoff: delay + jitter in [0, delay*0.25] + assert 2.0 <= sleep_time <= 2.5 + c.close() + + def test_compute_sleep_time_429_capped_at_max(self) -> None: + """429 Retry-After is capped at RETRY_AFTER_MAX (300).""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response(429, headers={"Retry-After": "600"}) + sleep_time = c._compute_sleep_time(response, delay=1.0) + assert sleep_time == 300.0 + c.close() + + def test_compute_sleep_time_500_uses_backoff(self) -> None: + """Non-429 retryable errors use exponential backoff with jitter.""" + c = HyperpingClient(api_key="sk_test") + response = httpx.Response(500, text="Server Error") + sleep_time = c._compute_sleep_time(response, delay=4.0) + assert 4.0 <= sleep_time <= 5.0 + c.close() + + +class TestRetryWithSleep: + """Tests for actual retry loop with sleep (lines 259-262).""" + + @respx.mock + def test_retry_sleeps_with_backoff(self) -> None: + """Verify retry loop sleeps with increasing delay.""" + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count <= 2: + return httpx.Response(500, json={"error": "Server error"}) + return httpx.Response(200, json=[]) + + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(side_effect=handler) + + with patch("hyperping.client.time.sleep") as mock_sleep: + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig( + max_retries=3, + initial_delay=1.0, + backoff_factor=2.0, + ), + ) + c.list_monitors() + + assert mock_sleep.call_count == 2 + first_sleep = mock_sleep.call_args_list[0][0][0] + second_sleep = mock_sleep.call_args_list[1][0][0] + # First delay ~1.0-1.25, second ~2.0-2.5 + assert 1.0 <= first_sleep <= 1.25 + assert 2.0 <= second_sleep <= 2.5 + c.close() + + +class TestTimeoutRetry: + """Tests for timeout/request error retry paths (lines 381-401).""" + + @respx.mock + def test_timeout_retries_then_raises(self) -> None: + """Timeout after all retries raises HyperpingAPIError.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + side_effect=httpx.TimeoutException("timed out") + ) + with patch("hyperping.client.time.sleep"): + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=2, initial_delay=0.01), + ) + with pytest.raises(HyperpingAPIError, match="Request timeout"): + c.list_monitors() + c.close() + + @respx.mock + def test_request_error_retries_then_raises(self) -> None: + """Connection error after all retries raises HyperpingAPIError.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + side_effect=httpx.ConnectError("connection refused") + ) + with patch("hyperping.client.time.sleep"): + c = HyperpingClient( + api_key="sk_test", + retry_config=RetryConfig(max_retries=1, initial_delay=0.01), + ) + with pytest.raises(HyperpingAPIError, match="Request failed"): + c.list_monitors() + c.close() diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index fc28483..64ca92d 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -3,6 +3,18 @@ from unittest.mock import MagicMock from hyperping.mcp_client import HyperpingMcpClient +from hyperping.models._integration_models import Integration +from hyperping.models._monitor_models import Monitor +from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember +from hyperping.models._outage_models import OutageTimeline +from hyperping.models._reporting_models import ( + AlertHistory, + MttaReport, + MttrReport, + ResponseTimeReport, + StatusSummary, +) def make_client() -> HyperpingMcpClient: @@ -13,44 +25,64 @@ def make_client() -> HyperpingMcpClient: def test_get_status_summary(): client = make_client() - client._transport.call_tool.return_value = {"total": 5, "up": 3, "down": 1, "paused": 1} + client._transport.call_tool.return_value = { + "total": 5, + "up": 3, + "down": 1, + "paused": 1, + "unknown": 0, + } result = client.get_status_summary() - assert result["total"] == 5 + assert isinstance(result, StatusSummary) + assert result.total == 5 client._transport.call_tool.assert_called_once_with("get_status_summary", {}) def test_list_on_call_schedules(): client = make_client() - client._transport.call_tool.return_value = [{"uuid": "s1", "name": "Primary"}] + client._transport.call_tool.return_value = { + "schedules": [{"uuid": "s1", "name": "Primary"}], + } result = client.list_on_call_schedules() assert len(result) == 1 - assert result[0]["uuid"] == "s1" + assert isinstance(result[0], OnCallSchedule) client._transport.call_tool.assert_called_once_with("list_on_call_schedules", {}) def test_list_team_members_bare_array(): client = make_client() - client._transport.call_tool.return_value = [{"uuid": "u1", "email": "a@b.com"}] + client._transport.call_tool.return_value = [ + {"uuid": "u1", "email": "a@b.com", "name": "A"}, + ] result = client.list_team_members() assert len(result) == 1 + assert isinstance(result[0], TeamMember) + assert result[0].email == "a@b.com" client._transport.call_tool.assert_called_once_with("list_team_members", {}) def test_search_monitors(): client = make_client() - client._transport.call_tool.return_value = [{"uuid": "m1", "name": "API"}] + client._transport.call_tool.return_value = [ + {"uuid": "m1", "name": "API", "url": "https://api.example.com", "protocol": "http"}, + ] result = client.search_monitors_by_name("API") assert len(result) == 1 + assert isinstance(result[0], Monitor) client._transport.call_tool.assert_called_once_with("search_monitors_by_name", {"query": "API"}) def test_get_outage_timeline(): client = make_client() client._transport.call_tool.return_value = { - "events": [{"type": "detected", "timestamp": "2026-01-01T00:00:00Z"}] + "outage": {"uuid": "o1"}, + "monitor": {"uuid": "m1"}, + "escalationPolicy": None, + "timeline": [{"type": "detected", "timestamp": "2026-01-01T00:00:00Z"}], } result = client.get_outage_timeline("outage_123") - assert "events" in result + assert isinstance(result, OutageTimeline) + assert len(result.timeline) == 1 client._transport.call_tool.assert_called_once_with( "get_outage_timeline", {"uuid": "outage_123"} ) @@ -58,9 +90,21 @@ def test_get_outage_timeline(): def test_get_monitor_anomalies(): client = make_client() - client._transport.call_tool.return_value = [{"type": "flapping", "startedAt": "2026-01-01"}] + client._transport.call_tool.return_value = { + "anomalies": [ + { + "id": 1, + "timestamp": "2026-01-01", + "type": "flapping", + "value": 0.5, + "baseline": 0, + "score": 0.5, + } + ], + } result = client.get_monitor_anomalies("mon_123") assert len(result) == 1 + assert isinstance(result[0], MonitorAnomaly) client._transport.call_tool.assert_called_once_with( "get_monitor_anomalies", {"uuid": "mon_123"} ) @@ -73,9 +117,12 @@ def test_context_manager(): def test_get_monitor_response_time(): client = make_client() - client._transport.call_tool.return_value = {"p50": 120, "p95": 350} + client._transport.call_tool.return_value = { + "timeGroups": [{"time": "2026-01-01", "avgResponseTime": 120, "count": 10}], + } result = client.get_monitor_response_time("mon_1") - assert result["p50"] == 120 + assert isinstance(result, ResponseTimeReport) + assert result.time_groups[0].avg_response_time == 120 client._transport.call_tool.assert_called_once_with( "get_monitor_response_time", {"uuid": "mon_1"} ) @@ -83,41 +130,77 @@ def test_get_monitor_response_time(): def test_get_monitor_mtta_with_uuid(): client = make_client() - client._transport.call_tool.return_value = {"mtta": 45} + client._transport.call_tool.return_value = { + "monitors": [], + "totalAcknowledged": 0, + "mtta": 45, + } result = client.get_monitor_mtta(monitor_uuid="mon_1") - assert result["mtta"] == 45 + assert isinstance(result, MttaReport) + assert result.mtta == 45 client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {"uuid": "mon_1"}) def test_get_monitor_mtta_without_uuid(): client = make_client() - client._transport.call_tool.return_value = {"mtta": 60} + client._transport.call_tool.return_value = { + "monitors": [], + "totalAcknowledged": 0, + "mtta": 60, + } result = client.get_monitor_mtta() - assert result["mtta"] == 60 + assert isinstance(result, MttaReport) client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {}) def test_get_monitor_mttr(): client = make_client() - client._transport.call_tool.return_value = {"mttr": 90} + client._transport.call_tool.return_value = { + "monitors": [], + "totalOutages": 1, + "totalOutagesLength": 90, + "mttr": 90, + "mtta": 0, + } result = client.get_monitor_mttr(monitor_uuid="mon_1") - assert result["mttr"] == 90 + assert isinstance(result, MttrReport) + assert result.mttr == 90 client._transport.call_tool.assert_called_once_with("get_monitor_mttr", {"uuid": "mon_1"}) def test_get_monitor_http_logs(): client = make_client() - client._transport.call_tool.return_value = [{"status": 200, "latency": 123}] + client._transport.call_tool.return_value = { + "pings": [ + { + "statusCode": 200, + "elapsedTime": 5, + "location": "nyc", + "date": "2026-01-01", + "isError": 0, + "continent": "na", + "bytes": 100, + "headers": "", + "id": "log1", + } + ], + "anomalies": [], + "pagination": {}, + "totals": {}, + } result = client.get_monitor_http_logs("mon_1") - assert len(result) == 1 + assert isinstance(result, ProbeLogResponse) + assert result.pings[0].status_code == 200 client._transport.call_tool.assert_called_once_with("get_monitor_http_logs", {"uuid": "mon_1"}) def test_list_recent_alerts(): client = make_client() - client._transport.call_tool.return_value = {"alerts": [{"uuid": "a1"}]} + client._transport.call_tool.return_value = { + "timeGroups": [{"time": "2026-01-01", "count": 3}], + } result = client.list_recent_alerts() - assert "alerts" in result + assert isinstance(result, AlertHistory) client._transport.call_tool.assert_called_once_with("list_recent_alerts", {}) @@ -125,37 +208,52 @@ def test_get_on_call_schedule(): client = make_client() client._transport.call_tool.return_value = {"uuid": "s1", "name": "Primary"} result = client.get_on_call_schedule("s1") - assert result["uuid"] == "s1" + assert isinstance(result, OnCallSchedule) client._transport.call_tool.assert_called_once_with("get_on_call_schedule", {"uuid": "s1"}) def test_list_escalation_policies(): client = make_client() - client._transport.call_tool.return_value = [{"uuid": "ep1"}] + client._transport.call_tool.return_value = [ + {"uuid": "ep1", "name": "Default", "steps": []}, + ] result = client.list_escalation_policies() assert len(result) == 1 + assert isinstance(result[0], EscalationPolicy) client._transport.call_tool.assert_called_once_with("list_escalation_policies", {}) def test_get_escalation_policy(): client = make_client() - client._transport.call_tool.return_value = {"uuid": "ep1", "name": "Default"} + client._transport.call_tool.return_value = { + "uuid": "ep1", + "name": "Default", + "steps": [], + } result = client.get_escalation_policy("ep1") - assert result["uuid"] == "ep1" + assert isinstance(result, EscalationPolicy) client._transport.call_tool.assert_called_once_with("get_escalation_policy", {"uuid": "ep1"}) def test_list_integrations(): client = make_client() - client._transport.call_tool.return_value = [{"uuid": "int1", "type": "slack"}] + client._transport.call_tool.return_value = [ + {"uuid": "int1", "name": "Slack", "type": "slack", "active": True}, + ] result = client.list_integrations() assert len(result) == 1 + assert isinstance(result[0], Integration) client._transport.call_tool.assert_called_once_with("list_integrations", {}) def test_get_integration(): client = make_client() - client._transport.call_tool.return_value = {"uuid": "int1", "type": "slack"} + client._transport.call_tool.return_value = { + "uuid": "int1", + "name": "Slack", + "type": "slack", + "active": True, + } result = client.get_integration("int1") - assert result["uuid"] == "int1" + assert isinstance(result, Integration) client._transport.call_tool.assert_called_once_with("get_integration", {"uuid": "int1"}) diff --git a/tests/unit/test_mcp_transport.py b/tests/unit/test_mcp_transport.py index 7ed43e8..88fc570 100644 --- a/tests/unit/test_mcp_transport.py +++ b/tests/unit/test_mcp_transport.py @@ -1,13 +1,20 @@ """Tests for the MCP JSON-RPC 2.0 transport layer.""" import json +from unittest.mock import patch import httpx import pytest import respx from hyperping._mcp_transport import MCP_URL, McpTransport -from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingAuthError, + HyperpingNotFoundError, + HyperpingRateLimitError, + HyperpingValidationError, +) @respx.mock @@ -170,3 +177,157 @@ def test_secretstr_api_key(): transport = McpTransport(api_key=SecretStr("sk_secret"), base_url=MCP_URL) assert transport._client.headers["Authorization"] == "Bearer sk_secret" transport.close() + + +@respx.mock +def test_call_tool_http_404(): + """Test that HTTP 404 raises HyperpingNotFoundError.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(404, text="Not Found")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingNotFoundError, match="Resource not found"): + transport.call_tool("missing_tool") + transport.close() + + +@respx.mock +def test_call_tool_http_429_with_retry_after(): + """Test that HTTP 429 with Retry-After header parses retry_after.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 429, + text="Rate limited", + headers={"retry-after": "30"}, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingRateLimitError) as exc_info: + transport.call_tool("some_tool") + assert exc_info.value.retry_after == 30 + assert exc_info.value.status_code == 429 + transport.close() + + +@respx.mock +def test_call_tool_http_429_no_retry_after(): + """Test that HTTP 429 without Retry-After header sets retry_after=None.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(429, text="Rate limited")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingRateLimitError) as exc_info: + transport.call_tool("some_tool") + assert exc_info.value.retry_after is None + transport.close() + + +@respx.mock +def test_call_tool_http_429_non_integer_retry_after(): + """Test 429 with non-integer Retry-After falls back to None.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 429, + text="Rate limited", + headers={"retry-after": "Thu, 01 Dec 2025 16:00:00 GMT"}, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingRateLimitError) as exc_info: + transport.call_tool("some_tool") + assert exc_info.value.retry_after is None + transport.close() + + +@respx.mock +def test_call_tool_http_400(): + """Test that HTTP 400 raises HyperpingValidationError.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(400, text="Bad Request")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingValidationError, match="Validation error"): + transport.call_tool("some_tool") + transport.close() + + +@respx.mock +def test_call_tool_http_422(): + """Test that HTTP 422 raises HyperpingValidationError.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(422, text="Unprocessable Entity")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingValidationError) as exc_info: + transport.call_tool("some_tool") + assert exc_info.value.status_code == 422 + transport.close() + + +@respx.mock +def test_call_tool_generic_error_response_body(): + """Test generic HTTP error attaches response_body with raw text.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(418, text="I'm a teapot")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError) as exc_info: + transport.call_tool("some_tool") + assert exc_info.value.status_code == 418 + assert exc_info.value.response_body["raw"] == "I'm a teapot" + transport.close() + + +@respx.mock +def test_call_tool_retry_exhausted(): + """Test that exhausting all retries on 5xx hits the for...else branch.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(502, text="Bad Gateway")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=2) + transport._initialized = True + with patch("hyperping._mcp_transport.time.sleep"): + with pytest.raises(HyperpingAPIError, match="HTTP 502"): + transport.call_tool("some_tool") + transport.close() + + +@respx.mock +def test_call_tool_result_none_after_retry(): + """Test that result=None after successful retry returns None.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": 1, "result": {"content": []}}, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = transport.call_tool("some_tool") + assert result is None + transport.close() + + +@respx.mock +def test_call_tool_empty_text_returns_none(): + """Test that content with empty text returns None (line 183).""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"type": "text", "text": ""}]}, + }, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = transport.call_tool("some_tool") + assert result is None + transport.close() + + +@respx.mock +def test_call_tool_notification_200_returns_none(): + """Test _send_rpc returns None for notifications with 200 (line 111).""" + respx.post(MCP_URL).mock(return_value=httpx.Response(200, json={"jsonrpc": "2.0"})) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + result = transport._send_rpc("notifications/test", is_notification=True) + assert result is None + transport.close() diff --git a/tests/unit/test_outages.py b/tests/unit/test_outages.py index 1edd4a9..67f1c09 100644 --- a/tests/unit/test_outages.py +++ b/tests/unit/test_outages.py @@ -138,3 +138,88 @@ def test_escalate_outage_not_found(self, client: HyperpingClient) -> None: ) with pytest.raises(HyperpingNotFoundError): client.escalate_outage("out_nope") + + def test_list_outages_invalid_status(self, client: HyperpingClient) -> None: + """list_outages raises ValueError for unrecognised status.""" + with pytest.raises(ValueError, match="Invalid status"): + client.list_outages(status="bad_status") + + def test_list_outages_invalid_outage_type(self, client: HyperpingClient) -> None: + """list_outages raises ValueError for unrecognised outage_type.""" + with pytest.raises(ValueError, match="Invalid outage_type"): + client.list_outages(outage_type="bad_type") + + @respx.mock + def test_unacknowledge_outage(self, client: HyperpingClient) -> None: + """Test unacknowledging an outage.""" + respx.post(f"{API_BASE}{Endpoint.OUTAGES}/out_1/unacknowledge").mock( + return_value=httpx.Response(200, json={"status": "unacknowledged"}) + ) + result = client.unacknowledge_outage("out_1") + assert isinstance(result, OutageAction) + assert result.status == "unacknowledged" + + @respx.mock + def test_unacknowledge_outage_not_found(self, client: HyperpingClient) -> None: + """Test unacknowledging a non-existent outage.""" + respx.post(f"{API_BASE}{Endpoint.OUTAGES}/out_nope/unacknowledge").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.unacknowledge_outage("out_nope") + + @respx.mock + def test_delete_outage(self, client: HyperpingClient) -> None: + """Test deleting an outage.""" + respx.delete(f"{API_BASE}{Endpoint.OUTAGES}/out_1").mock(return_value=httpx.Response(204)) + result = client.delete_outage("out_1") + assert result is None + + @respx.mock + def test_delete_outage_not_found(self, client: HyperpingClient) -> None: + """Test deleting a non-existent outage.""" + respx.delete(f"{API_BASE}{Endpoint.OUTAGES}/out_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.delete_outage("out_nope") + + @respx.mock + def test_create_outage(self, client: HyperpingClient) -> None: + """Test creating a manual outage.""" + from hyperping.models import Outage + + respx.post(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 201, + json={"uuid": "out_new", "monitor_uuid": "mon_1", "status": "active"}, + ) + ) + result = client.create_outage("mon_1") + assert isinstance(result, Outage) + assert result.uuid == "out_new" + assert result.monitor_uuid == "mon_1" + + @respx.mock + def test_get_outage(self, client: HyperpingClient) -> None: + """Test getting a single outage by ID.""" + from hyperping.models import Outage + + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "out_1", "monitor_uuid": "mon_1", "status": "active"}, + ) + ) + result = client.get_outage("out_1") + assert isinstance(result, Outage) + assert result.uuid == "out_1" + + @respx.mock + def test_get_outage_not_found(self, client: HyperpingClient) -> None: + """Test getting a non-existent outage.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_outage("out_nope") diff --git a/tests/unit/test_sdk_surface.py b/tests/unit/test_sdk_surface.py index ae30209..50e824c 100644 --- a/tests/unit/test_sdk_surface.py +++ b/tests/unit/test_sdk_surface.py @@ -1130,3 +1130,65 @@ def test_request_models_are_mutable(self) -> None: ) maint.name = "Updated MW" assert maint.name == "Updated MW" + + +# ==================== Protocol base classes ==================== + + +class TestProtocolBaseClasses: + """Verify the protocol base classes raise NotImplementedError.""" + + def test_sync_protocol_request_raises(self) -> None: + from hyperping._protocols import _ClientProtocol + + proto = _ClientProtocol() + with pytest.raises(NotImplementedError, match="HyperpingClient"): + proto._request("GET", "/test") + + def test_async_protocol_request_raises(self) -> None: + import asyncio + + from hyperping._protocols import _AsyncClientProtocol + + proto = _AsyncClientProtocol() + with pytest.raises(NotImplementedError, match="AsyncHyperpingClient"): + asyncio.get_event_loop().run_until_complete(proto._request("GET", "/test")) + + +# ==================== Deprecated aliases in hyperping.models ==================== + + +class TestModelsDeprecatedAliases: + """Verify deprecated aliases accessed via hyperping.models emit warnings.""" + + def test_incident_status_alias_emits_warning(self) -> None: + import warnings + + import hyperping.models as models_pkg + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + val = models_pkg.IncidentStatus # noqa: B018 + assert val is models_pkg.IncidentUpdateType + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "IncidentStatus" in str(w[0].message) + + def test_incident_update_create_alias_emits_warning(self) -> None: + import warnings + + import hyperping.models as models_pkg + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + val = models_pkg.IncidentUpdateCreate # noqa: B018 + assert val is models_pkg.AddIncidentUpdateRequest + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "IncidentUpdateCreate" in str(w[0].message) + + def test_unknown_attribute_raises_attribute_error(self) -> None: + import hyperping.models as models_pkg + + with pytest.raises(AttributeError, match="no attribute"): + models_pkg.NonExistentAttribute # noqa: B018