diff --git a/pyproject.toml b/pyproject.toml index f8c2ea6..0b5e862 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hyperping" -version = "1.2.1" +version = "1.3.0" description = "Python SDK for the Hyperping uptime monitoring and incident management API" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 4d80de0..0efe960 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -38,7 +38,9 @@ from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, + AlertNotification, DnsRecordType, + EscalationPolicy, Healthcheck, HealthcheckCreate, HealthcheckUpdate, @@ -49,11 +51,13 @@ IncidentUpdate, IncidentUpdateRequest, IncidentUpdateType, + Integration, LocalizedText, Maintenance, MaintenanceCreate, MaintenanceUpdate, Monitor, + MonitorAnomaly, MonitorBase, MonitorCreate, MonitorFrequency, @@ -63,10 +67,14 @@ MonitorTimeout, MonitorUpdate, NotificationOption, + OnCallSchedule, Outage, OutageAction, OutageDetail, OutageStats, + OutageTimeline, + OutageTimelineEvent, + ProbeLog, Region, ReportPeriod, RequestHeader, @@ -74,6 +82,7 @@ StatusPageCreate, StatusPageSubscriber, StatusPageUpdate, + StatusSummary, ) __all__ = [ @@ -137,6 +146,19 @@ # Outages "Outage", "OutageAction", + "OutageTimeline", + "OutageTimelineEvent", + # Observability + "MonitorAnomaly", + "ProbeLog", + "AlertNotification", + # On-call + "OnCallSchedule", + "EscalationPolicy", + # Integrations + "Integration", + # Reporting + "StatusSummary", # Healthchecks "Healthcheck", "HealthcheckCreate", @@ -161,8 +183,7 @@ def __getattr__(name: str) -> object: if name == "HYPERPING_API_BASE": warnings.warn( - "HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. " - "Use API_BASE instead.", + "HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. Use API_BASE instead.", DeprecationWarning, stacklevel=2, ) @@ -170,8 +191,7 @@ def __getattr__(name: str) -> object: if name == "API_PATHS": warnings.warn( - "API_PATHS is deprecated and will be removed in v0.3.0. " - "Use the Endpoint enum instead.", + "API_PATHS is deprecated and will be removed in v0.3.0. Use the Endpoint enum instead.", DeprecationWarning, stacklevel=2, ) @@ -199,7 +219,10 @@ def __getattr__(name: str) -> object: # Symbols removed from __all__ (H5) but still accessible for backward compat _endpoint_helpers = { - "EndpointConfig", "ENDPOINTS", "get_endpoint_url", "get_version_for_endpoint", + "EndpointConfig", + "ENDPOINTS", + "get_endpoint_url", + "get_version_for_endpoint", } if name in _endpoint_helpers: from hyperping import endpoints as _ep diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 5255b02..734283b 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -21,9 +21,13 @@ from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin from hyperping._async_incidents_mixin import AsyncIncidentsMixin +from hyperping._async_integrations_mixin import AsyncIntegrationsMixin from hyperping._async_maintenance_mixin import AsyncMaintenanceMixin from hyperping._async_monitors_mixin import AsyncMonitorsMixin +from hyperping._async_observability_mixin import AsyncObservabilityMixin +from hyperping._async_oncall_mixin import AsyncOnCallMixin from hyperping._async_outages_mixin import AsyncOutagesMixin +from hyperping._async_reporting_mixin import AsyncReportingMixin from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin from hyperping._circuit_breaker import ( CircuitBreaker, @@ -48,6 +52,10 @@ class AsyncHyperpingClient( AsyncOutagesMixin, AsyncStatusPagesMixin, AsyncHealthchecksMixin, + AsyncReportingMixin, + AsyncObservabilityMixin, + AsyncOnCallMixin, + AsyncIntegrationsMixin, ): """Async client for interacting with the Hyperping API. @@ -178,6 +186,7 @@ def _handle_response_error(self, response: httpx.Response) -> None: ) if status in (400, 422): from hyperping.exceptions import HyperpingValidationError + raise HyperpingValidationError( message=f"Validation error: {error_msg}", status_code=status, @@ -321,9 +330,7 @@ async def _request( continue self._circuit_breaker.record_failure() if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError( - f"Request timeout after {max_attempts} attempts" - ) from e + raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e raise HyperpingAPIError(f"Request failed: {e}") from e raise HyperpingAPIError( # pragma: no cover diff --git a/src/hyperping/_async_integrations_mixin.py b/src/hyperping/_async_integrations_mixin.py new file mode 100644 index 0000000..4e90a46 --- /dev/null +++ b/src/hyperping/_async_integrations_mixin.py @@ -0,0 +1,26 @@ +"""Async integrations mixin: notification channel management.""" + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._integration_models import Integration + + +class AsyncIntegrationsMixin(_AsyncClientProtocol): + """Async integration API operations.""" + + async def list_integrations(self) -> list[Integration]: + """Get all configured notification integrations.""" + try: + result = await self._request("GET", Endpoint.INTEGRATIONS) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Integration, "integration") + + async def get_integration(self, integration_id: str) -> Integration: + """Get a single integration.""" + validate_id(integration_id, "integration_id") + result = await self._request("GET", f"{Endpoint.INTEGRATIONS}/{integration_id}") + return Integration.model_validate(expect_dict(result, "get_integration")) diff --git a/src/hyperping/_async_monitors_mixin.py b/src/hyperping/_async_monitors_mixin.py index d12e042..3b4b2b2 100644 --- a/src/hyperping/_async_monitors_mixin.py +++ b/src/hyperping/_async_monitors_mixin.py @@ -99,9 +99,7 @@ async def update_monitor( ) payload.update(update.model_dump(exclude_none=True)) - response = await self._request( - "PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload - ) + response = await self._request("PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload) return Monitor.model_validate(expect_dict(response, "update_monitor")) async def delete_monitor(self, monitor_id: str) -> None: @@ -156,9 +154,7 @@ async def get_all_reports( HyperpingAPIError: On unexpected API errors. """ if period not in VALID_PERIODS: - raise ValueError( - f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}" - ) + raise ValueError(f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}") response = expect_dict( await self._request("GET", Endpoint.REPORTS, params={"period": period}), "get_all_reports", @@ -191,3 +187,16 @@ async def get_monitor_report( if r.uuid == monitor_id: return r raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}") + + async def search_monitors_by_name(self, query: str) -> list[Monitor]: + """Search monitors by name (case-insensitive substring match).""" + if not query: + return [] + try: + result = await self._request( + "GET", f"{Endpoint.MONITORS}/search", params={"query": query} + ) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Monitor, "monitor") diff --git a/src/hyperping/_async_observability_mixin.py b/src/hyperping/_async_observability_mixin.py new file mode 100644 index 0000000..6a191b7 --- /dev/null +++ b/src/hyperping/_async_observability_mixin.py @@ -0,0 +1,65 @@ +"""Async observability mixin: anomalies, probe logs, alert history.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class AsyncObservabilityMixin(_AsyncClientProtocol): + """Async observability API operations.""" + + async def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: + """Get detected anomalies for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + try: + result = await self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, MonitorAnomaly, "anomaly") + + async def get_monitor_http_logs( + self, monitor_uuid: str, page: int = 0, limit: int = 50, level: str | None = None + ) -> list[ProbeLog]: + """Get recent HTTP probe logs for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = {"page": page, "limit": limit} + if level is not None: + params["level"] = level + try: + result = await self._request( + "GET", f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", params=params + ) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, ProbeLog, "probe_log") + + async def list_recent_alerts( + self, + from_dt: str | None = None, + to_dt: str | None = None, + monitor_uuids: list[str] | None = None, + ) -> list[AlertNotification]: + """Get recent alert notification history.""" + params: dict[str, Any] = {} + if from_dt is not None: + params["from"] = from_dt + if to_dt is not None: + params["to"] = to_dt + if monitor_uuids: + params["monitor_uuids"] = ",".join(monitor_uuids) + try: + result = await self._request("GET", Endpoint.ALERTS, params=params) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_async_oncall_mixin.py b/src/hyperping/_async_oncall_mixin.py new file mode 100644 index 0000000..3429e0b --- /dev/null +++ b/src/hyperping/_async_oncall_mixin.py @@ -0,0 +1,53 @@ +"""Async on-call mixin: schedules, escalation policies, team members.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class AsyncOnCallMixin(_AsyncClientProtocol): + """Async on-call context API operations.""" + + async def list_on_call_schedules(self) -> list[OnCallSchedule]: + """Get all on-call rotation schedules.""" + try: + result = await self._request("GET", Endpoint.ON_CALL_SCHEDULES) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, OnCallSchedule, "on_call_schedule") + + async def get_on_call_schedule(self, schedule_id: str) -> OnCallSchedule: + """Get a single on-call schedule.""" + validate_id(schedule_id, "schedule_id") + result = await self._request("GET", f"{Endpoint.ON_CALL_SCHEDULES}/{schedule_id}") + return OnCallSchedule.model_validate(expect_dict(result, "get_on_call_schedule")) + + async def list_escalation_policies(self) -> list[EscalationPolicy]: + """Get all escalation policies.""" + try: + result = await self._request("GET", Endpoint.ESCALATION_POLICIES) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, EscalationPolicy, "escalation_policy") + + async def get_escalation_policy(self, policy_id: str) -> EscalationPolicy: + """Get a single escalation policy.""" + validate_id(policy_id, "policy_id") + result = await self._request("GET", f"{Endpoint.ESCALATION_POLICIES}/{policy_id}") + return EscalationPolicy.model_validate(expect_dict(result, "get_escalation_policy")) + + async def list_team_members(self) -> list[dict[str, Any]]: + """Get all team members.""" + try: + result = await self._request("GET", Endpoint.TEAM_MEMBERS) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + if isinstance(result, list): + return result + return [] diff --git a/src/hyperping/_async_outages_mixin.py b/src/hyperping/_async_outages_mixin.py index c330bd1..f17cd26 100644 --- a/src/hyperping/_async_outages_mixin.py +++ b/src/hyperping/_async_outages_mixin.py @@ -19,6 +19,7 @@ from hyperping.endpoints import Endpoint from hyperping.exceptions import HyperpingNotFoundError from hyperping.models import Outage, OutageAction +from hyperping.models._outage_models import OutageTimeline, OutageTimelineEvent logger = logging.getLogger(__name__) @@ -56,9 +57,7 @@ async def list_outages( ValueError: If *status* or *outage_type* is not a recognised value. """ if status not in _VALID_STATUSES: - raise ValueError( - f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}" - ) + raise ValueError(f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}") if outage_type not in _VALID_TYPES: raise ValueError( f"Invalid outage_type {outage_type!r}. Valid values: {sorted(_VALID_TYPES)}" @@ -75,7 +74,8 @@ async def list_outages( params["page"] = page data = await self._request("GET", Endpoint.OUTAGES, params=params) raw: list[Any] = ( - data.get("outages", []) if isinstance(data, dict) + data.get("outages", []) + if isinstance(data, dict) else (data if isinstance(data, list) else []) ) return parse_list(raw, Outage, "outage") @@ -86,9 +86,7 @@ async def list_outages( logger.debug("Outage endpoint not available (404)") return [] - async def acknowledge_outage( - self, outage_id: str, message: str | None = None - ) -> OutageAction: + async def acknowledge_outage(self, outage_id: str, message: str | None = None) -> OutageAction: """Acknowledge an outage. Args: @@ -110,9 +108,7 @@ async def acknowledge_outage( ) return OutageAction.from_raw(expect_dict(result, "outage operation")) - async def resolve_outage( - self, outage_id: str, message: str | None = None - ) -> OutageAction: + async def resolve_outage(self, outage_id: str, message: str | None = None) -> OutageAction: """Resolve an outage. Args: @@ -163,9 +159,7 @@ async def unacknowledge_outage(self, outage_id: str) -> OutageAction: HyperpingNotFoundError: If outage not found. """ validate_id(outage_id, "outage_id") - result = await self._request( - "POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge" - ) + result = await self._request("POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge") return OutageAction.from_raw(expect_dict(result, "outage operation")) async def delete_outage(self, outage_id: str) -> None: @@ -212,3 +206,33 @@ async def get_outage(self, outage_id: str) -> Outage: validate_id(outage_id, "outage_id") result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}") return Outage.model_validate(expect_dict(result, "get_outage")) + + async def get_outage_timeline(self, outage_id: str) -> OutageTimeline: + """Get the lifecycle timeline for an outage.""" + validate_id(outage_id, "outage_id") + result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}/timeline") + data = expect_dict(result, "get_outage_timeline") + raw_events = data.get("events", []) + events = parse_list(raw_events, OutageTimelineEvent, "timeline_event") + return OutageTimeline.model_validate({"outageUuid": outage_id, "events": events}) + + async def get_monitor_outages( + self, + monitor_uuid: str, + page: int | None = None, + status: str = "all", + ) -> list[Outage]: + """Get outages scoped to a single monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = { + "monitor_uuid": monitor_uuid, + "status": status, + } + if page is not None: + params["page"] = page + try: + result = await self._request("GET", Endpoint.OUTAGES, params=params) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Outage, "outage") diff --git a/src/hyperping/_async_reporting_mixin.py b/src/hyperping/_async_reporting_mixin.py new file mode 100644 index 0000000..911df12 --- /dev/null +++ b/src/hyperping/_async_reporting_mixin.py @@ -0,0 +1,39 @@ +"""Async reporting mixin: status summary, response time, MTTA.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._reporting_models import StatusSummary + + +class AsyncReportingMixin(_AsyncClientProtocol): + """Async status and reporting API operations.""" + + async def get_status_summary(self) -> StatusSummary: + """Get aggregate status counts for the project.""" + result = await self._request("GET", Endpoint.STATUS_SUMMARY) + return StatusSummary.model_validate(expect_dict(result, "get_status_summary")) + + async def get_monitor_response_time( + self, monitor_uuid: str, period: str = "24h" + ) -> dict[str, Any]: + """Get latency percentiles for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + result = await self._request( + "GET", + f"{Endpoint.MONITOR_RESPONSE_TIME}/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_response_time") + + async def get_monitor_mtta(self, monitor_uuid: str, period: str = "30d") -> dict[str, Any]: + """Get Mean Time To Acknowledge for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + result = await self._request( + "GET", + f"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_integrations_mixin.py b/src/hyperping/_integrations_mixin.py new file mode 100644 index 0000000..3c8da3f --- /dev/null +++ b/src/hyperping/_integrations_mixin.py @@ -0,0 +1,41 @@ +"""Integrations mixin: notification channel management.""" + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._integration_models import Integration + + +class IntegrationsMixin(_ClientProtocol): + """Integration API operations.""" + + def list_integrations(self) -> list[Integration]: + """Get all configured notification integrations. + + Returns: + List of :class:`~hyperping.models.Integration` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.INTEGRATIONS) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Integration, "integration") + + def get_integration(self, integration_id: str) -> Integration: + """Get a single integration. + + Args: + integration_id: Integration UUID. + + Returns: + :class:`~hyperping.models.Integration` object. + + Raises: + HyperpingNotFoundError: If integration not found. + """ + validate_id(integration_id, "integration_id") + result = self._request("GET", f"{Endpoint.INTEGRATIONS}/{integration_id}") + return Integration.model_validate(expect_dict(result, "get_integration")) diff --git a/src/hyperping/_monitors_mixin.py b/src/hyperping/_monitors_mixin.py index 50d70c2..fde5bc3 100644 --- a/src/hyperping/_monitors_mixin.py +++ b/src/hyperping/_monitors_mixin.py @@ -171,9 +171,7 @@ def get_all_reports( HyperpingAPIError: On unexpected API errors. """ if period not in VALID_PERIODS: - raise ValueError( - f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}" - ) + raise ValueError(f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}") response = expect_dict( self._request("GET", Endpoint.REPORTS, params={"period": period}), "get_all_reports", @@ -215,3 +213,23 @@ def get_monitor_report( if r.uuid == monitor_id: return r raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}") + + def search_monitors_by_name(self, query: str) -> list[Monitor]: + """Search monitors by name (case-insensitive substring match). + + Args: + query: Search string to match against monitor names and URLs. + + Returns: + List of matching :class:`~hyperping.models.Monitor` objects. + Returns empty list on 404 or no matches. + """ + if not query: + return [] + try: + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.MONITORS}/search", params={"query": query}) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Monitor, "monitor") diff --git a/src/hyperping/_observability_mixin.py b/src/hyperping/_observability_mixin.py new file mode 100644 index 0000000..692c9e8 --- /dev/null +++ b/src/hyperping/_observability_mixin.py @@ -0,0 +1,102 @@ +"""Observability mixin: anomalies, probe logs, alert history.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class ObservabilityMixin(_ClientProtocol): + """Observability API operations: anomalies, logs, alerts.""" + + def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: + """Get detected anomalies for a monitor. + + Args: + monitor_uuid: Monitor UUID. + + Returns: + List of :class:`~hyperping.models.MonitorAnomaly` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + try: + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, MonitorAnomaly, "anomaly") + + def get_monitor_http_logs( + self, + monitor_uuid: str, + page: int = 0, + limit: int = 50, + level: str | None = None, + ) -> list[ProbeLog]: + """Get recent HTTP probe logs for a monitor. + + Args: + monitor_uuid: Monitor UUID. + page: Page number (0-indexed). + limit: Results per page (max 200). + level: Filter by log level (e.g., ``"error"``). + + Returns: + List of :class:`~hyperping.models.ProbeLog` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = {"page": page, "limit": limit} + if level is not None: + params["level"] = level + try: + # Path is speculative; derived from MCP tool name. + result = self._request( + "GET", + f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", + params=params, + ) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, ProbeLog, "probe_log") + + def list_recent_alerts( + self, + from_dt: str | None = None, + to_dt: str | None = None, + monitor_uuids: list[str] | None = None, + ) -> list[AlertNotification]: + """Get recent alert notification history. + + Args: + from_dt: Start time filter (ISO 8601). + to_dt: End time filter (ISO 8601). + monitor_uuids: Filter to specific monitors. + + Returns: + List of :class:`~hyperping.models.AlertNotification` objects. + Returns empty list on 404. + """ + params: dict[str, Any] = {} + if from_dt is not None: + params["from"] = from_dt + if to_dt is not None: + params["to"] = to_dt + if monitor_uuids: + params["monitor_uuids"] = ",".join(monitor_uuids) + try: + result = self._request("GET", Endpoint.ALERTS, params=params) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_oncall_mixin.py b/src/hyperping/_oncall_mixin.py new file mode 100644 index 0000000..0da868d --- /dev/null +++ b/src/hyperping/_oncall_mixin.py @@ -0,0 +1,88 @@ +"""On-call mixin: schedules, escalation policies, team members.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class OnCallMixin(_ClientProtocol): + """On-call context API operations.""" + + def list_on_call_schedules(self) -> list[OnCallSchedule]: + """Get all on-call rotation schedules. + + Returns: + List of :class:`~hyperping.models.OnCallSchedule` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.ON_CALL_SCHEDULES) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, OnCallSchedule, "on_call_schedule") + + def get_on_call_schedule(self, schedule_id: str) -> OnCallSchedule: + """Get a single on-call schedule. + + Args: + schedule_id: Schedule UUID. + + Returns: + :class:`~hyperping.models.OnCallSchedule` object. + + Raises: + HyperpingNotFoundError: If schedule not found. + """ + validate_id(schedule_id, "schedule_id") + result = self._request("GET", f"{Endpoint.ON_CALL_SCHEDULES}/{schedule_id}") + return OnCallSchedule.model_validate(expect_dict(result, "get_on_call_schedule")) + + def list_escalation_policies(self) -> list[EscalationPolicy]: + """Get all escalation policies. + + Returns: + List of :class:`~hyperping.models.EscalationPolicy` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.ESCALATION_POLICIES) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, EscalationPolicy, "escalation_policy") + + def get_escalation_policy(self, policy_id: str) -> EscalationPolicy: + """Get a single escalation policy. + + Args: + policy_id: Policy UUID. + + Returns: + :class:`~hyperping.models.EscalationPolicy` object. + + Raises: + HyperpingNotFoundError: If policy not found. + """ + validate_id(policy_id, "policy_id") + result = self._request("GET", f"{Endpoint.ESCALATION_POLICIES}/{policy_id}") + return EscalationPolicy.model_validate(expect_dict(result, "get_escalation_policy")) + + def list_team_members(self) -> list[dict[str, Any]]: + """Get all team members. + + Returns: + List of dicts with member info (name, email). + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.TEAM_MEMBERS) + except (HyperpingNotFoundError, HyperpingAPIError): + return [] + if isinstance(result, list): + return result + return [] diff --git a/src/hyperping/_outages_mixin.py b/src/hyperping/_outages_mixin.py index 15549f2..4c9dc56 100644 --- a/src/hyperping/_outages_mixin.py +++ b/src/hyperping/_outages_mixin.py @@ -14,6 +14,7 @@ from hyperping.endpoints import Endpoint from hyperping.exceptions import HyperpingNotFoundError from hyperping.models import Outage, OutageAction +from hyperping.models._outage_models import OutageTimeline, OutageTimelineEvent logger = logging.getLogger(__name__) @@ -51,9 +52,7 @@ def list_outages( ValueError: If *status* or *outage_type* is not a recognised value. """ if status not in _VALID_STATUSES: - raise ValueError( - f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}" - ) + raise ValueError(f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}") if outage_type not in _VALID_TYPES: raise ValueError( f"Invalid outage_type {outage_type!r}. Valid values: {sorted(_VALID_TYPES)}" @@ -70,7 +69,8 @@ def list_outages( params["page"] = page data = self._request("GET", Endpoint.OUTAGES, params=params) raw: list[Any] = ( - data.get("outages", []) if isinstance(data, dict) + data.get("outages", []) + if isinstance(data, dict) else (data if isinstance(data, list) else []) ) return parse_list(raw, Outage, "outage") @@ -154,9 +154,7 @@ def unacknowledge_outage(self, outage_id: str) -> OutageAction: HyperpingNotFoundError: If outage not found. """ validate_id(outage_id, "outage_id") # H8 - result = self._request( - "POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge" - ) + result = self._request("POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge") return OutageAction.from_raw(expect_dict(result, "outage operation")) def delete_outage(self, outage_id: str) -> None: @@ -203,3 +201,57 @@ def get_outage(self, outage_id: str) -> Outage: validate_id(outage_id, "outage_id") # H8 result = self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}") return Outage.model_validate(expect_dict(result, "get_outage")) + + def get_outage_timeline(self, outage_id: str) -> OutageTimeline: + """Get the lifecycle timeline for an outage. + + Timeline events include detection, cross-region verification, + alert dispatch, acknowledgement, and resolution. + + Args: + outage_id: Outage UUID. + + Returns: + :class:`~hyperping.models.OutageTimeline` with chronological events. + + Raises: + HyperpingNotFoundError: If outage not found. + """ + validate_id(outage_id, "outage_id") + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}/timeline") + data = expect_dict(result, "get_outage_timeline") + raw_events = data.get("events", []) + events = parse_list(raw_events, OutageTimelineEvent, "timeline_event") + return OutageTimeline.model_validate({"outageUuid": outage_id, "events": events}) + + def get_monitor_outages( + self, + monitor_uuid: str, + page: int | None = None, + status: str = "all", + ) -> list[Outage]: + """Get outages scoped to a single monitor. + + Args: + monitor_uuid: Monitor UUID. + page: Page number (0-indexed). None fetches first page. + status: Filter: ``"all"``, ``"ongoing"``, ``"resolved"``. + + Returns: + List of :class:`~hyperping.models.Outage` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = { + "monitor_uuid": monitor_uuid, + "status": status, + } + if page is not None: + params["page"] = page + try: + result = self._request("GET", Endpoint.OUTAGES, params=params) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Outage, "outage") diff --git a/src/hyperping/_reporting_mixin.py b/src/hyperping/_reporting_mixin.py new file mode 100644 index 0000000..32fb079 --- /dev/null +++ b/src/hyperping/_reporting_mixin.py @@ -0,0 +1,71 @@ +"""Reporting mixin: status summary, response time, MTTA.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._reporting_models import StatusSummary + + +class ReportingMixin(_ClientProtocol): + """Status and reporting API operations.""" + + def get_status_summary(self) -> StatusSummary: + """Get aggregate status counts for the project. + + Returns: + :class:`~hyperping.models.StatusSummary` with up/down/paused counts. + """ + result = self._request("GET", Endpoint.STATUS_SUMMARY) + return StatusSummary.model_validate(expect_dict(result, "get_status_summary")) + + def get_monitor_response_time( + self, + monitor_uuid: str, + period: str = "24h", + ) -> dict[str, Any]: + """Get latency percentiles for a monitor. + + Args: + monitor_uuid: Monitor UUID. + period: Time period (e.g., ``"1h"``, ``"24h"``, ``"7d"``). + + Returns: + Dict with latency percentile data (shape depends on API). + + Raises: + HyperpingNotFoundError: If monitor not found. + """ + validate_id(monitor_uuid, "monitor_uuid") + result = self._request( + "GET", + f"{Endpoint.MONITOR_RESPONSE_TIME}/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_response_time") + + def get_monitor_mtta( + self, + monitor_uuid: str, + period: str = "30d", + ) -> dict[str, Any]: + """Get Mean Time To Acknowledge for a monitor. + + Args: + monitor_uuid: Monitor UUID. + period: Time period (e.g., ``"7d"``, ``"30d"``). + + Returns: + Dict with MTTA data (shape depends on API). + + Raises: + HyperpingNotFoundError: If monitor not found. + """ + validate_id(monitor_uuid, "monitor_uuid") + result = self._request( + "GET", + f"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_version.py b/src/hyperping/_version.py index a955fda..67bc602 100644 --- a/src/hyperping/_version.py +++ b/src/hyperping/_version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.3.0" diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 1c325df..7ccb92c 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -25,10 +25,14 @@ ) from hyperping._healthchecks_mixin import HealthchecksMixin from hyperping._incidents_mixin import IncidentsMixin +from hyperping._integrations_mixin import IntegrationsMixin from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log from hyperping._maintenance_mixin import MaintenanceMixin from hyperping._monitors_mixin import MonitorsMixin +from hyperping._observability_mixin import ObservabilityMixin +from hyperping._oncall_mixin import OnCallMixin from hyperping._outages_mixin import OutagesMixin +from hyperping._reporting_mixin import ReportingMixin from hyperping._statuspages_mixin import StatusPagesMixin from hyperping.endpoints import API_BASE from hyperping.exceptions import ( @@ -59,8 +63,16 @@ class RetryConfig: class HyperpingClient( - MonitorsMixin, IncidentsMixin, MaintenanceMixin, OutagesMixin, StatusPagesMixin, + MonitorsMixin, + IncidentsMixin, + MaintenanceMixin, + OutagesMixin, + StatusPagesMixin, HealthchecksMixin, + ReportingMixin, + ObservabilityMixin, + OnCallMixin, + IntegrationsMixin, ): """Client for interacting with Hyperping API. @@ -393,9 +405,7 @@ def _request( continue self._circuit_breaker.record_failure() if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError( - f"Request timeout after {max_attempts} attempts" - ) from e + raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e raise HyperpingAPIError(f"Request failed: {e}") from e # Should not reach here, but just in case diff --git a/src/hyperping/endpoints.py b/src/hyperping/endpoints.py index 39aeb2c..3b1e80b 100644 --- a/src/hyperping/endpoints.py +++ b/src/hyperping/endpoints.py @@ -136,6 +136,32 @@ class Endpoint(StrEnum): HEALTHCHECKS = "/v2/healthchecks" """Healthcheck (cron/heartbeat monitor) management. Version: v2""" + # --- MCP-discovered endpoints (paths are speculative; verify against live API) --- + + ALERTS = "/v2/alerts" + """Alert notification history. Version: v2""" + + ON_CALL_SCHEDULES = "/v2/on-call-schedules" + """On-call rotation schedules. Version: v2""" + + ESCALATION_POLICIES = "/v2/escalation-policies" + """Escalation policy chains. Version: v2""" + + TEAM_MEMBERS = "/v2/team-members" + """Team member directory. Version: v2""" + + INTEGRATIONS = "/v2/integrations" + """Notification channel integrations. Version: v2""" + + STATUS_SUMMARY = "/v2/status-summary" + """Aggregate status counts. Version: v2""" + + MONITOR_RESPONSE_TIME = "/v2/reporting/response-time" + """Monitor latency percentiles. Version: v2. Append /{uuid}.""" + + MONITOR_MTTA = "/v2/reporting/mtta" + """Monitor mean time to acknowledge. Version: v2. Append /{uuid}.""" + # Detailed endpoint metadata for programmatic access ENDPOINTS: Final[dict[Endpoint, EndpointConfig]] = { @@ -174,6 +200,47 @@ class Endpoint(StrEnum): resource="healthchecks", description="Healthcheck (cron/heartbeat) CRUD and pause/resume", ), + # MCP-discovered (speculative paths) + Endpoint.ALERTS: EndpointConfig( + version=APIVersion.V2, + resource="alerts", + description="Alert notification history across channels", + ), + Endpoint.ON_CALL_SCHEDULES: EndpointConfig( + version=APIVersion.V2, + resource="on-call-schedules", + description="On-call rotation schedules", + ), + Endpoint.ESCALATION_POLICIES: EndpointConfig( + version=APIVersion.V2, + resource="escalation-policies", + description="Escalation policy chains", + ), + Endpoint.TEAM_MEMBERS: EndpointConfig( + version=APIVersion.V2, + resource="team-members", + description="Team member name and email directory", + ), + Endpoint.INTEGRATIONS: EndpointConfig( + version=APIVersion.V2, + resource="integrations", + description="Notification channel configuration", + ), + Endpoint.STATUS_SUMMARY: EndpointConfig( + version=APIVersion.V2, + resource="status-summary", + description="Aggregate monitor status counts", + ), + Endpoint.MONITOR_RESPONSE_TIME: EndpointConfig( + version=APIVersion.V2, + resource="reporting/response-time", + description="Monitor latency percentiles (append /{uuid})", + ), + Endpoint.MONITOR_MTTA: EndpointConfig( + version=APIVersion.V2, + resource="reporting/mtta", + description="Monitor mean time to acknowledge (append /{uuid})", + ), } @@ -195,6 +262,14 @@ class Endpoint(StrEnum): "outages": Endpoint.OUTAGES.value, "statuspages": Endpoint.STATUSPAGES.value, "healthchecks": Endpoint.HEALTHCHECKS.value, + "alerts": Endpoint.ALERTS.value, + "on-call-schedules": Endpoint.ON_CALL_SCHEDULES.value, + "escalation-policies": Endpoint.ESCALATION_POLICIES.value, + "team-members": Endpoint.TEAM_MEMBERS.value, + "integrations": Endpoint.INTEGRATIONS.value, + "status-summary": Endpoint.STATUS_SUMMARY.value, + "monitor-response-time": Endpoint.MONITOR_RESPONSE_TIME.value, + "monitor-mtta": Endpoint.MONITOR_MTTA.value, } """Deprecated: Use Endpoint enum instead for type safety.""" diff --git a/src/hyperping/models/__init__.py b/src/hyperping/models/__init__.py index 1909f64..c66fef8 100644 --- a/src/hyperping/models/__init__.py +++ b/src/hyperping/models/__init__.py @@ -23,6 +23,7 @@ IncidentUpdateRequest, IncidentUpdateType, ) +from hyperping.models._integration_models import Integration from hyperping.models._maintenance_models import ( Maintenance, MaintenanceCreate, @@ -50,7 +51,19 @@ ReportPeriod, RequestHeader, ) -from hyperping.models._outage_models import Outage, OutageAction +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule +from hyperping.models._outage_models import ( + Outage, + OutageAction, + OutageTimeline, + OutageTimelineEvent, +) +from hyperping.models._reporting_models import StatusSummary from hyperping.models._statuspage_models import ( StatusPage, StatusPageCreate, @@ -102,6 +115,19 @@ # Outage models "Outage", "OutageAction", + "OutageTimeline", + "OutageTimelineEvent", + # Observability models + "MonitorAnomaly", + "ProbeLog", + "AlertNotification", + # On-call models + "OnCallSchedule", + "EscalationPolicy", + # Integration models + "Integration", + # Reporting models + "StatusSummary", # Healthcheck models "Healthcheck", "HealthcheckCreate", diff --git a/src/hyperping/models/_integration_models.py b/src/hyperping/models/_integration_models.py new file mode 100644 index 0000000..e44c047 --- /dev/null +++ b/src/hyperping/models/_integration_models.py @@ -0,0 +1,14 @@ +"""Integration models: notification channel configuration.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class Integration(BaseModel): + """Configured notification integration (Slack, Teams, PagerDuty, etc.).""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Integration UUID") + name: str = Field(..., description="Integration display name") + integration_type: str = Field(..., alias="type", description="Channel type") + active: bool = Field(default=True, description="Whether the integration is active") diff --git a/src/hyperping/models/_observability_models.py b/src/hyperping/models/_observability_models.py new file mode 100644 index 0000000..ebcb318 --- /dev/null +++ b/src/hyperping/models/_observability_models.py @@ -0,0 +1,46 @@ +"""Observability models: anomalies, probe logs, and alert notifications.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class MonitorAnomaly(BaseModel): + """Anomaly detected on a monitor (flapping, latency spike, etc.).""" + + model_config = ConfigDict(extra="ignore", 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" + ) + 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.""" + + model_config = ConfigDict(extra="ignore", 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") + + +class AlertNotification(BaseModel): + """Alert notification record from notification history.""" + + model_config = ConfigDict(extra="ignore", 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" + ) diff --git a/src/hyperping/models/_oncall_models.py b/src/hyperping/models/_oncall_models.py new file mode 100644 index 0000000..a682dd9 --- /dev/null +++ b/src/hyperping/models/_oncall_models.py @@ -0,0 +1,27 @@ +"""On-call models: schedules and escalation policies.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class OnCallSchedule(BaseModel): + """On-call rotation schedule.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Schedule UUID") + name: str = Field(..., description="Schedule name") + current_on_call: str | None = Field( + default=None, alias="currentOnCall", description="Current on-call person" + ) + + +class EscalationPolicy(BaseModel): + """Escalation policy with step chain.""" + + model_config = ConfigDict(extra="ignore", 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") diff --git a/src/hyperping/models/_outage_models.py b/src/hyperping/models/_outage_models.py index 840eb0d..8ba800a 100644 --- a/src/hyperping/models/_outage_models.py +++ b/src/hyperping/models/_outage_models.py @@ -78,3 +78,28 @@ class Outage(BaseModel): def from_raw(cls, data: dict[str, Any]) -> Outage: """Parse an outage from a raw API response dict.""" return cls.model_validate(data) + + +class OutageTimelineEvent(BaseModel): + """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) + + event_type: str = Field(..., alias="eventType", description="Event category") + timestamp: str = Field(..., description="Event time ISO 8601") + detail: str | None = Field(default=None, description="Event detail text") + + +class OutageTimeline(BaseModel): + """Full lifecycle timeline for an outage.""" + + model_config = ConfigDict(extra="ignore", 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" + ) diff --git a/src/hyperping/models/_reporting_models.py b/src/hyperping/models/_reporting_models.py new file mode 100644 index 0000000..9558a39 --- /dev/null +++ b/src/hyperping/models/_reporting_models.py @@ -0,0 +1,17 @@ +"""Reporting models: status summary and aggregated metrics.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class StatusSummary(BaseModel): + """Aggregate status summary from a single API call.""" + + model_config = ConfigDict(extra="ignore", 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") diff --git a/tests/unit/test_async_new_mixins.py b/tests/unit/test_async_new_mixins.py new file mode 100644 index 0000000..67d8c83 --- /dev/null +++ b/tests/unit/test_async_new_mixins.py @@ -0,0 +1,262 @@ +"""Tests for new async mixins: reporting, observability, on-call, integrations.""" + +import httpx +import pytest +import pytest_asyncio +import respx + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.endpoints import API_BASE, Endpoint + + +@pytest_asyncio.fixture +async def async_client(): + """Async client with retries disabled.""" + client = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# ==================== Reporting ==================== + + +class TestAsyncReporting: + @respx.mock + @pytest.mark.asyncio + async def test_get_status_summary(self, async_client): + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response( + 200, + json={ + "totalMonitors": 10, + "upCount": 8, + "downCount": 1, + "pausedCount": 1, + }, + ) + ) + result = await async_client.get_status_summary() + assert result.total_monitors == 10 + assert result.up_count == 8 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_response_time(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/mon_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0, "p95": 120.0}) + ) + result = await async_client.get_monitor_response_time("mon_1") + assert result["p50"] == 45.0 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_mtta(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/mon_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) + ) + result = await async_client.get_monitor_mtta("mon_1") + assert result["mtta_seconds"] == 120.0 + + +# ==================== Observability ==================== + + +class TestAsyncObservability: + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_anomalies(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_1/anomalies").mock( + return_value=httpx.Response( + 200, + json=[ + { + "anomalyType": "flapping", + "startedAt": "2026-01-01T00:00:00Z", + "severity": "warning", + } + ], + ) + ) + result = await async_client.get_monitor_anomalies("mon_1") + assert len(result) == 1 + assert result[0].anomaly_type == "flapping" + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_http_logs(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_1/http-logs").mock( + return_value=httpx.Response( + 200, + json=[{"status": 200, "location": "london", "responseTimeMs": 45.2}], + ) + ) + result = await async_client.get_monitor_http_logs("mon_1") + assert len(result) == 1 + assert result[0].response_time_ms == 45.2 + + @respx.mock + @pytest.mark.asyncio + async def test_list_recent_alerts(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "a1", "channel": "slack", "sentAt": "2026-01-01T00:00:00Z"}], + ) + ) + result = await async_client.list_recent_alerts() + assert len(result) == 1 + assert result[0].channel == "slack" + + +# ==================== On-call ==================== + + +class TestAsyncOnCall: + @respx.mock + @pytest.mark.asyncio + async def test_list_on_call_schedules(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "s1", "name": "Primary", "currentOnCall": "alice"}], + ) + ) + result = await async_client.list_on_call_schedules() + assert len(result) == 1 + assert result[0].current_on_call == "alice" + + @respx.mock + @pytest.mark.asyncio + async def test_get_on_call_schedule(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "s1", "name": "Primary", "currentOnCall": "bob"}, + ) + ) + result = await async_client.get_on_call_schedule("s1") + assert result.current_on_call == "bob" + + @respx.mock + @pytest.mark.asyncio + async def test_list_escalation_policies(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response(200, json=[{"uuid": "p1", "name": "Default", "steps": []}]) + ) + result = await async_client.list_escalation_policies() + assert len(result) == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_get_escalation_policy(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p1").mock( + return_value=httpx.Response( + 200, json={"uuid": "p1", "name": "Tiered", "steps": [{"level": 1}]} + ) + ) + result = await async_client.get_escalation_policy("p1") + assert result.name == "Tiered" + + @respx.mock + @pytest.mark.asyncio + async def test_list_team_members(self, async_client): + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response(200, json=[{"name": "Alice", "email": "alice@example.com"}]) + ) + result = await async_client.list_team_members() + assert len(result) == 1 + assert result[0]["name"] == "Alice" + + +# ==================== Integrations ==================== + + +class TestAsyncIntegrations: + @respx.mock + @pytest.mark.asyncio + async def test_list_integrations(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "i1", "name": "Slack", "type": "slack", "active": True}], + ) + ) + result = await async_client.list_integrations() + assert len(result) == 1 + assert result[0].integration_type == "slack" + + @respx.mock + @pytest.mark.asyncio + async def test_get_integration(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "i1", "name": "PD", "type": "pagerduty", "active": False}, + ) + ) + result = await async_client.get_integration("i1") + assert result.integration_type == "pagerduty" + assert result.active is False + + +# ==================== Async outage/monitor extensions ==================== + + +class TestAsyncOutageExtensions: + @respx.mock + @pytest.mark.asyncio + async def test_get_outage_timeline(self, async_client): + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1/timeline").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "eventType": "detection", + "timestamp": "2026-01-01T00:00:00Z", + "detail": "Probe failed", + } + ] + }, + ) + ) + result = await async_client.get_outage_timeline("out_1") + assert result.outage_uuid == "out_1" + assert len(result.events) == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_outages(self, async_client): + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response(200, json=[{"uuid": "out_1", "monitorUuid": "mon_1"}]) + ) + result = await async_client.get_monitor_outages("mon_1") + assert len(result) == 1 + + +class TestAsyncMonitorExtensions: + @respx.mock + @pytest.mark.asyncio + async def test_search_monitors_by_name(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "mon_1", + "name": "API Monitor", + "url": "https://api.example.com", + "down": False, + "paused": False, + } + ], + ) + ) + result = await async_client.search_monitors_by_name("API") + assert len(result) == 1 + assert result[0].name == "API Monitor" diff --git a/tests/unit/test_async_preexisting.py b/tests/unit/test_async_preexisting.py new file mode 100644 index 0000000..8f1fedd --- /dev/null +++ b/tests/unit/test_async_preexisting.py @@ -0,0 +1,331 @@ +"""Tests for pre-existing async mixins: healthchecks, maintenance, incidents. + +These bring coverage of existing async code that was previously untested. +""" + +import httpx +import pytest +import pytest_asyncio +import respx + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError + + +@pytest_asyncio.fixture +async def async_client(): + """Async client with retries disabled.""" + client = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# ==================== Async Healthchecks ==================== + + +class TestAsyncHealthchecks: + @respx.mock + @pytest.mark.asyncio + async def test_list_healthchecks(self, async_client): + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "hc_1", + "name": "Cron Job", + "period": 300, + "grace": 60, + } + ], + ) + ) + result = await async_client.list_healthchecks() + assert len(result) == 1 + assert result[0].uuid == "hc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_healthcheck(self, async_client): + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "hc_1", "name": "Cron", "period": 300, "grace": 60}, + ) + ) + result = await async_client.get_healthcheck("hc_1") + assert result.name == "Cron" + + @respx.mock + @pytest.mark.asyncio + async def test_create_healthcheck(self, async_client): + from hyperping.models import HealthcheckCreate + + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response( + 201, + json={"uuid": "hc_new", "name": "New HC", "period": 600, "grace": 120}, + ) + ) + hc = HealthcheckCreate(name="New HC", period=600, grace=120) + result = await async_client.create_healthcheck(hc) + assert result.uuid == "hc_new" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_healthcheck(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_healthcheck("hc_1") + + @respx.mock + @pytest.mark.asyncio + async def test_pause_healthcheck(self, async_client): + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1/pause").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "hc_1", + "name": "HC", + "period": 300, + "grace": 60, + "isPaused": True, + }, + ) + ) + result = await async_client.pause_healthcheck("hc_1") + assert result.uuid == "hc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_resume_healthcheck(self, async_client): + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1/resume").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "hc_1", + "name": "HC", + "period": 300, + "grace": 60, + "isPaused": False, + }, + ) + ) + result = await async_client.resume_healthcheck("hc_1") + assert result.uuid == "hc_1" + + +# ==================== Async Maintenance ==================== + + +class TestAsyncMaintenance: + @respx.mock + @pytest.mark.asyncio + async def test_list_maintenance(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenanceWindows": [ + { + "uuid": "mw_1", + "name": "Upgrade", + "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_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_maintenance(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "Upgrade", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": [], + }, + ) + ) + result = await async_client.get_maintenance("mw_1") + assert result.name == "Upgrade" + + @respx.mock + @pytest.mark.asyncio + async def test_create_maintenance(self, async_client): + from hyperping.models import MaintenanceCreate + + respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 201, + json={ + "uuid": "mw_new", + "name": "Deploy", + "start_date": "2026-02-01T00:00:00Z", + "end_date": "2026-02-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + mw = MaintenanceCreate( + name="Deploy", + start_date="2026-02-01T00:00:00Z", + end_date="2026-02-01T02:00:00Z", + monitors=["mon_1"], + ) + result = await async_client.create_maintenance(mw) + assert result.uuid == "mw_new" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_maintenance(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_maintenance("mw_1") + + @respx.mock + @pytest.mark.asyncio + async def test_update_maintenance(self, async_client): + from hyperping.models import MaintenanceUpdate + + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "Old Name", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + respx.put(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "New Name", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + result = await async_client.update_maintenance( + "mw_1", MaintenanceUpdate(name="New Name") + ) + assert result.name == "New Name" + + +# ==================== Async Incidents ==================== + + +class TestAsyncIncidents: + @respx.mock + @pytest.mark.asyncio + async def test_list_incidents(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + } + ], + ) + ) + result = await async_client.list_incidents() + assert len(result) == 1 + assert result[0].uuid == "inc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_incident(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + }, + ) + ) + result = await async_client.get_incident("inc_1") + assert result.title_en == "Outage" + + @respx.mock + @pytest.mark.asyncio + async def test_resolve_incident(self, async_client): + # resolve_incident calls add_incident_update which POSTs then GETs + respx.post(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1/updates").mock( + return_value=httpx.Response( + 200, json={"uuid": "u1", "message": "Update added"} + ) + ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [ + { + "uuid": "u1", + "type": "resolved", + "date": "2026-01-01T01:00:00Z", + "text": {"en": "Fixed"}, + } + ], + }, + ) + ) + result = await async_client.resolve_incident("inc_1", message="Fixed") + assert result.uuid == "inc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_incident(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_incident("inc_1") + + @respx.mock + @pytest.mark.asyncio + async def test_incident_not_found(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_x").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + await async_client.get_incident("inc_x") diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py new file mode 100644 index 0000000..8782da8 --- /dev/null +++ b/tests/unit/test_integrations.py @@ -0,0 +1,98 @@ +"""Tests for integrations API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._integration_models import Integration + + +class TestListIntegrations: + """Tests for list_integrations().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing integrations returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "i1", + "name": "Slack", + "type": "slack", + "active": True, + } + ], + ) + ) + + result = client.list_integrations() + assert len(result) == 1 + assert isinstance(result[0], Integration) + assert result[0].uuid == "i1" + assert result[0].name == "Slack" + # Verify "type" alias maps to integration_type field + assert result[0].integration_type == "slack" + assert result[0].active is True + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no integrations exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_integrations() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_integrations() + assert result == [] + + +class TestGetIntegration: + """Tests for get_integration().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single integration.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "i1", + "name": "Slack", + "type": "slack", + "active": True, + }, + ) + ) + + result = client.get_integration("i1") + assert isinstance(result, Integration) + assert result.uuid == "i1" + assert result.name == "Slack" + assert result.integration_type == "slack" + assert result.active is True + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_integration("i_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_integration("../bad") diff --git a/tests/unit/test_monitors.py b/tests/unit/test_monitors.py index 1b845c8..9fbba2f 100644 --- a/tests/unit/test_monitors.py +++ b/tests/unit/test_monitors.py @@ -682,3 +682,46 @@ def handler(request: httpx.Request) -> httpx.Response: slept = mock_sleep.call_args[0][0] assert slept == 45.0 c.close() + + +class TestSearchMonitorsByName: + """Tests for search_monitors_by_name method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test searching monitors by name returns matching monitors.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response( + 200, + json=[ + { + "monitorUuid": "mon_1", + "name": "API Monitor", + "url": "https://api.example.com", + "down": False, + "paused": False, + } + ], + ) + ) + + result = client.search_monitors_by_name("API") + + assert len(result) == 1 + assert result[0].uuid == "mon_1" + assert result[0].name == "API Monitor" + + def test_empty_query_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty query returns an empty list without making an API call.""" + result = client.search_monitors_by_name("") + assert result == [] + + @respx.mock + def test_not_found_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.search_monitors_by_name("nonexistent") + assert result == [] diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py new file mode 100644 index 0000000..5f6b5dd --- /dev/null +++ b/tests/unit/test_observability.py @@ -0,0 +1,203 @@ +"""Tests for observability mixin API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class TestGetMonitorAnomalies: + """Tests for get_monitor_anomalies().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful anomaly retrieval.""" + mock_response = [ + { + "anomalyType": "flapping", + "startedAt": "2026-01-01T00:00:00Z", + "severity": "warning", + } + ] + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/anomalies").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_anomalies("uuid_1") + + assert len(result) == 1 + assert isinstance(result[0], MonitorAnomaly) + assert result[0].anomaly_type == "flapping" + assert result[0].started_at == "2026-01-01T00:00:00Z" + assert result[0].severity == "warning" + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/anomalies").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.get_monitor_anomalies("uuid_1") + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_nope/anomalies").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_anomalies("uuid_nope") + assert result == [] + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_anomalies("../bad") + + +class TestGetMonitorHttpLogs: + """Tests for get_monitor_http_logs().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful HTTP log retrieval.""" + mock_response = [ + { + "status": 200, + "location": "london", + "responseTimeMs": 45.2, + } + ] + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_http_logs("uuid_1") + + assert len(result) == 1 + assert isinstance(result[0], ProbeLog) + assert result[0].status == 200 + assert result[0].location == "london" + assert result[0].response_time_ms == 45.2 + + @respx.mock + def test_with_level_param(self, client: HyperpingClient) -> None: + """Test that level param is forwarded in query string.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.get_monitor_http_logs("uuid_1", level="error") + + request = respx.calls.last.request + assert "level=error" in str(request.url) + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.get_monitor_http_logs("uuid_1") + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_nope/http-logs").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_http_logs("uuid_nope") + assert result == [] + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_http_logs("../bad") + + +class TestListRecentAlerts: + """Tests for list_recent_alerts().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful alert listing.""" + mock_response = [ + { + "uuid": "a1", + "channel": "slack", + "sentAt": "2026-01-01T00:00:00Z", + } + ] + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.list_recent_alerts() + + assert len(result) == 1 + assert isinstance(result[0], AlertNotification) + assert result[0].uuid == "a1" + assert result[0].channel == "slack" + assert result[0].sent_at == "2026-01-01T00:00:00Z" + + @respx.mock + def test_with_time_params(self, client: HyperpingClient) -> None: + """Test that from_dt and to_dt are forwarded as query params.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.list_recent_alerts( + from_dt="2026-01-01T00:00:00Z", + to_dt="2026-01-31T23:59:59Z", + ) + + request = respx.calls.last.request + url_str = str(request.url) + assert "from=2026-01-01" in url_str + assert "to=2026-01-31" in url_str + + @respx.mock + def test_with_monitor_uuids(self, client: HyperpingClient) -> None: + """Test that monitor_uuids are comma-joined in query params.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.list_recent_alerts(monitor_uuids=["mon_1", "mon_2"]) + + request = respx.calls.last.request + url_str = str(request.url) + assert "monitor_uuids=mon_1" in url_str or "monitor_uuids=mon_1%2Cmon_2" in url_str + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.list_recent_alerts() + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.list_recent_alerts() + assert result == [] diff --git a/tests/unit/test_oncall.py b/tests/unit/test_oncall.py new file mode 100644 index 0000000..ec52fa5 --- /dev/null +++ b/tests/unit/test_oncall.py @@ -0,0 +1,217 @@ +"""Tests for on-call API methods (schedules, escalation policies, team members).""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class TestListOnCallSchedules: + """Tests for list_on_call_schedules().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing on-call schedules returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "s1", + "name": "Primary", + "currentOnCall": "alice", + } + ], + ) + ) + + result = client.list_on_call_schedules() + assert len(result) == 1 + assert isinstance(result[0], OnCallSchedule) + assert result[0].uuid == "s1" + assert result[0].name == "Primary" + # Verify camelCase alias is correctly mapped to snake_case field + assert result[0].current_on_call == "alice" + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no schedules exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_on_call_schedules() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_on_call_schedules() + assert result == [] + + +class TestGetOnCallSchedule: + """Tests for get_on_call_schedule().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single on-call schedule.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "s1", + "name": "Primary", + "currentOnCall": "bob", + }, + ) + ) + + result = client.get_on_call_schedule("s1") + assert isinstance(result, OnCallSchedule) + assert result.uuid == "s1" + assert result.name == "Primary" + assert result.current_on_call == "bob" + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_on_call_schedule("s_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_on_call_schedule("../bad") + + +class TestListEscalationPolicies: + """Tests for list_escalation_policies().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing escalation policies returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "p1", + "name": "Default", + "steps": [{"level": 1}], + } + ], + ) + ) + + result = client.list_escalation_policies() + assert len(result) == 1 + assert isinstance(result[0], EscalationPolicy) + assert result[0].uuid == "p1" + assert result[0].name == "Default" + assert result[0].steps == [{"level": 1}] + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no policies exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_escalation_policies() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_escalation_policies() + assert result == [] + + +class TestGetEscalationPolicy: + """Tests for get_escalation_policy().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single escalation policy.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "p1", + "name": "Default", + "steps": [{"level": 1}, {"level": 2}], + }, + ) + ) + + result = client.get_escalation_policy("p1") + assert isinstance(result, EscalationPolicy) + assert result.uuid == "p1" + assert result.name == "Default" + assert len(result.steps) == 2 + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_escalation_policy("p_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_escalation_policy("../bad") + + +class TestListTeamMembers: + """Tests for list_team_members().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing team members returns raw dicts.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response( + 200, + json=[ + {"name": "Alice", "email": "alice@example.com"}, + ], + ) + ) + + result = client.list_team_members() + assert len(result) == 1 + assert isinstance(result[0], dict) + assert result[0]["name"] == "Alice" + assert result[0]["email"] == "alice@example.com" + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no team members exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_team_members() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_team_members() + assert result == [] diff --git a/tests/unit/test_outages.py b/tests/unit/test_outages.py index a170b77..d1dc388 100644 --- a/tests/unit/test_outages.py +++ b/tests/unit/test_outages.py @@ -7,7 +7,7 @@ from hyperping.client import HyperpingClient from hyperping.endpoints import API_BASE, Endpoint from hyperping.exceptions import HyperpingNotFoundError -from hyperping.models import OutageAction +from hyperping.models import Outage, OutageAction, OutageTimeline class TestOutageAPIClient: @@ -138,3 +138,100 @@ def test_escalate_outage_not_found(self, client: HyperpingClient) -> None: ) with pytest.raises(HyperpingNotFoundError): client.escalate_outage("out_nope") + + +class TestGetOutageTimeline: + """Tests for get_outage_timeline method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test retrieving an outage timeline with events.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1/timeline").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "eventType": "detection", + "timestamp": "2026-01-01T00:00:00Z", + "detail": "Probe failed", + } + ] + }, + ) + ) + + result = client.get_outage_timeline("out_1") + + assert isinstance(result, OutageTimeline) + assert result.outage_uuid == "out_1" + assert len(result.events) == 1 + assert result.events[0].event_type == "detection" + assert result.events[0].timestamp == "2026-01-01T00:00:00Z" + assert result.events[0].detail == "Probe failed" + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_x/timeline").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + with pytest.raises(HyperpingNotFoundError): + client.get_outage_timeline("out_x") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that a path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_outage_timeline("../bad") + + +class TestGetMonitorOutages: + """Tests for get_monitor_outages method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test retrieving outages scoped to a monitor.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "out_10", + "monitorUuid": "mon_1", + "status": "active", + "acknowledged": False, + "resolved": False, + }, + { + "uuid": "out_11", + "monitorUuid": "mon_1", + "status": "resolved", + "acknowledged": True, + "resolved": True, + }, + ], + ) + ) + + result = client.get_monitor_outages("mon_1") + + assert len(result) == 2 + assert all(isinstance(o, Outage) for o in result) + assert result[0].uuid == "out_10" + assert result[1].uuid == "out_11" + + @respx.mock + def test_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_outages("mon_1") + assert result == [] + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that a path-traversal monitor UUID raises ValueError.""" + with pytest.raises(ValueError): + client.get_monitor_outages("../bad") diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py new file mode 100644 index 0000000..0bcd141 --- /dev/null +++ b/tests/unit/test_reporting.py @@ -0,0 +1,140 @@ +"""Tests for reporting mixin API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._reporting_models import StatusSummary + + +class TestGetStatusSummary: + """Tests for get_status_summary().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful status summary retrieval.""" + mock_response = { + "totalMonitors": 10, + "upCount": 8, + "downCount": 1, + "pausedCount": 1, + "downMonitors": [], + } + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_status_summary() + + assert isinstance(result, StatusSummary) + assert result.total_monitors == 10 + assert result.up_count == 8 + assert result.down_count == 1 + assert result.paused_count == 1 + assert result.down_monitors == [] + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + with pytest.raises(HyperpingNotFoundError): + client.get_status_summary() + + +class TestGetMonitorResponseTime: + """Tests for get_monitor_response_time().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful response time retrieval.""" + mock_response = {"p50": 45.0, "p95": 120.0} + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_response_time("uuid_1") + + assert isinstance(result, dict) + assert result["p50"] == 45.0 + assert result["p95"] == 120.0 + + @respx.mock + def test_default_period_is_24h(self, client: HyperpingClient) -> None: + """Test that the default period query param is 24h.""" + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0}) + ) + + client.get_monitor_response_time("uuid_1") + + request = respx.calls.last.request + assert "period=24h" in str(request.url) + + @respx.mock + def test_custom_period(self, client: HyperpingClient) -> None: + """Test that a custom period is forwarded as a query param.""" + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0}) + ) + + client.get_monitor_response_time("uuid_1", period="7d") + + request = respx.calls.last.request + assert "period=7d" in str(request.url) + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID with path traversal raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_response_time("../bad") + + +class TestGetMonitorMtta: + """Tests for get_monitor_mtta().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful MTTA retrieval.""" + mock_response = {"mtta_seconds": 120.0} + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_mtta("uuid_1") + + assert isinstance(result, dict) + assert result["mtta_seconds"] == 120.0 + + @respx.mock + def test_default_period_is_30d(self, client: HyperpingClient) -> None: + """Test that the default period query param is 30d.""" + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) + ) + + client.get_monitor_mtta("uuid_1") + + request = respx.calls.last.request + assert "period=30d" in str(request.url) + + @respx.mock + def test_custom_period(self, client: HyperpingClient) -> None: + """Test that a custom period is forwarded as a query param.""" + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 60.0}) + ) + + client.get_monitor_mtta("uuid_1", period="7d") + + request = respx.calls.last.request + assert "period=7d" in str(request.url) + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID with path traversal raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_mtta("../bad") diff --git a/uv.lock b/uv.lock index bea9e2c..6f71c35 100644 --- a/uv.lock +++ b/uv.lock @@ -335,7 +335,7 @@ wheels = [ [[package]] name = "hyperping" -version = "1.2.1" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },