Files
core/tests/components/xthings_cloud/test_camera.py
T
714431e7c1 Add xthings cloud camera (#172831)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2026-07-26 23:10:20 +02:00

488 lines
17 KiB
Python

"""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"