test_device.py 34 KB

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