Files
shelly-ui/tests/integration/test_real_network.py
T
jonas 71803418e5 Initial commit: Shelly Manager with Textual CLI, Streamlit UI, and comprehensive .gitignore
Shelly device management app with mDNS/subnet discovery, inventory,
configuration, and mass operations for Gen1/Gen2+ devices.

Includes .gitignore excluding runtime data (device DB, user config),
AI conversation history, build artifacts, and common Python/OS patterns.
2026-03-23 21:51:59 +01:00

159 lines
5.6 KiB
Python

"""
Real-network integration tests: mDNS, HTTP /shelly, optional subnet scan, read snapshot.
Enable with:
SHELLY_INTEGRATION=1 uv run pytest tests/integration -v -s
Optional:
SHELLY_MDNS_SECONDS=8 # browse time (default 6)
SHELLY_SUBNET=192.168.1.0/24 # also run subnet scan (slow on large nets)
SHELLY_SCAN_CONCURRENCY=48
SHELLY_TEST_IP=192.168.1.42 # optional :8080, or [fe80::1]:80 for IPv6
"""
from __future__ import annotations
import os
import aiohttp
import pytest
from shelly_manager.api.client import ShellyRuntime, fetch_device_snapshot, probe_ip
from shelly_manager.core.discovery import browse_mdns, fetch_shelly_json, scan_subnet_http
from shelly_manager.core.models import DiscoveredEndpoint
from tests.integration.conftest import mdns_timeout_sec, requires_integration, subnet_cidr
def _parse_host_port(raw: str) -> tuple[str, int]:
raw = raw.strip()
if raw.startswith("["):
end = raw.index("]")
host = raw[1:end]
rest = raw[end + 1 :]
if rest.startswith(":") and rest[1:].isdigit():
return host, int(rest[1:])
return host, 80
if raw.count(":") == 1:
h, p = raw.rsplit(":", 1)
if p.isdigit():
return h, int(p)
return raw, 80
@requires_integration
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_mdns_browse_returns_without_error() -> None:
"""Browse local mDNS; must complete (list may be empty)."""
timeout = mdns_timeout_sec()
endpoints = await browse_mdns(timeout_sec=timeout)
assert isinstance(endpoints, list)
print(f"\n[integration] mDNS endpoints after {timeout}s: {len(endpoints)}")
for ep in endpoints[:20]:
print(f" - {ep.ip}:{ep.port} ({ep.source}) host={ep.hostname}")
@requires_integration
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_probe_mdns_endpoints() -> None:
"""For each discovered service, GET /shelly (non-Shelly on _http._tcp may fail probe)."""
timeout = mdns_timeout_sec()
endpoints = await browse_mdns(timeout_sec=timeout)
if not endpoints:
pytest.skip("No mDNS services (no devices or multicast blocked).")
async with aiohttp.ClientSession() as session:
ok = 0
for ep in endpoints:
r = await probe_ip(session, ep.ip, port=ep.port)
if r:
gen, data = r
assert isinstance(gen, int)
assert isinstance(data, dict)
ok += 1
print(f"\n[integration] probe OK {ep.ip}:{ep.port} gen={gen} mac={data.get('mac')}")
print(f"\n[integration] successful /shelly probes: {ok}/{len(endpoints)}")
@requires_integration
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_read_snapshot_first_unauthenticated_device() -> None:
"""Full aioshelly read for first device that answers /shelly without auth."""
timeout = mdns_timeout_sec()
endpoints = await browse_mdns(timeout_sec=timeout)
if not endpoints:
pytest.skip("No mDNS endpoints.")
async with aiohttp.ClientSession() as session:
chosen: tuple[DiscoveredEndpoint, int] | None = None
for ep in endpoints:
r = await probe_ip(session, ep.ip, port=ep.port)
if not r:
continue
gen, data = r
if data.get("auth") or data.get("auth_en"):
print(f"\n[integration] skip auth device {ep.ip}")
continue
chosen = (ep, gen)
break
if chosen is None:
for ep in endpoints:
r = await probe_ip(session, ep.ip, port=ep.port)
if r:
chosen = (ep, r[0])
break
if chosen is None:
pytest.skip("No /shelly response on mDNS endpoints.")
ep, gen = chosen
try:
async with ShellyRuntime() as runtime:
snap = await fetch_device_snapshot(runtime, ep.ip, gen)
except Exception as exc: # noqa: BLE001
pytest.skip(f"Snapshot read failed (network/gen/COAP): {exc}")
assert snap.mac
assert snap.ip == ep.ip
print(f"\n[integration] snapshot OK id={snap.id} model={snap.model} gen={snap.generation}")
@requires_integration
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_subnet_scan_when_env_set() -> None:
"""Optional subnet scan — set SHELLY_SUBNET."""
cidr = subnet_cidr()
if not cidr:
pytest.skip("Set SHELLY_SUBNET e.g. 192.168.1.0/24 to run subnet scan.")
conc = int(os.environ.get("SHELLY_SCAN_CONCURRENCY", "48"))
async with aiohttp.ClientSession() as session:
results = await scan_subnet_http(
session,
cidr,
concurrency=min(conc, 128),
)
print(f"\n[integration] subnet {cidr}: {len(results)} Shelly /shelly responses")
for ip, data in results[:10]:
print(f" - {ip} mac={data.get('mac')} gen={data.get('gen', '1')}")
assert isinstance(results, list)
@requires_integration
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_fetch_shelly_json_direct_ip() -> None:
"""If SHELLY_TEST_IP is set, GET /shelly on that host:port."""
raw = os.environ.get("SHELLY_TEST_IP", "").strip()
if not raw:
pytest.skip("Set SHELLY_TEST_IP=192.168.1.42 or [fe80::1]:80 for direct probe.")
host, port = _parse_host_port(raw)
async with aiohttp.ClientSession() as session:
data = await fetch_shelly_json(session, host, port=port, timeout_sec=3.0)
assert data is not None
assert isinstance(data, dict)
print(f"\n[integration] SHELLY_TEST_IP {host}:{port} -> keys {list(data.keys())[:8]}")