"""End-to-end HA WebSocket contracts for persistent virtual lights (#107).""" import pytest @pytest.fixture(autouse=True) def _enable_custom_integrations(enable_custom_integrations): """Allow loading custom_components in the test hass.""" yield from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.typing import WebSocketGenerator from custom_components.houseplan.const import DOMAIN from custom_components.houseplan.virtual_lights import EVENT_VIRTUAL_LIGHT_UPDATED def _config(*markers): return {"spaces": [], "markers": list(markers), "settings": {}} def _manual(marker_id="lamp", **extra): return { "id": marker_id, "binding": "virtual", "is_light": True, "tap_action": "toggle", **extra, } async def _setup(hass: HomeAssistant) -> MockConfigEntry: entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={}) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry async def _set_config(client, config, expected_rev): await client.send_json_auto_id({ "type": "houseplan/config/set", "config": config, "expected_rev": expected_rev, }) response = await client.receive_json() assert response["success"], response return response["result"] async def _get_config(client): await client.send_json_auto_id({"type": "houseplan/config/get"}) response = await client.receive_json() assert response["success"], response return response["result"] async def _toggle(client, marker_id="lamp"): await client.send_json_auto_id({ "type": "houseplan/virtual_light/toggle", "marker_id": marker_id, }) return await client.receive_json() async def test_default_toggle_event_and_restart_persistence( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: entry = await _setup(hass) client = await hass_ws_client(hass) await _set_config(client, _config(_manual()), 0) first = await _get_config(client) assert first["virtual_lights"] == {"rev": 0, "config_rev": 1, "off": []} events = [] remove = hass.bus.async_listen( EVENT_VIRTUAL_LIGHT_UPDATED, lambda event: events.append(event.data), ) response = await _toggle(client) assert response["success"] assert response["result"] == {"marker_id": "lamp", "on": False, "rev": 1} await hass.async_block_till_done() assert events == [response["result"]], "event follows and matches the durable reply" remove() stored = await _get_config(client) assert stored["virtual_lights"] == {"rev": 1, "config_rev": 1, "off": ["lamp"]} assert await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() restarted = await hass_ws_client(hass) stored = await _get_config(restarted) assert stored["virtual_lights"] == {"rev": 1, "config_rev": 1, "off": ["lamp"]} async def test_read_only_authenticated_user_can_toggle( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_read_only_access_token: str, ) -> None: await _setup(hass) admin = await hass_ws_client(hass) await _set_config(admin, _config(_manual()), 0) viewer = await hass_ws_client(hass, access_token=hass_read_only_access_token) response = await _toggle(viewer) assert response["success"] assert response["result"]["on"] is False async def test_unload_flushes_a_toggle_still_inside_the_debounce_window( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, ) -> None: from custom_components.houseplan import virtual_lights as virtual_lights_module monkeypatch.setattr(virtual_lights_module, "SAVE_DELAY_S", 3600) entry = await _setup(hass) client = await hass_ws_client(hass) await _set_config(client, _config(_manual()), 0) assert (await _toggle(client))["result"]["on"] is False assert (await entry.runtime_data.virtual_light_store.async_load())["off"] == [] assert await hass.config_entries.async_reload(entry.entry_id) restarted = await hass_ws_client(hass) assert (await _get_config(restarted))["virtual_lights"]["off"] == ["lamp"] async def test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: await _setup(hass) client = await hass_ws_client(hass) await _set_config(client, _config(_manual()), 0) assert (await _toggle(client))["result"]["on"] is False await _set_config(client, _config(_manual(name="Renamed", hidden=True)), 1) hidden = await _get_config(client) assert hidden["virtual_lights"]["off"] == ["lamp"] assert hidden["virtual_lights"]["config_rev"] == 2 await _set_config(client, _config(_manual(is_light=False)), 2) disabled = await _get_config(client) assert disabled["virtual_lights"]["off"] == [] await _set_config(client, _config(_manual()), 3) enabled = await _get_config(client) assert enabled["virtual_lights"]["off"] == [], "re-entry starts on" async def test_invalid_target_and_concurrent_toggles_are_server_atomic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: await _setup(hass) client = await hass_ws_client(hass) await _set_config(client, _config(_manual()), 0) invalid = await _toggle(client, "missing") assert not invalid["success"] and invalid["error"]["code"] == "not_toggleable" await client.send_json_auto_id({ "type": "houseplan/virtual_light/toggle", "marker_id": "lamp", }) await client.send_json_auto_id({ "type": "houseplan/virtual_light/toggle", "marker_id": "lamp", }) answers = [await client.receive_json(), await client.receive_json()] assert all(answer["success"] for answer in answers) results = sorted((answer["result"] for answer in answers), key=lambda result: result["rev"]) assert [(result["rev"], result["on"]) for result in results] == [(1, False), (2, True)] assert (await _get_config(client))["virtual_lights"]["off"] == [] async def test_failed_delayed_save_keeps_immediate_result_and_is_flushable( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, ) -> None: entry = await _setup(hass) client = await hass_ws_client(hass) await _set_config(client, _config(_manual()), 0) events = [] remove = hass.bus.async_listen( EVENT_VIRTUAL_LIGHT_UPDATED, lambda event: events.append(event.data), ) async def fail_save(_data): raise OSError("disk full") from custom_components.houseplan import virtual_lights as virtual_lights_module original_save = entry.runtime_data.virtual_light_store.async_save monkeypatch.setattr(virtual_lights_module, "SAVE_DELAY_S", 0) monkeypatch.setattr(entry.runtime_data.virtual_light_store, "async_save", fail_save) response = await _toggle(client) await hass.async_block_till_done() assert response["success"] assert events == [response["result"]] assert entry.runtime_data.virtual_lights._dirty is True monkeypatch.setattr(entry.runtime_data.virtual_light_store, "async_save", original_save) await entry.runtime_data.virtual_lights.async_flush() remove() assert entry.runtime_data.virtual_lights._dirty is False assert await entry.runtime_data.virtual_light_store.async_load() == { "rev": 1, "config_rev": 1, "off": ["lamp"], }