test_tasmota.py 14 KB

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