test_cloud.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. """Tests for the Tuya cloud interface."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from custom_components.tuya_local.cloud import (
  5. HUB_CATEGORIES,
  6. Cloud,
  7. DeviceListener,
  8. TokenListener,
  9. )
  10. from custom_components.tuya_local.const import (
  11. CONF_ENDPOINT,
  12. CONF_LOCAL_KEY,
  13. CONF_TERMINAL_ID,
  14. DOMAIN,
  15. TUYA_RESPONSE_CODE,
  16. TUYA_RESPONSE_MSG,
  17. TUYA_RESPONSE_QR_CODE,
  18. TUYA_RESPONSE_RESULT,
  19. TUYA_RESPONSE_SUCCESS,
  20. )
  21. @pytest.fixture
  22. def mock_hass():
  23. hass = MagicMock()
  24. hass.data = {DOMAIN: {}}
  25. async def run_in_executor(fn, *args):
  26. return fn(*args)
  27. hass.async_add_executor_job = AsyncMock(side_effect=run_in_executor)
  28. return hass
  29. @pytest.fixture
  30. def cloud(mock_hass):
  31. return Cloud(mock_hass)
  32. class TestCloudInit:
  33. def test_init_without_cache(self, mock_hass):
  34. c = Cloud(mock_hass)
  35. assert c.is_authenticated is False
  36. def test_init_with_cached_auth(self, mock_hass):
  37. mock_hass.data[DOMAIN]["auth_cache"] = {
  38. "user_code": "abc",
  39. "terminal_id": "tid",
  40. "endpoint": "ep",
  41. "token_info": {},
  42. }
  43. c = Cloud(mock_hass)
  44. assert c.is_authenticated is True
  45. class TestIsAuthenticated:
  46. def test_false_by_default(self, cloud):
  47. assert cloud.is_authenticated is False
  48. def test_true_after_login(self, cloud, mock_hass):
  49. mock_hass.data[DOMAIN]["auth_cache"] = {"user_code": "test"}
  50. c = Cloud(mock_hass)
  51. assert c.is_authenticated is True
  52. class TestLastError:
  53. def test_none_by_default(self, cloud):
  54. assert cloud.last_error is None
  55. @pytest.mark.asyncio
  56. async def test_set_after_failed_qr(self, cloud):
  57. with patch(
  58. "custom_components.tuya_local.cloud.LoginControl"
  59. ) as MockLoginControl:
  60. mock_lc = MockLoginControl.return_value
  61. mock_lc.qr_code.return_value = {
  62. TUYA_RESPONSE_SUCCESS: False,
  63. TUYA_RESPONSE_CODE: 1001,
  64. TUYA_RESPONSE_MSG: "Invalid code",
  65. }
  66. cloud._Cloud__login_control = mock_lc
  67. await cloud.async_get_qr_code("test_code")
  68. error = cloud.last_error
  69. assert error is not None
  70. assert error[TUYA_RESPONSE_CODE] == 1001
  71. assert error[TUYA_RESPONSE_MSG] == "Invalid code"
  72. class TestGetQrCode:
  73. @pytest.mark.asyncio
  74. async def test_without_user_code_returns_false(self, cloud):
  75. result = await cloud.async_get_qr_code()
  76. assert result is not None # Returns (False, {...}) tuple
  77. @pytest.mark.asyncio
  78. async def test_success(self, cloud):
  79. mock_lc = MagicMock()
  80. mock_lc.qr_code.return_value = {
  81. TUYA_RESPONSE_SUCCESS: True,
  82. TUYA_RESPONSE_RESULT: {
  83. TUYA_RESPONSE_QR_CODE: "https://qr.example.com/code123",
  84. },
  85. }
  86. cloud._Cloud__login_control = mock_lc
  87. result = await cloud.async_get_qr_code("my_user_code")
  88. assert result == "https://qr.example.com/code123"
  89. @pytest.mark.asyncio
  90. async def test_failure(self, cloud):
  91. mock_lc = MagicMock()
  92. mock_lc.qr_code.return_value = {
  93. TUYA_RESPONSE_SUCCESS: False,
  94. TUYA_RESPONSE_CODE: 500,
  95. TUYA_RESPONSE_MSG: "Server error",
  96. }
  97. cloud._Cloud__login_control = mock_lc
  98. result = await cloud.async_get_qr_code("my_user_code")
  99. assert result is False
  100. @pytest.mark.asyncio
  101. async def test_reuses_user_code(self, cloud):
  102. mock_lc = MagicMock()
  103. mock_lc.qr_code.return_value = {
  104. TUYA_RESPONSE_SUCCESS: True,
  105. TUYA_RESPONSE_RESULT: {
  106. TUYA_RESPONSE_QR_CODE: "qr1",
  107. },
  108. }
  109. cloud._Cloud__login_control = mock_lc
  110. await cloud.async_get_qr_code("my_code")
  111. # Second call without user_code should reuse
  112. await cloud.async_get_qr_code()
  113. assert mock_lc.qr_code.call_count == 2
  114. assert mock_lc.qr_code.call_args_list[1][0][2] == "my_code"
  115. class TestLogin:
  116. @pytest.mark.asyncio
  117. async def test_without_qr_returns_false(self, cloud):
  118. result = await cloud.async_login()
  119. # Returns (False, {}) when no user_code/qr_code
  120. assert result is not None
  121. @pytest.mark.asyncio
  122. async def test_success(self, cloud, mock_hass):
  123. # First get QR code
  124. mock_lc = MagicMock()
  125. mock_lc.qr_code.return_value = {
  126. TUYA_RESPONSE_SUCCESS: True,
  127. TUYA_RESPONSE_RESULT: {TUYA_RESPONSE_QR_CODE: "qr_code_value"},
  128. }
  129. mock_lc.login_result.return_value = (
  130. True,
  131. {
  132. CONF_TERMINAL_ID: "term_123",
  133. CONF_ENDPOINT: "https://openapi.tuyaus.com",
  134. "t": 1234567890,
  135. "uid": "user_abc",
  136. "expire_time": 7200,
  137. "access_token": "at_xyz",
  138. "refresh_token": "rt_xyz",
  139. },
  140. )
  141. cloud._Cloud__login_control = mock_lc
  142. await cloud.async_get_qr_code("user_code_123")
  143. result = await cloud.async_login()
  144. assert result is True
  145. assert cloud.is_authenticated is True
  146. assert mock_hass.data[DOMAIN]["auth_cache"] is not None
  147. @pytest.mark.asyncio
  148. async def test_failure_clears_auth(self, cloud, mock_hass):
  149. mock_lc = MagicMock()
  150. mock_lc.qr_code.return_value = {
  151. TUYA_RESPONSE_SUCCESS: True,
  152. TUYA_RESPONSE_RESULT: {TUYA_RESPONSE_QR_CODE: "qr_code_value"},
  153. }
  154. mock_lc.login_result.return_value = (
  155. False,
  156. {
  157. TUYA_RESPONSE_CODE: 2000,
  158. TUYA_RESPONSE_MSG: "Auth failed",
  159. },
  160. )
  161. cloud._Cloud__login_control = mock_lc
  162. await cloud.async_get_qr_code("user_code_123")
  163. result = await cloud.async_login()
  164. assert result is False
  165. assert cloud.is_authenticated is False
  166. assert mock_hass.data[DOMAIN]["auth_cache"] is None
  167. class TestLogout:
  168. def test_logout_clears_auth(self, cloud, mock_hass):
  169. # Manually set authentication
  170. mock_hass.data[DOMAIN]["auth_cache"] = {"some": "data"}
  171. cloud._Cloud__authentication = {"some": "data"}
  172. assert cloud.is_authenticated is True
  173. cloud.logout()
  174. assert cloud.is_authenticated is False
  175. assert mock_hass.data[DOMAIN]["auth_cache"] is None
  176. class TestGetDevices:
  177. @pytest.mark.asyncio
  178. async def test_get_devices(self, cloud, mock_hass):
  179. # Set up authentication
  180. cloud._Cloud__authentication = {
  181. "user_code": "uc",
  182. "terminal_id": "tid",
  183. "endpoint": "ep",
  184. "token_info": {"access_token": "at"},
  185. }
  186. mock_device = MagicMock()
  187. mock_device.category = "dj"
  188. mock_device.id = "dev_001"
  189. mock_device.ip = "192.168.1.100"
  190. mock_device.local_key = "local_key_123"
  191. mock_device.name = "Test Light"
  192. mock_device.node_id = ""
  193. mock_device.online = True
  194. mock_device.product_id = "prod_001"
  195. mock_device.product_name = "Smart Light"
  196. mock_device.uid = "uid_001"
  197. mock_device.uuid = "uuid_001"
  198. mock_device.support_local = True
  199. with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
  200. mock_manager = MockManager.return_value
  201. mock_manager.device_map = {"dev_001": mock_device}
  202. mock_manager.update_device_cache = MagicMock()
  203. devices = await cloud.async_get_devices()
  204. assert "dev_001/" in devices
  205. assert devices["dev_001/"]["name"] == "Test Light"
  206. assert devices["dev_001/"][CONF_LOCAL_KEY] == "local_key_123"
  207. assert devices["dev_001/"]["is_hub"] is False
  208. @pytest.mark.asyncio
  209. async def test_get_devices_hub_category(self, cloud, mock_hass):
  210. cloud._Cloud__authentication = {
  211. "user_code": "uc",
  212. "terminal_id": "tid",
  213. "endpoint": "ep",
  214. "token_info": {"access_token": "at"},
  215. }
  216. mock_device = MagicMock()
  217. mock_device.category = "zigbee"
  218. mock_device.id = "hub_001"
  219. mock_device.ip = "192.168.1.200"
  220. mock_device.local_key = "lk"
  221. mock_device.name = "Hub"
  222. mock_device.node_id = ""
  223. mock_device.online = True
  224. mock_device.product_id = "hp_001"
  225. mock_device.product_name = "Zigbee Hub"
  226. mock_device.uid = "uid_hub"
  227. mock_device.uuid = "uuid_hub"
  228. mock_device.support_local = True
  229. with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
  230. mock_manager = MockManager.return_value
  231. mock_manager.device_map = {"hub_001": mock_device}
  232. mock_manager.update_device_cache = MagicMock()
  233. devices = await cloud.async_get_devices()
  234. assert devices["hub_001/"]["is_hub"] is True
  235. class TestGetDatamodel:
  236. @pytest.mark.asyncio
  237. async def test_get_datamodel(self, cloud, mock_hass):
  238. cloud._Cloud__authentication = {
  239. "user_code": "uc",
  240. "terminal_id": "tid",
  241. "endpoint": "ep",
  242. "token_info": {"access_token": "at"},
  243. }
  244. mock_response = {
  245. "result": {
  246. "dpStatusRelationDTOS": [
  247. {
  248. "dpId": 1,
  249. "dpCode": "switch",
  250. "valueType": "Boolean",
  251. "valueDesc": "{}",
  252. "enumMappingMap": {},
  253. "supportLocal": True,
  254. },
  255. {
  256. "dpId": 2,
  257. "dpCode": "cloud_only",
  258. "valueType": "Boolean",
  259. "valueDesc": "{}",
  260. "enumMappingMap": {},
  261. "supportLocal": False,
  262. },
  263. ]
  264. }
  265. }
  266. with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
  267. mock_manager = MockManager.return_value
  268. mock_manager.customer_api.get.return_value = mock_response
  269. result = await cloud.async_get_datamodel("dev_001")
  270. assert len(result) == 1
  271. assert result[0]["id"] == 1
  272. assert result[0]["name"] == "switch"
  273. class TestDeviceListener:
  274. def test_update_device(self):
  275. hass = MagicMock()
  276. manager = MagicMock()
  277. device = MagicMock()
  278. device.id = "dev_001"
  279. manager.device_map = {"dev_001": MagicMock()}
  280. listener = DeviceListener(hass, manager)
  281. # Should not raise
  282. listener.update_device(device, ["status"])
  283. def test_add_device(self):
  284. hass = MagicMock()
  285. manager = MagicMock()
  286. device = MagicMock()
  287. device.id = "dev_001"
  288. manager.device_map = {"dev_001": MagicMock()}
  289. listener = DeviceListener(hass, manager)
  290. listener.add_device(device)
  291. def test_remove_device(self):
  292. hass = MagicMock()
  293. manager = MagicMock()
  294. manager.device_map = {"dev_001": MagicMock()}
  295. listener = DeviceListener(hass, manager)
  296. listener.remove_device("dev_001")
  297. class TestTokenListener:
  298. def test_update_token(self):
  299. hass = MagicMock()
  300. listener = TokenListener(hass)
  301. # Should not raise
  302. listener.update_token({"access_token": "new_token"})
  303. class TestHubCategories:
  304. def test_known_hub_categories(self):
  305. assert "zigbee" in HUB_CATEGORIES
  306. assert "wg2" in HUB_CATEGORIES
  307. assert "wnykq" in HUB_CATEGORIES
  308. def test_non_hub_category(self):
  309. assert "dj" not in HUB_CATEGORIES