from unittest.mock import AsyncMock, patch import pytest import caldav.lib.error as caldav_error from homeassistant.config_entries import SOURCE_USER from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.core import HomeAssistant from custom_components.room_cal_ha.const import ( CONF_API_BASE_URL, CONF_API_TOKEN, CONF_CALDAV_URL, CONF_CALENDAR_NAME, CONF_PASSWORD, CONF_REFRESH_INTERVAL, CONF_RESERVATION_DEFAULTS, CONF_ROOM_NAME, CONF_SYNC_MODE, CONF_USERNAME, CONF_ZLOCK_ENTRIES, CONF_ZWAVE_SLOT, DEFAULT_REFRESH_INTERVAL, DOMAIN, SYNC_MODE_API, SYNC_MODE_CALDAV, SYNC_MODE_MANUAL, ) _EMPTY_DEFAULTS = { "guest_name": "", "check_in_time": "", "check_out_time": "", "wifi_ssid": "", "wifi_key": "", "door_code": "", } _EMPTY_LOCKS = {CONF_ZLOCK_ENTRIES: [], CONF_ZWAVE_SLOT: 1} async def _options_init(hass: HomeAssistant, mock_entry): """Start an options flow; returns the init-step result.""" mock_entry.add_to_hass(hass) result = await hass.config_entries.options.async_init(mock_entry.entry_id) assert result["type"] == "menu" assert result["step_id"] == "configure" return result async def _select_section(hass: HomeAssistant, flow_id: str, step_id: str): """Choose a section from the init menu.""" return await hass.config_entries.options.async_configure( flow_id, user_input={"next_step_id": step_id} ) async def _submit_locks(hass: HomeAssistant, flow_id: str, user_input: dict | None = None): return await hass.config_entries.options.async_configure( flow_id, user_input=user_input if user_input is not None else _EMPTY_LOCKS ) # --------------------------------------------------------------------------- # Config flow — user step # --------------------------------------------------------------------------- async def test_user_step_shows_form(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == "form" assert result["step_id"] == "user" async def test_user_step_creates_entry_with_room_name(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ROOM_NAME: "Beach House"}, ) assert result["type"] == "create_entry" assert result["title"] == "Beach House" assert result["data"][CONF_ROOM_NAME] == "Beach House" # --------------------------------------------------------------------------- # Options flow — init (menu) step # --------------------------------------------------------------------------- async def test_options_init_shows_menu(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) assert result["type"] == "menu" assert result["step_id"] == "configure" async def test_options_init_sync_leads_to_sync_mode(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") assert result["type"] == "form" assert result["step_id"] == "sync_mode" async def test_options_init_defaults_leads_to_defaults(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "defaults") assert result["type"] == "form" assert result["step_id"] == "defaults" async def test_options_init_locks_leads_to_locks(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "locks") assert result["type"] == "form" assert result["step_id"] == "locks" # --------------------------------------------------------------------------- # Options flow — sync section # --------------------------------------------------------------------------- async def test_options_sync_mode_manual_saves(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") assert result["step_id"] == "sync_mode" result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_MANUAL}, ) assert result["type"] == "create_entry" assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_MANUAL async def test_options_sync_mode_caldav_proceeds_to_caldav_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_CALDAV}, ) assert result["type"] == "form" assert result["step_id"] == "caldav" async def test_options_sync_mode_api_proceeds_to_api_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_API}, ) assert result["type"] == "form" assert result["step_id"] == "api" # --------------------------------------------------------------------------- # Options flow — CalDAV step # --------------------------------------------------------------------------- async def _reach_caldav_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_CALDAV} ) assert result["step_id"] == "caldav" return result async def test_options_caldav_saves_on_success(hass: HomeAssistant, mock_entry): result = await _reach_caldav_step(hass, mock_entry) with patch("custom_components.room_cal_ha.config_flow._test_caldav_auth", return_value=None): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room", CONF_USERNAME: "user", CONF_PASSWORD: "pass", CONF_REFRESH_INTERVAL: 30, }, ) assert result["type"] == "create_entry" assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com" assert result["data"][CONF_REFRESH_INTERVAL] == 30 async def test_options_caldav_bad_credentials_shows_error(hass: HomeAssistant, mock_entry): result = await _reach_caldav_step(hass, mock_entry) with patch( "custom_components.room_cal_ha.config_flow._test_caldav_auth", side_effect=caldav_error.AuthorizationError, ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room", CONF_USERNAME: "user", CONF_PASSWORD: "bad", CONF_REFRESH_INTERVAL: 15, }, ) assert result["type"] == "form" assert result["errors"]["base"] == "invalid_auth" async def test_options_caldav_connect_error_shows_error(hass: HomeAssistant, mock_entry): result = await _reach_caldav_step(hass, mock_entry) with patch( "custom_components.room_cal_ha.config_flow._test_caldav_auth", side_effect=Exception("timeout"), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room", CONF_USERNAME: "user", CONF_PASSWORD: "pass", CONF_REFRESH_INTERVAL: 15, }, ) assert result["type"] == "form" assert result["errors"]["base"] == "cannot_connect" # --------------------------------------------------------------------------- # Options flow — API step # --------------------------------------------------------------------------- async def _reach_api_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "sync_mode") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_API} ) assert result["step_id"] == "api" return result async def test_options_api_saves_on_success(hass: HomeAssistant, mock_entry): result = await _reach_api_step(hass, mock_entry) with patch( "custom_components.room_cal_ha.config_flow._test_api_connection", new=AsyncMock(return_value=None), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_BASE_URL: "https://api.example.com", CONF_API_TOKEN: "tok-secret", CONF_REFRESH_INTERVAL: 10, }, ) assert result["type"] == "create_entry" assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_API assert result["data"][CONF_API_BASE_URL] == "https://api.example.com" assert result["data"][CONF_REFRESH_INTERVAL] == 10 async def test_options_api_bad_token_shows_error(hass: HomeAssistant, mock_entry): from custom_components.room_cal_ha.config_flow import _ApiAuthError result = await _reach_api_step(hass, mock_entry) with patch( "custom_components.room_cal_ha.config_flow._test_api_connection", new=AsyncMock(side_effect=_ApiAuthError), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_BASE_URL: "https://api.example.com", CONF_API_TOKEN: "bad-token", CONF_REFRESH_INTERVAL: 15, }, ) assert result["type"] == "form" assert result["errors"]["base"] == "invalid_auth" async def test_options_api_connect_error_shows_error(hass: HomeAssistant, mock_entry): result = await _reach_api_step(hass, mock_entry) with patch( "custom_components.room_cal_ha.config_flow._test_api_connection", new=AsyncMock(side_effect=Exception("timeout")), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_BASE_URL: "https://api.example.com", CONF_API_TOKEN: "tok", CONF_REFRESH_INTERVAL: 15, }, ) assert result["type"] == "form" assert result["errors"]["base"] == "cannot_connect" # --------------------------------------------------------------------------- # Options flow — defaults step # --------------------------------------------------------------------------- async def _reach_defaults_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "defaults") assert result["step_id"] == "defaults" return result async def test_options_defaults_saves_values(hass: HomeAssistant, mock_entry): result = await _reach_defaults_step(hass, mock_entry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "guest_name": "Default Guest", "check_in_time": "15:00", "check_out_time": "11:00", "wifi_ssid": "HomeNet", "wifi_key": "secret", "door_code": "1234", }, ) assert result["type"] == "create_entry" defaults = result["data"][CONF_RESERVATION_DEFAULTS] assert defaults["guest_name"] == "Default Guest" assert defaults["check_in_time"] == "15:00" assert defaults["check_out_time"] == "11:00" assert defaults["wifi_ssid"] == "HomeNet" assert defaults["wifi_key"] == "secret" assert defaults["door_code"] == "1234" async def test_options_defaults_empty_strings_become_none(hass: HomeAssistant, mock_entry): result = await _reach_defaults_step(hass, mock_entry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=_EMPTY_DEFAULTS, ) assert result["type"] == "create_entry" defaults = result["data"][CONF_RESERVATION_DEFAULTS] assert defaults["guest_name"] is None assert defaults["check_in_time"] is None assert defaults["wifi_ssid"] is None assert defaults["door_code"] is None async def test_options_defaults_blank_wifi_key_preserved_as_empty_string( hass: HomeAssistant, mock_entry ): """A blank wifi_key should save as "" (open network), not collapse to None, while other blank string fields still collapse to None.""" result = await _reach_defaults_step(hass, mock_entry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=_EMPTY_DEFAULTS, ) assert result["type"] == "create_entry" defaults = result["data"][CONF_RESERVATION_DEFAULTS] assert defaults["wifi_key"] == "" assert defaults["guest_name"] is None assert defaults["wifi_ssid"] is None assert defaults["door_code"] is None async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mock_entry): result = await _reach_defaults_step(hass, mock_entry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={**_EMPTY_DEFAULTS, "check_in_time": "not-a-time"}, ) assert result["type"] == "form" assert "check_in_time" in result["errors"] # --------------------------------------------------------------------------- # Options flow — locks step # --------------------------------------------------------------------------- async def _reach_locks_step(hass: HomeAssistant, mock_entry): result = await _options_init(hass, mock_entry) result = await _select_section(hass, result["flow_id"], "locks") assert result["step_id"] == "locks" return result async def test_options_locks_step_shows_form(hass: HomeAssistant, mock_entry): result = await _reach_locks_step(hass, mock_entry) assert result["type"] == "form" assert result["step_id"] == "locks" async def test_options_locks_saves_entities_and_slot(hass: HomeAssistant, mock_entry): z1 = MockConfigEntry(domain="zlock_slots", title="Front Lock", data={"lock_entity_ids": ["lock.front"]}) z2 = MockConfigEntry(domain="zlock_slots", title="Back Lock", data={"lock_entity_ids": ["lock.back"]}) z1.add_to_hass(hass) z2.add_to_hass(hass) result = await _reach_locks_step(hass, mock_entry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_ZLOCK_ENTRIES: [z1.entry_id, z2.entry_id], CONF_ZWAVE_SLOT: 5}, ) assert result["type"] == "create_entry" assert result["data"][CONF_ZLOCK_ENTRIES] == [z1.entry_id, z2.entry_id] assert result["data"][CONF_ZWAVE_SLOT] == 5 async def test_options_locks_empty_saves_empty_list(hass: HomeAssistant, mock_entry): result = await _reach_locks_step(hass, mock_entry) result = await _submit_locks(hass, result["flow_id"]) assert result["type"] == "create_entry" assert result["data"][CONF_ZLOCK_ENTRIES] == [] # --------------------------------------------------------------------------- # Cross-section preservation # --------------------------------------------------------------------------- async def test_options_editing_defaults_preserves_sync_mode(hass: HomeAssistant, mock_entry): """Editing defaults alone should not wipe a previously configured sync mode.""" from homeassistant.config_entries import ConfigEntry existing_options = {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com"} mock_entry.add_to_hass(hass) hass.config_entries.async_update_entry(mock_entry, options=existing_options) result = await hass.config_entries.options.async_init(mock_entry.entry_id) result = await _select_section(hass, result["flow_id"], "defaults") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=_EMPTY_DEFAULTS ) assert result["type"] == "create_entry" assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com"