"""Tests for Xthings Cloud camera platform.""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from ha_xthings_cloud import XthingsCloudApiError import pytest from syrupy.assertion import SnapshotAssertion from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( WebRTCAnswer, WebRTCError, async_get_image, get_camera_from_entity_id, ) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import get_device_by_id, setup_integration from tests.common import MockConfigEntry, snapshot_platform async def setup_camera_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Set up the integration with only the camera platform.""" with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.CAMERA]): await setup_integration(hass, mock_config_entry) async def test_cameras( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test camera entities are created correctly.""" with patch("homeassistant.components.camera._RND.getrandbits", return_value=1): await setup_camera_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_camera_unavailable_when_offline( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test camera shows unavailable when device is offline.""" get_device_by_id(mock_api_client, "dev_camera_001")["online"] = False await setup_camera_integration(hass, mock_config_entry) state = hass.states.get("camera.front_door_camera") assert state is not None assert state.state == STATE_UNAVAILABLE async def test_camera_image( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test camera image fetching.""" mock_api_client.async_get_snapshot.return_value = b"image_data" await setup_camera_integration(hass, mock_config_entry) state = hass.states.get("camera.front_door_camera") assert state is not None image = await async_get_image(hass, "camera.front_door_camera") assert image.content == b"image_data" mock_api_client.async_get_snapshot.assert_called_once_with( "https://example.com/snapshot.jpg" ) async def test_camera_image_fetch_error_returns_no_image( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test camera image fetch errors return no image.""" mock_api_client.async_get_snapshot.return_value = b"image_data" await setup_camera_integration(hass, mock_config_entry) image = await async_get_image(hass, "camera.front_door_camera") assert image.content == b"image_data" get_device_by_id(mock_api_client, "dev_camera_001")["status"]["snapshot_url"] = ( "https://example.com/new_snapshot.jpg" ) mock_api_client.async_get_snapshot.side_effect = XthingsCloudApiError( "Request failed" ) with pytest.raises(HomeAssistantError, match="Unable to get image"): await async_get_image(hass, "camera.front_door_camera") mock_api_client.async_get_snapshot.assert_called_with( "https://example.com/new_snapshot.jpg" ) async def test_updating_state_updates_snapshot_url( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, mock_websocket: AsyncMock, ) -> None: """Test updating state uses the new snapshot URL on the next image request.""" mock_api_client.async_get_snapshot.return_value = b"new_image_data" await setup_camera_integration(hass, mock_config_entry) assert mock_websocket.call_args is not None mock_websocket.call_args[1]["on_device_status"]( "dev_camera_001", { "snapshot_url": "https://example.com/new_snapshot.jpg", }, ) await hass.async_block_till_done() mock_api_client.async_get_snapshot.assert_not_called() image = await async_get_image(hass, "camera.front_door_camera") assert image.content == b"new_image_data" mock_api_client.async_get_snapshot.assert_called_once_with( "https://example.com/new_snapshot.jpg" ) async def test_webrtc_offer( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test WebRTC offer handling.""" mock_api_client.async_get_camera_webrtc.return_value = { "region": "us-east-1", "channel_arn": "arn:aws:kinesisvideo:us-east-1:111111111111:channel/test/123", "viewer": { "AccessKeyId": "test", "SecretAccessKey": "test", "SessionToken": "test", }, } await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient" ) as mock_kvs_client_class: mock_kvs_client = mock_kvs_client_class.return_value async def mock_get_answer_sdp(_offer_sdp, on_ice_candidate): on_ice_candidate( { "candidate": "mock_candidate_string", "sdpMid": "mock_sdp_mid", "sdpMLineIndex": 0, } ) return "mock_answer_sdp" mock_kvs_client.async_get_answer_sdp = AsyncMock( side_effect=mock_get_answer_sdp ) mock_send_message = MagicMock() await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id="mock_session_id", send_message=mock_send_message, ) mock_api_client.async_get_camera_webrtc.assert_called_once_with( "dev_camera_001" ) mock_kvs_client_class.assert_called_once() mock_kvs_client.async_get_answer_sdp.assert_called_once() assert mock_send_message.call_count == 2 call_arg = mock_send_message.call_args[0][0] assert isinstance(call_arg, WebRTCAnswer) assert call_arg.answer == "mock_answer_sdp" async def test_webrtc_candidate_caching_and_flush( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test ICE candidate caching and flushing on offer.""" kvs_data = { "region": "us-east-1", "channel_arn": "arn:aws:kinesisvideo:us-east-1:111111111111:channel/test/123", "viewer": { "AccessKeyId": "test", "SecretAccessKey": "test", "SessionToken": "test", }, } await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None session_id = "mock_session_id" candidate = RTCIceCandidateInit( candidate="mock_candidate_string", sdp_mid="mock_sdp_mid", sdp_m_line_index=0, ) async def mock_get_camera_webrtc(_device_id): await camera_entity.async_on_webrtc_candidate(session_id, candidate) return kvs_data mock_api_client.async_get_camera_webrtc.side_effect = mock_get_camera_webrtc with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient" ) as mock_kvs_client_class: mock_kvs_client = mock_kvs_client_class.return_value mock_kvs_client.async_get_answer_sdp = AsyncMock(return_value="mock_answer_sdp") mock_kvs_client.async_send_ice_candidate = AsyncMock() mock_kvs_client.async_close = AsyncMock() await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id=session_id, send_message=MagicMock(), ) # Verify candidate was flushed to KVS client mock_kvs_client.async_send_ice_candidate.assert_called_once_with( candidate="mock_candidate_string", sdp_mid="mock_sdp_mid", sdp_m_line_index=0, ) # Re-send candidate after session is established to verify direct send await camera_entity.async_on_webrtc_candidate(session_id, candidate) assert mock_kvs_client.async_send_ice_candidate.call_count == 2 mock_kvs_client.async_send_ice_candidate.side_effect = ClientError( "Send failed" ) await camera_entity.async_on_webrtc_candidate(session_id, candidate) await hass.async_block_till_done() mock_kvs_client.async_close.assert_not_called() mock_kvs_client.async_send_ice_candidate.side_effect = None await camera_entity.async_on_webrtc_candidate(session_id, candidate) assert mock_kvs_client.async_send_ice_candidate.call_count == 4 async def test_webrtc_session_cleanup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test WebRTC session cleanup clears cache and closes client.""" mock_api_client.async_get_camera_webrtc.return_value = { "region": "us-east-1", "channel_arn": "arn:aws:kinesisvideo:us-east-1:111111111111:channel/test/123", "viewer": { "AccessKeyId": "test", "SecretAccessKey": "test", "SessionToken": "test", }, } await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None session_id = "mock_session_id" candidate = RTCIceCandidateInit( candidate="mock_candidate_string", sdp_mid="mock_sdp_mid", sdp_m_line_index=0, ) with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient" ) as mock_kvs_client_class: mock_kvs_client = mock_kvs_client_class.return_value mock_kvs_client.async_get_answer_sdp = AsyncMock(return_value="mock_answer_sdp") mock_kvs_client.async_send_ice_candidate = AsyncMock() mock_kvs_client.async_close = AsyncMock() await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id=session_id, send_message=MagicMock(), ) # Test close_webrtc_session camera_entity.close_webrtc_session(session_id) await hass.async_block_till_done() mock_kvs_client.async_close.assert_called_once() # Verify that candidate is not flushed after session is closed mock_kvs_client.async_send_ice_candidate = AsyncMock() await camera_entity.async_on_webrtc_candidate(session_id, candidate) mock_kvs_client.async_send_ice_candidate.assert_not_called() # Verify a new offer with the same session id accepts candidates again await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id=session_id, send_message=MagicMock(), ) await camera_entity.async_on_webrtc_candidate(session_id, candidate) mock_kvs_client.async_send_ice_candidate.assert_called_once_with( candidate="mock_candidate_string", sdp_mid="mock_sdp_mid", sdp_m_line_index=0, ) async def test_webrtc_offer_closed_while_fetching_credentials( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test closing a session while the offer is awaiting credentials.""" credentials_requested = asyncio.Event() resume_credentials = asyncio.Event() async def mock_get_camera_webrtc(_device_id): credentials_requested.set() await resume_credentials.wait() return { "region": "us-east-1", "channel_arn": ( "arn:aws:kinesisvideo:us-east-1:111111111111:channel/test/123" ), "viewer": { "AccessKeyId": "test", "SecretAccessKey": "test", "SessionToken": "test", }, } mock_api_client.async_get_camera_webrtc.side_effect = mock_get_camera_webrtc await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None session_id = "mock_session_id" mock_send_message = MagicMock() with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient" ) as mock_kvs_client_class: offer_task = hass.async_create_task( camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id=session_id, send_message=mock_send_message, ) ) await credentials_requested.wait() camera_entity.close_webrtc_session(session_id) resume_credentials.set() await offer_task mock_kvs_client_class.assert_not_called() mock_send_message.assert_not_called() async def test_webrtc_session_cleanup_on_entity_removal( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test WebRTC sessions are awaited during entity removal.""" mock_api_client.async_get_camera_webrtc.return_value = { "region": "us-east-1", "channel_arn": "arn:aws:kinesisvideo:us-east-1:111111111111:channel/test/123", "viewer": { "AccessKeyId": "test", "SecretAccessKey": "test", "SessionToken": "test", }, } await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient" ) as mock_kvs_client_class: mock_kvs_client = mock_kvs_client_class.return_value mock_kvs_client.async_get_answer_sdp = AsyncMock(return_value="mock_answer_sdp") mock_kvs_client.async_close = AsyncMock() await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id="mock_session_id", send_message=MagicMock(), ) assert await hass.config_entries.async_unload(mock_config_entry.entry_id) mock_kvs_client.async_close.assert_awaited_once() async def test_webrtc_offer_kvs_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test WebRTC offer handling when the KVS library raises.""" mock_api_client.async_get_camera_webrtc.return_value = { "region": "us-east-1", "channel_arn": None, } await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None mock_send_message = MagicMock() with patch( "homeassistant.components.xthings_cloud.camera.KvsSignalingClient", side_effect=XthingsCloudApiError("Invalid KVS credentials"), ): await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id="mock_session_id", send_message=mock_send_message, ) mock_send_message.assert_called_once() call_arg = mock_send_message.call_args[0][0] assert isinstance(call_arg, WebRTCError) assert call_arg.code == "kvs_error" assert call_arg.message == "WebRTC negotiation failed" async def test_webrtc_offer_unexpected_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: """Test WebRTC offer handling when an unexpected error occurs.""" mock_api_client.async_get_camera_webrtc.side_effect = XthingsCloudApiError( "KVS failed" ) await setup_camera_integration(hass, mock_config_entry) camera_entity = get_camera_from_entity_id(hass, "camera.front_door_camera") assert camera_entity is not None mock_send_message = MagicMock() await camera_entity.async_handle_async_webrtc_offer( offer_sdp="mock_offer_sdp", session_id="mock_session_id", send_message=mock_send_message, ) mock_send_message.assert_called_once() call_arg = mock_send_message.call_args[0][0] assert isinstance(call_arg, WebRTCError) assert call_arg.code == "kvs_error" assert call_arg.message == "WebRTC negotiation failed"