test_device.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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
  9. from switchbot.adv_parser import _MODEL_TO_MAC_CACHE, populate_model_to_mac_cache
  10. from switchbot.const import (
  11. SwitchbotAccountConnectionError,
  12. SwitchbotAuthenticationError,
  13. SwitchbotModel,
  14. )
  15. from switchbot.devices.device import (
  16. SwitchbotBaseDevice,
  17. SwitchbotDevice,
  18. _extract_region,
  19. )
  20. from .test_adv_parser import generate_ble_device
  21. @pytest.fixture
  22. def mock_auth_response() -> dict[str, Any]:
  23. """Mock authentication response."""
  24. return {
  25. "access_token": "test_token_123",
  26. "refresh_token": "refresh_token_123",
  27. "expires_in": 3600,
  28. }
  29. @pytest.fixture
  30. def mock_user_info() -> dict[str, Any]:
  31. """Mock user info response."""
  32. return {
  33. "botRegion": "us",
  34. "country": "us",
  35. "email": "test@example.com",
  36. }
  37. @pytest.fixture
  38. def mock_device_response() -> dict[str, Any]:
  39. """Mock device list response."""
  40. return {
  41. "Items": [
  42. {
  43. "device_mac": "aabbccddeeff",
  44. "device_name": "Test Bot",
  45. "device_detail": {
  46. "device_type": "WoHand",
  47. "version": "1.0.0",
  48. },
  49. },
  50. {
  51. "device_mac": "112233445566",
  52. "device_name": "Test Curtain",
  53. "device_detail": {
  54. "device_type": "WoCurtain",
  55. "version": "2.0.0",
  56. },
  57. },
  58. {
  59. "device_mac": "778899aabbcc",
  60. "device_name": "Test Lock",
  61. "device_detail": {
  62. "device_type": "WoLock",
  63. "version": "1.5.0",
  64. },
  65. },
  66. {
  67. "device_mac": "ddeeff001122",
  68. "device_name": "Unknown Device",
  69. "device_detail": {
  70. "device_type": "WoUnknown",
  71. "version": "1.0.0",
  72. "extra_field": "extra_value",
  73. },
  74. },
  75. {
  76. "device_mac": "invalid_device",
  77. # Missing device_detail
  78. },
  79. {
  80. "device_mac": "another_invalid",
  81. "device_detail": {
  82. # Missing device_type
  83. "version": "1.0.0",
  84. },
  85. },
  86. ]
  87. }
  88. @pytest.mark.asyncio
  89. async def test_get_devices(
  90. mock_auth_response: dict[str, Any],
  91. mock_user_info: dict[str, Any],
  92. mock_device_response: dict[str, Any],
  93. caplog: pytest.LogCaptureFixture,
  94. ) -> None:
  95. """Test get_devices method."""
  96. caplog.set_level(logging.DEBUG)
  97. with (
  98. patch.object(
  99. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  100. ),
  101. patch.object(
  102. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  103. ),
  104. patch.object(
  105. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  106. ) as mock_api_request,
  107. patch(
  108. "switchbot.devices.device.populate_model_to_mac_cache"
  109. ) as mock_populate_cache,
  110. ):
  111. session = MagicMock(spec=aiohttp.ClientSession)
  112. result = await SwitchbotBaseDevice.get_devices(
  113. session, "test@example.com", "password123"
  114. )
  115. # Check that api_request was called with correct parameters
  116. mock_api_request.assert_called_once_with(
  117. session,
  118. "wonderlabs.us",
  119. "wonder/device/v3/getdevice",
  120. {"required_type": "All"},
  121. {"authorization": "test_token_123"},
  122. )
  123. # Check returned dictionary
  124. assert len(result) == 3 # Only valid devices with known models
  125. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  126. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  127. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  128. # Check that cache was populated
  129. assert mock_populate_cache.call_count == 3
  130. mock_populate_cache.assert_any_call("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  131. mock_populate_cache.assert_any_call("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  132. mock_populate_cache.assert_any_call("77:88:99:AA:BB:CC", SwitchbotModel.LOCK)
  133. # Check that unknown model was logged
  134. assert "Unknown model WoUnknown for device DD:EE:FF:00:11:22" in caplog.text
  135. assert "extra_field" in caplog.text # Full item should be logged
  136. @pytest.mark.asyncio
  137. async def test_get_devices_with_region(
  138. mock_auth_response: dict[str, Any],
  139. mock_device_response: dict[str, Any],
  140. caplog: pytest.LogCaptureFixture,
  141. ) -> None:
  142. """Test get_devices with different region."""
  143. mock_user_info_eu = {
  144. "botRegion": "eu",
  145. "country": "de",
  146. "email": "test@example.com",
  147. }
  148. with (
  149. patch.object(
  150. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  151. ),
  152. patch.object(
  153. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info_eu
  154. ),
  155. patch.object(
  156. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  157. ) as mock_api_request,
  158. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  159. ):
  160. session = MagicMock(spec=aiohttp.ClientSession)
  161. await SwitchbotBaseDevice.get_devices(
  162. session, "test@example.com", "password123"
  163. )
  164. # Check that EU region was used
  165. mock_api_request.assert_called_once_with(
  166. session,
  167. "wonderlabs.eu",
  168. "wonder/device/v3/getdevice",
  169. {"required_type": "All"},
  170. {"authorization": "test_token_123"},
  171. )
  172. @pytest.mark.asyncio
  173. async def test_get_devices_no_region(
  174. mock_auth_response: dict[str, Any],
  175. mock_device_response: dict[str, Any],
  176. ) -> None:
  177. """Test get_devices with no region specified (defaults to us)."""
  178. mock_user_info_no_region = {
  179. "email": "test@example.com",
  180. }
  181. with (
  182. patch.object(
  183. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  184. ),
  185. patch.object(
  186. SwitchbotBaseDevice,
  187. "_async_get_user_info",
  188. return_value=mock_user_info_no_region,
  189. ),
  190. patch.object(
  191. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  192. ) as mock_api_request,
  193. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  194. ):
  195. session = MagicMock(spec=aiohttp.ClientSession)
  196. await SwitchbotBaseDevice.get_devices(
  197. session, "test@example.com", "password123"
  198. )
  199. # Check that default US region was used
  200. mock_api_request.assert_called_once_with(
  201. session,
  202. "wonderlabs.us",
  203. "wonder/device/v3/getdevice",
  204. {"required_type": "All"},
  205. {"authorization": "test_token_123"},
  206. )
  207. @pytest.mark.asyncio
  208. async def test_get_devices_empty_region(
  209. mock_auth_response: dict[str, Any],
  210. mock_device_response: dict[str, Any],
  211. ) -> None:
  212. """Test get_devices with empty region string (defaults to us)."""
  213. mock_user_info_empty_region = {
  214. "botRegion": "",
  215. "email": "test@example.com",
  216. }
  217. with (
  218. patch.object(
  219. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  220. ),
  221. patch.object(
  222. SwitchbotBaseDevice,
  223. "_async_get_user_info",
  224. return_value=mock_user_info_empty_region,
  225. ),
  226. patch.object(
  227. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  228. ) as mock_api_request,
  229. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  230. ):
  231. session = MagicMock(spec=aiohttp.ClientSession)
  232. await SwitchbotBaseDevice.get_devices(
  233. session, "test@example.com", "password123"
  234. )
  235. # Check that default US region was used
  236. mock_api_request.assert_called_once_with(
  237. session,
  238. "wonderlabs.us",
  239. "wonder/device/v3/getdevice",
  240. {"required_type": "All"},
  241. {"authorization": "test_token_123"},
  242. )
  243. @pytest.mark.asyncio
  244. async def test_fetch_cloud_devices(
  245. mock_auth_response: dict[str, Any],
  246. mock_user_info: dict[str, Any],
  247. mock_device_response: dict[str, Any],
  248. ) -> None:
  249. """Test fetch_cloud_devices wrapper function."""
  250. with (
  251. patch.object(
  252. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  253. ),
  254. patch.object(
  255. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  256. ),
  257. patch.object(
  258. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  259. ),
  260. patch(
  261. "switchbot.devices.device.populate_model_to_mac_cache"
  262. ) as mock_populate_cache,
  263. ):
  264. session = MagicMock(spec=aiohttp.ClientSession)
  265. result = await fetch_cloud_devices(session, "test@example.com", "password123")
  266. # Check returned dictionary
  267. assert len(result) == 3
  268. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  269. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  270. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  271. # Check that cache was populated
  272. assert mock_populate_cache.call_count == 3
  273. @pytest.mark.asyncio
  274. async def test_get_devices_authentication_error() -> None:
  275. """Test get_devices with authentication error."""
  276. with patch.object(
  277. SwitchbotBaseDevice,
  278. "_get_auth_result",
  279. side_effect=Exception("Auth failed"),
  280. ):
  281. session = MagicMock(spec=aiohttp.ClientSession)
  282. with pytest.raises(SwitchbotAuthenticationError) as exc_info:
  283. await SwitchbotBaseDevice.get_devices(
  284. session, "test@example.com", "wrong_password"
  285. )
  286. assert "Authentication failed" in str(exc_info.value)
  287. @pytest.mark.asyncio
  288. async def test_get_devices_connection_error(
  289. mock_auth_response: dict[str, Any],
  290. mock_user_info: dict[str, Any],
  291. ) -> None:
  292. """Test get_devices with connection error."""
  293. with (
  294. patch.object(
  295. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  296. ),
  297. patch.object(
  298. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  299. ),
  300. patch.object(
  301. SwitchbotBaseDevice,
  302. "api_request",
  303. side_effect=Exception("Network error"),
  304. ),
  305. ):
  306. session = MagicMock(spec=aiohttp.ClientSession)
  307. with pytest.raises(SwitchbotAccountConnectionError) as exc_info:
  308. await SwitchbotBaseDevice.get_devices(
  309. session, "test@example.com", "password123"
  310. )
  311. assert "Failed to retrieve devices" in str(exc_info.value)
  312. @pytest.mark.asyncio
  313. async def test_populate_model_to_mac_cache() -> None:
  314. """Test the populate_model_to_mac_cache helper function."""
  315. # Clear the cache first
  316. _MODEL_TO_MAC_CACHE.clear()
  317. # Populate cache with test data
  318. populate_model_to_mac_cache("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  319. populate_model_to_mac_cache("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  320. # Check cache contents
  321. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  322. assert _MODEL_TO_MAC_CACHE["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  323. assert len(_MODEL_TO_MAC_CACHE) == 2
  324. # Clear cache after test
  325. _MODEL_TO_MAC_CACHE.clear()
  326. def test_extract_region() -> None:
  327. """Test the _extract_region helper function."""
  328. # Test with botRegion present and not empty
  329. assert _extract_region({"botRegion": "eu", "country": "de"}) == "eu"
  330. assert _extract_region({"botRegion": "us", "country": "us"}) == "us"
  331. assert _extract_region({"botRegion": "jp", "country": "jp"}) == "jp"
  332. # Test with botRegion empty string
  333. assert _extract_region({"botRegion": "", "country": "de"}) == "us"
  334. # Test with botRegion missing
  335. assert _extract_region({"country": "de"}) == "us"
  336. # Test with empty dict
  337. assert _extract_region({}) == "us"
  338. @pytest.mark.asyncio
  339. @pytest.mark.parametrize(
  340. ("commands", "results", "final_result"),
  341. [
  342. # All fail -> False
  343. (("command1", "command2"), [(b"\x01", False), (None, False)], False),
  344. # First fails -> False (short-circuits, second not called)
  345. (("command1", "command2"), [(b"\x01", False)], False),
  346. # First succeeds, second fails -> False
  347. (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], False),
  348. # All succeed -> True
  349. (("command1", "command2"), [(b"\x01", True), (b"\x01", True)], True),
  350. ],
  351. )
  352. async def test_send_command_sequence(
  353. commands: tuple[str, ...],
  354. results: list[tuple[bytes | None, bool]],
  355. final_result: bool,
  356. ) -> None:
  357. """Test sending command sequence where all must succeed."""
  358. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  359. device = SwitchbotDevice(ble_device)
  360. device._send_command = AsyncMock(side_effect=[r[0] for r in results])
  361. device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
  362. result = await device._send_command_sequence(list(commands))
  363. assert result is final_result