test_tasmota.py 13 KB

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