Files
core/tests/components/labs/test_websocket_api.py
2025-11-26 09:10:19 +01:00

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"