test_device.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077
  1. """Tests for device.py functionality."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import aiohttp
  7. import pytest
  8. from switchbot import fetch_cloud_devices, fetch_cloud_devices_by_token
  9. from switchbot.adv_parser import (
  10. _MODEL_TO_MAC_CACHE,
  11. parse_advertisement_data,
  12. populate_model_to_mac_cache,
  13. )
  14. from switchbot.const import (
  15. SwitchbotAccountConnectionError,
  16. SwitchbotApiError,
  17. SwitchbotAuthenticationError,
  18. SwitchbotModel,
  19. )
  20. from switchbot.devices.device import (
  21. API_MODEL_TO_ENUM,
  22. SwitchbotBaseDevice,
  23. SwitchbotDevice,
  24. SwitchbotEncryptedDevice,
  25. _extract_region,
  26. _masked_device_id,
  27. )
  28. from .test_adv_parser import generate_advertisement_data, generate_ble_device
  29. @pytest.mark.parametrize(
  30. ("api_model", "expected_model"),
  31. [
  32. ("WoCurtain", SwitchbotModel.CURTAIN),
  33. ("WoCurtain3", SwitchbotModel.CURTAIN_3),
  34. ("WoPlugUS", SwitchbotModel.PLUG_MINI_US),
  35. ("WoPlugJP", SwitchbotModel.PLUG_MINI_JP),
  36. ("WoMeter", SwitchbotModel.METER),
  37. ("WoMeterPlus", SwitchbotModel.METER_PLUS),
  38. ("W1079000", SwitchbotModel.METER_PRO),
  39. ("W1079001", SwitchbotModel.METER_PRO_CO2),
  40. ("WoIOSensor", SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER),
  41. ("WoCeiling", SwitchbotModel.CEILING_LIGHT),
  42. ("WoCeilingPro", SwitchbotModel.CEILING_LIGHT_PRO),
  43. ("WoLinkMini", SwitchbotModel.HUB_MINI),
  44. ("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER),
  45. ],
  46. )
  47. def test_api_model_mapping(api_model: str, expected_model: SwitchbotModel) -> None:
  48. """Test API model names map to the exact physical product."""
  49. assert API_MODEL_TO_ENUM[api_model] is expected_model
  50. @pytest.mark.asyncio
  51. async def test_cloud_maps_uwb_lock(mock_user_info: dict[str, Any]) -> None:
  52. """Recognize the UWB Lock product ID in the account device list."""
  53. response = {
  54. "Items": [
  55. {"device_mac": "aabbccddeeff", "device_detail": {"device_type": "W1184000"}}
  56. ]
  57. }
  58. with (
  59. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  60. patch.object(
  61. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  62. ),
  63. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  64. ):
  65. devices = await fetch_cloud_devices_by_token(
  66. MagicMock(spec=aiohttp.ClientSession), "test-token"
  67. )
  68. assert devices == {"AA:BB:CC:DD:EE:FF": SwitchbotModel.UWB_LOCK}
  69. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] is SwitchbotModel.UWB_LOCK
  70. def test_legacy_plug_is_not_mapped_to_plug_mini() -> None:
  71. """Test the legacy Plug is not identified as a Plug Mini."""
  72. assert "WoPlug" not in API_MODEL_TO_ENUM
  73. assert SwitchbotModel.PLUG_MINI not in API_MODEL_TO_ENUM.values()
  74. def test_deprecated_model_aliases() -> None:
  75. """Test old model names remain as compatibility aliases."""
  76. assert SwitchbotModel.IO_METER is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
  77. assert SwitchbotModel.METER_PRO_C is SwitchbotModel.METER_PRO_CO2
  78. assert SwitchbotModel.IO_METER.name == "INDOOR_OUTDOOR_THERMO_HYGROMETER"
  79. assert SwitchbotModel.METER_PRO_C.name == "METER_PRO_CO2"
  80. assert SwitchbotModel["IO_METER"] is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
  81. assert SwitchbotModel["METER_PRO_C"] is SwitchbotModel.METER_PRO_CO2
  82. @pytest.mark.asyncio
  83. @pytest.mark.parametrize(
  84. ("api_model", "expected_model", "can_parse"),
  85. [
  86. pytest.param("WoLinkMini", SwitchbotModel.HUB_MINI, False, id="ordinary"),
  87. pytest.param("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER, True, id="matter"),
  88. ],
  89. )
  90. async def test_hub_mini_cloud_cache_parsing(
  91. mock_user_info: dict[str, Any],
  92. api_model: str,
  93. expected_model: SwitchbotModel,
  94. can_parse: bool,
  95. ) -> None:
  96. """Cloud hints distinguish ordinary Hub Mini from Matter during parsing."""
  97. mac_address = "E6:A1:CD:1F:5B:65"
  98. device_response = {
  99. "Items": [
  100. {
  101. "device_mac": "e6a1cd1f5b65",
  102. "device_detail": {"device_type": api_model},
  103. }
  104. ]
  105. }
  106. ble_device = generate_ble_device(mac_address)
  107. adv_data = generate_advertisement_data(
  108. manufacturer_data={
  109. 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
  110. },
  111. )
  112. with (
  113. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  114. patch.object(
  115. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  116. ),
  117. patch.object(SwitchbotBaseDevice, "api_request", return_value=device_response),
  118. ):
  119. session = MagicMock(spec=aiohttp.ClientSession)
  120. devices = await fetch_cloud_devices_by_token(session, "test-token")
  121. assert devices == {mac_address: expected_model}
  122. assert _MODEL_TO_MAC_CACHE[mac_address] is expected_model
  123. assert (parse_advertisement_data(ble_device, adv_data) is not None) is can_parse
  124. def test_hubmini_matter_model_name() -> None:
  125. """Preserve the existing Hub Mini Matter enum name."""
  126. assert SwitchbotModel.HUBMINI_MATTER.name == "HUBMINI_MATTER"
  127. @pytest.fixture
  128. def mock_auth_response() -> dict[str, Any]:
  129. """Mock authentication response."""
  130. return {
  131. "access_token": "test_token_123",
  132. "refresh_token": "refresh_token_123",
  133. "expires_in": 3600,
  134. }
  135. @pytest.fixture
  136. def mock_user_info() -> dict[str, Any]:
  137. """Mock user info response."""
  138. return {
  139. "botRegion": "us",
  140. "country": "us",
  141. "email": "test@example.com",
  142. }
  143. @pytest.fixture
  144. def mock_device_response() -> dict[str, Any]:
  145. """Mock device list response."""
  146. return {
  147. "Items": [
  148. {
  149. "device_mac": "aabbccddeeff",
  150. "device_name": "Test Bot",
  151. "device_detail": {
  152. "device_type": "WoHand",
  153. "version": "1.0.0",
  154. },
  155. },
  156. {
  157. "device_mac": "112233445566",
  158. "device_name": "Test Curtain",
  159. "device_detail": {
  160. "device_type": "WoCurtain",
  161. "version": "2.0.0",
  162. },
  163. },
  164. {
  165. "device_mac": "778899aabbcc",
  166. "device_name": "Test Lock",
  167. "device_detail": {
  168. "device_type": "WoLock",
  169. "version": "1.5.0",
  170. },
  171. },
  172. {
  173. "device_mac": "ddeeff001122",
  174. "device_name": "Unknown Device",
  175. "device_detail": {
  176. "device_type": "WoUnknown",
  177. "version": "1.0.0",
  178. "extra_field": "extra_value",
  179. },
  180. },
  181. {
  182. "device_mac": "invalid_device",
  183. # Missing device_detail
  184. },
  185. {
  186. "device_mac": "another_invalid",
  187. "device_detail": {
  188. # Missing device_type
  189. "version": "1.0.0",
  190. },
  191. },
  192. ]
  193. }
  194. @pytest.mark.asyncio
  195. async def test_get_devices(
  196. mock_auth_response: dict[str, Any],
  197. mock_user_info: dict[str, Any],
  198. mock_device_response: dict[str, Any],
  199. caplog: pytest.LogCaptureFixture,
  200. ) -> None:
  201. """Test get_devices method."""
  202. caplog.set_level(logging.DEBUG)
  203. with (
  204. patch.object(
  205. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  206. ),
  207. patch.object(
  208. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  209. ),
  210. patch.object(
  211. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  212. ) as mock_api_request,
  213. patch(
  214. "switchbot.devices.device.populate_model_to_mac_cache"
  215. ) as mock_populate_cache,
  216. ):
  217. session = MagicMock(spec=aiohttp.ClientSession)
  218. result = await SwitchbotBaseDevice.get_devices(
  219. session, "test@example.com", "password123"
  220. )
  221. # Check that api_request was called with correct parameters
  222. mock_api_request.assert_called_once_with(
  223. session,
  224. "wonderlabs.us",
  225. "wonder/device/v3/getdevice",
  226. {"required_type": "All"},
  227. {"authorization": "test_token_123"},
  228. )
  229. # Check returned dictionary
  230. assert len(result) == 3 # Only valid devices with known models
  231. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  232. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  233. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  234. # Check that cache was populated
  235. assert mock_populate_cache.call_count == 3
  236. mock_populate_cache.assert_any_call("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  237. mock_populate_cache.assert_any_call("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  238. mock_populate_cache.assert_any_call("77:88:99:AA:BB:CC", SwitchbotModel.LOCK)
  239. # Check that unknown model was logged
  240. assert "Unknown model WoUnknown for device DD:EE:FF:00:11:22" in caplog.text
  241. assert "extra_field" in caplog.text
  242. assert "extra_value" in caplog.text
  243. @pytest.mark.asyncio
  244. async def test_get_devices_with_region(
  245. mock_auth_response: dict[str, Any],
  246. mock_device_response: dict[str, Any],
  247. caplog: pytest.LogCaptureFixture,
  248. ) -> None:
  249. """Test get_devices with different region."""
  250. mock_user_info_eu = {
  251. "botRegion": "eu",
  252. "country": "de",
  253. "email": "test@example.com",
  254. }
  255. with (
  256. patch.object(
  257. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  258. ),
  259. patch.object(
  260. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info_eu
  261. ),
  262. patch.object(
  263. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  264. ) as mock_api_request,
  265. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  266. ):
  267. session = MagicMock(spec=aiohttp.ClientSession)
  268. await SwitchbotBaseDevice.get_devices(
  269. session, "test@example.com", "password123"
  270. )
  271. # Check that EU region was used
  272. mock_api_request.assert_called_once_with(
  273. session,
  274. "wonderlabs.eu",
  275. "wonder/device/v3/getdevice",
  276. {"required_type": "All"},
  277. {"authorization": "test_token_123"},
  278. )
  279. @pytest.mark.asyncio
  280. async def test_get_devices_no_region(
  281. mock_auth_response: dict[str, Any],
  282. mock_device_response: dict[str, Any],
  283. ) -> None:
  284. """Test get_devices with no region specified (defaults to us)."""
  285. mock_user_info_no_region = {
  286. "email": "test@example.com",
  287. }
  288. with (
  289. patch.object(
  290. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  291. ),
  292. patch.object(
  293. SwitchbotBaseDevice,
  294. "_async_get_user_info",
  295. return_value=mock_user_info_no_region,
  296. ),
  297. patch.object(
  298. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  299. ) as mock_api_request,
  300. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  301. ):
  302. session = MagicMock(spec=aiohttp.ClientSession)
  303. await SwitchbotBaseDevice.get_devices(
  304. session, "test@example.com", "password123"
  305. )
  306. # Check that default US region was used
  307. mock_api_request.assert_called_once_with(
  308. session,
  309. "wonderlabs.us",
  310. "wonder/device/v3/getdevice",
  311. {"required_type": "All"},
  312. {"authorization": "test_token_123"},
  313. )
  314. @pytest.mark.asyncio
  315. async def test_get_devices_empty_region(
  316. mock_auth_response: dict[str, Any],
  317. mock_device_response: dict[str, Any],
  318. ) -> None:
  319. """Test get_devices with empty region string (defaults to us)."""
  320. mock_user_info_empty_region = {
  321. "botRegion": "",
  322. "email": "test@example.com",
  323. }
  324. with (
  325. patch.object(
  326. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  327. ),
  328. patch.object(
  329. SwitchbotBaseDevice,
  330. "_async_get_user_info",
  331. return_value=mock_user_info_empty_region,
  332. ),
  333. patch.object(
  334. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  335. ) as mock_api_request,
  336. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  337. ):
  338. session = MagicMock(spec=aiohttp.ClientSession)
  339. await SwitchbotBaseDevice.get_devices(
  340. session, "test@example.com", "password123"
  341. )
  342. # Check that default US region was used
  343. mock_api_request.assert_called_once_with(
  344. session,
  345. "wonderlabs.us",
  346. "wonder/device/v3/getdevice",
  347. {"required_type": "All"},
  348. {"authorization": "test_token_123"},
  349. )
  350. @pytest.mark.asyncio
  351. async def test_fetch_cloud_devices(
  352. mock_auth_response: dict[str, Any],
  353. mock_user_info: dict[str, Any],
  354. mock_device_response: dict[str, Any],
  355. ) -> None:
  356. """Test fetch_cloud_devices wrapper function."""
  357. with (
  358. patch.object(
  359. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  360. ),
  361. patch.object(
  362. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  363. ),
  364. patch.object(
  365. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  366. ),
  367. patch(
  368. "switchbot.devices.device.populate_model_to_mac_cache"
  369. ) as mock_populate_cache,
  370. ):
  371. session = MagicMock(spec=aiohttp.ClientSession)
  372. result = await fetch_cloud_devices(session, "test@example.com", "password123")
  373. # Check returned dictionary
  374. assert len(result) == 3
  375. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  376. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  377. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  378. # Check that cache was populated
  379. assert mock_populate_cache.call_count == 3
  380. @pytest.mark.asyncio
  381. @pytest.mark.parametrize("region", ["us", "eu", "jp"])
  382. async def test_fetch_cloud_devices_by_token(
  383. mock_user_info: dict[str, Any],
  384. mock_device_response: dict[str, Any],
  385. region: str,
  386. caplog: pytest.LogCaptureFixture,
  387. ) -> None:
  388. """Test fetching cloud devices with an OAuth access token."""
  389. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  390. with (
  391. patch.object(SwitchbotBaseDevice, "_get_auth_result") as mock_get_auth_result,
  392. patch.object(
  393. SwitchbotBaseDevice,
  394. "_async_get_user_info",
  395. return_value={**mock_user_info, "botRegion": region},
  396. ) as mock_get_user_info,
  397. patch.object(
  398. SwitchbotBaseDevice,
  399. "api_request",
  400. return_value=mock_device_response,
  401. ) as mock_api_request,
  402. patch(
  403. "switchbot.devices.device.populate_model_to_mac_cache"
  404. ) as mock_populate_cache,
  405. ):
  406. session = MagicMock(spec=aiohttp.ClientSession)
  407. result = await fetch_cloud_devices_by_token(session, "oauth-access-token")
  408. mock_get_auth_result.assert_not_called()
  409. mock_get_user_info.assert_awaited_once_with(
  410. session, {"authorization": "oauth-access-token"}
  411. )
  412. mock_api_request.assert_awaited_once_with(
  413. session,
  414. f"wonderlabs.{region}",
  415. "wonder/device/v3/getdevice",
  416. {"required_type": "All"},
  417. {"authorization": "oauth-access-token"},
  418. )
  419. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  420. assert mock_populate_cache.call_count == 3
  421. assert "retrieval finished; supported_devices=3 duration_ms=" in caplog.text
  422. assert f"region resolved to {region}" in caplog.text
  423. assert "oauth-access-token" not in caplog.text
  424. @pytest.mark.asyncio
  425. async def test_fetch_cloud_devices_by_token_connection_error(
  426. mock_user_info: dict[str, Any],
  427. ) -> None:
  428. """Test an API error while fetching cloud devices with an OAuth token."""
  429. with (
  430. patch.object(
  431. SwitchbotBaseDevice,
  432. "_async_get_user_info",
  433. return_value=mock_user_info,
  434. ),
  435. patch.object(
  436. SwitchbotBaseDevice,
  437. "api_request",
  438. side_effect=Exception("Network error"),
  439. ),
  440. ):
  441. session = MagicMock(spec=aiohttp.ClientSession)
  442. with pytest.raises(
  443. SwitchbotAccountConnectionError, match="Failed to retrieve devices"
  444. ):
  445. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  446. @pytest.mark.asyncio
  447. async def test_fetch_cloud_devices_by_token_authentication_error() -> None:
  448. """Test an authentication error while fetching devices with an OAuth token."""
  449. with patch.object(
  450. SwitchbotBaseDevice,
  451. "_async_get_user_info",
  452. side_effect=SwitchbotAuthenticationError("invalid token"),
  453. ):
  454. session = MagicMock(spec=aiohttp.ClientSession)
  455. with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
  456. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  457. @pytest.mark.asyncio
  458. async def test_get_devices_preserves_authentication_error_after_user_info(
  459. mock_user_info: dict[str, Any],
  460. ) -> None:
  461. """Test device retrieval preserves authentication errors."""
  462. with (
  463. patch.object(
  464. SwitchbotBaseDevice,
  465. "_async_get_user_info",
  466. return_value=mock_user_info,
  467. ),
  468. patch.object(
  469. SwitchbotBaseDevice,
  470. "api_request",
  471. side_effect=SwitchbotAuthenticationError("expired token"),
  472. ),
  473. ):
  474. session = MagicMock(spec=aiohttp.ClientSession)
  475. with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
  476. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  477. @pytest.mark.asyncio
  478. async def test_get_devices_preserves_api_error_after_user_info(
  479. mock_user_info: dict[str, Any],
  480. ) -> None:
  481. """Test device retrieval preserves API errors."""
  482. with (
  483. patch.object(
  484. SwitchbotBaseDevice,
  485. "_async_get_user_info",
  486. return_value=mock_user_info,
  487. ),
  488. patch.object(
  489. SwitchbotBaseDevice,
  490. "api_request",
  491. side_effect=SwitchbotApiError("API error"),
  492. ),
  493. ):
  494. session = MagicMock(spec=aiohttp.ClientSession)
  495. with pytest.raises(SwitchbotApiError, match="API error"):
  496. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  497. @pytest.mark.asyncio
  498. @pytest.mark.parametrize(
  499. "device_info",
  500. [
  501. pytest.param({}, id="missing-items"),
  502. pytest.param({"Items": None}, id="invalid-items"),
  503. pytest.param({"Items": {}}, id="items-not-list"),
  504. pytest.param({"Items": ["invalid"]}, id="invalid-item"),
  505. ],
  506. )
  507. async def test_get_devices_rejects_invalid_response(
  508. mock_user_info: dict[str, Any], device_info: dict[str, Any]
  509. ) -> None:
  510. """Test malformed device responses retain their API error classification."""
  511. with (
  512. patch.object(
  513. SwitchbotBaseDevice,
  514. "_async_get_user_info",
  515. return_value=mock_user_info,
  516. ),
  517. patch.object(
  518. SwitchbotBaseDevice,
  519. "api_request",
  520. return_value=device_info,
  521. ),
  522. ):
  523. session = MagicMock(spec=aiohttp.ClientSession)
  524. with pytest.raises(SwitchbotApiError, match="Invalid device response"):
  525. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  526. @pytest.mark.asyncio
  527. async def test_get_user_info_preserves_authentication_error() -> None:
  528. """Test user info retrieval preserves authentication errors."""
  529. with patch.object(
  530. SwitchbotBaseDevice,
  531. "api_request",
  532. side_effect=SwitchbotAuthenticationError("invalid token"),
  533. ):
  534. session = MagicMock(spec=aiohttp.ClientSession)
  535. with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
  536. await SwitchbotBaseDevice._async_get_user_info(
  537. session,
  538. {"authorization": "invalid-token"},
  539. )
  540. @pytest.mark.asyncio
  541. async def test_get_user_info_preserves_api_error() -> None:
  542. """Test user info retrieval preserves API errors."""
  543. with patch.object(
  544. SwitchbotBaseDevice,
  545. "api_request",
  546. side_effect=SwitchbotApiError("API error"),
  547. ):
  548. session = MagicMock(spec=aiohttp.ClientSession)
  549. with pytest.raises(SwitchbotApiError, match="API error"):
  550. await SwitchbotBaseDevice._async_get_user_info(
  551. session,
  552. {"authorization": "invalid-token"},
  553. )
  554. @pytest.mark.asyncio
  555. async def test_api_request_debug_logs_response_shape_without_values(
  556. caplog: pytest.LogCaptureFixture,
  557. ) -> None:
  558. """Test API debug logs contain response fields but no sensitive values."""
  559. response = MagicMock()
  560. response.status = 200
  561. response.headers = {"x-amzn-requestid": "api-request-id"}
  562. response.json = AsyncMock(
  563. return_value={
  564. "statusCode": 100,
  565. "message": "success",
  566. "body": {
  567. "access_token": "sensitive-access-token",
  568. "deviceId": "sensitive-device-id",
  569. "encryptionKey": "sensitive-encryption-key",
  570. },
  571. }
  572. )
  573. session = MagicMock(spec=aiohttp.ClientSession)
  574. session.post.return_value.__aenter__ = AsyncMock(return_value=response)
  575. session.post.return_value.__aexit__ = AsyncMock(return_value=None)
  576. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  577. result = await SwitchbotBaseDevice.api_request(
  578. session, "account", "account/api/v1/user/userinfo"
  579. )
  580. assert result["deviceId"] == "sensitive-device-id"
  581. assert "response fields=['body', 'message', 'statusCode']" in caplog.text
  582. assert "body fields=['access_token', 'deviceId', 'encryptionKey']" in caplog.text
  583. assert "duration_ms=" in caplog.text
  584. assert "request_id=api-request-id" in caplog.text
  585. for sensitive_value in (
  586. "sensitive-access-token",
  587. "sensitive-device-id",
  588. "sensitive-encryption-key",
  589. ):
  590. assert sensitive_value not in caplog.text
  591. @pytest.mark.asyncio
  592. async def test_api_request_authentication_error() -> None:
  593. """Test HTTP authentication errors retain their specific error type."""
  594. response = MagicMock()
  595. response.status = 401
  596. session = MagicMock(spec=aiohttp.ClientSession)
  597. session.post.return_value.__aenter__.return_value = response
  598. with pytest.raises(SwitchbotAuthenticationError, match="Authentication rejected"):
  599. await SwitchbotBaseDevice.api_request(
  600. session,
  601. "account",
  602. "account/api/v1/user/userinfo",
  603. {},
  604. {"authorization": "invalid-token"},
  605. )
  606. @pytest.mark.asyncio
  607. async def test_retrieve_encryption_key_with_password() -> None:
  608. """Test the password flow delegates with its access token."""
  609. key_details = {
  610. "key_id": "ff",
  611. "encryption_key": "ffffffffffffffffffffffffffffffff",
  612. }
  613. with (
  614. patch.object(
  615. SwitchbotEncryptedDevice,
  616. "_get_auth_result",
  617. return_value={"access_token": "password-access-token"},
  618. ) as mock_get_auth_result,
  619. patch.object(
  620. SwitchbotEncryptedDevice,
  621. "_async_retrieve_encryption_key",
  622. return_value=key_details,
  623. ) as mock_retrieve_key,
  624. ):
  625. session = MagicMock(spec=aiohttp.ClientSession)
  626. result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key(
  627. session,
  628. "aa:bb:cc:dd:ee:ff",
  629. "test@example.com",
  630. "password",
  631. )
  632. mock_get_auth_result.assert_awaited_once_with(
  633. session, "test@example.com", "password"
  634. )
  635. mock_retrieve_key.assert_awaited_once_with(
  636. session,
  637. "aa:bb:cc:dd:ee:ff",
  638. {"authorization": "password-access-token"},
  639. )
  640. assert result == key_details
  641. @pytest.mark.asyncio
  642. async def test_retrieve_encryption_key_by_token_api_error(
  643. mock_user_info: dict[str, Any],
  644. ) -> None:
  645. """Test an API error while retrieving a key with an OAuth token."""
  646. with (
  647. patch.object(
  648. SwitchbotEncryptedDevice,
  649. "_async_get_user_info",
  650. return_value=mock_user_info,
  651. ),
  652. patch.object(
  653. SwitchbotEncryptedDevice,
  654. "api_request",
  655. side_effect=SwitchbotApiError("API error"),
  656. ),
  657. ):
  658. session = MagicMock(spec=aiohttp.ClientSession)
  659. with pytest.raises(SwitchbotApiError, match="API error"):
  660. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  661. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  662. )
  663. @pytest.mark.asyncio
  664. async def test_retrieve_encryption_key_by_token_authentication_error(
  665. mock_user_info: dict[str, Any],
  666. ) -> None:
  667. """Test key retrieval preserves authentication errors."""
  668. with (
  669. patch.object(
  670. SwitchbotEncryptedDevice,
  671. "_async_get_user_info",
  672. return_value=mock_user_info,
  673. ),
  674. patch.object(
  675. SwitchbotEncryptedDevice,
  676. "api_request",
  677. side_effect=SwitchbotAuthenticationError("expired token"),
  678. ),
  679. ):
  680. session = MagicMock(spec=aiohttp.ClientSession)
  681. with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
  682. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  683. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  684. )
  685. @pytest.mark.asyncio
  686. async def test_retrieve_encryption_key_by_token_connection_error(
  687. mock_user_info: dict[str, Any],
  688. ) -> None:
  689. """Test key retrieval maps unexpected request errors to connection errors."""
  690. with (
  691. patch.object(
  692. SwitchbotEncryptedDevice,
  693. "_async_get_user_info",
  694. return_value=mock_user_info,
  695. ),
  696. patch.object(
  697. SwitchbotEncryptedDevice,
  698. "api_request",
  699. side_effect=Exception("network error"),
  700. ),
  701. ):
  702. session = MagicMock(spec=aiohttp.ClientSession)
  703. with pytest.raises(
  704. SwitchbotAccountConnectionError,
  705. match="Failed to retrieve encryption key",
  706. ):
  707. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  708. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  709. )
  710. @pytest.mark.asyncio
  711. @pytest.mark.parametrize(
  712. "device_info",
  713. [
  714. pytest.param({}, id="missing-communication-key"),
  715. pytest.param({"communicationKey": None}, id="invalid-communication-key"),
  716. pytest.param(
  717. {"communicationKey": {"key": "encryption-key"}}, id="missing-key-id"
  718. ),
  719. pytest.param(
  720. {"communicationKey": {"keyId": "ff"}}, id="missing-encryption-key"
  721. ),
  722. pytest.param(
  723. {"communicationKey": {"keyId": 1, "key": "encryption-key"}},
  724. id="invalid-key-id",
  725. ),
  726. ],
  727. )
  728. async def test_retrieve_encryption_key_by_token_invalid_response(
  729. mock_user_info: dict[str, Any], device_info: dict[str, Any]
  730. ) -> None:
  731. """Test malformed key responses retain their API error classification."""
  732. with (
  733. patch.object(
  734. SwitchbotEncryptedDevice,
  735. "_async_get_user_info",
  736. return_value=mock_user_info,
  737. ),
  738. patch.object(
  739. SwitchbotEncryptedDevice,
  740. "api_request",
  741. return_value=device_info,
  742. ),
  743. ):
  744. session = MagicMock(spec=aiohttp.ClientSession)
  745. with pytest.raises(SwitchbotApiError, match="Invalid encryption key response"):
  746. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  747. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  748. )
  749. @pytest.mark.asyncio
  750. async def test_retrieve_encryption_key_by_token(
  751. mock_user_info: dict[str, Any],
  752. caplog: pytest.LogCaptureFixture,
  753. ) -> None:
  754. """Test retrieving an encryption key with an OAuth access token."""
  755. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  756. with (
  757. patch.object(
  758. SwitchbotEncryptedDevice,
  759. "_async_get_user_info",
  760. return_value=mock_user_info,
  761. ) as mock_get_user_info,
  762. patch.object(
  763. SwitchbotEncryptedDevice,
  764. "api_request",
  765. return_value={
  766. "communicationKey": {
  767. "keyId": "ff",
  768. "key": "ffffffffffffffffffffffffffffffff",
  769. }
  770. },
  771. ) as mock_api_request,
  772. ):
  773. session = MagicMock(spec=aiohttp.ClientSession)
  774. result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  775. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  776. )
  777. auth_headers = {"authorization": "oauth-access-token"}
  778. mock_get_user_info.assert_awaited_once_with(session, auth_headers)
  779. mock_api_request.assert_awaited_once_with(
  780. session,
  781. "wonderlabs.us",
  782. "wonder/keys/v1/communicate",
  783. {"device_mac": "AABBCCDDEEFF", "keyType": "user"},
  784. auth_headers,
  785. )
  786. assert result == {
  787. "key_id": "ff",
  788. "encryption_key": "ffffffffffffffffffffffffffffffff",
  789. }
  790. assert "device=****EEFF" in caplog.text
  791. assert "retrieval finished; device=****EEFF duration_ms=" in caplog.text
  792. for sensitive_value in (
  793. "aa:bb:cc:dd:ee:ff",
  794. "oauth-access-token",
  795. "ffffffffffffffffffffffffffffffff",
  796. ):
  797. assert sensitive_value not in caplog.text
  798. @pytest.mark.asyncio
  799. async def test_get_devices_authentication_error() -> None:
  800. """Test get_devices with authentication error."""
  801. with patch.object(
  802. SwitchbotBaseDevice,
  803. "_get_auth_result",
  804. side_effect=Exception("Auth failed"),
  805. ):
  806. session = MagicMock(spec=aiohttp.ClientSession)
  807. with pytest.raises(SwitchbotAuthenticationError) as exc_info:
  808. await SwitchbotBaseDevice.get_devices(
  809. session, "test@example.com", "wrong_password"
  810. )
  811. assert "Authentication failed" in str(exc_info.value)
  812. @pytest.mark.asyncio
  813. async def test_get_devices_connection_error(
  814. mock_auth_response: dict[str, Any],
  815. mock_user_info: dict[str, Any],
  816. ) -> None:
  817. """Test get_devices with connection error."""
  818. with (
  819. patch.object(
  820. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  821. ),
  822. patch.object(
  823. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  824. ),
  825. patch.object(
  826. SwitchbotBaseDevice,
  827. "api_request",
  828. side_effect=Exception("Network error"),
  829. ),
  830. ):
  831. session = MagicMock(spec=aiohttp.ClientSession)
  832. with pytest.raises(SwitchbotAccountConnectionError) as exc_info:
  833. await SwitchbotBaseDevice.get_devices(
  834. session, "test@example.com", "password123"
  835. )
  836. assert "Failed to retrieve devices" in str(exc_info.value)
  837. @pytest.mark.asyncio
  838. async def test_populate_model_to_mac_cache() -> None:
  839. """Test the populate_model_to_mac_cache helper function."""
  840. # Clear the cache first
  841. _MODEL_TO_MAC_CACHE.clear()
  842. # Populate cache with test data
  843. populate_model_to_mac_cache("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  844. populate_model_to_mac_cache("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  845. # Check cache contents
  846. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  847. assert _MODEL_TO_MAC_CACHE["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  848. assert len(_MODEL_TO_MAC_CACHE) == 2
  849. # Clear cache after test
  850. _MODEL_TO_MAC_CACHE.clear()
  851. def test_masked_device_id_empty() -> None:
  852. """Test an empty device identifier is represented safely."""
  853. assert _masked_device_id("") == "unknown"
  854. def test_extract_region(caplog: pytest.LogCaptureFixture) -> None:
  855. """Test the _extract_region helper function."""
  856. caplog.set_level(logging.WARNING, logger="switchbot.devices.device")
  857. # Test with botRegion present and not empty
  858. assert _extract_region({"botRegion": "eu", "country": "de"}) == "eu"
  859. assert _extract_region({"botRegion": "us", "country": "us"}) == "us"
  860. assert _extract_region({"botRegion": "jp", "country": "jp"}) == "jp"
  861. # Test with botRegion empty string
  862. assert _extract_region({"botRegion": "", "country": "de"}) == "us"
  863. # Test with botRegion missing
  864. assert _extract_region({"country": "de"}) == "us"
  865. # Test with empty dict
  866. assert _extract_region({}) == "us"
  867. assert "account region missing; defaulting to us" in caplog.text
  868. @pytest.mark.asyncio
  869. @pytest.mark.parametrize(
  870. ("commands", "results", "final_result"),
  871. [
  872. # All fail -> False
  873. (("command1", "command2"), [(b"\x01", False), (None, False)], False),
  874. # First fails -> False (short-circuits, second not called)
  875. (("command1", "command2"), [(b"\x01", False)], False),
  876. # First succeeds, second fails -> False
  877. (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], False),
  878. # All succeed -> True
  879. (("command1", "command2"), [(b"\x01", True), (b"\x01", True)], True),
  880. ],
  881. )
  882. async def test_send_command_sequence(
  883. commands: tuple[str, ...],
  884. results: list[tuple[bytes | None, bool]],
  885. final_result: bool,
  886. ) -> None:
  887. """Test sending command sequence where all must succeed."""
  888. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  889. device = SwitchbotDevice(ble_device)
  890. device._send_command = AsyncMock(side_effect=[r[0] for r in results])
  891. device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
  892. result = await device._send_command_sequence(list(commands))
  893. assert result is final_result
  894. def test_update_parsed_data_without_advertisement_does_not_log_exception(
  895. caplog: pytest.LogCaptureFixture,
  896. ) -> None:
  897. """
  898. Calling _update_parsed_data before any advertisement is a no-op, not an error.
  899. Regression for #285: previously emitted ``_LOGGER.exception(...)`` outside an
  900. ``except`` block, which logged "No advertisement data to update / NoneType: None"
  901. on every press()/turn_on()/update() until the first advertisement arrived.
  902. """
  903. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  904. device = SwitchbotDevice(ble_device)
  905. assert device._sb_adv_data is None
  906. with caplog.at_level(logging.DEBUG, logger="switchbot.devices.device"):
  907. result = device._update_parsed_data({"isOn": True})
  908. assert result is False
  909. exception_records = [
  910. record for record in caplog.records if record.levelno >= logging.WARNING
  911. ]
  912. assert exception_records == []
  913. assert all(record.exc_info is None for record in caplog.records)
  914. @pytest.mark.asyncio
  915. async def test_hub4_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
  916. """Map the backend product ID and populate the Bluetooth model cache."""
  917. response = {
  918. "Items": [
  919. {
  920. "device_mac": "e6a1cd1f5b65",
  921. "device_detail": {"device_type": "W1213000"},
  922. }
  923. ]
  924. }
  925. with (
  926. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  927. patch.object(
  928. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  929. ),
  930. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  931. ):
  932. devices = await fetch_cloud_devices_by_token(
  933. MagicMock(spec=aiohttp.ClientSession), "test-token"
  934. )
  935. assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.HUB4}
  936. assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.HUB4
  937. @pytest.mark.asyncio
  938. async def test_plug_mini_it_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
  939. """Map the backend product ID and populate the Bluetooth model cache."""
  940. response = {
  941. "Items": [
  942. {
  943. "device_mac": "e6a1cd1f5b65",
  944. "device_detail": {"device_type": "W1197000"},
  945. }
  946. ]
  947. }
  948. with (
  949. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  950. patch.object(
  951. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  952. ),
  953. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  954. ):
  955. devices = await fetch_cloud_devices_by_token(
  956. MagicMock(spec=aiohttp.ClientSession), "test-token"
  957. )
  958. assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.PLUG_MINI_IT}
  959. assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.PLUG_MINI_IT