test_device.py 30 KB

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