mirror of
https://github.com/home-assistant/core.git
synced 2025-11-30 21:18:08 +00:00
698 lines
20 KiB
Python
698 lines
20 KiB
Python
"""Tests for the Home Assistant Labs WebSocket API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import ANY, AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from homeassistant.components.labs import async_setup
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.labs import (
|
|
EVENT_LABS_UPDATED,
|
|
async_is_preview_feature_enabled,
|
|
)
|
|
|
|
from . import assert_stored_labs_data
|
|
|
|
from tests.common import MockUser
|
|
from tests.typing import WebSocketGenerator
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("load_integration", "expected_features"),
|
|
[
|
|
(False, []), # No integration loaded
|
|
(
|
|
True, # Integration loaded
|
|
[
|
|
{
|
|
"preview_feature": "special_repair",
|
|
"domain": "kitchen_sink",
|
|
"enabled": False,
|
|
"is_built_in": True,
|
|
"feedback_url": ANY,
|
|
"learn_more_url": ANY,
|
|
"report_issue_url": ANY,
|
|
}
|
|
],
|
|
),
|
|
],
|
|
)
|
|
async def test_websocket_list_preview_features(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
load_integration: bool,
|
|
expected_features: list,
|
|
) -> None:
|
|
"""Test listing preview features with different integration states."""
|
|
if load_integration:
|
|
hass.config.components.add("kitchen_sink")
|
|
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
assert msg["result"] == {"features": expected_features}
|
|
|
|
|
|
async def test_websocket_update_preview_feature_enable(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test enabling a preview feature via WebSocket."""
|
|
# Load kitchen_sink integration
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
assert "core.labs" not in hass_storage
|
|
|
|
# Track events
|
|
events = []
|
|
|
|
def event_listener(event):
|
|
events.append(event)
|
|
|
|
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
|
|
|
|
# Enable the preview feature
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
assert msg["result"] is None
|
|
|
|
# Verify event was fired
|
|
await hass.async_block_till_done()
|
|
assert len(events) == 1
|
|
assert events[0].data["domain"] == "kitchen_sink"
|
|
assert events[0].data["preview_feature"] == "special_repair"
|
|
assert events[0].data["enabled"] is True
|
|
|
|
# Verify feature is now enabled
|
|
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
|
|
|
assert_stored_labs_data(
|
|
hass_storage,
|
|
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
|
|
)
|
|
|
|
|
|
async def test_websocket_update_preview_feature_disable(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test disabling a preview feature via WebSocket."""
|
|
# Pre-populate storage with enabled preview feature
|
|
hass_storage["core.labs"] = {
|
|
"version": 1,
|
|
"minor_version": 1,
|
|
"key": "core.labs",
|
|
"data": {
|
|
"preview_feature_status": [
|
|
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
|
|
]
|
|
},
|
|
}
|
|
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json(
|
|
{
|
|
"id": 5,
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": False,
|
|
}
|
|
)
|
|
|
|
msg = await client.receive_json()
|
|
assert msg["success"]
|
|
|
|
# Verify feature is disabled
|
|
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
|
assert_stored_labs_data(
|
|
hass_storage,
|
|
[],
|
|
)
|
|
|
|
|
|
async def test_websocket_update_nonexistent_feature(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test updating a preview feature that doesn't exist."""
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "nonexistent",
|
|
"preview_feature": "feature",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert not msg["success"]
|
|
assert msg["error"]["code"] == "not_found"
|
|
assert "not found" in msg["error"]["message"].lower()
|
|
|
|
assert "core.labs" not in hass_storage
|
|
|
|
|
|
async def test_websocket_update_unavailable_preview_feature(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test updating a preview feature whose integration is not loaded still works."""
|
|
# Don't load kitchen_sink integration
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Preview feature is pre-loaded, so update succeeds even though integration isn't loaded
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
assert msg["result"] is None
|
|
|
|
assert_stored_labs_data(
|
|
hass_storage,
|
|
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"command_type",
|
|
["labs/list", "labs/update"],
|
|
)
|
|
async def test_websocket_requires_admin(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_admin_user: MockUser,
|
|
hass_storage: dict[str, Any],
|
|
command_type: str,
|
|
) -> None:
|
|
"""Test that websocket commands require admin privileges."""
|
|
# Remove admin privileges
|
|
hass_admin_user.groups = []
|
|
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
command = {"type": command_type}
|
|
if command_type == "labs/update":
|
|
command.update(
|
|
{
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
|
|
await client.send_json_auto_id(command)
|
|
msg = await client.receive_json()
|
|
|
|
assert not msg["success"]
|
|
assert msg["error"]["code"] == "unauthorized"
|
|
|
|
assert "core.labs" not in hass_storage
|
|
|
|
|
|
async def test_websocket_update_validates_enabled_parameter(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that enabled parameter must be boolean."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Try with string instead of boolean
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": "true",
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert not msg["success"]
|
|
# Validation error from voluptuous
|
|
|
|
|
|
async def test_storage_persists_preview_feature_across_calls(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test that storage persists preview feature state across multiple calls."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
assert "core.labs" not in hass_storage
|
|
|
|
# Enable the preview feature
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
assert msg["success"]
|
|
|
|
assert_stored_labs_data(
|
|
hass_storage,
|
|
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
|
|
)
|
|
|
|
# List preview features - should show enabled
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
assert msg["success"]
|
|
assert msg["result"]["features"][0]["enabled"] is True
|
|
|
|
# Disable preview feature
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": False,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
assert msg["success"]
|
|
|
|
assert_stored_labs_data(
|
|
hass_storage,
|
|
[],
|
|
)
|
|
|
|
# List preview features - should show disabled
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
assert msg["success"]
|
|
assert msg["result"]["features"][0]["enabled"] is False
|
|
|
|
|
|
async def test_preview_feature_urls_present(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that preview features include feedback and report URLs."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
feature = msg["result"]["features"][0]
|
|
assert "feedback_url" in feature
|
|
assert "learn_more_url" in feature
|
|
assert "report_issue_url" in feature
|
|
assert feature["feedback_url"] is not None
|
|
assert feature["learn_more_url"] is not None
|
|
assert feature["report_issue_url"] is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"create_backup",
|
|
"backup_fails",
|
|
"enabled",
|
|
"should_call_backup",
|
|
"should_succeed",
|
|
),
|
|
[
|
|
# Enable with successful backup
|
|
(True, False, True, True, True),
|
|
# Enable with failed backup
|
|
(True, True, True, True, False),
|
|
# Disable ignores backup flag
|
|
(True, False, False, False, True),
|
|
],
|
|
ids=[
|
|
"enable_with_backup_success",
|
|
"enable_with_backup_failure",
|
|
"disable_ignores_backup",
|
|
],
|
|
)
|
|
async def test_websocket_update_preview_feature_backup_scenarios(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
create_backup: bool,
|
|
backup_fails: bool,
|
|
enabled: bool,
|
|
should_call_backup: bool,
|
|
should_succeed: bool,
|
|
) -> None:
|
|
"""Test various backup scenarios when updating preview features."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Mock the backup manager
|
|
mock_backup_manager = AsyncMock()
|
|
if backup_fails:
|
|
mock_backup_manager.async_create_automatic_backup = AsyncMock(
|
|
side_effect=Exception("Backup failed")
|
|
)
|
|
else:
|
|
mock_backup_manager.async_create_automatic_backup = AsyncMock()
|
|
|
|
with patch(
|
|
"homeassistant.components.labs.websocket_api.async_get_manager",
|
|
return_value=mock_backup_manager,
|
|
):
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": enabled,
|
|
"create_backup": create_backup,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
if should_succeed:
|
|
assert msg["success"]
|
|
if should_call_backup:
|
|
mock_backup_manager.async_create_automatic_backup.assert_called_once()
|
|
else:
|
|
mock_backup_manager.async_create_automatic_backup.assert_not_called()
|
|
else:
|
|
assert not msg["success"]
|
|
assert msg["error"]["code"] == "unknown_error"
|
|
assert "backup" in msg["error"]["message"].lower()
|
|
# Verify preview feature was NOT enabled
|
|
assert not async_is_preview_feature_enabled(
|
|
hass, "kitchen_sink", "special_repair"
|
|
)
|
|
|
|
|
|
async def test_websocket_list_multiple_enabled_features(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test listing when multiple preview features are enabled."""
|
|
# Pre-populate with multiple enabled features
|
|
hass_storage["core.labs"] = {
|
|
"version": 1,
|
|
"data": {
|
|
"preview_feature_status": [
|
|
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
|
|
]
|
|
},
|
|
}
|
|
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
features = msg["result"]["features"]
|
|
assert len(features) >= 1
|
|
# Verify at least one is enabled
|
|
enabled_features = [f for f in features if f["enabled"]]
|
|
assert len(enabled_features) == 1
|
|
|
|
|
|
async def test_websocket_update_rapid_toggle(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test rapid toggling of a preview feature."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Enable
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg1 = await client.receive_json()
|
|
assert msg1["success"]
|
|
|
|
# Disable immediately
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": False,
|
|
}
|
|
)
|
|
msg2 = await client.receive_json()
|
|
assert msg2["success"]
|
|
|
|
# Enable again
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg3 = await client.receive_json()
|
|
assert msg3["success"]
|
|
|
|
# Final state should be enabled
|
|
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
|
|
|
|
|
async def test_websocket_update_same_state_idempotent(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that enabling an already-enabled feature is idempotent."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Enable feature
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg1 = await client.receive_json()
|
|
assert msg1["success"]
|
|
|
|
# Enable again (should be idempotent)
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
msg2 = await client.receive_json()
|
|
assert msg2["success"]
|
|
|
|
# Should still be enabled
|
|
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
|
|
|
|
|
|
async def test_websocket_list_filtered_by_loaded_components(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that list only shows features from loaded integrations."""
|
|
# Don't load kitchen_sink - its preview feature shouldn't appear
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
# Should be empty since kitchen_sink isn't loaded
|
|
assert msg["result"]["features"] == []
|
|
|
|
# Now load kitchen_sink
|
|
hass.config.components.add("kitchen_sink")
|
|
|
|
await client.send_json_auto_id({"type": "labs/list"})
|
|
msg = await client.receive_json()
|
|
|
|
assert msg["success"]
|
|
# Now should have kitchen_sink features
|
|
assert len(msg["result"]["features"]) >= 1
|
|
|
|
|
|
async def test_websocket_update_with_missing_required_field(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that missing required fields are rejected."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Missing 'enabled' field
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
# enabled is missing
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert not msg["success"]
|
|
# Should get validation error
|
|
|
|
|
|
async def test_websocket_event_data_structure(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test that event data has correct structure."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
events = []
|
|
|
|
def event_listener(event):
|
|
events.append(event)
|
|
|
|
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
|
|
|
|
# Enable a feature
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
}
|
|
)
|
|
await client.receive_json()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(events) == 1
|
|
event_data = events[0].data
|
|
# Verify all required fields are present
|
|
assert "domain" in event_data
|
|
assert "preview_feature" in event_data
|
|
assert "enabled" in event_data
|
|
assert event_data["domain"] == "kitchen_sink"
|
|
assert event_data["preview_feature"] == "special_repair"
|
|
assert event_data["enabled"] is True
|
|
assert isinstance(event_data["enabled"], bool)
|
|
|
|
|
|
async def test_websocket_backup_timeout_handling(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Test handling of backup timeout/long-running backup."""
|
|
hass.config.components.add("kitchen_sink")
|
|
assert await async_setup(hass, {})
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
|
|
# Mock backup manager with timeout
|
|
mock_backup_manager = AsyncMock()
|
|
mock_backup_manager.async_create_automatic_backup = AsyncMock(
|
|
side_effect=TimeoutError("Backup timed out")
|
|
)
|
|
|
|
with patch(
|
|
"homeassistant.components.labs.websocket_api.async_get_manager",
|
|
return_value=mock_backup_manager,
|
|
):
|
|
await client.send_json_auto_id(
|
|
{
|
|
"type": "labs/update",
|
|
"domain": "kitchen_sink",
|
|
"preview_feature": "special_repair",
|
|
"enabled": True,
|
|
"create_backup": True,
|
|
}
|
|
)
|
|
msg = await client.receive_json()
|
|
|
|
assert not msg["success"]
|
|
assert msg["error"]["code"] == "unknown_error"
|