Files
core/tests/components/unifiprotect/conftest.py
T

607 lines
19 KiB
Python

"""Fixtures and test data for UniFi Protect methods."""
from collections.abc import Callable, Generator
from datetime import datetime, timedelta
from functools import partial
from ipaddress import IPv4Address
from pathlib import Path
from tempfile import gettempdir
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
from uiprotect import ProtectApiClient
from uiprotect.api import RTSPSStreams
from uiprotect.data import (
NVR,
AiPort,
Bootstrap,
Camera,
Chime,
CloudAccount,
Light,
Liveview,
ModelType,
ProtectModelWithId,
Sensor,
SmartDetectObjectType,
StateType,
VideoMode,
Viewer,
WSSubscriptionMessage,
)
from uiprotect.websocket import WebsocketState
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.components.unifiprotect.utils import _async_unifi_mac_from_hass
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import _patch_discovery
from .utils import MockUFPFixture, make_public_camera, public_rtsps_for
from tests.common import MockConfigEntry, load_json_object_fixture
MAC_ADDR = "aa:bb:cc:dd:ee:ff"
# Common test data constants
DEFAULT_HOST = "1.1.1.1"
DEFAULT_PORT = 443
DEFAULT_VERIFY_SSL = False
DEFAULT_USERNAME = "test-username"
DEFAULT_PASSWORD = "test-password"
DEFAULT_API_KEY = "test-api-key"
@pytest.fixture(autouse=True)
def mock_discovery():
"""Prevent real network scanning in all unifiprotect tests."""
with _patch_discovery(no_device=True):
yield
@pytest.fixture(name="nvr")
def mock_nvr():
"""Mock UniFi Protect Camera device."""
data = load_json_object_fixture("sample_nvr.json", DOMAIN)
nvr = NVR.from_unifi_dict(**data)
# disable pydantic validation so mocking can happen
NVR.model_config["validate_assignment"] = False
yield nvr
NVR.model_config["validate_assignment"] = True
@pytest.fixture(name="ufp_options")
def mock_ufp_options(request: pytest.FixtureRequest) -> dict[str, Any]:
"""Options for the mock config entry (override per-test via indirect param)."""
options: dict[str, Any] = {}
if hasattr(request, "param"):
options.update(request.param)
return options
@pytest.fixture(name="ufp_config_entry")
def mock_ufp_config_entry(ufp_options: dict[str, Any]):
"""Mock the unifiprotect config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
options=ufp_options,
version=2,
unique_id="A1E00C826924",
)
@pytest.fixture(name="old_nvr")
def old_nvr():
"""Mock UniFi Protect Camera device."""
data = load_json_object_fixture("sample_nvr.json", DOMAIN)
data["version"] = "1.19.0"
return NVR.from_unifi_dict(**data)
@pytest.fixture(name="bootstrap")
def bootstrap_fixture(nvr: NVR):
"""Mock Bootstrap fixture."""
data = load_json_object_fixture("sample_bootstrap.json", DOMAIN)
data["nvr"] = nvr
data["cameras"] = []
data["lights"] = []
data["sensors"] = []
data["viewers"] = []
data["liveviews"] = []
data["events"] = []
data["chimes"] = []
data["aiports"] = []
return Bootstrap.from_unifi_dict(**data)
@pytest.fixture(name="ufp_client")
def mock_ufp_client(bootstrap: Bootstrap):
"""Mock ProtectApiClient for testing."""
client = Mock()
client.bootstrap = bootstrap
client._bootstrap = bootstrap
client.api_path = "/api"
client.cache_dir = Path(gettempdir()) / "ufp_cache"
# functionality from API client tests actually need
client._stream_response = partial(ProtectApiClient._stream_response, client)
client.get_camera_video = partial(ProtectApiClient.get_camera_video, client)
nvr = client.bootstrap.nvr
nvr._api = client
client.bootstrap._api = client
client.base_url = "https://127.0.0.1"
client.connection_host = IPv4Address("127.0.0.1")
async def get_nvr(*args: Any, **kwargs: Any) -> NVR:
return client.bootstrap.nvr
client.get_nvr = get_nvr
client.get_bootstrap = AsyncMock(return_value=bootstrap)
client.update = AsyncMock(return_value=bootstrap)
client.update_public = AsyncMock()
client.async_disconnect_ws = AsyncMock()
client.has_public_bootstrap = True
client.is_public_only = False
# The library owns RTSPS streams on ``PublicCamera.rtsps_streams`` and primes
# them in ``update_public()``; the integration reads them synchronously. Start
# with empty collections; the ``update_public`` side effect (see ``mock_entry``)
# primes the cameras from the private bootstrap.
client.public_bootstrap = Mock()
client.public_bootstrap.cameras = {}
client.public_bootstrap.relays = {}
client.public_bootstrap.sirens = {}
client.public_bootstrap.arm_profiles = {}
client.public_bootstrap.arm_mode = None
# Cameras resolve to their primed public model (see ``update_public`` in
# ``mock_entry``); other device types opt in via the ``setup_public_*``
# helpers, so they default to no paired public object.
def _public_bootstrap_get(
model: ModelType, obj_id: str
) -> ProtectModelWithId | None:
if model is ModelType.CAMERA:
return client.public_bootstrap.cameras.get(obj_id)
return None
client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get)
async def get_camera_rtsps_streams(
camera_id: str, *args: Any, **kwargs: Any
) -> RTSPSStreams | None:
"""Fetch a camera's RTSPS streams (used by the repair flow)."""
camera = client.bootstrap.cameras.get(camera_id)
return public_rtsps_for(camera) if camera is not None else None
client.get_camera_rtsps_streams = AsyncMock(side_effect=get_camera_rtsps_streams)
client.create_camera_rtsps_streams = AsyncMock(return_value=None)
return client
@pytest.fixture(name="ufp")
def mock_entry(
hass: HomeAssistant, ufp_config_entry: MockConfigEntry, ufp_client: ProtectApiClient
):
"""Mock ProtectApiClient for testing."""
with (
patch(
"homeassistant.components.unifiprotect.utils.ProtectApiClient"
) as mock_api,
):
ufp_config_entry.add_to_hass(hass)
mock_api.return_value = ufp_client
ufp = MockUFPFixture(ufp_config_entry, ufp_client)
def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any:
ufp.ws_subscription = ws_callback
return Mock()
def subscribe_websocket_state(
ws_state_subscription: Callable[[WebsocketState], None],
) -> Any:
ufp.ws_state_subscription = ws_state_subscription
return Mock()
def subscribe_devices_websocket(
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> Any:
ufp.devices_ws_subscription = ws_callback
return Mock()
def subscribe_events(events_callback: Callable[..., None]) -> Any:
# Mirror uiprotect: subscribe_events() requires update_public() to
# have primed the public bootstrap first, otherwise it raises.
if not ufp_client.has_public_bootstrap:
raise RuntimeError(
"subscribe_events() requires update_public() to have been"
" called at least once"
)
ufp.events_subscription = events_callback
return Mock()
def subscribe_devices_websocket_state(
ws_state_subscription: Callable[[WebsocketState], None],
) -> Any:
ufp.devices_ws_state_subscription = ws_state_subscription
return Mock()
def subscribe_events_websocket_state(
ws_state_subscription: Callable[[WebsocketState], None],
) -> Any:
ufp.events_ws_state_subscription = ws_state_subscription
return Mock()
ufp_client.subscribe_websocket = subscribe
ufp_client.subscribe_websocket_state = subscribe_websocket_state
ufp_client.subscribe_devices_websocket = subscribe_devices_websocket
ufp_client.subscribe_events = subscribe_events
ufp_client.subscribe_devices_websocket_state = subscribe_devices_websocket_state
ufp_client.subscribe_events_websocket_state = subscribe_events_websocket_state
async def update_public() -> Any:
# Mirror the library prime: build each camera's public model from the
# private bootstrap and attach its RTSPS streams (connected cameras
# only, so a disconnected camera stays streamless), keyed by id.
pb = ufp_client.public_bootstrap
cameras: dict[str, Any] = {}
for camera in ufp_client.bootstrap.cameras.values():
public = make_public_camera(camera)
public.rtsps_streams = (
public_rtsps_for(camera)
if camera.state is StateType.CONNECTED
else None
)
cameras[camera.id] = public
pb.cameras = cameras
return pb
ufp_client.update_public = AsyncMock(side_effect=update_public)
ufp_client.has_public_bootstrap = True
yield ufp
@pytest.fixture
def liveview():
"""Mock UniFi Protect Liveview."""
data = load_json_object_fixture("sample_liveview.json", DOMAIN)
return Liveview.from_unifi_dict(**data)
@pytest.fixture(name="camera")
def camera_fixture(fixed_now: datetime):
"""Mock UniFi Protect Camera device."""
# disable pydantic validation so mocking can happen
Camera.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_camera.json", DOMAIN)
camera = Camera.from_unifi_dict(**data)
camera.last_motion = fixed_now - timedelta(hours=1)
yield camera
Camera.model_config["validate_assignment"] = True
@pytest.fixture(name="camera_all")
def camera_all_fixture(camera: Camera):
"""Mock UniFi Protect Camera device."""
all_camera = camera.model_copy()
all_camera.channels = [all_camera.channels[0].model_copy()]
medium_channel = all_camera.channels[0].model_copy()
medium_channel.name = "Medium"
medium_channel.id = 1
medium_channel.rtsp_alias = "test_medium_alias"
all_camera.channels.append(medium_channel)
low_channel = all_camera.channels[0].model_copy()
low_channel.name = "Low"
low_channel.id = 2
low_channel.rtsp_alias = "test_medium_alias"
all_camera.channels.append(low_channel)
return all_camera
@pytest.fixture(name="camera_all_features")
def camera_all_features_fixture(fixed_now: datetime):
"""Mock UniFi Protect Camera device with all features enabled."""
# disable pydantic validation so mocking can happen
Camera.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_camera_all_features.json", DOMAIN)
camera = Camera.from_unifi_dict(**data)
camera.last_motion = fixed_now - timedelta(hours=1)
yield camera
Camera.model_config["validate_assignment"] = True
@pytest.fixture(name="doorbell")
def doorbell_fixture(camera: Camera, fixed_now: datetime):
"""Mock UniFi Protect Camera device (with chime)."""
doorbell = camera.model_copy()
doorbell.channels = [c.model_copy() for c in doorbell.channels]
package_channel = doorbell.channels[0].model_copy()
package_channel.name = "Package Camera"
package_channel.id = 3
package_channel.fps = 2
package_channel.rtsp_alias = "test_package_alias"
doorbell.channels.append(package_channel)
doorbell.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS]
doorbell.feature_flags.smart_detect_types = [
SmartDetectObjectType.PERSON,
SmartDetectObjectType.VEHICLE,
SmartDetectObjectType.ANIMAL,
SmartDetectObjectType.PACKAGE,
]
doorbell.has_speaker = True
doorbell.feature_flags.has_hdr = True
doorbell.feature_flags.has_lcd_screen = True
doorbell.feature_flags.has_speaker = True
doorbell.feature_flags.has_privacy_mask = True
doorbell.feature_flags.is_doorbell = True
doorbell.feature_flags.has_fingerprint_sensor = True
doorbell.feature_flags.support_nfc = True
doorbell.feature_flags.has_chime = True
doorbell.feature_flags.has_smart_detect = True
doorbell.feature_flags.has_package_camera = True
doorbell.feature_flags.has_led_status = True
doorbell.last_ring = fixed_now - timedelta(hours=1)
return doorbell
@pytest.fixture(name="ptz_camera")
def ptz_camera_fixture(camera: Camera):
"""Mock UniFi Protect PTZ Camera device."""
ptz_cam = camera.model_copy()
ptz_cam.channels = [c.model_copy() for c in ptz_cam.channels]
ptz_cam.name = "PTZ Camera"
ptz_cam.feature_flags.is_ptz = True
ptz_cam.active_patrol_slot = None
# Disable pydantic validation on this instance so we can mock methods
object.__setattr__(ptz_cam, "get_ptz_presets", AsyncMock(return_value=[]))
object.__setattr__(ptz_cam, "get_ptz_patrols", AsyncMock(return_value=[]))
object.__setattr__(ptz_cam, "ptz_goto_preset_public", AsyncMock())
object.__setattr__(ptz_cam, "ptz_patrol_start_public", AsyncMock())
object.__setattr__(ptz_cam, "ptz_patrol_stop_public", AsyncMock())
return ptz_cam
@pytest.fixture
def unadopted_camera(camera: Camera):
"""Mock UniFi Protect Camera device (unadopted)."""
no_camera = camera.model_copy()
no_camera.channels = [c.model_copy() for c in no_camera.channels]
no_camera.name = "Unadopted Camera"
no_camera.is_adopted = False
return no_camera
@pytest.fixture(name="light")
def light_fixture():
"""Mock UniFi Protect Light device."""
# disable pydantic validation so mocking can happen
Light.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_light.json", DOMAIN)
yield Light.from_unifi_dict(**data)
Light.model_config["validate_assignment"] = True
@pytest.fixture
def unadopted_light(light: Light):
"""Mock UniFi Protect Light device (unadopted)."""
no_light = light.model_copy()
no_light.name = "Unadopted Light"
no_light.is_adopted = False
return no_light
@pytest.fixture
def viewer():
"""Mock UniFi Protect Viewport device."""
# disable pydantic validation so mocking can happen
Viewer.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_viewport.json", DOMAIN)
yield Viewer.from_unifi_dict(**data)
Viewer.model_config["validate_assignment"] = True
@pytest.fixture(name="sensor")
def sensor_fixture(fixed_now: datetime):
"""Mock UniFi Protect Sensor device."""
# disable pydantic validation so mocking can happen
Sensor.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_sensor.json", DOMAIN)
sensor: Sensor = Sensor.from_unifi_dict(**data)
sensor.motion_detected_at = fixed_now - timedelta(hours=1)
sensor.open_status_changed_at = fixed_now - timedelta(hours=1)
sensor.alarm_triggered_at = fixed_now - timedelta(hours=1)
yield sensor
Sensor.model_config["validate_assignment"] = True
@pytest.fixture(name="sensor_all")
def sensor_all_fixture(sensor: Sensor):
"""Mock UniFi Protect Sensor device."""
all_sensor = sensor.model_copy()
all_sensor.light_settings.is_enabled = True
all_sensor.humidity_settings.is_enabled = True
all_sensor.temperature_settings.is_enabled = True
all_sensor.alarm_settings.is_enabled = True
all_sensor.led_settings.is_enabled = True
all_sensor.motion_settings.is_enabled = True
return all_sensor
@pytest.fixture
def chime():
"""Mock UniFi Protect Chime device."""
# disable pydantic validation so mocking can happen
Chime.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_chime.json", DOMAIN)
yield Chime.from_unifi_dict(**data)
Chime.model_config["validate_assignment"] = True
@pytest.fixture(name="aiport")
def aiport_fixture():
"""Mock UniFi Protect AI Port device."""
# disable pydantic validation so mocking can happen
AiPort.model_config["validate_assignment"] = False
data = load_json_object_fixture("sample_aiport.json", DOMAIN)
yield AiPort.from_unifi_dict(**data)
AiPort.model_config["validate_assignment"] = True
@pytest.fixture(name="fixed_now")
def fixed_now_fixture():
"""Return datetime object that will be consistent throughout test."""
return dt_util.utcnow()
@pytest.fixture(name="ufp_reauth_entry")
def mock_ufp_reauth_entry():
"""Mock the unifiprotect config entry for reauth and reconfigure tests."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
unique_id=_async_unifi_mac_from_hass(MAC_ADDR),
)
@pytest.fixture(name="ufp_reauth_entry_alt")
def mock_ufp_reauth_entry_alt():
"""Mock the unifiprotect config entry with alt port/SSL for reauth tests."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: 8443,
CONF_VERIFY_SSL: True,
},
unique_id=_async_unifi_mac_from_hass(MAC_ADDR),
)
@pytest.fixture(name="mock_setup")
def mock_setup_fixture() -> Generator[AsyncMock]:
"""Mock async_setup and async_setup_entry to prevent reload issues in tests."""
with (
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock,
):
yield mock
@pytest.fixture(name="mock_api_bootstrap")
def mock_api_bootstrap_fixture(bootstrap: Bootstrap):
"""Mock the ProtectApiClient.get_bootstrap method."""
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
) as mock:
yield mock
@pytest.fixture(name="mock_api_meta_info")
def mock_api_meta_info_fixture():
"""Mock the ProtectApiClient.get_meta_info method."""
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
) as mock:
yield mock
@pytest.fixture(name="cloud_account")
def cloud_account() -> CloudAccount:
"""Return UI Cloud Account."""
return CloudAccount(
id="42",
first_name="Test",
last_name="User",
email="test@example.com",
user_id="42",
name="Test User",
location=None,
profile_img=None,
)