Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion aiopnsense/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
from collections.abc import Callable, MutableMapping
from datetime import UTC, datetime
from datetime import UTC, datetime, tzinfo
from functools import wraps
import ipaddress
import logging
Expand Down Expand Up @@ -294,6 +294,29 @@ def timestamp_to_datetime(timestamp: int | None) -> datetime | None:
return utc_datetime.astimezone()


def normalize_datetime(value: object, default_tz: tzinfo) -> str | None:
"""Return a timezone-aware ISO 8601 datetime string.

Args:
value (object): Raw datetime value to normalize.
default_tz (tzinfo): Timezone assigned when ``value`` is naive.

Returns:
str | None: ISO 8601 timestamp including a UTC offset, or ``None``
when the value is missing or malformed.
"""
if not isinstance(value, str):
return None
try:
parsed_date = datetime.fromisoformat(value)
except ValueError:
_LOGGER.debug("Failed to parse datetime: %s", value)
return None
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=default_tz)
return parsed_date.isoformat()


def try_to_int(value: Any | None, retval: int | None = None) -> int | None:
"""Convert a value to ``int`` and return a fallback on conversion failure.

Expand Down
15 changes: 11 additions & 4 deletions aiopnsense/speedtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import Any

from ._typing import AiopnsenseClientProtocol
from .helpers import _LOGGER, _log_errors, try_to_float, try_to_int
from .helpers import _LOGGER, _log_errors, normalize_datetime, try_to_float, try_to_int

SPEEDTEST_SHOW_LOG_ENDPOINT = "/api/speedtest/service/showlog"
SPEEDTEST_SHOW_STAT_ENDPOINT = "/api/speedtest/service/showstat"
Expand Down Expand Up @@ -58,13 +58,20 @@ async def get_speedtest(self) -> dict[str, Any]:
server_name = latest_result.get("server")
if not isinstance(server_name, str):
server_name = None
date = latest_result.get("date") if isinstance(latest_result.get("date"), str) else None
opnsense_tz = await self._get_opnsense_timezone()
date = normalize_datetime(latest_result.get("date"), opnsense_tz)
Comment thread
Snuffy2 marked this conversation as resolved.
Outdated
url = latest_result.get("url") if isinstance(latest_result.get("url"), str) else None

samples = try_to_int(show_stat.get("samples"))
period = show_stat.get("period", {})
oldest = period.get("oldest") if isinstance(period, MutableMapping) else None
youngest = period.get("youngest") if isinstance(period, MutableMapping) else None
oldest = normalize_datetime(
period.get("oldest") if isinstance(period, MutableMapping) else None,
opnsense_tz,
)
youngest = normalize_datetime(
period.get("youngest") if isinstance(period, MutableMapping) else None,
opnsense_tz,
)

output: dict[str, Any] = {
"available": True,
Expand Down
31 changes: 30 additions & 1 deletion tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for `aiopnsense.helpers` utility and decorator helpers."""

from collections.abc import Callable
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta, timezone
import inspect
import logging
from unittest.mock import MagicMock
Expand Down Expand Up @@ -89,6 +89,35 @@ def test_timestamp_to_datetime() -> None:
assert aiopnsense_helpers.timestamp_to_datetime(None) is None


@pytest.mark.parametrize(
("value", "expected"),
[
("2026-03-14T03:09:45", "2026-03-14T03:09:45-04:00"),
("2023-01-22 00:29:00", "2023-01-22T00:29:00-04:00"),
("2026-03-14T03:09:45+01:30", "2026-03-14T03:09:45+01:30"),
("not-a-date", None),
(12345, None),
],
)
def test_normalize_datetime(value: object, expected: str | None) -> None:
"""Normalize naive and aware datetimes while rejecting malformed values.

Args:
value (object): Raw datetime value under test.
expected (str | None): Expected timezone-aware ISO result.

Returns:
None: This test validates helper output via assertions.
"""
assert (
aiopnsense_helpers.normalize_datetime(
value,
timezone(timedelta(hours=-4)),
)
== expected
)


@pytest.mark.parametrize(
("firmware_version", "expected"),
[
Expand Down
73 changes: 71 additions & 2 deletions tests/test_speedtest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Tests for `aiopnsense.speedtest`."""

from collections.abc import Callable
from datetime import timedelta, timezone
from unittest.mock import AsyncMock, call
from zoneinfo import ZoneInfo

import pytest

Expand Down Expand Up @@ -58,19 +60,21 @@ async def test_get_speedtest_normalizes_latest_and_stat_payloads(make_client) ->
"upload": {"avg": 706.7, "min": 1.54, "max": 890.32},
}
)
client._get_opnsense_timezone = AsyncMock(return_value=ZoneInfo("America/New_York"))

result = await client.get_speedtest()

assert result["available"] is True
assert result["last"]["download"]["value"] == 836.05
assert result["last"]["download"]["server_id"] == "72800"
assert result["last"]["download"]["server"] == "RippleFiber, Newark, NJ"
assert result["last"]["download"]["date"] == "2026-03-14T03:09:45-04:00"
assert result["average"]["download"]["value"] == 723.83
assert result["average"]["download"]["min"] == 4.18
assert result["average"]["download"]["max"] == 942.02
assert result["average"]["download"]["samples"] == 10717
assert result["average"]["download"]["oldest"] == "2023-01-22 00:29:00"
assert result["average"]["download"]["youngest"] == "2026-03-14 03:09:45"
assert result["average"]["download"]["oldest"] == "2023-01-22T00:29:00-05:00"
assert result["average"]["download"]["youngest"] == "2026-03-14T03:09:45-04:00"
finally:
await client.async_close()

Expand Down Expand Up @@ -125,6 +129,7 @@ async def test_get_speedtest_probes_showstat_before_fetching_optional_payload(
]
)
client._safe_dict_get = AsyncMock(return_value={})
client._get_opnsense_timezone = AsyncMock(return_value=timezone(timedelta(hours=-4)))

result = await client.get_speedtest()

Expand All @@ -133,6 +138,7 @@ async def test_get_speedtest_probes_showstat_before_fetching_optional_payload(
call("/api/speedtest/service/showlog"),
call("/api/speedtest/service/showstat"),
]
client._get_opnsense_timezone.assert_awaited_once_with()
client._safe_list_get.assert_awaited_once_with("/api/speedtest/service/showlog")

if showstat_available:
Expand All @@ -149,6 +155,68 @@ async def test_get_speedtest_probes_showstat_before_fetching_optional_payload(
await client.async_close()


@pytest.mark.asyncio
async def test_get_speedtest_preserves_timezone_aware_date(make_client) -> None:
"""get_speedtest should preserve an existing timestamp UTC offset."""
client, _session = make_mock_session_client(make_client)
try:
client._is_get_endpoint_available = AsyncMock(side_effect=[True, False])
client._safe_list_get = AsyncMock(
return_value=[
[
"2026-03-14T03:09:45+01:30",
"198.51.100.10",
"72800",
"Test ISP",
"United States",
"1",
"2",
"3",
"https://www.speedtest.net/result/c/abc",
]
]
)
client._get_opnsense_timezone = AsyncMock()

result = await client.get_speedtest()

assert result["last"]["download"]["date"] == "2026-03-14T03:09:45+01:30"
client._get_opnsense_timezone.assert_awaited_once_with()
finally:
await client.async_close()


@pytest.mark.asyncio
async def test_get_speedtest_drops_malformed_date(make_client) -> None:
"""get_speedtest should omit malformed timestamp values."""
client, _session = make_mock_session_client(make_client)
try:
client._is_get_endpoint_available = AsyncMock(side_effect=[True, False])
client._safe_list_get = AsyncMock(
return_value=[
[
"not-a-date",
"198.51.100.10",
"72800",
"Test ISP",
"United States",
"1",
"2",
"3",
"https://www.speedtest.net/result/c/abc",
]
]
)
client._get_opnsense_timezone = AsyncMock()

result = await client.get_speedtest()

assert result["last"]["download"]["date"] is None
client._get_opnsense_timezone.assert_awaited_once_with()
finally:
await client.async_close()


@pytest.mark.asyncio
async def test_get_speedtest_normalizes_malformed_payloads(make_client) -> None:
"""get_speedtest should coerce malformed or missing values to None safely."""
Expand Down Expand Up @@ -179,6 +247,7 @@ async def test_get_speedtest_normalizes_malformed_payloads(make_client) -> None:
"latency": ["bad-latency-shape"],
}
)
client._get_opnsense_timezone = AsyncMock(return_value=timezone(timedelta(hours=-4)))

result = await client.get_speedtest()

Expand Down
Loading