"""Fixtures and test data for UniFi Protect methods.""" from collections.abc import Callable, Coroutine, Generator, Iterator 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, PropertyMock, 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, NvrArmMode, NvrArmModeStatus, ProtectModelWithId, PublicBootstrap, Sensor, SmartDetectObjectType, StateType, Version, VideoMode, Viewer, WSSubscriptionMessage, ) from uiprotect.data.public_devices import PublicNVR from uiprotect.exceptions import BadRequest, PublicOnlyModeError from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import ( CONF_CONNECTION_MODE, CONNECTION_MODE_API_KEY_ONLY, 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" UNIFI_MAC = _async_unifi_mac_from_hass(MAC_ADDR) # The public-only NVR mock is named "Test NVR" (see ``ufp_public_only_client``) PUBLIC_ONLY_ALARM_ENTITY_ID = "alarm_control_panel.test_nvr_alarm_manager" # 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 # Only consulted on the public-only setup path (tests that flip # ``is_public_only`` on this client); a valid version and NVR mac let # that path complete instead of requiring per-test setup. meta = Mock() meta.version = Version("7.2.105") client.get_meta_info = AsyncMock(return_value=meta) # 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(spec=PublicBootstrap) client.public_bootstrap.cameras = {} client.public_bootstrap.lights = {} client.public_bootstrap.relays = {} client.public_bootstrap.sirens = {} client.public_bootstrap.arm_profiles = {} client.public_bootstrap.arm_mode = None client.public_bootstrap.nvr = Mock() client.public_bootstrap.nvr.mac = nvr.mac client.public_bootstrap.nvr.name = nvr.name client.public_bootstrap.nvr.display_name = nvr.name client.public_bootstrap.nvr.device_type = None client.public_bootstrap.nvr.type = None # Cameras and lights resolve to their primed public model (see # ``update_public`` in ``mock_entry`` / ``setup_public_light``); 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) if model is ModelType.LIGHT: return client.public_bootstrap.lights.get(obj_id) return None client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) def _public_all_devices() -> Iterator[Mock]: pb = client.public_bootstrap yield from pb.cameras.values() yield from pb.lights.values() yield from pb.relays.values() yield from pb.sirens.values() client.public_bootstrap.all_devices = _public_all_devices 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="ufp_public_only_entry") def mock_ufp_public_only_entry(): """Mock a public-API-only (API key, no local user) config entry.""" return MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: DEFAULT_HOST, CONF_API_KEY: DEFAULT_API_KEY, "id": "UnifiProtect", CONF_PORT: DEFAULT_PORT, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_CONNECTION_MODE: CONNECTION_MODE_API_KEY_ONLY, }, version=2, unique_id=UNIFI_MAC, ) @pytest.fixture(name="ufp_public_only_client") def mock_ufp_public_only_client() -> Mock: """Mock an API-key-only ProtectApiClient for setup.""" client = Mock() # Reading ``bootstrap`` on an API-key-only client raises in the library; # mirroring that makes every accidental private-bootstrap read in a # public-only code path fail loudly. A property needs the instance type, # which mock gives each Mock of its own. type(client).bootstrap = PropertyMock( side_effect=BadRequest("Client not initialized, run `update` first") ) client.is_public_only = True client.has_public_bootstrap = True meta = Mock() meta.version = Version("7.2.105") client.get_meta_info = AsyncMock(return_value=meta) client.update_public = AsyncMock() client.update = AsyncMock(side_effect=PublicOnlyModeError("public-only")) client.get_bootstrap = AsyncMock(side_effect=PublicOnlyModeError("public-only")) client.async_disconnect_ws = AsyncMock() arm_mode = Mock(spec=NvrArmMode) arm_mode.status = NvrArmModeStatus.DISABLED nvr = Mock(spec=PublicNVR) # The library backfills the mac during update_public(); it is present here. nvr.mac = UNIFI_MAC nvr.name = "Test NVR" nvr.display_name = "Test NVR" nvr.device_type = "UNVR4" # present on firmware newer than 7.1 nvr.type = "UNVR4" nvr.id = "nvr-id" nvr.model = ModelType.NVR pb = Mock(spec=PublicBootstrap) pb.nvr = nvr pb.arm_mode = arm_mode # One map per device family forwarded in public-only mode; tests replace # them, so both helpers below read the attribute at call time. pb.cameras = {} pb.lights = {} device_maps = {ModelType.CAMERA: "cameras", ModelType.LIGHT: "lights"} def _all_devices(*, include_nvr: bool = False) -> Iterator[Mock]: if include_nvr and pb.nvr is not None: yield pb.nvr for attr in device_maps.values(): yield from getattr(pb, attr).values() def _get(model: ModelType, obj_id: str) -> Mock | None: if (attr := device_maps.get(model)) is None: return None return getattr(pb, attr).get(obj_id) pb.all_devices = _all_devices pb.get = Mock(side_effect=_get) client.public_bootstrap = pb return client @pytest.fixture(name="ufp_public_only") def mock_ufp_public_only( ufp_public_only_entry: MockConfigEntry, ufp_public_only_client: Mock, ) -> MockUFPFixture: """Bundle the API-key-only entry and client, like ``ufp`` does for hybrid.""" ufp = MockUFPFixture(ufp_public_only_entry, ufp_public_only_client) def subscribe_devices_websocket( ws_callback: Callable[[WSSubscriptionMessage], None], ) -> Any: ufp.devices_ws_subscription = ws_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(events_callback: Callable[..., None]) -> Any: ufp.events_subscription = events_callback 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_public_only_client.subscribe_devices_websocket = subscribe_devices_websocket ufp_public_only_client.subscribe_devices_websocket_state = ( subscribe_devices_websocket_state ) ufp_public_only_client.subscribe_events = subscribe_events ufp_public_only_client.subscribe_events_websocket_state = ( subscribe_events_websocket_state ) return ufp @pytest.fixture(name="setup_public_only") def setup_public_only_fixture( hass: HomeAssistant, ufp_public_only: MockUFPFixture, ) -> Callable[[], Coroutine[Any, Any, None]]: """Return a callable setting up the API-key-only entry with its mock client. Deferred rather than an auto-setup fixture because several tests mutate the client (or the entry) first. The entry is added here so those tests can. """ ufp_public_only.entry.add_to_hass(hass) async def _setup() -> None: with patch( "homeassistant.components.unifiprotect.async_create_api_client", return_value=ufp_public_only.api, ): await hass.config_entries.async_setup(ufp_public_only.entry.entry_id) await hass.async_block_till_done() return _setup @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, )