test_config_flow.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. from unittest.mock import AsyncMock, patch
  2. import pytest
  3. import caldav.lib.error as caldav_error
  4. from homeassistant.config_entries import SOURCE_USER
  5. from pytest_homeassistant_custom_component.common import MockConfigEntry
  6. from homeassistant.core import HomeAssistant
  7. from custom_components.room_cal_ha.const import (
  8. CONF_API_BASE_URL,
  9. CONF_API_TOKEN,
  10. CONF_CALDAV_URL,
  11. CONF_CALENDAR_NAME,
  12. CONF_PASSWORD,
  13. CONF_REFRESH_INTERVAL,
  14. CONF_RESERVATION_DEFAULTS,
  15. CONF_ROOM_NAME,
  16. CONF_SYNC_MODE,
  17. CONF_USERNAME,
  18. CONF_ZLOCK_ENTRIES,
  19. CONF_ZWAVE_SLOT,
  20. DEFAULT_REFRESH_INTERVAL,
  21. DOMAIN,
  22. SYNC_MODE_API,
  23. SYNC_MODE_CALDAV,
  24. SYNC_MODE_MANUAL,
  25. )
  26. _EMPTY_DEFAULTS = {
  27. "guest_name": "",
  28. "check_in_time": "",
  29. "check_out_time": "",
  30. "wifi_ssid": "",
  31. "wifi_key": "",
  32. "door_code": "",
  33. }
  34. _EMPTY_LOCKS = {CONF_ZLOCK_ENTRIES: [], CONF_ZWAVE_SLOT: 1}
  35. async def _options_init(hass: HomeAssistant, mock_entry):
  36. """Start an options flow; returns the init-step result."""
  37. mock_entry.add_to_hass(hass)
  38. result = await hass.config_entries.options.async_init(mock_entry.entry_id)
  39. assert result["type"] == "menu"
  40. assert result["step_id"] == "configure"
  41. return result
  42. async def _select_section(hass: HomeAssistant, flow_id: str, step_id: str):
  43. """Choose a section from the init menu."""
  44. return await hass.config_entries.options.async_configure(
  45. flow_id, user_input={"next_step_id": step_id}
  46. )
  47. async def _submit_locks(hass: HomeAssistant, flow_id: str, user_input: dict | None = None):
  48. return await hass.config_entries.options.async_configure(
  49. flow_id, user_input=user_input if user_input is not None else _EMPTY_LOCKS
  50. )
  51. # ---------------------------------------------------------------------------
  52. # Config flow — user step
  53. # ---------------------------------------------------------------------------
  54. async def test_user_step_shows_form(hass: HomeAssistant):
  55. result = await hass.config_entries.flow.async_init(
  56. DOMAIN, context={"source": SOURCE_USER}
  57. )
  58. assert result["type"] == "form"
  59. assert result["step_id"] == "user"
  60. async def test_user_step_creates_entry_with_room_name(hass: HomeAssistant):
  61. result = await hass.config_entries.flow.async_init(
  62. DOMAIN, context={"source": SOURCE_USER}
  63. )
  64. result = await hass.config_entries.flow.async_configure(
  65. result["flow_id"],
  66. user_input={CONF_ROOM_NAME: "Beach House"},
  67. )
  68. assert result["type"] == "create_entry"
  69. assert result["title"] == "Beach House"
  70. assert result["data"][CONF_ROOM_NAME] == "Beach House"
  71. # ---------------------------------------------------------------------------
  72. # Options flow — init (menu) step
  73. # ---------------------------------------------------------------------------
  74. async def test_options_init_shows_menu(hass: HomeAssistant, mock_entry):
  75. result = await _options_init(hass, mock_entry)
  76. assert result["type"] == "menu"
  77. assert result["step_id"] == "configure"
  78. async def test_options_init_sync_leads_to_sync_mode(hass: HomeAssistant, mock_entry):
  79. result = await _options_init(hass, mock_entry)
  80. result = await _select_section(hass, result["flow_id"], "sync_mode")
  81. assert result["type"] == "form"
  82. assert result["step_id"] == "sync_mode"
  83. async def test_options_init_defaults_leads_to_defaults(hass: HomeAssistant, mock_entry):
  84. result = await _options_init(hass, mock_entry)
  85. result = await _select_section(hass, result["flow_id"], "defaults")
  86. assert result["type"] == "form"
  87. assert result["step_id"] == "defaults"
  88. async def test_options_init_locks_leads_to_locks(hass: HomeAssistant, mock_entry):
  89. result = await _options_init(hass, mock_entry)
  90. result = await _select_section(hass, result["flow_id"], "locks")
  91. assert result["type"] == "form"
  92. assert result["step_id"] == "locks"
  93. # ---------------------------------------------------------------------------
  94. # Options flow — sync section
  95. # ---------------------------------------------------------------------------
  96. async def test_options_sync_mode_manual_saves(hass: HomeAssistant, mock_entry):
  97. result = await _options_init(hass, mock_entry)
  98. result = await _select_section(hass, result["flow_id"], "sync_mode")
  99. assert result["step_id"] == "sync_mode"
  100. result = await hass.config_entries.options.async_configure(
  101. result["flow_id"],
  102. user_input={CONF_SYNC_MODE: SYNC_MODE_MANUAL},
  103. )
  104. assert result["type"] == "create_entry"
  105. assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_MANUAL
  106. async def test_options_sync_mode_caldav_proceeds_to_caldav_step(hass: HomeAssistant, mock_entry):
  107. result = await _options_init(hass, mock_entry)
  108. result = await _select_section(hass, result["flow_id"], "sync_mode")
  109. result = await hass.config_entries.options.async_configure(
  110. result["flow_id"],
  111. user_input={CONF_SYNC_MODE: SYNC_MODE_CALDAV},
  112. )
  113. assert result["type"] == "form"
  114. assert result["step_id"] == "caldav"
  115. async def test_options_sync_mode_api_proceeds_to_api_step(hass: HomeAssistant, mock_entry):
  116. result = await _options_init(hass, mock_entry)
  117. result = await _select_section(hass, result["flow_id"], "sync_mode")
  118. result = await hass.config_entries.options.async_configure(
  119. result["flow_id"],
  120. user_input={CONF_SYNC_MODE: SYNC_MODE_API},
  121. )
  122. assert result["type"] == "form"
  123. assert result["step_id"] == "api"
  124. # ---------------------------------------------------------------------------
  125. # Options flow — CalDAV step
  126. # ---------------------------------------------------------------------------
  127. async def _reach_caldav_step(hass: HomeAssistant, mock_entry):
  128. result = await _options_init(hass, mock_entry)
  129. result = await _select_section(hass, result["flow_id"], "sync_mode")
  130. result = await hass.config_entries.options.async_configure(
  131. result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_CALDAV}
  132. )
  133. assert result["step_id"] == "caldav"
  134. return result
  135. async def test_options_caldav_saves_on_success(hass: HomeAssistant, mock_entry):
  136. result = await _reach_caldav_step(hass, mock_entry)
  137. with patch("custom_components.room_cal_ha.config_flow._test_caldav_auth", return_value=None):
  138. result = await hass.config_entries.options.async_configure(
  139. result["flow_id"],
  140. user_input={
  141. CONF_CALDAV_URL: "https://cal.example.com",
  142. CONF_CALENDAR_NAME: "Room",
  143. CONF_USERNAME: "user",
  144. CONF_PASSWORD: "pass",
  145. CONF_REFRESH_INTERVAL: 30,
  146. },
  147. )
  148. assert result["type"] == "create_entry"
  149. assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV
  150. assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com"
  151. assert result["data"][CONF_REFRESH_INTERVAL] == 30
  152. async def test_options_caldav_bad_credentials_shows_error(hass: HomeAssistant, mock_entry):
  153. result = await _reach_caldav_step(hass, mock_entry)
  154. with patch(
  155. "custom_components.room_cal_ha.config_flow._test_caldav_auth",
  156. side_effect=caldav_error.AuthorizationError,
  157. ):
  158. result = await hass.config_entries.options.async_configure(
  159. result["flow_id"],
  160. user_input={
  161. CONF_CALDAV_URL: "https://cal.example.com",
  162. CONF_CALENDAR_NAME: "Room",
  163. CONF_USERNAME: "user",
  164. CONF_PASSWORD: "bad",
  165. CONF_REFRESH_INTERVAL: 15,
  166. },
  167. )
  168. assert result["type"] == "form"
  169. assert result["errors"]["base"] == "invalid_auth"
  170. async def test_options_caldav_connect_error_shows_error(hass: HomeAssistant, mock_entry):
  171. result = await _reach_caldav_step(hass, mock_entry)
  172. with patch(
  173. "custom_components.room_cal_ha.config_flow._test_caldav_auth",
  174. side_effect=Exception("timeout"),
  175. ):
  176. result = await hass.config_entries.options.async_configure(
  177. result["flow_id"],
  178. user_input={
  179. CONF_CALDAV_URL: "https://cal.example.com",
  180. CONF_CALENDAR_NAME: "Room",
  181. CONF_USERNAME: "user",
  182. CONF_PASSWORD: "pass",
  183. CONF_REFRESH_INTERVAL: 15,
  184. },
  185. )
  186. assert result["type"] == "form"
  187. assert result["errors"]["base"] == "cannot_connect"
  188. # ---------------------------------------------------------------------------
  189. # Options flow — API step
  190. # ---------------------------------------------------------------------------
  191. async def _reach_api_step(hass: HomeAssistant, mock_entry):
  192. result = await _options_init(hass, mock_entry)
  193. result = await _select_section(hass, result["flow_id"], "sync_mode")
  194. result = await hass.config_entries.options.async_configure(
  195. result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_API}
  196. )
  197. assert result["step_id"] == "api"
  198. return result
  199. async def test_options_api_saves_on_success(hass: HomeAssistant, mock_entry):
  200. result = await _reach_api_step(hass, mock_entry)
  201. with patch(
  202. "custom_components.room_cal_ha.config_flow._test_api_connection",
  203. new=AsyncMock(return_value=None),
  204. ):
  205. result = await hass.config_entries.options.async_configure(
  206. result["flow_id"],
  207. user_input={
  208. CONF_API_BASE_URL: "https://api.example.com",
  209. CONF_API_TOKEN: "tok-secret",
  210. CONF_REFRESH_INTERVAL: 10,
  211. },
  212. )
  213. assert result["type"] == "create_entry"
  214. assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_API
  215. assert result["data"][CONF_API_BASE_URL] == "https://api.example.com"
  216. assert result["data"][CONF_REFRESH_INTERVAL] == 10
  217. async def test_options_api_bad_token_shows_error(hass: HomeAssistant, mock_entry):
  218. from custom_components.room_cal_ha.config_flow import _ApiAuthError
  219. result = await _reach_api_step(hass, mock_entry)
  220. with patch(
  221. "custom_components.room_cal_ha.config_flow._test_api_connection",
  222. new=AsyncMock(side_effect=_ApiAuthError),
  223. ):
  224. result = await hass.config_entries.options.async_configure(
  225. result["flow_id"],
  226. user_input={
  227. CONF_API_BASE_URL: "https://api.example.com",
  228. CONF_API_TOKEN: "bad-token",
  229. CONF_REFRESH_INTERVAL: 15,
  230. },
  231. )
  232. assert result["type"] == "form"
  233. assert result["errors"]["base"] == "invalid_auth"
  234. async def test_options_api_connect_error_shows_error(hass: HomeAssistant, mock_entry):
  235. result = await _reach_api_step(hass, mock_entry)
  236. with patch(
  237. "custom_components.room_cal_ha.config_flow._test_api_connection",
  238. new=AsyncMock(side_effect=Exception("timeout")),
  239. ):
  240. result = await hass.config_entries.options.async_configure(
  241. result["flow_id"],
  242. user_input={
  243. CONF_API_BASE_URL: "https://api.example.com",
  244. CONF_API_TOKEN: "tok",
  245. CONF_REFRESH_INTERVAL: 15,
  246. },
  247. )
  248. assert result["type"] == "form"
  249. assert result["errors"]["base"] == "cannot_connect"
  250. # ---------------------------------------------------------------------------
  251. # Options flow — defaults step
  252. # ---------------------------------------------------------------------------
  253. async def _reach_defaults_step(hass: HomeAssistant, mock_entry):
  254. result = await _options_init(hass, mock_entry)
  255. result = await _select_section(hass, result["flow_id"], "defaults")
  256. assert result["step_id"] == "defaults"
  257. return result
  258. async def test_options_defaults_saves_values(hass: HomeAssistant, mock_entry):
  259. result = await _reach_defaults_step(hass, mock_entry)
  260. result = await hass.config_entries.options.async_configure(
  261. result["flow_id"],
  262. user_input={
  263. "guest_name": "Default Guest",
  264. "check_in_time": "15:00",
  265. "check_out_time": "11:00",
  266. "wifi_ssid": "HomeNet",
  267. "wifi_key": "secret",
  268. "door_code": "1234",
  269. },
  270. )
  271. assert result["type"] == "create_entry"
  272. defaults = result["data"][CONF_RESERVATION_DEFAULTS]
  273. assert defaults["guest_name"] == "Default Guest"
  274. assert defaults["check_in_time"] == "15:00"
  275. assert defaults["check_out_time"] == "11:00"
  276. assert defaults["wifi_ssid"] == "HomeNet"
  277. assert defaults["wifi_key"] == "secret"
  278. assert defaults["door_code"] == "1234"
  279. async def test_options_defaults_empty_strings_become_none(hass: HomeAssistant, mock_entry):
  280. result = await _reach_defaults_step(hass, mock_entry)
  281. result = await hass.config_entries.options.async_configure(
  282. result["flow_id"],
  283. user_input=_EMPTY_DEFAULTS,
  284. )
  285. assert result["type"] == "create_entry"
  286. defaults = result["data"][CONF_RESERVATION_DEFAULTS]
  287. assert defaults["guest_name"] is None
  288. assert defaults["check_in_time"] is None
  289. assert defaults["wifi_ssid"] is None
  290. async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mock_entry):
  291. result = await _reach_defaults_step(hass, mock_entry)
  292. result = await hass.config_entries.options.async_configure(
  293. result["flow_id"],
  294. user_input={**_EMPTY_DEFAULTS, "check_in_time": "not-a-time"},
  295. )
  296. assert result["type"] == "form"
  297. assert "check_in_time" in result["errors"]
  298. # ---------------------------------------------------------------------------
  299. # Options flow — locks step
  300. # ---------------------------------------------------------------------------
  301. async def _reach_locks_step(hass: HomeAssistant, mock_entry):
  302. result = await _options_init(hass, mock_entry)
  303. result = await _select_section(hass, result["flow_id"], "locks")
  304. assert result["step_id"] == "locks"
  305. return result
  306. async def test_options_locks_step_shows_form(hass: HomeAssistant, mock_entry):
  307. result = await _reach_locks_step(hass, mock_entry)
  308. assert result["type"] == "form"
  309. assert result["step_id"] == "locks"
  310. async def test_options_locks_saves_entities_and_slot(hass: HomeAssistant, mock_entry):
  311. z1 = MockConfigEntry(domain="zlock_slots", title="Front Lock", data={"lock_entity_ids": ["lock.front"]})
  312. z2 = MockConfigEntry(domain="zlock_slots", title="Back Lock", data={"lock_entity_ids": ["lock.back"]})
  313. z1.add_to_hass(hass)
  314. z2.add_to_hass(hass)
  315. result = await _reach_locks_step(hass, mock_entry)
  316. result = await hass.config_entries.options.async_configure(
  317. result["flow_id"],
  318. user_input={CONF_ZLOCK_ENTRIES: [z1.entry_id, z2.entry_id], CONF_ZWAVE_SLOT: 5},
  319. )
  320. assert result["type"] == "create_entry"
  321. assert result["data"][CONF_ZLOCK_ENTRIES] == [z1.entry_id, z2.entry_id]
  322. assert result["data"][CONF_ZWAVE_SLOT] == 5
  323. async def test_options_locks_empty_saves_empty_list(hass: HomeAssistant, mock_entry):
  324. result = await _reach_locks_step(hass, mock_entry)
  325. result = await _submit_locks(hass, result["flow_id"])
  326. assert result["type"] == "create_entry"
  327. assert result["data"][CONF_ZLOCK_ENTRIES] == []
  328. # ---------------------------------------------------------------------------
  329. # Cross-section preservation
  330. # ---------------------------------------------------------------------------
  331. async def test_options_editing_defaults_preserves_sync_mode(hass: HomeAssistant, mock_entry):
  332. """Editing defaults alone should not wipe a previously configured sync mode."""
  333. from homeassistant.config_entries import ConfigEntry
  334. existing_options = {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com"}
  335. mock_entry.add_to_hass(hass)
  336. hass.config_entries.async_update_entry(mock_entry, options=existing_options)
  337. result = await hass.config_entries.options.async_init(mock_entry.entry_id)
  338. result = await _select_section(hass, result["flow_id"], "defaults")
  339. result = await hass.config_entries.options.async_configure(
  340. result["flow_id"], user_input=_EMPTY_DEFAULTS
  341. )
  342. assert result["type"] == "create_entry"
  343. assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV
  344. assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com"