mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +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" | ||||
|     ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user