test_tasmota.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """Unit tests for TasmotaService.
  2. Tests smart plug HTTP communication and error handling.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import httpx
  6. import pytest
  7. from backend.app.services.tasmota import TasmotaService
  8. class TestTasmotaService:
  9. """Tests for TasmotaService class."""
  10. @pytest.fixture
  11. def service(self):
  12. """Create a TasmotaService instance."""
  13. return TasmotaService(timeout=5.0)
  14. @pytest.fixture
  15. def mock_plug(self):
  16. """Create a mock SmartPlug object."""
  17. plug = MagicMock()
  18. plug.ip_address = "192.168.1.100"
  19. plug.username = None
  20. plug.password = None
  21. plug.name = "Test Plug"
  22. return plug
  23. # ========================================================================
  24. # Tests for URL building
  25. # ========================================================================
  26. def test_build_url_without_auth(self, service):
  27. """Verify URL is built correctly without auth."""
  28. url = service._build_url("192.168.1.100", "Power On")
  29. assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
  30. def test_build_url_never_includes_credentials(self, service):
  31. """Verify URL never contains credentials (they go via httpx auth param)."""
  32. url = service._build_url("192.168.1.100", "Power On")
  33. assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
  34. assert "@" not in url
  35. def test_build_url_encodes_special_characters(self, service):
  36. """Verify special characters in commands are encoded."""
  37. url = service._build_url("192.168.1.100", "Backlog Power On; Delay 100")
  38. assert "Backlog%20Power%20On" in url
  39. # ========================================================================
  40. # Tests for turn_on
  41. # ========================================================================
  42. @pytest.mark.asyncio
  43. async def test_turn_on_success(self, service, mock_plug):
  44. """Verify turn_on returns True on success."""
  45. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  46. mock_send.return_value = {"POWER": "ON"}
  47. result = await service.turn_on(mock_plug)
  48. assert result is True
  49. mock_send.assert_called_once_with("192.168.1.100", "Power On", None, None)
  50. @pytest.mark.asyncio
  51. async def test_turn_on_failure(self, service, mock_plug):
  52. """Verify turn_on returns False on failure."""
  53. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  54. mock_send.return_value = None
  55. result = await service.turn_on(mock_plug)
  56. assert result is False
  57. @pytest.mark.asyncio
  58. async def test_turn_on_with_auth(self, service, mock_plug):
  59. """Verify turn_on passes credentials when provided."""
  60. mock_plug.username = "admin"
  61. mock_plug.password = "secret"
  62. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  63. mock_send.return_value = {"POWER": "ON"}
  64. await service.turn_on(mock_plug)
  65. mock_send.assert_called_once_with("192.168.1.100", "Power On", "admin", "secret")
  66. # ========================================================================
  67. # Tests for turn_off
  68. # ========================================================================
  69. @pytest.mark.asyncio
  70. async def test_turn_off_success(self, service, mock_plug):
  71. """Verify turn_off returns True on success."""
  72. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  73. mock_send.return_value = {"POWER": "OFF"}
  74. result = await service.turn_off(mock_plug)
  75. assert result is True
  76. @pytest.mark.asyncio
  77. async def test_turn_off_failure(self, service, mock_plug):
  78. """Verify turn_off returns False on failure."""
  79. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  80. mock_send.return_value = None
  81. result = await service.turn_off(mock_plug)
  82. assert result is False
  83. # ========================================================================
  84. # Tests for toggle
  85. # ========================================================================
  86. @pytest.mark.asyncio
  87. async def test_toggle_success(self, service, mock_plug):
  88. """Verify toggle returns True on success."""
  89. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  90. mock_send.return_value = {"POWER": "ON"}
  91. result = await service.toggle(mock_plug)
  92. assert result is True
  93. mock_send.assert_called_once_with("192.168.1.100", "Power Toggle", None, None)
  94. # ========================================================================
  95. # Tests for get_status
  96. # ========================================================================
  97. @pytest.mark.asyncio
  98. async def test_get_status_returns_on(self, service, mock_plug):
  99. """Verify get_status returns correct state when ON."""
  100. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  101. # Tasmota returns {"POWER": "ON"} for Power command
  102. mock_send.return_value = {"POWER": "ON"}
  103. result = await service.get_status(mock_plug)
  104. assert result is not None
  105. assert result["state"] == "ON"
  106. assert result["reachable"] is True
  107. @pytest.mark.asyncio
  108. async def test_get_status_returns_off(self, service, mock_plug):
  109. """Verify get_status returns correct state when OFF."""
  110. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  111. # Tasmota returns {"POWER": "OFF"} for Power command
  112. mock_send.return_value = {"POWER": "OFF"}
  113. result = await service.get_status(mock_plug)
  114. assert result is not None
  115. assert result["state"] == "OFF"
  116. @pytest.mark.asyncio
  117. async def test_get_status_unreachable(self, service, mock_plug):
  118. """Verify get_status handles unreachable device."""
  119. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  120. mock_send.return_value = None
  121. result = await service.get_status(mock_plug)
  122. assert result is not None
  123. assert result["reachable"] is False
  124. # ========================================================================
  125. # Tests for get_energy
  126. # ========================================================================
  127. @pytest.mark.asyncio
  128. async def test_get_energy_returns_data(self, service, mock_plug):
  129. """Verify get_energy parses energy data correctly."""
  130. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  131. mock_send.return_value = {
  132. "StatusSNS": {
  133. "ENERGY": {
  134. "Power": 150.5,
  135. "Voltage": 120.0,
  136. "Current": 1.25,
  137. "Today": 2.5,
  138. "Total": 100.0,
  139. "Factor": 0.95,
  140. }
  141. }
  142. }
  143. result = await service.get_energy(mock_plug)
  144. assert result is not None
  145. assert result["power"] == 150.5
  146. assert result["voltage"] == 120.0
  147. assert result["current"] == 1.25
  148. assert result["today"] == 2.5
  149. assert result["total"] == 100.0
  150. assert result["factor"] == 0.95
  151. @pytest.mark.asyncio
  152. async def test_get_energy_handles_missing_data(self, service, mock_plug):
  153. """Verify get_energy handles devices without energy monitoring."""
  154. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  155. mock_send.return_value = {"StatusSNS": {}}
  156. result = await service.get_energy(mock_plug)
  157. assert result is None
  158. @pytest.mark.asyncio
  159. async def test_get_energy_handles_unreachable(self, service, mock_plug):
  160. """Verify get_energy handles unreachable device."""
  161. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  162. mock_send.return_value = None
  163. result = await service.get_energy(mock_plug)
  164. assert result is None
  165. @pytest.mark.asyncio
  166. async def test_get_energy_handles_partial_data(self, service, mock_plug):
  167. """Verify get_energy handles partial energy data."""
  168. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  169. mock_send.return_value = {
  170. "StatusSNS": {
  171. "ENERGY": {
  172. "Power": 150.5,
  173. # Missing other fields
  174. }
  175. }
  176. }
  177. result = await service.get_energy(mock_plug)
  178. assert result is not None
  179. assert result["power"] == 150.5
  180. # Missing fields should be None or 0
  181. assert result.get("voltage") is None or result.get("voltage") == 0
  182. # ========================================================================
  183. # Tests for test_connection
  184. # ========================================================================
  185. @pytest.mark.asyncio
  186. async def test_test_connection_success(self, service):
  187. """Verify test_connection returns success on reachable device."""
  188. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  189. # First call (Power) returns state, second call (Status 0) returns device info
  190. mock_send.side_effect = [
  191. {"POWER": "ON"}, # Power command response
  192. {"Status": {"DeviceName": "Test Plug"}}, # Status 0 response
  193. ]
  194. result = await service.test_connection("192.168.1.100")
  195. assert result["success"] is True
  196. assert result["state"] == "ON"
  197. assert result["device_name"] == "Test Plug"
  198. @pytest.mark.asyncio
  199. async def test_test_connection_failure(self, service):
  200. """Verify test_connection returns failure on unreachable device."""
  201. with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
  202. mock_send.return_value = None
  203. result = await service.test_connection("192.168.1.100")
  204. assert result["success"] is False
  205. # ========================================================================
  206. # Tests for _send_command
  207. # ========================================================================
  208. @pytest.mark.asyncio
  209. async def test_send_command_handles_timeout(self, service):
  210. """Verify timeout is handled gracefully."""
  211. with patch("httpx.AsyncClient") as mock_client_class:
  212. mock_client = AsyncMock()
  213. mock_client.get.side_effect = httpx.TimeoutException("Timeout")
  214. mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
  215. mock_client_class.return_value.__aexit__ = AsyncMock()
  216. result = await service._send_command("192.168.1.100", "Power")
  217. assert result is None
  218. @pytest.mark.asyncio
  219. async def test_send_command_handles_connection_error(self, service):
  220. """Verify connection error is handled gracefully."""
  221. with patch("httpx.AsyncClient") as mock_client_class:
  222. mock_client = AsyncMock()
  223. mock_client.get.side_effect = httpx.ConnectError("Connection refused")
  224. mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
  225. mock_client_class.return_value.__aexit__ = AsyncMock()
  226. result = await service._send_command("192.168.1.100", "Power")
  227. assert result is None
  228. @pytest.mark.asyncio
  229. async def test_send_command_handles_invalid_json(self, service):
  230. """Verify invalid JSON response is handled gracefully."""
  231. with patch("httpx.AsyncClient") as mock_client_class:
  232. mock_client = AsyncMock()
  233. mock_response = MagicMock()
  234. mock_response.json.side_effect = ValueError("Invalid JSON")
  235. mock_client.get.return_value = mock_response
  236. mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
  237. mock_client_class.return_value.__aexit__ = AsyncMock()
  238. result = await service._send_command("192.168.1.100", "Power")
  239. assert result is None
  240. @pytest.mark.asyncio
  241. async def test_send_command_success(self, service):
  242. """Verify successful command returns parsed JSON."""
  243. with patch("httpx.AsyncClient") as mock_client_class:
  244. mock_client = AsyncMock()
  245. mock_response = MagicMock()
  246. mock_response.json.return_value = {"POWER": "ON"}
  247. mock_client.get.return_value = mock_response
  248. mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
  249. mock_client_class.return_value.__aexit__ = AsyncMock()
  250. result = await service._send_command("192.168.1.100", "Power")
  251. assert result == {"POWER": "ON"}
  252. class TestTasmotaServiceSingleton:
  253. """Tests for the global tasmota_service singleton."""
  254. def test_singleton_exists(self):
  255. """Verify global tasmota_service instance exists."""
  256. from backend.app.services.tasmota import tasmota_service
  257. assert tasmota_service is not None
  258. assert isinstance(tasmota_service, TasmotaService)