diff --git a/pyproject.toml b/pyproject.toml index 1683da2..0be1622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ include = [ "/pythonkuma", ] +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.14", "3.13", "3.12"] + [tool.hatch.envs.default] dependencies = [ "ruff==0.15.1", @@ -45,6 +48,10 @@ dependencies = [ "mashumaro==3.20", "mkdocs-material==9.7.1", "mkdocstrings[python]==1.0.3", + "pytest-asyncio==1.3.0", + "pytest==9.0.2", + "pytest-cov==7.0.0", + "syrupy==5.1.0", ] [tool.hatch.envs.hatch-static-analysis] @@ -63,6 +70,8 @@ pythonpath = ["pythonkuma"] [tool.hatch.envs.hatch-test] extra-dependencies = [ "pytest-cov==7.0.0", + "pytest-asyncio==1.3.0", + "syrupy==5.1.0", ] [tool.hatch.envs.default.scripts] @@ -83,6 +92,9 @@ indent-style = "space" select = ["ALL"] ignore = ["TRY003", "COM812", "N818", "C901"] +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "TC002", "TC003"] + [lint.per-file-ignores] "**/scripts/*" = [ "INP001", diff --git a/tests/__snapshots__/test_metrics.ambr b/tests/__snapshots__/test_metrics.ambr new file mode 100644 index 0000000..7c92e08 --- /dev/null +++ b/tests/__snapshots__/test_metrics.ambr @@ -0,0 +1,107 @@ +# serializer version: 1 +# name: test_metrics + dict({ + 1: dict({ + 'monitor_cert_days_remaining': 80, + 'monitor_cert_is_valid': True, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Home Assistant', + 'monitor_port': None, + 'monitor_response_time': 85, + 'monitor_response_time_seconds_1d': 0.10396079958463136, + 'monitor_response_time_seconds_30d': 0.10284582478851578, + 'monitor_response_time_seconds_365d': 0.10957428212662089, + 'monitor_status': 1, + 'monitor_tags': list([ + ]), + 'monitor_type': 'http', + 'monitor_uptime_ratio_1d': 1.0, + 'monitor_uptime_ratio_30d': 0.999247554552295, + 'monitor_uptime_ratio_365d': 0.9944324016912971, + 'monitor_url': 'https://home.example.com:8123', + }), + 2: dict({ + 'monitor_cert_days_remaining': 80, + 'monitor_cert_is_valid': True, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'FritzBox', + 'monitor_port': None, + 'monitor_response_time': 2725, + 'monitor_response_time_seconds_1d': 2.339521038961039, + 'monitor_response_time_seconds_30d': 2.3636583723629956, + 'monitor_response_time_seconds_365d': 2.3783335690116663, + 'monitor_status': 1, + 'monitor_tags': list([ + ]), + 'monitor_type': 'http', + 'monitor_uptime_ratio_1d': 0.9992213859330392, + 'monitor_uptime_ratio_30d': 0.9998319004850872, + 'monitor_uptime_ratio_365d': 0.9947252084798553, + 'monitor_url': 'https://home.example.com', + }), + 3: dict({ + 'monitor_cert_days_remaining': 46, + 'monitor_cert_is_valid': True, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Jellyfin', + 'monitor_port': None, + 'monitor_response_time': 85, + 'monitor_response_time_seconds_1d': 0.10102960288808664, + 'monitor_response_time_seconds_30d': 0.09908259629443207, + 'monitor_response_time_seconds_365d': 0.10429958790526786, + 'monitor_status': 1, + 'monitor_tags': list([ + 'Test', + 'Zuhause', + ]), + 'monitor_type': 'keyword', + 'monitor_uptime_ratio_1d': 1.0, + 'monitor_uptime_ratio_30d': 0.9993293252532994, + 'monitor_uptime_ratio_365d': 0.9941631600380073, + 'monitor_url': 'https://home.example.com:8920/health', + }), + 8: dict({ + 'monitor_cert_days_remaining': 81, + 'monitor_cert_is_valid': True, + 'monitor_hostname': None, + 'monitor_id': 8, + 'monitor_name': 'Nextcloud', + 'monitor_port': None, + 'monitor_response_time': 150, + 'monitor_response_time_seconds_1d': 0.16155477855477854, + 'monitor_response_time_seconds_30d': 0.3391915450984161, + 'monitor_response_time_seconds_365d': 0.34379255863250385, + 'monitor_status': 1, + 'monitor_tags': list([ + ]), + 'monitor_type': 'json-query', + 'monitor_uptime_ratio_1d': 1.0, + 'monitor_uptime_ratio_30d': 0.9991115593334294, + 'monitor_uptime_ratio_365d': 0.9994703389830508, + 'monitor_url': 'https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json', + }), + 9: dict({ + 'monitor_cert_days_remaining': None, + 'monitor_cert_is_valid': None, + 'monitor_hostname': None, + 'monitor_id': 9, + 'monitor_name': 'Proxy', + 'monitor_port': None, + 'monitor_response_time': 19, + 'monitor_response_time_seconds_1d': 0.032, + 'monitor_response_time_seconds_30d': 0.032, + 'monitor_response_time_seconds_365d': 0.032, + 'monitor_status': 1, + 'monitor_tags': list([ + ]), + 'monitor_type': 'unknown', + 'monitor_uptime_ratio_1d': 0.6666666666666666, + 'monitor_uptime_ratio_30d': 0.6666666666666666, + 'monitor_uptime_ratio_365d': 0.6666666666666666, + 'monitor_url': 'https://', + }), + }) +# --- diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d3aa1dd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +"""Fixtures for pythonkuma.""" + +from collections.abc import Generator +from functools import lru_cache +import pathlib +from unittest.mock import AsyncMock + +from aiohttp import ClientResponse +import pytest + + +@lru_cache +def load_fixture(filename: str) -> str: + """Load a fixture.""" + return ( + pathlib.Path(__file__) + .parent.joinpath("fixtures", filename) + .read_text(encoding="utf-8") + ) + + +@pytest.fixture +def mock_session() -> Generator[AsyncMock]: + """Mock aiohttp ClientSession.""" + mock_session = AsyncMock() + mock_response = AsyncMock(spec=ClientResponse, status=200) + mock_response.text.return_value = load_fixture("metrics.txt") + + mock_session.get.return_value = mock_response + + return mock_session diff --git a/tests/fixtures/metrics.txt b/tests/fixtures/metrics.txt new file mode 100644 index 0000000..983ad1f --- /dev/null +++ b/tests/fixtures/metrics.txt @@ -0,0 +1,69 @@ +# HELP monitor_cert_days_remaining The number of days remaining until the certificate expires +# TYPE monitor_cert_days_remaining gauge +monitor_cert_days_remaining{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null"} 80 +monitor_cert_days_remaining{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null"} 80 +monitor_cert_days_remaining{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null"} 46 +monitor_cert_days_remaining{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null"} 81 + +# HELP monitor_cert_is_valid Is the certificate still valid? (1 = Yes, 0= No) +# TYPE monitor_cert_is_valid gauge +monitor_cert_is_valid{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null"} 1 +monitor_cert_is_valid{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null"} 1 +monitor_cert_is_valid{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null"} 1 +monitor_cert_is_valid{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null"} 1 + +# HELP monitor_uptime_ratio Uptime ratio calculated over sliding window specified by the 'window' label. (0.0 - 1.0) +# TYPE monitor_uptime_ratio gauge +monitor_uptime_ratio{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="1d"} 1 +monitor_uptime_ratio{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="30d"} 0.999247554552295 +monitor_uptime_ratio{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="365d"} 0.9944324016912971 +monitor_uptime_ratio{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="1d"} 0.9992213859330392 +monitor_uptime_ratio{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="30d"} 0.9998319004850872 +monitor_uptime_ratio{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="365d"} 0.9947252084798553 +monitor_uptime_ratio{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="1d"} 1 +monitor_uptime_ratio{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="30d"} 0.9993293252532994 +monitor_uptime_ratio{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="365d"} 0.9941631600380073 +monitor_uptime_ratio{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="1d"} 1 +monitor_uptime_ratio{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="30d"} 0.9991115593334294 +monitor_uptime_ratio{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="365d"} 0.9994703389830508 +monitor_uptime_ratio{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="1d"} 0.6666666666666666 +monitor_uptime_ratio{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="30d"} 0.6666666666666666 +monitor_uptime_ratio{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="365d"} 0.6666666666666666 + +# HELP monitor_response_time_seconds Average response time in seconds calculated over sliding window specified by the 'window' label +# TYPE monitor_response_time_seconds gauge +monitor_response_time_seconds{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="1d"} 0.10396079958463136 +monitor_response_time_seconds{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="30d"} 0.10284582478851578 +monitor_response_time_seconds{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null",window="365d"} 0.10957428212662089 +monitor_response_time_seconds{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="1d"} 2.339521038961039 +monitor_response_time_seconds{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="30d"} 2.3636583723629956 +monitor_response_time_seconds{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null",window="365d"} 2.3783335690116663 +monitor_response_time_seconds{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="1d"} 0.10102960288808664 +monitor_response_time_seconds{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="30d"} 0.09908259629443207 +monitor_response_time_seconds{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null",window="365d"} 0.10429958790526786 +monitor_response_time_seconds{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="1d"} 0.16155477855477854 +monitor_response_time_seconds{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="30d"} 0.3391915450984161 +monitor_response_time_seconds{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null",window="365d"} 0.34379255863250385 +monitor_response_time_seconds{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="1d"} 0.032 +monitor_response_time_seconds{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="30d"} 0.032 +monitor_response_time_seconds{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null",window="365d"} 0.032 + +# HELP monitor_response_time Monitor Response Time (ms) +# TYPE monitor_response_time gauge +monitor_response_time{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null"} 85 +monitor_response_time{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null"} 2725 +monitor_response_time{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null"} 85 +monitor_response_time{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null"} 150 +monitor_response_time{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null"} 19 + +# HELP monitor_status Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE) +# TYPE monitor_status gauge +monitor_status{monitor_id="1",monitor_name="Home Assistant",monitor_type="http",monitor_url="https://home.example.com:8123",monitor_hostname="null",monitor_port="null"} 1 +monitor_status{monitor_id="2",monitor_name="FritzBox",monitor_type="http",monitor_url="https://home.example.com",monitor_hostname="null",monitor_port="null"} 1 +monitor_status{Test="",Zuhause="",monitor_id="3",monitor_name="Jellyfin",monitor_type="keyword",monitor_url="https://home.example.com:8920/health",monitor_hostname="null",monitor_port="null"} 1 +monitor_status{monitor_id="8",monitor_name="Nextcloud",monitor_type="json-query",monitor_url="https://cloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json",monitor_hostname="null",monitor_port="null"} 1 +monitor_status{monitor_id="9",monitor_name="Proxy",monitor_type="laserping",monitor_url="https://",monitor_hostname="null",monitor_port="null"} 1 + +# HELP app_version The service version by package.json +# TYPE app_version gauge +app_version{version="2.1.0",major="2",minor="1",patch="0"} 1 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..4fdcb00 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,84 @@ +"""Tests for pythonkuma.""" + +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError, ClientResponseError, ConnectionTimeoutError +import pytest +from syrupy.assertion import SnapshotAssertion + +from pythonkuma import UptimeKuma +from pythonkuma.exceptions import ( + UptimeKumaAuthenticationException, + UptimeKumaConnectionException, + UptimeKumaParseException, +) + + +async def test_metrics(mock_session: AsyncMock, snapshot: SnapshotAssertion) -> None: + """Test metrics.""" + uptime_kuma = UptimeKuma(mock_session, "http://uptime.example.com", "test-apikey") + + response = await uptime_kuma.metrics() + + assert {k: v.to_dict() for k, v in response.items()} == snapshot + + assert uptime_kuma.version.version == "2.1.0" + assert uptime_kuma.version.major == "2" + assert uptime_kuma.version.minor == "1" + assert uptime_kuma.version.patch == "0" + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "error_msg"), + [ + ( + ClientResponseError( + request_info=Mock(), history=(Mock()), status=HTTPStatus.NOT_FOUND + ), + UptimeKumaConnectionException, + ( + "Request for %s failed with status code %s", + "http://uptime.example.com/metrics", + HTTPStatus.NOT_FOUND, + ), + ), + ( + ClientResponseError( + request_info=Mock(), history=(Mock()), status=HTTPStatus.UNAUTHORIZED + ), + UptimeKumaAuthenticationException, + ("Authentication failed for %s", "http://uptime.example.com/metrics"), + ), + (ClientError, UptimeKumaConnectionException, ()), + ( + ConnectionTimeoutError, + UptimeKumaConnectionException, + ("Request timeout for %s", "http://uptime.example.com/metrics"), + ), + ], +) +async def test_exceptions( + mock_session: AsyncMock, + exception: Exception, + expected_exception: Exception, + error_msg: tuple[Any], +) -> None: + """Test request exceptions.""" + mock_session.get.side_effect = exception + uptime_kuma = UptimeKuma(mock_session, "http://uptime.example.com", "test-apikey") + + with pytest.raises(expected_exception) as e: + await uptime_kuma.metrics() + + assert e.value.args == error_msg + + +async def test_metrics_parse_exceptions(mock_session: AsyncMock) -> None: + """Test prometheus metrics parsing fails.""" + mock_session.get.return_value.text.return_value = "invalid metrics" + uptime_kuma = UptimeKuma(mock_session, "http://uptime.example.com", "test-apikey") + + with pytest.raises(UptimeKumaParseException): + await uptime_kuma.metrics()