mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 21:35:13 +01:00
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>
488 lines
17 KiB
Python
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"
|