Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 23 additions & 1 deletion aiopnsense/speedtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

from collections.abc import MutableMapping
from datetime import datetime
from typing import Any

from ._typing import AiopnsenseClientProtocol
Expand Down Expand Up @@ -58,7 +59,7 @@ 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
date = await self._normalize_speedtest_date(latest_result.get("date"))
url = latest_result.get("url") if isinstance(latest_result.get("url"), str) else None

samples = try_to_int(show_stat.get("samples"))
Expand Down Expand Up @@ -98,6 +99,27 @@ async def get_speedtest(self) -> dict[str, Any]:
}
return output

async def _normalize_speedtest_date(self, value: object) -> str | None:
"""Return a timezone-aware ISO 8601 Speedtest timestamp.

Args:
value (object): Raw date value returned by the Speedtest plugin.

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 Speedtest date: %s", value)
return None
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=await self._get_opnsense_timezone())
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
Snuffy2 marked this conversation as resolved.
Outdated
return parsed_date.isoformat()

def _parse_showlog_latest(self, show_log: object) -> dict[str, Any]:
"""Normalize the newest row returned by the Speedtest ``showlog`` endpoint.

Expand Down
67 changes: 67 additions & 0 deletions tests/test_speedtest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for `aiopnsense.speedtest`."""

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

import pytest
Expand Down Expand Up @@ -58,13 +59,15 @@ 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=timezone(timedelta(hours=-4)))

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
Expand Down Expand Up @@ -125,6 +128,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 +137,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 +154,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_not_awaited()
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_not_awaited()
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
Loading