mirror of
https://github.com/esphome/esphome.git
synced 2025-10-29 22:24:26 +00:00
[dashboard] Transfer DNS/mDNS cache from dashboard to CLI to avoid blocking (#10685)
This commit is contained in:
21
tests/dashboard/conftest.py
Normal file
21
tests/dashboard/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Common fixtures for dashboard tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.dashboard.core import ESPHomeDashboard
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dashboard() -> Mock:
|
||||
"""Create a mock dashboard."""
|
||||
dashboard = Mock(spec=ESPHomeDashboard)
|
||||
dashboard.entries = Mock()
|
||||
dashboard.entries.async_all.return_value = []
|
||||
dashboard.stop_event = Mock()
|
||||
dashboard.stop_event.is_set.return_value = True
|
||||
dashboard.ping_request = Mock()
|
||||
return dashboard
|
||||
0
tests/dashboard/status/__init__.py
Normal file
0
tests/dashboard/status/__init__.py
Normal file
121
tests/dashboard/status/test_dns.py
Normal file
121
tests/dashboard/status/test_dns.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Unit tests for esphome.dashboard.dns module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.dashboard.dns import DNSCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dns_cache_fixture() -> DNSCache:
|
||||
"""Create a DNSCache instance."""
|
||||
return DNSCache()
|
||||
|
||||
|
||||
def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when hostname is not in cache."""
|
||||
now = time.monotonic()
|
||||
result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when cache entry is expired."""
|
||||
now = time.monotonic()
|
||||
# Add entry that's already expired
|
||||
dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result is None
|
||||
# Expired entry should still be in cache (not removed by get_cached_addresses)
|
||||
assert "example.com" in dns_cache_fixture._cache
|
||||
|
||||
|
||||
def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with valid cache entry."""
|
||||
now = time.monotonic()
|
||||
# Add entry that expires in 60 seconds
|
||||
dns_cache_fixture._cache["example.com"] = (
|
||||
now + 60,
|
||||
["192.168.1.10", "192.168.1.11"],
|
||||
)
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == ["192.168.1.10", "192.168.1.11"]
|
||||
# Entry should still be in cache
|
||||
assert "example.com" in dns_cache_fixture._cache
|
||||
|
||||
|
||||
def test_get_cached_addresses_hostname_normalization(
|
||||
dns_cache_fixture: DNSCache,
|
||||
) -> None:
|
||||
"""Test get_cached_addresses normalizes hostname."""
|
||||
now = time.monotonic()
|
||||
# Add entry with lowercase hostname
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"])
|
||||
|
||||
# Test with various forms
|
||||
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
|
||||
|
||||
def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with IPv6 addresses."""
|
||||
now = time.monotonic()
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == ["2001:db8::1", "fe80::1"]
|
||||
|
||||
|
||||
def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with empty address list."""
|
||||
now = time.monotonic()
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, [])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when cache contains an exception."""
|
||||
now = time.monotonic()
|
||||
# Store an exception (from failed resolution)
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed"))
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result is None # Should return None for exceptions
|
||||
|
||||
|
||||
def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test that get_cached_addresses never calls async_resolve."""
|
||||
now = time.monotonic()
|
||||
|
||||
with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve:
|
||||
# Test non-cached
|
||||
result = dns_cache_fixture.get_cached_addresses("uncached.com", now)
|
||||
assert result is None
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# Test expired
|
||||
dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"])
|
||||
result = dns_cache_fixture.get_cached_addresses("expired.com", now)
|
||||
assert result is None
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# Test valid
|
||||
dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"])
|
||||
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
|
||||
assert result == ["192.168.1.10"]
|
||||
mock_resolve.assert_not_called()
|
||||
168
tests/dashboard/status/test_mdns.py
Normal file
168
tests/dashboard/status/test_mdns.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Unit tests for esphome.dashboard.status.mdns module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from zeroconf import AddressResolver, IPVersion
|
||||
|
||||
from esphome.dashboard.status.mdns import MDNSStatus
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mdns_status(mock_dashboard: Mock) -> MDNSStatus:
|
||||
"""Create an MDNSStatus instance in async context."""
|
||||
# We're in an async context so get_running_loop will work
|
||||
return MDNSStatus(mock_dashboard)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when no zeroconf instance is available."""
|
||||
mdns_status.aiozc = None
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when address is not in cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = False
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result is None
|
||||
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when address is found in cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == ["192.168.1.10", "fe80::1"]
|
||||
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||
mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with hostname having trailing dot."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local.")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should normalize to device.local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with uppercase hostname."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("DEVICE.LOCAL")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should normalize to device.local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with simple hostname (no domain)."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should append .local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses returning only IPv6 addresses."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == ["fe80::1", "2001:db8::1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses returning empty list from cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = []
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_success(mock_dashboard: Mock) -> None:
|
||||
"""Test successful async_setup."""
|
||||
mdns_status = MDNSStatus(mock_dashboard)
|
||||
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||
mock_zc.return_value = Mock()
|
||||
result = mdns_status.async_setup()
|
||||
assert result is True
|
||||
assert mdns_status.aiozc is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_failure(mock_dashboard: Mock) -> None:
|
||||
"""Test async_setup with OSError."""
|
||||
mdns_status = MDNSStatus(mock_dashboard)
|
||||
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||
mock_zc.side_effect = OSError("Network error")
|
||||
result = mdns_status.async_setup()
|
||||
assert result is False
|
||||
assert mdns_status.aiozc is None
|
||||
@@ -730,3 +730,83 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None:
|
||||
mock_server_class.assert_called_once_with(app)
|
||||
mock_bind.assert_called_once_with(str(socket_path), mode=0o666)
|
||||
server.add_socket.assert_called_once()
|
||||
|
||||
|
||||
def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None:
|
||||
"""Test with no entry returns empty list."""
|
||||
result = web_server.build_cache_arguments(None, mock_dashboard, 0.0)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None:
|
||||
"""Test with entry but no address or name."""
|
||||
entry = Mock(spec=web_server.DashboardEntry)
|
||||
entry.address = None
|
||||
entry.name = None
|
||||
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None:
|
||||
"""Test with .local address that has cached mDNS results."""
|
||||
entry = Mock(spec=web_server.DashboardEntry)
|
||||
entry.address = "device.local"
|
||||
entry.name = None
|
||||
mock_dashboard.mdns_status = Mock()
|
||||
mock_dashboard.mdns_status.get_cached_addresses.return_value = [
|
||||
"192.168.1.10",
|
||||
"fe80::1",
|
||||
]
|
||||
|
||||
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||
|
||||
assert result == [
|
||||
"--mdns-address-cache",
|
||||
"device.local=192.168.1.10,fe80::1",
|
||||
]
|
||||
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
|
||||
"device.local"
|
||||
)
|
||||
|
||||
|
||||
def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None:
|
||||
"""Test with non-.local address that has cached DNS results."""
|
||||
entry = Mock(spec=web_server.DashboardEntry)
|
||||
entry.address = "example.com"
|
||||
entry.name = None
|
||||
mock_dashboard.dns_cache = Mock()
|
||||
mock_dashboard.dns_cache.get_cached_addresses.return_value = [
|
||||
"93.184.216.34",
|
||||
"2606:2800:220:1:248:1893:25c8:1946",
|
||||
]
|
||||
|
||||
now = 100.0
|
||||
result = web_server.build_cache_arguments(entry, mock_dashboard, now)
|
||||
|
||||
# IPv6 addresses are sorted before IPv4
|
||||
assert result == [
|
||||
"--dns-address-cache",
|
||||
"example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34",
|
||||
]
|
||||
mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with(
|
||||
"example.com", now
|
||||
)
|
||||
|
||||
|
||||
def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None:
|
||||
"""Test with name but no address - should check mDNS with .local suffix."""
|
||||
entry = Mock(spec=web_server.DashboardEntry)
|
||||
entry.name = "my-device"
|
||||
entry.address = None
|
||||
mock_dashboard.mdns_status = Mock()
|
||||
mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"]
|
||||
|
||||
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||
|
||||
assert result == [
|
||||
"--mdns-address-cache",
|
||||
"my-device.local=192.168.1.20",
|
||||
]
|
||||
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
|
||||
"my-device.local"
|
||||
)
|
||||
|
||||
305
tests/unit_tests/test_address_cache.py
Normal file
305
tests/unit_tests/test_address_cache.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Tests for the address_cache module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from esphome.address_cache import AddressCache, normalize_hostname
|
||||
|
||||
|
||||
def test_normalize_simple_hostname() -> None:
|
||||
"""Test normalizing a simple hostname."""
|
||||
assert normalize_hostname("device") == "device"
|
||||
assert normalize_hostname("device.local") == "device.local"
|
||||
assert normalize_hostname("server.example.com") == "server.example.com"
|
||||
|
||||
|
||||
def test_normalize_removes_trailing_dots() -> None:
|
||||
"""Test that trailing dots are removed."""
|
||||
assert normalize_hostname("device.") == "device"
|
||||
assert normalize_hostname("device.local.") == "device.local"
|
||||
assert normalize_hostname("server.example.com.") == "server.example.com"
|
||||
assert normalize_hostname("device...") == "device"
|
||||
|
||||
|
||||
def test_normalize_converts_to_lowercase() -> None:
|
||||
"""Test that hostnames are converted to lowercase."""
|
||||
assert normalize_hostname("DEVICE") == "device"
|
||||
assert normalize_hostname("Device.Local") == "device.local"
|
||||
assert normalize_hostname("Server.Example.COM") == "server.example.com"
|
||||
|
||||
|
||||
def test_normalize_combined() -> None:
|
||||
"""Test combination of trailing dots and case conversion."""
|
||||
assert normalize_hostname("DEVICE.LOCAL.") == "device.local"
|
||||
assert normalize_hostname("Server.Example.COM...") == "server.example.com"
|
||||
|
||||
|
||||
def test_init_empty() -> None:
|
||||
"""Test initialization with empty caches."""
|
||||
cache = AddressCache()
|
||||
assert cache.mdns_cache == {}
|
||||
assert cache.dns_cache == {}
|
||||
assert not cache.has_cache()
|
||||
|
||||
|
||||
def test_init_with_caches() -> None:
|
||||
"""Test initialization with provided caches."""
|
||||
mdns_cache: dict[str, list[str]] = {"device.local": ["192.168.1.10"]}
|
||||
dns_cache: dict[str, list[str]] = {"server.com": ["10.0.0.1"]}
|
||||
cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache)
|
||||
assert cache.mdns_cache == mdns_cache
|
||||
assert cache.dns_cache == dns_cache
|
||||
assert cache.has_cache()
|
||||
|
||||
|
||||
def test_get_mdns_addresses() -> None:
|
||||
"""Test getting mDNS addresses."""
|
||||
cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]})
|
||||
|
||||
# Direct lookup
|
||||
assert cache.get_mdns_addresses("device.local") == [
|
||||
"192.168.1.10",
|
||||
"192.168.1.11",
|
||||
]
|
||||
|
||||
# Case insensitive lookup
|
||||
assert cache.get_mdns_addresses("Device.Local") == [
|
||||
"192.168.1.10",
|
||||
"192.168.1.11",
|
||||
]
|
||||
|
||||
# With trailing dot
|
||||
assert cache.get_mdns_addresses("device.local.") == [
|
||||
"192.168.1.10",
|
||||
"192.168.1.11",
|
||||
]
|
||||
|
||||
# Not found
|
||||
assert cache.get_mdns_addresses("unknown.local") is None
|
||||
|
||||
|
||||
def test_get_dns_addresses() -> None:
|
||||
"""Test getting DNS addresses."""
|
||||
cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]})
|
||||
|
||||
# Direct lookup
|
||||
assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"]
|
||||
|
||||
# Case insensitive lookup
|
||||
assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"]
|
||||
|
||||
# With trailing dot
|
||||
assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"]
|
||||
|
||||
# Not found
|
||||
assert cache.get_dns_addresses("unknown.com") is None
|
||||
|
||||
|
||||
def test_get_addresses_auto_detection() -> None:
|
||||
"""Test automatic cache selection based on hostname."""
|
||||
cache = AddressCache(
|
||||
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||
dns_cache={"server.com": ["10.0.0.1"]},
|
||||
)
|
||||
|
||||
# Should use mDNS cache for .local domains
|
||||
assert cache.get_addresses("device.local") == ["192.168.1.10"]
|
||||
assert cache.get_addresses("device.local.") == ["192.168.1.10"]
|
||||
assert cache.get_addresses("Device.Local") == ["192.168.1.10"]
|
||||
|
||||
# Should use DNS cache for non-.local domains
|
||||
assert cache.get_addresses("server.com") == ["10.0.0.1"]
|
||||
assert cache.get_addresses("server.com.") == ["10.0.0.1"]
|
||||
assert cache.get_addresses("Server.COM") == ["10.0.0.1"]
|
||||
|
||||
# Not found
|
||||
assert cache.get_addresses("unknown.local") is None
|
||||
assert cache.get_addresses("unknown.com") is None
|
||||
|
||||
|
||||
def test_has_cache() -> None:
|
||||
"""Test checking if cache has entries."""
|
||||
# Empty cache
|
||||
cache = AddressCache()
|
||||
assert not cache.has_cache()
|
||||
|
||||
# Only mDNS cache
|
||||
cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]})
|
||||
assert cache.has_cache()
|
||||
|
||||
# Only DNS cache
|
||||
cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]})
|
||||
assert cache.has_cache()
|
||||
|
||||
# Both caches
|
||||
cache = AddressCache(
|
||||
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||
dns_cache={"server.com": ["10.0.0.1"]},
|
||||
)
|
||||
assert cache.has_cache()
|
||||
|
||||
|
||||
def test_from_cli_args_empty() -> None:
|
||||
"""Test creating cache from empty CLI arguments."""
|
||||
cache = AddressCache.from_cli_args([], [])
|
||||
assert cache.mdns_cache == {}
|
||||
assert cache.dns_cache == {}
|
||||
|
||||
|
||||
def test_from_cli_args_single_entry() -> None:
|
||||
"""Test creating cache from single CLI argument."""
|
||||
mdns_args: list[str] = ["device.local=192.168.1.10"]
|
||||
dns_args: list[str] = ["server.com=10.0.0.1"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
assert cache.mdns_cache == {"device.local": ["192.168.1.10"]}
|
||||
assert cache.dns_cache == {"server.com": ["10.0.0.1"]}
|
||||
|
||||
|
||||
def test_from_cli_args_multiple_ips() -> None:
|
||||
"""Test creating cache with multiple IPs per host."""
|
||||
mdns_args: list[str] = ["device.local=192.168.1.10,192.168.1.11"]
|
||||
dns_args: list[str] = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]}
|
||||
assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]}
|
||||
|
||||
|
||||
def test_from_cli_args_multiple_entries() -> None:
|
||||
"""Test creating cache with multiple host entries."""
|
||||
mdns_args: list[str] = [
|
||||
"device1.local=192.168.1.10",
|
||||
"device2.local=192.168.1.20,192.168.1.21",
|
||||
]
|
||||
dns_args: list[str] = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
assert cache.mdns_cache == {
|
||||
"device1.local": ["192.168.1.10"],
|
||||
"device2.local": ["192.168.1.20", "192.168.1.21"],
|
||||
}
|
||||
assert cache.dns_cache == {
|
||||
"server1.com": ["10.0.0.1"],
|
||||
"server2.com": ["10.0.0.2"],
|
||||
}
|
||||
|
||||
|
||||
def test_from_cli_args_normalization() -> None:
|
||||
"""Test that CLI arguments are normalized."""
|
||||
mdns_args: list[str] = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"]
|
||||
dns_args: list[str] = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
# Hostnames should be normalized (lowercase, no trailing dots)
|
||||
assert cache.mdns_cache == {
|
||||
"device1.local": ["192.168.1.10"],
|
||||
"device2.local": ["192.168.1.20"],
|
||||
}
|
||||
assert cache.dns_cache == {
|
||||
"server1.com": ["10.0.0.1"],
|
||||
"server2.com": ["10.0.0.2"],
|
||||
}
|
||||
|
||||
|
||||
def test_from_cli_args_whitespace_handling() -> None:
|
||||
"""Test that whitespace in IPs is handled."""
|
||||
mdns_args: list[str] = ["device.local= 192.168.1.10 , 192.168.1.11 "]
|
||||
dns_args: list[str] = ["server.com= 10.0.0.1 , 10.0.0.2 "]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]}
|
||||
assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]}
|
||||
|
||||
|
||||
def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None:
|
||||
"""Test handling of invalid argument format."""
|
||||
mdns_args: list[str] = ["invalid_format", "device.local=192.168.1.10"]
|
||||
dns_args: list[str] = ["server.com=10.0.0.1", "also_invalid"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
# Valid entries should still be processed
|
||||
assert cache.mdns_cache == {"device.local": ["192.168.1.10"]}
|
||||
assert cache.dns_cache == {"server.com": ["10.0.0.1"]}
|
||||
|
||||
# Check that warnings were logged for invalid entries
|
||||
assert "Invalid cache format: invalid_format" in caplog.text
|
||||
assert "Invalid cache format: also_invalid" in caplog.text
|
||||
|
||||
|
||||
def test_from_cli_args_ipv6() -> None:
|
||||
"""Test handling of IPv6 addresses."""
|
||||
mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"]
|
||||
dns_args: list[str] = ["server.com=2001:db8::2,::1"]
|
||||
|
||||
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||
|
||||
assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]}
|
||||
assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]}
|
||||
|
||||
|
||||
def test_logging_output(caplog: LogCaptureFixture) -> None:
|
||||
"""Test that appropriate debug logging occurs."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
cache = AddressCache(
|
||||
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||
dns_cache={"server.com": ["10.0.0.1"]},
|
||||
)
|
||||
|
||||
# Test successful lookups log at debug level
|
||||
result: list[str] | None = cache.get_mdns_addresses("device.local")
|
||||
assert result == ["192.168.1.10"]
|
||||
assert "Using mDNS cache for device.local" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
result = cache.get_dns_addresses("server.com")
|
||||
assert result == ["10.0.0.1"]
|
||||
assert "Using DNS cache for server.com" in caplog.text
|
||||
|
||||
# Test that failed lookups don't log
|
||||
caplog.clear()
|
||||
result = cache.get_mdns_addresses("unknown.local")
|
||||
assert result is None
|
||||
assert "Using mDNS cache" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname,expected",
|
||||
[
|
||||
("test.local", "test.local"),
|
||||
("Test.Local.", "test.local"),
|
||||
("TEST.LOCAL...", "test.local"),
|
||||
("example.com", "example.com"),
|
||||
("EXAMPLE.COM.", "example.com"),
|
||||
],
|
||||
)
|
||||
def test_normalize_hostname_parametrized(hostname: str, expected: str) -> None:
|
||||
"""Test hostname normalization with various inputs."""
|
||||
assert normalize_hostname(hostname) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mdns_arg,expected",
|
||||
[
|
||||
("host=1.2.3.4", {"host": ["1.2.3.4"]}),
|
||||
("Host.Local=1.2.3.4,5.6.7.8", {"host.local": ["1.2.3.4", "5.6.7.8"]}),
|
||||
("HOST.LOCAL.=::1", {"host.local": ["::1"]}),
|
||||
],
|
||||
)
|
||||
def test_parse_cache_args_parametrized(
|
||||
mdns_arg: str, expected: dict[str, list[str]]
|
||||
) -> None:
|
||||
"""Test parsing of cache arguments with various formats."""
|
||||
cache = AddressCache.from_cli_args([mdns_arg], [])
|
||||
assert cache.mdns_cache == expected
|
||||
@@ -11,6 +11,7 @@ from hypothesis.strategies import ip_addresses
|
||||
import pytest
|
||||
|
||||
from esphome import helpers
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
@@ -830,3 +831,84 @@ def test_resolve_ip_address_sorting() -> None:
|
||||
assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1)
|
||||
assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2)
|
||||
assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3)
|
||||
|
||||
|
||||
def test_resolve_ip_address_with_cache() -> None:
|
||||
"""Test that the cache is used when provided."""
|
||||
cache = AddressCache(
|
||||
mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]},
|
||||
dns_cache={
|
||||
"example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]
|
||||
},
|
||||
)
|
||||
|
||||
# Test mDNS cache hit
|
||||
result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache)
|
||||
|
||||
# Should return cached addresses without calling resolver
|
||||
assert len(result) == 2
|
||||
assert result[0][4][0] == "192.168.1.100"
|
||||
assert result[1][4][0] == "192.168.1.101"
|
||||
|
||||
# Test DNS cache hit
|
||||
result = helpers.resolve_ip_address("example.com", 6053, address_cache=cache)
|
||||
|
||||
# Should return cached addresses with IPv6 first due to preference
|
||||
assert len(result) == 2
|
||||
assert result[0][4][0] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 first
|
||||
assert result[1][4][0] == "93.184.216.34" # IPv4 second
|
||||
|
||||
|
||||
def test_resolve_ip_address_cache_miss() -> None:
|
||||
"""Test that resolver is called when not in cache."""
|
||||
cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]})
|
||||
|
||||
mock_addr_info = AddrInfo(
|
||||
family=socket.AF_INET,
|
||||
type=socket.SOCK_STREAM,
|
||||
proto=socket.IPPROTO_TCP,
|
||||
sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053),
|
||||
)
|
||||
|
||||
with patch("esphome.resolver.AsyncResolver") as MockResolver:
|
||||
mock_resolver = MockResolver.return_value
|
||||
mock_resolver.resolve.return_value = [mock_addr_info]
|
||||
|
||||
result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache)
|
||||
|
||||
# Should call resolver since test.local is not in cache
|
||||
MockResolver.assert_called_once_with(["test.local"], 6053)
|
||||
assert len(result) == 1
|
||||
assert result[0][4][0] == "192.168.1.100"
|
||||
|
||||
|
||||
def test_resolve_ip_address_mixed_cached_uncached() -> None:
|
||||
"""Test resolution with mix of cached and uncached hosts."""
|
||||
cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]})
|
||||
|
||||
mock_addr_info = AddrInfo(
|
||||
family=socket.AF_INET,
|
||||
type=socket.SOCK_STREAM,
|
||||
proto=socket.IPPROTO_TCP,
|
||||
sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053),
|
||||
)
|
||||
|
||||
with patch("esphome.resolver.AsyncResolver") as MockResolver:
|
||||
mock_resolver = MockResolver.return_value
|
||||
mock_resolver.resolve.return_value = [mock_addr_info]
|
||||
|
||||
# Pass a list with cached IP, cached hostname, and uncached hostname
|
||||
result = helpers.resolve_ip_address(
|
||||
["192.168.1.10", "cached.local", "uncached.local"],
|
||||
6053,
|
||||
address_cache=cache,
|
||||
)
|
||||
|
||||
# Should only resolve uncached.local
|
||||
MockResolver.assert_called_once_with(["uncached.local"], 6053)
|
||||
|
||||
# Results should include all addresses
|
||||
addresses = [r[4][0] for r in result]
|
||||
assert "192.168.1.10" in addresses # Direct IP
|
||||
assert "192.168.1.50" in addresses # From cache
|
||||
assert "192.168.1.100" in addresses # From resolver
|
||||
|
||||
Reference in New Issue
Block a user