| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- """Unit tests for TasmotaService.
- Tests smart plug HTTP communication and error handling.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import httpx
- import pytest
- from backend.app.services.tasmota import TasmotaService
- class TestTasmotaService:
- """Tests for TasmotaService class."""
- @pytest.fixture
- def service(self):
- """Create a TasmotaService instance."""
- return TasmotaService(timeout=5.0)
- @pytest.fixture
- def mock_plug(self):
- """Create a mock SmartPlug object."""
- plug = MagicMock()
- plug.ip_address = "192.168.1.100"
- plug.username = None
- plug.password = None
- plug.name = "Test Plug"
- return plug
- # ========================================================================
- # Tests for URL building
- # ========================================================================
- def test_build_url_without_auth(self, service):
- """Verify URL is built correctly without auth."""
- url = service._build_url("192.168.1.100", "Power On")
- assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
- def test_build_url_with_auth(self, service):
- """Verify URL includes credentials when provided."""
- url = service._build_url("192.168.1.100", "Power On", username="admin", password="secret")
- assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
- def test_build_url_encodes_special_characters(self, service):
- """Verify special characters in commands are encoded."""
- url = service._build_url("192.168.1.100", "Backlog Power On; Delay 100")
- assert "Backlog%20Power%20On" in url
- # ========================================================================
- # Tests for turn_on
- # ========================================================================
- @pytest.mark.asyncio
- async def test_turn_on_success(self, service, mock_plug):
- """Verify turn_on returns True on success."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {"POWER": "ON"}
- result = await service.turn_on(mock_plug)
- assert result is True
- mock_send.assert_called_once_with("192.168.1.100", "Power On", None, None)
- @pytest.mark.asyncio
- async def test_turn_on_failure(self, service, mock_plug):
- """Verify turn_on returns False on failure."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = None
- result = await service.turn_on(mock_plug)
- assert result is False
- @pytest.mark.asyncio
- async def test_turn_on_with_auth(self, service, mock_plug):
- """Verify turn_on passes credentials when provided."""
- mock_plug.username = "admin"
- mock_plug.password = "secret"
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {"POWER": "ON"}
- await service.turn_on(mock_plug)
- mock_send.assert_called_once_with("192.168.1.100", "Power On", "admin", "secret")
- # ========================================================================
- # Tests for turn_off
- # ========================================================================
- @pytest.mark.asyncio
- async def test_turn_off_success(self, service, mock_plug):
- """Verify turn_off returns True on success."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {"POWER": "OFF"}
- result = await service.turn_off(mock_plug)
- assert result is True
- @pytest.mark.asyncio
- async def test_turn_off_failure(self, service, mock_plug):
- """Verify turn_off returns False on failure."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = None
- result = await service.turn_off(mock_plug)
- assert result is False
- # ========================================================================
- # Tests for toggle
- # ========================================================================
- @pytest.mark.asyncio
- async def test_toggle_success(self, service, mock_plug):
- """Verify toggle returns True on success."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {"POWER": "ON"}
- result = await service.toggle(mock_plug)
- assert result is True
- mock_send.assert_called_once_with("192.168.1.100", "Power Toggle", None, None)
- # ========================================================================
- # Tests for get_status
- # ========================================================================
- @pytest.mark.asyncio
- async def test_get_status_returns_on(self, service, mock_plug):
- """Verify get_status returns correct state when ON."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- # Tasmota returns {"POWER": "ON"} for Power command
- mock_send.return_value = {"POWER": "ON"}
- result = await service.get_status(mock_plug)
- assert result is not None
- assert result["state"] == "ON"
- assert result["reachable"] is True
- @pytest.mark.asyncio
- async def test_get_status_returns_off(self, service, mock_plug):
- """Verify get_status returns correct state when OFF."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- # Tasmota returns {"POWER": "OFF"} for Power command
- mock_send.return_value = {"POWER": "OFF"}
- result = await service.get_status(mock_plug)
- assert result is not None
- assert result["state"] == "OFF"
- @pytest.mark.asyncio
- async def test_get_status_unreachable(self, service, mock_plug):
- """Verify get_status handles unreachable device."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = None
- result = await service.get_status(mock_plug)
- assert result is not None
- assert result["reachable"] is False
- # ========================================================================
- # Tests for get_energy
- # ========================================================================
- @pytest.mark.asyncio
- async def test_get_energy_returns_data(self, service, mock_plug):
- """Verify get_energy parses energy data correctly."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {
- "StatusSNS": {
- "ENERGY": {
- "Power": 150.5,
- "Voltage": 120.0,
- "Current": 1.25,
- "Today": 2.5,
- "Total": 100.0,
- "Factor": 0.95,
- }
- }
- }
- result = await service.get_energy(mock_plug)
- assert result is not None
- assert result["power"] == 150.5
- assert result["voltage"] == 120.0
- assert result["current"] == 1.25
- assert result["today"] == 2.5
- assert result["total"] == 100.0
- assert result["factor"] == 0.95
- @pytest.mark.asyncio
- async def test_get_energy_handles_missing_data(self, service, mock_plug):
- """Verify get_energy handles devices without energy monitoring."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {"StatusSNS": {}}
- result = await service.get_energy(mock_plug)
- assert result is None
- @pytest.mark.asyncio
- async def test_get_energy_handles_unreachable(self, service, mock_plug):
- """Verify get_energy handles unreachable device."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = None
- result = await service.get_energy(mock_plug)
- assert result is None
- @pytest.mark.asyncio
- async def test_get_energy_handles_partial_data(self, service, mock_plug):
- """Verify get_energy handles partial energy data."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = {
- "StatusSNS": {
- "ENERGY": {
- "Power": 150.5,
- # Missing other fields
- }
- }
- }
- result = await service.get_energy(mock_plug)
- assert result is not None
- assert result["power"] == 150.5
- # Missing fields should be None or 0
- assert result.get("voltage") is None or result.get("voltage") == 0
- # ========================================================================
- # Tests for test_connection
- # ========================================================================
- @pytest.mark.asyncio
- async def test_test_connection_success(self, service):
- """Verify test_connection returns success on reachable device."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- # First call (Power) returns state, second call (Status 0) returns device info
- mock_send.side_effect = [
- {"POWER": "ON"}, # Power command response
- {"Status": {"DeviceName": "Test Plug"}}, # Status 0 response
- ]
- result = await service.test_connection("192.168.1.100")
- assert result["success"] is True
- assert result["state"] == "ON"
- assert result["device_name"] == "Test Plug"
- @pytest.mark.asyncio
- async def test_test_connection_failure(self, service):
- """Verify test_connection returns failure on unreachable device."""
- with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
- mock_send.return_value = None
- result = await service.test_connection("192.168.1.100")
- assert result["success"] is False
- # ========================================================================
- # Tests for _send_command
- # ========================================================================
- @pytest.mark.asyncio
- async def test_send_command_handles_timeout(self, service):
- """Verify timeout is handled gracefully."""
- with patch("httpx.AsyncClient") as mock_client_class:
- mock_client = AsyncMock()
- mock_client.get.side_effect = httpx.TimeoutException("Timeout")
- mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client_class.return_value.__aexit__ = AsyncMock()
- result = await service._send_command("192.168.1.100", "Power")
- assert result is None
- @pytest.mark.asyncio
- async def test_send_command_handles_connection_error(self, service):
- """Verify connection error is handled gracefully."""
- with patch("httpx.AsyncClient") as mock_client_class:
- mock_client = AsyncMock()
- mock_client.get.side_effect = httpx.ConnectError("Connection refused")
- mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client_class.return_value.__aexit__ = AsyncMock()
- result = await service._send_command("192.168.1.100", "Power")
- assert result is None
- @pytest.mark.asyncio
- async def test_send_command_handles_invalid_json(self, service):
- """Verify invalid JSON response is handled gracefully."""
- with patch("httpx.AsyncClient") as mock_client_class:
- mock_client = AsyncMock()
- mock_response = MagicMock()
- mock_response.json.side_effect = ValueError("Invalid JSON")
- mock_client.get.return_value = mock_response
- mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client_class.return_value.__aexit__ = AsyncMock()
- result = await service._send_command("192.168.1.100", "Power")
- assert result is None
- @pytest.mark.asyncio
- async def test_send_command_success(self, service):
- """Verify successful command returns parsed JSON."""
- with patch("httpx.AsyncClient") as mock_client_class:
- mock_client = AsyncMock()
- mock_response = MagicMock()
- mock_response.json.return_value = {"POWER": "ON"}
- mock_client.get.return_value = mock_response
- mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client_class.return_value.__aexit__ = AsyncMock()
- result = await service._send_command("192.168.1.100", "Power")
- assert result == {"POWER": "ON"}
- class TestTasmotaServiceSingleton:
- """Tests for the global tasmota_service singleton."""
- def test_singleton_exists(self):
- """Verify global tasmota_service instance exists."""
- from backend.app.services.tasmota import tasmota_service
- assert tasmota_service is not None
- assert isinstance(tasmota_service, TasmotaService)
|