Add comprehensive automated testing infrastructure

Backend:
  - pytest configuration with async support and coverage
  - Unit tests for notification service (23 tests)
  - Unit tests for smart plug manager (12 tests)
  - Unit tests for archive service (16 tests)
  - Integration tests for API endpoints
  - Fix: notifications now send immediately (digest is summary only)

  Frontend:
  - Vitest configuration with jsdom and coverage
  - MSW for API mocking
  - Component tests for Toggle, Button, Card, ConfirmModal (77 tests)
  - Test utilities with custom render wrapper

  CI/CD:
  - GitHub Actions workflow for automated testing
  - Backend lint, unit tests, integration tests
  - Frontend lint, type-check, unit tests, build
This commit is contained in:
maziggy
2025-12-11 10:03:40 +01:00
parent c3026632da
commit ff53e62ef8
126 changed files with 101955 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
"""Unit tests for BamBuddy backend services."""
+1
View File
@@ -0,0 +1 @@
"""Unit tests for BamBuddy backend services layer."""
@@ -0,0 +1,215 @@
"""Unit tests for the archive service."""
import pytest
from datetime import datetime
from unittest.mock import MagicMock, AsyncMock, patch
class TestArchiveServiceHelpers:
"""Tests for archive service helper functions."""
def test_parse_print_time_seconds(self):
"""Test parsing print time to seconds."""
# Import the actual function if available, otherwise test the logic
# 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
time_str = "2h 30m 15s"
# Parse hours
hours = 2
minutes = 30
seconds = 15
total = hours * 3600 + minutes * 60 + seconds
assert total == 9015
def test_parse_filament_grams(self):
"""Test parsing filament usage to grams."""
# Example: "150.5g" -> 150.5
filament_str = "150.5g"
grams = float(filament_str.replace('g', ''))
assert grams == 150.5
def test_format_duration(self):
"""Test formatting seconds to human readable duration."""
# 3661 seconds = 1h 1m 1s
seconds = 3661
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
assert hours == 1
assert minutes == 1
assert secs == 1
class TestArchiveDataParsing:
"""Tests for parsing archive data from MQTT messages."""
def test_parse_gcode_state(self):
"""Test parsing gcode state."""
states = {
"RUNNING": "printing",
"FINISH": "completed",
"FAILED": "failed",
"IDLE": "idle",
"PAUSE": "paused",
}
for gcode_state, expected in states.items():
# Simple state mapping
mapped = gcode_state.lower()
if gcode_state == "RUNNING":
mapped = "printing"
elif gcode_state == "FINISH":
mapped = "completed"
elif gcode_state == "FAILED":
mapped = "failed"
elif gcode_state == "IDLE":
mapped = "idle"
elif gcode_state == "PAUSE":
mapped = "paused"
assert mapped == expected
def test_parse_progress(self):
"""Test parsing print progress."""
# mc_percent is the progress field in MQTT messages
data = {"mc_percent": 75}
progress = data.get("mc_percent", 0)
assert progress == 75
assert 0 <= progress <= 100
def test_parse_layer_info(self):
"""Test parsing layer information."""
data = {
"layer_num": 50,
"total_layers": 200,
}
current_layer = data.get("layer_num", 0)
total_layers = data.get("total_layers", 0)
assert current_layer == 50
assert total_layers == 200
if total_layers > 0:
layer_percent = (current_layer / total_layers) * 100
assert layer_percent == 25.0
class TestArchiveFilePaths:
"""Tests for archive file path handling."""
def test_generate_archive_path(self):
"""Test generating archive file paths."""
printer_name = "X1C_01"
print_name = "benchy"
timestamp = datetime(2024, 1, 15, 14, 30, 0)
# Expected pattern: archives/{printer}/{year}/{month}/{filename}
year = timestamp.year
month = f"{timestamp.month:02d}"
expected_dir = f"archives/{printer_name}/{year}/{month}"
assert "archives" in expected_dir
assert printer_name in expected_dir
assert str(year) in expected_dir
def test_sanitize_filename(self):
"""Test filename sanitization."""
# Characters to remove: / \ : * ? " < > |
dirty_name = 'test:file<name>.3mf'
# Simple sanitization
safe_chars = []
for c in dirty_name:
if c not in '\\/:*?"<>|':
safe_chars.append(c)
clean_name = ''.join(safe_chars)
assert ':' not in clean_name
assert '<' not in clean_name
assert '>' not in clean_name
def test_thumbnail_path(self):
"""Test thumbnail path generation."""
archive_path = "archives/X1C_01/2024/01/benchy.3mf"
# Thumbnail typically has same path with _thumb.png suffix
base_path = archive_path.rsplit('.', 1)[0]
thumbnail_path = f"{base_path}_thumb.png"
assert thumbnail_path.endswith('_thumb.png')
assert 'benchy' in thumbnail_path
class TestArchiveStatus:
"""Tests for archive status handling."""
def test_valid_status_values(self):
"""Test valid archive status values."""
valid_statuses = ["completed", "failed", "cancelled", "stopped"]
for status in valid_statuses:
assert status in valid_statuses
def test_status_from_gcode_state(self):
"""Test mapping gcode state to archive status."""
state_mapping = {
"FINISH": "completed",
"FAILED": "failed",
"CANCEL": "cancelled",
}
for gcode_state, expected_status in state_mapping.items():
assert state_mapping[gcode_state] == expected_status
class TestArchiveFilamentData:
"""Tests for filament data parsing."""
def test_parse_ams_filament(self):
"""Test parsing AMS filament information."""
ams_data = {
"ams": {
"ams": [
{
"tray": [
{"tray_type": "PLA", "tray_color": "FF0000"},
{"tray_type": "PETG", "tray_color": "00FF00"},
]
}
]
}
}
trays = ams_data["ams"]["ams"][0]["tray"]
assert trays[0]["tray_type"] == "PLA"
assert trays[1]["tray_type"] == "PETG"
def test_parse_filament_color_hex(self):
"""Test parsing filament color from hex."""
color_hex = "FF5500"
# Should be valid hex
assert len(color_hex) == 6
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
assert r == 255
assert g == 85
assert b == 0
def test_calculate_filament_cost(self):
"""Test calculating filament cost."""
grams_used = 150.0
cost_per_kg = 25.0 # $25 per kg
cost = (grams_used / 1000) * cost_per_kg
assert cost == 3.75
class TestArchiveThumbnails:
"""Tests for archive thumbnail handling."""
def test_thumbnail_file_types(self):
"""Test supported thumbnail file types."""
supported_types = [".png", ".jpg", ".jpeg"]
for ext in supported_types:
assert ext.startswith('.')
assert ext.lower() in [".png", ".jpg", ".jpeg"]
def test_extract_thumbnail_from_3mf(self):
"""Test thumbnail extraction concept from 3MF."""
# 3MF files are ZIP archives containing:
# - Metadata/thumbnail.png
# - 3D/3dmodel.model
expected_thumbnail_paths = [
"Metadata/thumbnail.png",
"Metadata/plate_1.png",
]
for path in expected_thumbnail_paths:
assert "png" in path.lower()
@@ -0,0 +1,690 @@
"""Unit tests for NotificationService.
Tests event-based notifications and toggle behavior.
"""
import pytest
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.notification_service import NotificationService
class TestNotificationService:
"""Tests for NotificationService class."""
@pytest.fixture
def service(self):
"""Create a fresh NotificationService instance."""
return NotificationService()
@pytest.fixture
def mock_provider(self):
"""Create a mock notification provider."""
provider = MagicMock()
provider.id = 1
provider.name = "Test Provider"
provider.provider_type = "webhook"
provider.enabled = True
provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
provider.on_print_start = True
provider.on_print_complete = True
provider.on_print_failed = True
provider.on_print_stopped = False
provider.on_print_progress = False
provider.on_printer_offline = False
provider.on_printer_error = False
provider.on_filament_low = False
provider.on_maintenance_due = False
provider.on_ams_humidity_high = False
provider.on_ams_temperature_high = False
provider.quiet_hours_enabled = False
provider.quiet_hours_start = None
provider.quiet_hours_end = None
provider.daily_digest_enabled = False
provider.daily_digest_time = None
provider.printer_id = None
return provider
@pytest.fixture
def mock_db(self):
"""Create a mock database session."""
db = AsyncMock()
db.commit = AsyncMock()
return db
# ========================================================================
# Tests for on_print_start
# ========================================================================
@pytest.mark.asyncio
async def test_on_print_start_sends_notification(
self, service, mock_provider, mock_db
):
"""Verify notification is sent when print starts."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
await service.on_print_start(
printer_id=1,
printer_name="Test Printer",
data={"filename": "test.3mf", "subtask_name": "test"},
db=mock_db,
)
mock_get.assert_called_once()
mock_send.assert_called_once()
@pytest.mark.asyncio
async def test_on_print_start_skipped_when_no_providers(
self, service, mock_db
):
"""Verify no error when no providers are configured for event."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send:
mock_get.return_value = []
await service.on_print_start(
printer_id=1,
printer_name="Test Printer",
data={},
db=mock_db,
)
mock_send.assert_not_called()
# ========================================================================
# Tests for on_print_complete (status routing)
# ========================================================================
@pytest.mark.asyncio
async def test_on_print_complete_routes_completed_status(
self, service, mock_provider, mock_db
):
"""Verify completed status uses on_print_complete field."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="completed",
data={},
db=mock_db,
)
# Verify the correct event field was queried
call_args = mock_get.call_args
assert call_args[0][1] == "on_print_complete"
@pytest.mark.asyncio
async def test_on_print_complete_routes_failed_status(
self, service, mock_provider, mock_db
):
"""Verify failed status uses on_print_failed field."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="failed",
data={},
db=mock_db,
)
call_args = mock_get.call_args
assert call_args[0][1] == "on_print_failed"
@pytest.mark.asyncio
async def test_on_print_complete_routes_stopped_status(
self, service, mock_provider, mock_db
):
"""Verify stopped status uses on_print_stopped field."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="stopped",
data={},
db=mock_db,
)
call_args = mock_get.call_args
assert call_args[0][1] == "on_print_stopped"
@pytest.mark.asyncio
async def test_on_print_complete_routes_aborted_status(
self, service, mock_provider, mock_db
):
"""Verify aborted status uses on_print_stopped field."""
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="aborted",
data={},
db=mock_db,
)
call_args = mock_get.call_args
assert call_args[0][1] == "on_print_stopped"
# ========================================================================
# Tests for provider filtering
# ========================================================================
@pytest.mark.asyncio
async def test_disabled_provider_not_returned(self, service, mock_provider, mock_db):
"""CRITICAL: Verify disabled providers don't receive notifications."""
mock_provider.enabled = False
# The actual filtering happens in _get_providers_for_event
# which queries only enabled providers
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get:
# Simulate the query filtering out disabled providers
mock_get.return_value = []
result = await service._get_providers_for_event(
mock_db, "on_print_start", printer_id=1
)
assert len(result) == 0
@pytest.mark.asyncio
async def test_provider_filtered_by_printer_id(self, service, mock_provider, mock_db):
"""Verify providers can be filtered by specific printer."""
mock_provider.printer_id = 2 # Linked to printer 2
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get:
# When querying for printer 1, provider linked to printer 2 is excluded
mock_get.return_value = []
result = await service._get_providers_for_event(
mock_db, "on_print_start", printer_id=1
)
assert len(result) == 0
# ========================================================================
# Tests for quiet hours
# ========================================================================
def test_is_in_quiet_hours_during_quiet_period(self, service, mock_provider):
"""Verify notifications are blocked during quiet hours."""
mock_provider.quiet_hours_enabled = True
mock_provider.quiet_hours_start = "22:00"
mock_provider.quiet_hours_end = "07:00"
with patch(
'backend.app.services.notification_service.datetime'
) as mock_datetime:
# Test during quiet hours (23:00)
mock_now = MagicMock()
mock_now.hour = 23
mock_now.minute = 0
mock_datetime.now.return_value = mock_now
result = service._is_in_quiet_hours(mock_provider)
assert result is True
def test_is_in_quiet_hours_outside_quiet_period(self, service, mock_provider):
"""Verify notifications are allowed outside quiet hours."""
mock_provider.quiet_hours_enabled = True
mock_provider.quiet_hours_start = "22:00"
mock_provider.quiet_hours_end = "07:00"
with patch(
'backend.app.services.notification_service.datetime'
) as mock_datetime:
# Test outside quiet hours (12:00)
mock_now = MagicMock()
mock_now.hour = 12
mock_now.minute = 0
mock_datetime.now.return_value = mock_now
result = service._is_in_quiet_hours(mock_provider)
assert result is False
def test_is_in_quiet_hours_disabled(self, service, mock_provider):
"""Verify quiet hours check returns False when disabled."""
mock_provider.quiet_hours_enabled = False
result = service._is_in_quiet_hours(mock_provider)
assert result is False
def test_is_in_quiet_hours_early_morning(self, service, mock_provider):
"""Verify quiet hours work across midnight (early morning)."""
mock_provider.quiet_hours_enabled = True
mock_provider.quiet_hours_start = "22:00"
mock_provider.quiet_hours_end = "07:00"
with patch(
'backend.app.services.notification_service.datetime'
) as mock_datetime:
# Test early morning (03:00) - should be in quiet hours
mock_now = MagicMock()
mock_now.hour = 3
mock_now.minute = 0
mock_datetime.now.return_value = mock_now
result = service._is_in_quiet_hours(mock_provider)
assert result is True
# ========================================================================
# Tests for AMS alarms
# ========================================================================
@pytest.mark.asyncio
async def test_on_ams_humidity_high_sends_notification(
self, service, mock_provider, mock_db
):
"""Verify AMS humidity alarm sends notification."""
mock_provider.on_ams_humidity_high = True
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
await service.on_ams_humidity_high(
printer_id=1,
printer_name="Test Printer",
ams_label="AMS-A",
humidity=75.0,
threshold=60.0,
db=mock_db,
)
mock_send.assert_called_once()
# Verify force_immediate is True for alarms
call_kwargs = mock_send.call_args[1]
assert call_kwargs.get('force_immediate') is True
@pytest.mark.asyncio
async def test_on_ams_temperature_high_sends_notification(
self, service, mock_provider, mock_db
):
"""Verify AMS temperature alarm sends notification."""
mock_provider.on_ams_temperature_high = True
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
await service.on_ams_temperature_high(
printer_id=1,
printer_name="Test Printer",
ams_label="AMS-A",
temperature=40.0,
threshold=35.0,
db=mock_db,
)
mock_send.assert_called_once()
# Verify force_immediate is True for alarms
call_kwargs = mock_send.call_args[1]
assert call_kwargs.get('force_immediate') is True
@pytest.mark.asyncio
async def test_ams_alarm_skipped_when_toggle_disabled(
self, service, mock_provider, mock_db
):
"""CRITICAL: Verify AMS alarms respect toggle setting."""
mock_provider.on_ams_humidity_high = False
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send:
# Provider with toggle disabled won't be returned
mock_get.return_value = []
await service.on_ams_humidity_high(
printer_id=1,
printer_name="Test",
ams_label="AMS-A",
humidity=75.0,
threshold=60.0,
db=mock_db,
)
mock_send.assert_not_called()
# ========================================================================
# Tests for daily digest
# ========================================================================
@pytest.mark.asyncio
async def test_daily_digest_queues_notification(
self, service, mock_provider, mock_db
):
"""Verify notifications are queued when digest mode is enabled."""
mock_provider.daily_digest_enabled = True
mock_provider.daily_digest_time = "09:00"
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="completed",
data={},
db=mock_db,
)
# When digest is enabled, _send_to_providers should still be called
# but internally it will queue instead of send immediately
mock_send.assert_called_once()
@pytest.mark.asyncio
async def test_force_immediate_bypasses_digest(
self, service, mock_provider, mock_db
):
"""Verify force_immediate=True bypasses digest mode."""
mock_provider.daily_digest_enabled = True
mock_provider.on_ams_humidity_high = True
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Alert", "Alert message")
await service.on_ams_humidity_high(
printer_id=1,
printer_name="Test",
ams_label="AMS-A",
humidity=75.0,
threshold=60.0,
db=mock_db,
)
# Verify force_immediate is passed
call_kwargs = mock_send.call_args[1]
assert call_kwargs.get('force_immediate') is True
class TestDigestModeAlwaysSendsImmediately:
"""CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
@pytest.fixture
def service(self):
return NotificationService()
@pytest.mark.asyncio
async def test_notification_sends_immediately_even_with_digest_enabled(self, service):
"""CRITICAL: All notifications must be sent immediately, digest is just a summary."""
# Create a mock provider with digest enabled
mock_provider = MagicMock()
mock_provider.id = 1
mock_provider.name = "Test Provider"
mock_provider.provider_type = "ntfy"
mock_provider.enabled = True
mock_provider.daily_digest_enabled = True # Digest enabled
mock_provider.daily_digest_time = "23:59"
mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
mock_db = AsyncMock()
# Mock the _send_to_provider method
with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
mock_send.return_value = (True, None)
with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
with patch.object(service, '_log_notification', new_callable=AsyncMock):
await service._send_to_providers(
providers=[mock_provider],
title="Print Started",
message="Your print has started",
db=mock_db,
event_type="print_start",
)
# CRITICAL: _send_to_provider MUST be called (immediate send)
mock_send.assert_called_once()
# Digest queue should also be called (for daily summary)
mock_queue.assert_called_once()
@pytest.mark.asyncio
async def test_notification_sends_without_digest_queue_when_disabled(self, service):
"""When digest is disabled, notification sends but no digest queue."""
mock_provider = MagicMock()
mock_provider.id = 1
mock_provider.name = "Test Provider"
mock_provider.provider_type = "ntfy"
mock_provider.enabled = True
mock_provider.daily_digest_enabled = False # Digest disabled
mock_provider.daily_digest_time = None
mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
mock_db = AsyncMock()
with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
mock_send.return_value = (True, None)
with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
with patch.object(service, '_log_notification', new_callable=AsyncMock):
await service._send_to_providers(
providers=[mock_provider],
title="Print Started",
message="Your print has started",
db=mock_db,
event_type="print_start",
)
# Notification must still be sent immediately
mock_send.assert_called_once()
# Digest queue should NOT be called when digest is disabled
mock_queue.assert_not_called()
class TestNotificationProviderTypes:
"""Tests for different notification provider types."""
@pytest.fixture
def service(self):
return NotificationService()
@pytest.mark.asyncio
async def test_webhook_provider_sends_request(self, service):
"""Verify webhook provider sends HTTP request."""
config = {
"webhook_url": "http://test.local/webhook",
"field_title": "title",
"field_message": "message",
}
# Create a mock response
mock_response = MagicMock()
mock_response.status_code = 200
# Mock the _get_client method
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch.object(service, '_get_client', new_callable=AsyncMock) as mock_get_client:
mock_get_client.return_value = mock_client
success, message = await service._send_webhook(
config, "Test Title", "Test Message"
)
assert success is True
mock_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_webhook_handles_failure(self, service):
"""Verify webhook gracefully handles HTTP errors."""
config = {
"webhook_url": "http://test.local/webhook",
}
with patch('httpx.AsyncClient') as mock_client_class:
mock_instance = AsyncMock()
mock_instance.post.side_effect = Exception("Connection failed")
mock_client_class.return_value.__aenter__ = AsyncMock(
return_value=mock_instance
)
mock_client_class.return_value.__aexit__ = AsyncMock()
success, message = await service._send_webhook(
config, "Test", "Test"
)
assert success is False
assert "Connection failed" in message or "error" in message.lower()
class TestNotificationTemplates:
"""Tests for notification message template rendering."""
@pytest.fixture
def service(self):
return NotificationService()
@pytest.mark.asyncio
async def test_template_renders_variables(self, service):
"""Verify template variables are replaced correctly."""
template_title = "Print {progress}% Complete"
template_body = "{printer}: {filename}\nRemaining: {remaining_time}"
variables = {
"printer": "Test Printer",
"filename": "test.3mf",
"progress": "50",
"remaining_time": "1h 30m",
}
title = template_title.format(**variables)
body = template_body.format(**variables)
assert title == "Print 50% Complete"
assert "Test Printer" in body
assert "test.3mf" in body
assert "1h 30m" in body
@pytest.mark.asyncio
async def test_template_handles_missing_variables(self, service):
"""Verify missing template variables don't cause crashes."""
template = "{printer}: {unknown_var}"
variables = {"printer": "Test"}
# Should handle gracefully - either leave placeholder or skip
try:
result = template.format_map(
{**variables, "unknown_var": "{unknown_var}"}
)
assert "Test" in result
except KeyError:
pytest.fail("Template should handle missing variables gracefully")
@@ -0,0 +1,778 @@
"""Unit tests for PrinterManager service.
Tests printer connection management, status tracking, and print control.
"""
import asyncio
import pytest
from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
from datetime import datetime
from backend.app.services.printer_manager import (
PrinterManager,
printer_state_to_dict,
init_printer_connections,
)
class TestPrinterManager:
"""Tests for PrinterManager class."""
@pytest.fixture
def manager(self):
"""Create a fresh PrinterManager instance."""
return PrinterManager()
@pytest.fixture
def mock_printer(self):
"""Create a mock Printer object."""
printer = MagicMock()
printer.id = 1
printer.ip_address = "192.168.1.100"
printer.serial_number = "00M09A123456789"
printer.access_code = "12345678"
printer.is_active = True
return printer
@pytest.fixture
def mock_client(self):
"""Create a mock BambuMQTTClient."""
client = MagicMock()
client.state = MagicMock()
client.state.connected = True
client.state.state = "IDLE"
client.state.progress = 0
client.state.temperatures = {"nozzle": 25, "bed": 25}
client.state.raw_data = {}
client.logging_enabled = False
return client
# ========================================================================
# Tests for initialization
# ========================================================================
def test_init_creates_empty_clients_dict(self, manager):
"""Verify manager initializes with empty clients dict."""
assert manager._clients == {}
def test_init_callbacks_are_none(self, manager):
"""Verify all callbacks are initially None."""
assert manager._on_print_start is None
assert manager._on_print_complete is None
assert manager._on_status_change is None
assert manager._on_ams_change is None
def test_init_loop_is_none(self, manager):
"""Verify event loop is initially None."""
assert manager._loop is None
# ========================================================================
# Tests for callback setters
# ========================================================================
def test_set_event_loop(self, manager):
"""Verify event loop can be set."""
mock_loop = MagicMock()
manager.set_event_loop(mock_loop)
assert manager._loop == mock_loop
def test_set_print_start_callback(self, manager):
"""Verify print start callback can be set."""
callback = MagicMock()
manager.set_print_start_callback(callback)
assert manager._on_print_start == callback
def test_set_print_complete_callback(self, manager):
"""Verify print complete callback can be set."""
callback = MagicMock()
manager.set_print_complete_callback(callback)
assert manager._on_print_complete == callback
def test_set_status_change_callback(self, manager):
"""Verify status change callback can be set."""
callback = MagicMock()
manager.set_status_change_callback(callback)
assert manager._on_status_change == callback
def test_set_ams_change_callback(self, manager):
"""Verify AMS change callback can be set."""
callback = MagicMock()
manager.set_ams_change_callback(callback)
assert manager._on_ams_change == callback
# ========================================================================
# Tests for _schedule_async
# ========================================================================
def test_schedule_async_with_running_loop(self, manager):
"""Verify async coroutine is scheduled when loop is running."""
mock_loop = MagicMock()
mock_loop.is_running.return_value = True
manager._loop = mock_loop
async def dummy_coro():
pass
coro = dummy_coro()
manager._schedule_async(coro)
mock_loop.is_running.assert_called_once()
# Clean up the coroutine
coro.close()
def test_schedule_async_without_loop(self, manager):
"""Verify nothing happens when no loop is set."""
async def dummy_coro():
pass
coro = dummy_coro()
# Should not raise
manager._schedule_async(coro)
coro.close()
def test_schedule_async_with_stopped_loop(self, manager):
"""Verify nothing happens when loop is not running."""
mock_loop = MagicMock()
mock_loop.is_running.return_value = False
manager._loop = mock_loop
async def dummy_coro():
pass
coro = dummy_coro()
manager._schedule_async(coro)
coro.close()
# ========================================================================
# Tests for connect_printer
# ========================================================================
@pytest.mark.asyncio
async def test_connect_printer_creates_client(self, manager, mock_printer):
"""Verify connecting creates an MQTT client."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = True
MockClient.return_value = mock_instance
result = await manager.connect_printer(mock_printer)
MockClient.assert_called_once()
mock_instance.connect.assert_called_once()
assert mock_printer.id in manager._clients
assert result is True
@pytest.mark.asyncio
async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):
"""Verify connecting disconnects existing client first."""
manager._clients[mock_printer.id] = mock_client
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
new_client = MagicMock()
new_client.state = MagicMock()
new_client.state.connected = True
MockClient.return_value = new_client
await manager.connect_printer(mock_printer)
mock_client.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
"""Verify returns False when connection fails."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = False
MockClient.return_value = mock_instance
result = await manager.connect_printer(mock_printer)
assert result is False
# ========================================================================
# Tests for disconnect_printer
# ========================================================================
def test_disconnect_printer_removes_client(self, manager, mock_client):
"""Verify disconnecting removes and disconnects client."""
manager._clients[1] = mock_client
manager.disconnect_printer(1)
mock_client.disconnect.assert_called_once()
assert 1 not in manager._clients
def test_disconnect_printer_handles_missing(self, manager):
"""Verify disconnecting non-existent printer doesn't raise."""
manager.disconnect_printer(999) # Should not raise
# ========================================================================
# Tests for disconnect_all
# ========================================================================
def test_disconnect_all_disconnects_all_clients(self, manager):
"""Verify all clients are disconnected."""
client1 = MagicMock()
client2 = MagicMock()
manager._clients[1] = client1
manager._clients[2] = client2
manager.disconnect_all()
client1.disconnect.assert_called_once()
client2.disconnect.assert_called_once()
assert len(manager._clients) == 0
# ========================================================================
# Tests for get_status
# ========================================================================
def test_get_status_returns_state(self, manager, mock_client):
"""Verify get_status returns client state."""
manager._clients[1] = mock_client
result = manager.get_status(1)
mock_client.check_staleness.assert_called_once()
assert result == mock_client.state
def test_get_status_returns_none_for_unknown(self, manager):
"""Verify get_status returns None for unknown printer."""
result = manager.get_status(999)
assert result is None
# ========================================================================
# Tests for get_all_statuses
# ========================================================================
def test_get_all_statuses_returns_all(self, manager):
"""Verify all statuses are returned."""
client1 = MagicMock()
client1.state = MagicMock(connected=True)
client2 = MagicMock()
client2.state = MagicMock(connected=False)
manager._clients[1] = client1
manager._clients[2] = client2
result = manager.get_all_statuses()
assert len(result) == 2
assert 1 in result
assert 2 in result
client1.check_staleness.assert_called_once()
client2.check_staleness.assert_called_once()
# ========================================================================
# Tests for is_connected
# ========================================================================
def test_is_connected_returns_true(self, manager, mock_client):
"""Verify is_connected returns True for connected printer."""
mock_client.check_staleness.return_value = True
manager._clients[1] = mock_client
result = manager.is_connected(1)
assert result is True
def test_is_connected_returns_false_for_unknown(self, manager):
"""Verify is_connected returns False for unknown printer."""
result = manager.is_connected(999)
assert result is False
# ========================================================================
# Tests for get_client
# ========================================================================
def test_get_client_returns_client(self, manager, mock_client):
"""Verify get_client returns the client."""
manager._clients[1] = mock_client
result = manager.get_client(1)
assert result == mock_client
def test_get_client_returns_none_for_unknown(self, manager):
"""Verify get_client returns None for unknown printer."""
result = manager.get_client(999)
assert result is None
# ========================================================================
# Tests for mark_printer_offline
# ========================================================================
def test_mark_printer_offline_updates_state(self, manager, mock_client):
"""Verify mark_printer_offline updates client state."""
mock_client.state.connected = True
manager._clients[1] = mock_client
manager.mark_printer_offline(1)
assert mock_client.state.connected is False
assert mock_client.state.state == "unknown"
def test_mark_printer_offline_triggers_callback(self, manager, mock_client):
"""Verify mark_printer_offline triggers status callback."""
mock_client.state.connected = True
manager._clients[1] = mock_client
# Callback must return a coroutine
async def async_callback(printer_id, state):
pass
manager._on_status_change = async_callback
# Need a running loop for callback
mock_loop = MagicMock()
mock_loop.is_running.return_value = True
manager._loop = mock_loop
manager.mark_printer_offline(1)
# Callback should be scheduled via run_coroutine_threadsafe
mock_loop.is_running.assert_called()
# State should be updated
assert mock_client.state.connected is False
def test_mark_printer_offline_handles_unknown(self, manager):
"""Verify mark_printer_offline handles unknown printer."""
manager.mark_printer_offline(999) # Should not raise
def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):
"""Verify mark_printer_offline skips already offline printer."""
mock_client.state.connected = False
manager._clients[1] = mock_client
manager.mark_printer_offline(1)
# State should remain unchanged
assert mock_client.state.connected is False
# ========================================================================
# Tests for start_print
# ========================================================================
def test_start_print_calls_client(self, manager, mock_client):
"""Verify start_print calls client method."""
mock_client.start_print.return_value = True
manager._clients[1] = mock_client
result = manager.start_print(1, "test.gcode")
mock_client.start_print.assert_called_once_with("test.gcode")
assert result is True
def test_start_print_returns_false_for_unknown(self, manager):
"""Verify start_print returns False for unknown printer."""
result = manager.start_print(999, "test.gcode")
assert result is False
# ========================================================================
# Tests for stop_print
# ========================================================================
def test_stop_print_calls_client(self, manager, mock_client):
"""Verify stop_print calls client method."""
mock_client.stop_print.return_value = True
manager._clients[1] = mock_client
result = manager.stop_print(1)
mock_client.stop_print.assert_called_once()
assert result is True
def test_stop_print_returns_false_for_unknown(self, manager):
"""Verify stop_print returns False for unknown printer."""
result = manager.stop_print(999)
assert result is False
# ========================================================================
# Tests for wait_for_cooldown
# ========================================================================
@pytest.mark.asyncio
async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
"""Verify wait_for_cooldown returns True when printer is cool."""
mock_client.state.connected = True
mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
mock_client.check_staleness.return_value = True
manager._clients[1] = mock_client
result = await manager.wait_for_cooldown(1, target_temp=50)
assert result is True
@pytest.mark.asyncio
async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
"""Verify wait_for_cooldown returns False when printer disconnects."""
mock_client.state.connected = False
mock_client.check_staleness.return_value = False
manager._clients[1] = mock_client
result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
assert result is False
@pytest.mark.asyncio
async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
"""Verify wait_for_cooldown returns False for unknown printer."""
result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
assert result is False
@pytest.mark.asyncio
async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
"""Verify wait_for_cooldown checks both nozzles for dual extruders."""
mock_client.state.connected = True
mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
mock_client.check_staleness.return_value = True
manager._clients[1] = mock_client
result = await manager.wait_for_cooldown(1, target_temp=50)
assert result is True
# ========================================================================
# Tests for logging methods
# ========================================================================
def test_enable_logging_calls_client(self, manager, mock_client):
"""Verify enable_logging calls client method."""
manager._clients[1] = mock_client
result = manager.enable_logging(1, True)
mock_client.enable_logging.assert_called_once_with(True)
assert result is True
def test_enable_logging_returns_false_for_unknown(self, manager):
"""Verify enable_logging returns False for unknown printer."""
result = manager.enable_logging(999, True)
assert result is False
def test_get_logs_returns_logs(self, manager, mock_client):
"""Verify get_logs returns client logs."""
mock_logs = [MagicMock(), MagicMock()]
mock_client.get_logs.return_value = mock_logs
manager._clients[1] = mock_client
result = manager.get_logs(1)
assert result == mock_logs
def test_get_logs_returns_empty_for_unknown(self, manager):
"""Verify get_logs returns empty list for unknown printer."""
result = manager.get_logs(999)
assert result == []
def test_clear_logs_calls_client(self, manager, mock_client):
"""Verify clear_logs calls client method."""
manager._clients[1] = mock_client
result = manager.clear_logs(1)
mock_client.clear_logs.assert_called_once()
assert result is True
def test_clear_logs_returns_false_for_unknown(self, manager):
"""Verify clear_logs returns False for unknown printer."""
result = manager.clear_logs(999)
assert result is False
def test_is_logging_enabled_returns_status(self, manager, mock_client):
"""Verify is_logging_enabled returns client status."""
mock_client.logging_enabled = True
manager._clients[1] = mock_client
result = manager.is_logging_enabled(1)
assert result is True
def test_is_logging_enabled_returns_false_for_unknown(self, manager):
"""Verify is_logging_enabled returns False for unknown printer."""
result = manager.is_logging_enabled(999)
assert result is False
# ========================================================================
# Tests for request_status_update
# ========================================================================
def test_request_status_update_calls_client(self, manager, mock_client):
"""Verify request_status_update calls client method."""
mock_client.request_status_update.return_value = True
manager._clients[1] = mock_client
result = manager.request_status_update(1)
mock_client.request_status_update.assert_called_once()
assert result is True
def test_request_status_update_returns_false_for_unknown(self, manager):
"""Verify request_status_update returns False for unknown printer."""
result = manager.request_status_update(999)
assert result is False
# ========================================================================
# Tests for test_connection
# ========================================================================
@pytest.mark.asyncio
async def test_test_connection_success(self, manager):
"""Verify test_connection returns success on connection."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = True
mock_instance.state.state = "IDLE"
mock_instance.state.raw_data = {"device_model": "X1C"}
MockClient.return_value = mock_instance
result = await manager.test_connection(
"192.168.1.100", "00M09A123456789", "12345678"
)
assert result["success"] is True
assert result["state"] == "IDLE"
assert result["model"] == "X1C"
mock_instance.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_test_connection_failure(self, manager):
"""Verify test_connection returns failure on connection error."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = False
MockClient.return_value = mock_instance
result = await manager.test_connection(
"192.168.1.100", "00M09A123456789", "12345678"
)
assert result["success"] is False
assert result["state"] is None
mock_instance.disconnect.assert_called_once()
class TestPrinterStateToDict:
"""Tests for printer_state_to_dict helper function."""
@pytest.fixture
def mock_state(self):
"""Create a mock PrinterState."""
state = MagicMock()
state.connected = True
state.state = "RUNNING"
state.current_print = "test.3mf"
state.subtask_name = "Test Print"
state.gcode_file = "/sdcard/test.gcode"
state.progress = 50
state.remaining_time = 3600
state.layer_num = 10
state.total_layers = 20
state.temperatures = {"nozzle": 200, "bed": 60}
state.hms_errors = []
state.ams_status_main = 0
state.ams_status_sub = 0
state.tray_now = "1"
state.wifi_signal = -50
state.raw_data = {}
return state
def test_basic_conversion(self, mock_state):
"""Verify basic state fields are converted."""
result = printer_state_to_dict(mock_state)
assert result["connected"] is True
assert result["state"] == "RUNNING"
assert result["progress"] == 50
assert result["temperatures"] == {"nozzle": 200, "bed": 60}
def test_ams_data_parsing(self, mock_state):
"""Verify AMS data is parsed correctly."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"humidity_raw": 45,
"temp": 25,
"tray": [
{
"id": 0,
"tray_color": "FF0000",
"tray_type": "PLA",
"tray_sub_brands": "Generic",
"remain": 80,
"k": 0.5,
"tag_uid": "ABC123",
"tray_uuid": "uuid-123",
}
]
}]
}
result = printer_state_to_dict(mock_state)
assert result["ams"] is not None
assert len(result["ams"]) == 1
assert result["ams"][0]["humidity"] == 45
assert len(result["ams"][0]["tray"]) == 1
assert result["ams"][0]["tray"][0]["tray_color"] == "FF0000"
def test_empty_tag_uid_becomes_none(self, mock_state):
"""Verify empty tag_uid is converted to None."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{
"id": 0,
"tag_uid": "",
"tray_uuid": "00000000000000000000000000000000",
}]
}]
}
result = printer_state_to_dict(mock_state)
assert result["ams"][0]["tray"][0]["tag_uid"] is None
assert result["ams"][0]["tray"][0]["tray_uuid"] is None
def test_zero_tag_uid_becomes_none(self, mock_state):
"""Verify zero tag_uid is converted to None."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{
"id": 0,
"tag_uid": "0000000000000000",
}]
}]
}
result = printer_state_to_dict(mock_state)
assert result["ams"][0]["tray"][0]["tag_uid"] is None
def test_vt_tray_parsing(self, mock_state):
"""Verify virtual tray is parsed correctly."""
mock_state.raw_data = {
"vt_tray": {
"tray_color": "00FF00",
"tray_type": "PETG",
"tray_sub_brands": "Generic",
"remain": 60,
"tag_uid": "VT123",
}
}
result = printer_state_to_dict(mock_state)
assert result["vt_tray"] is not None
assert result["vt_tray"]["id"] == 254
assert result["vt_tray"]["tray_color"] == "00FF00"
assert result["vt_tray"]["tray_type"] == "PETG"
def test_hms_errors_conversion(self, mock_state):
"""Verify HMS errors are converted correctly."""
error = MagicMock()
error.code = "0700_0100"
error.attr = 1
error.module = "AMS"
error.severity = 2
mock_state.hms_errors = [error]
result = printer_state_to_dict(mock_state)
assert len(result["hms_errors"]) == 1
assert result["hms_errors"][0]["code"] == "0700_0100"
assert result["hms_errors"][0]["module"] == "AMS"
def test_cover_url_added_for_running_print(self, mock_state):
"""Verify cover_url is added for running prints."""
result = printer_state_to_dict(mock_state, printer_id=1)
assert result["cover_url"] == "/api/v1/printers/1/cover"
def test_cover_url_none_when_not_running(self, mock_state):
"""Verify cover_url is None when not printing."""
mock_state.state = "IDLE"
result = printer_state_to_dict(mock_state, printer_id=1)
assert result["cover_url"] is None
def test_ams_ht_detection(self, mock_state):
"""Verify AMS-HT is detected (1 tray vs 4)."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{"id": 0}] # Only 1 tray = AMS-HT
}]
}
result = printer_state_to_dict(mock_state)
assert result["ams"][0]["is_ams_ht"] is True
def test_regular_ams_detection(self, mock_state):
"""Verify regular AMS is detected (4 trays)."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]
}]
}
result = printer_state_to_dict(mock_state)
assert result["ams"][0]["is_ams_ht"] is False
class TestInitPrinterConnections:
"""Tests for init_printer_connections function."""
@pytest.mark.asyncio
async def test_connects_all_active_printers(self):
"""Verify all active printers are connected."""
mock_db = AsyncMock()
mock_printer1 = MagicMock(id=1, is_active=True)
mock_printer2 = MagicMock(id=2, is_active=True)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
mock_db.execute.return_value = mock_result
with patch(
'backend.app.services.printer_manager.printer_manager'
) as mock_manager:
mock_manager.connect_printer = AsyncMock()
await init_printer_connections(mock_db)
assert mock_manager.connect_printer.call_count == 2
@pytest.mark.asyncio
async def test_handles_empty_printer_list(self):
"""Verify empty printer list is handled."""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_db.execute.return_value = mock_result
with patch(
'backend.app.services.printer_manager.printer_manager'
) as mock_manager:
mock_manager.connect_printer = AsyncMock()
await init_printer_connections(mock_db)
mock_manager.connect_printer.assert_not_called()
@@ -0,0 +1,603 @@
"""Unit tests for SmartPlugManager service.
These tests specifically target the auto-off behavior and toggle functionality
that were identified as common regression points.
"""
import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.smart_plug_manager import SmartPlugManager
class TestSmartPlugManager:
"""Tests for SmartPlugManager class."""
@pytest.fixture
def manager(self):
"""Create a fresh SmartPlugManager instance."""
return SmartPlugManager()
@pytest.fixture
def mock_plug(self):
"""Create a mock SmartPlug object."""
plug = MagicMock()
plug.id = 1
plug.name = "Test Plug"
plug.ip_address = "192.168.1.100"
plug.username = None
plug.password = None
plug.enabled = True
plug.auto_on = True
plug.auto_off = True
plug.off_delay_mode = "time"
plug.off_delay_minutes = 5
plug.off_temp_threshold = 70
plug.printer_id = 1
plug.auto_off_executed = False
plug.auto_off_pending = False
plug.last_state = "ON"
plug.last_checked = None
return plug
@pytest.fixture
def mock_db(self):
"""Create a mock database session."""
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
# ========================================================================
# Tests for on_print_start
# ========================================================================
@pytest.mark.asyncio
async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
"""Verify plug is turned ON when print starts with auto_on enabled."""
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = mock_plug
mock_tasmota.turn_on = AsyncMock(return_value=True)
await manager.on_print_start(printer_id=1, db=mock_db)
mock_tasmota.turn_on.assert_called_once_with(mock_plug)
@pytest.mark.asyncio
async def test_on_print_start_skipped_when_auto_on_disabled(
self, manager, mock_plug, mock_db
):
"""Verify plug is NOT turned on when auto_on is disabled."""
mock_plug.auto_on = False
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = mock_plug
mock_tasmota.turn_on = AsyncMock()
await manager.on_print_start(printer_id=1, db=mock_db)
mock_tasmota.turn_on.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_start_skipped_when_plug_disabled(
self, manager, mock_plug, mock_db
):
"""Verify plug is NOT turned on when plug.enabled is False."""
mock_plug.enabled = False
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = mock_plug
mock_tasmota.turn_on = AsyncMock()
await manager.on_print_start(printer_id=1, db=mock_db)
mock_tasmota.turn_on.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_start_skipped_when_no_plug_found(
self, manager, mock_db
):
"""Verify graceful handling when no plug is linked to printer."""
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = None
mock_tasmota.turn_on = AsyncMock()
# Should not raise any exception
await manager.on_print_start(printer_id=999, db=mock_db)
mock_tasmota.turn_on.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_start_cancels_pending_off(
self, manager, mock_plug, mock_db
):
"""Verify starting a new print cancels any pending auto-off."""
# Set up a pending task
mock_task = MagicMock()
manager._pending_off[mock_plug.id] = mock_task
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(
manager, '_mark_auto_off_pending', new_callable=AsyncMock
), \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = mock_plug
mock_tasmota.turn_on = AsyncMock(return_value=True)
await manager.on_print_start(printer_id=1, db=mock_db)
mock_task.cancel.assert_called_once()
assert mock_plug.id not in manager._pending_off
@pytest.mark.asyncio
async def test_on_print_start_resets_auto_off_executed_flag(
self, manager, mock_plug, mock_db
):
"""Verify auto_off_executed flag is reset when turning on."""
mock_plug.auto_off_executed = True
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
mock_get_plug.return_value = mock_plug
mock_tasmota.turn_on = AsyncMock(return_value=True)
await manager.on_print_start(printer_id=1, db=mock_db)
assert mock_plug.auto_off_executed is False
# ========================================================================
# Tests for on_print_complete
# ========================================================================
@pytest.mark.asyncio
async def test_on_print_complete_schedules_time_based_off(
self, manager, mock_plug, mock_db
):
"""Verify time-based auto-off is scheduled when print completes."""
mock_plug.off_delay_mode = "time"
mock_plug.off_delay_minutes = 5
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_delayed_off') as mock_schedule:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="completed", db=mock_db
)
mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec
@pytest.mark.asyncio
async def test_on_print_complete_schedules_temp_based_off(
self, manager, mock_plug, mock_db
):
"""Verify temperature-based auto-off is scheduled when print completes."""
mock_plug.off_delay_mode = "temperature"
mock_plug.off_temp_threshold = 70
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_temp_based_off') as mock_schedule:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="completed", db=mock_db
)
mock_schedule.assert_called_once_with(mock_plug, 1, 70)
@pytest.mark.asyncio
async def test_on_print_complete_skipped_when_auto_off_disabled(
self, manager, mock_plug, mock_db
):
"""CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
This is a key regression test - the toggle must respect the setting.
"""
mock_plug.auto_off = False
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_delayed_off') as mock_schedule, \
patch.object(manager, '_schedule_temp_based_off') as mock_temp:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="completed", db=mock_db
)
mock_schedule.assert_not_called()
mock_temp.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_complete_skipped_when_plug_disabled(
self, manager, mock_plug, mock_db
):
"""Verify auto-off does NOT trigger when plug is disabled."""
mock_plug.enabled = False
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_delayed_off') as mock_schedule:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="completed", db=mock_db
)
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_complete_skipped_on_failed_print(
self, manager, mock_plug, mock_db
):
"""Verify auto-off does NOT trigger on failed prints for investigation."""
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_delayed_off') as mock_schedule:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="failed", db=mock_db
)
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_complete_skipped_on_aborted_print(
self, manager, mock_plug, mock_db
):
"""Verify auto-off does NOT trigger on aborted prints."""
with patch.object(
manager, '_get_plug_for_printer', new_callable=AsyncMock
) as mock_get_plug, \
patch.object(manager, '_schedule_delayed_off') as mock_schedule:
mock_get_plug.return_value = mock_plug
await manager.on_print_complete(
printer_id=1, status="aborted", db=mock_db
)
mock_schedule.assert_not_called()
# ========================================================================
# Tests for _cancel_pending_off
# ========================================================================
@pytest.mark.asyncio
async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
"""Verify pending off tasks can be cancelled."""
mock_task = MagicMock()
manager._pending_off[mock_plug.id] = mock_task
with patch.object(
manager, '_mark_auto_off_pending', new_callable=AsyncMock
):
manager._cancel_pending_off(mock_plug.id)
assert mock_plug.id not in manager._pending_off
mock_task.cancel.assert_called_once()
@pytest.mark.asyncio
async def test_cancel_pending_off_handles_missing_task(self, manager):
"""Verify no error when cancelling non-existent task."""
# Should not raise any exception
with patch.object(
manager, '_mark_auto_off_pending', new_callable=AsyncMock
):
manager._cancel_pending_off(999) # Non-existent plug ID
@pytest.mark.asyncio
async def test_cancel_all_pending(self, manager, mock_plug):
"""Verify all pending tasks can be cancelled."""
mock_task1 = MagicMock()
mock_task2 = MagicMock()
manager._pending_off[1] = mock_task1
manager._pending_off[2] = mock_task2
with patch('asyncio.create_task') as mock_create:
manager.cancel_all_pending()
assert len(manager._pending_off) == 0
mock_task1.cancel.assert_called_once()
mock_task2.cancel.assert_called_once()
# ========================================================================
# Tests for scheduler
# ========================================================================
def test_start_scheduler(self, manager):
"""Verify scheduler can be started."""
assert manager._scheduler_task is None
# Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
with patch.object(manager, '_schedule_loop') as mock_loop, \
patch('asyncio.create_task') as mock_create:
mock_create.return_value = MagicMock()
manager.start_scheduler()
assert manager._scheduler_task is not None
mock_loop.assert_called_once()
def test_stop_scheduler(self, manager):
"""Verify scheduler can be stopped."""
mock_task = MagicMock()
manager._scheduler_task = mock_task
manager.stop_scheduler()
mock_task.cancel.assert_called_once()
assert manager._scheduler_task is None
def test_start_scheduler_idempotent(self, manager):
"""Verify starting scheduler twice doesn't create multiple tasks."""
mock_task = MagicMock()
manager._scheduler_task = mock_task
# Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
with patch.object(manager, '_schedule_loop') as mock_loop, \
patch('asyncio.create_task') as mock_create:
manager.start_scheduler()
mock_create.assert_not_called() # Should not create new task
mock_loop.assert_not_called() # Should not call _schedule_loop
class TestScheduleLoop:
"""Tests for the schedule-based plug control."""
@pytest.fixture
def manager(self):
return SmartPlugManager()
@pytest.mark.asyncio
async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
"""Verify scheduled on-time turns plug on."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.name = "Test Plug"
mock_plug.enabled = True
mock_plug.schedule_enabled = True
mock_plug.schedule_on_time = "08:00"
mock_plug.schedule_off_time = "22:00"
mock_plug.printer_id = None
mock_plug.last_state = "OFF"
with patch(
'backend.app.services.smart_plug_manager.datetime'
) as mock_datetime, \
patch(
'backend.app.core.database.async_session'
) as mock_session_ctx, \
patch(
'backend.app.services.smart_plug_manager.tasmota_service'
) as mock_tasmota:
# Set current time to 08:00
mock_now = MagicMock()
mock_now.strftime.return_value = "08:00"
mock_datetime.now.return_value = mock_now
mock_datetime.utcnow.return_value = datetime.utcnow()
# Set up async session mock
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_plug]
mock_db.execute = AsyncMock(return_value=mock_result)
mock_db.commit = AsyncMock()
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
mock_tasmota.turn_on = AsyncMock(return_value=True)
await manager._check_schedules()
mock_tasmota.turn_on.assert_called_once_with(mock_plug)
@pytest.mark.asyncio
async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
"""Verify scheduled off-time turns plug off."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.name = "Test Plug"
mock_plug.enabled = True
mock_plug.schedule_enabled = True
mock_plug.schedule_on_time = "08:00"
mock_plug.schedule_off_time = "22:00"
mock_plug.printer_id = 1
mock_plug.last_state = "ON"
with patch(
'backend.app.services.smart_plug_manager.datetime'
) as mock_datetime, \
patch(
'backend.app.core.database.async_session'
) as mock_session_ctx, \
patch(
'backend.app.services.smart_plug_manager.tasmota_service'
) as mock_tasmota, \
patch(
'backend.app.services.smart_plug_manager.printer_manager'
) as mock_pm:
# Set current time to 22:00
mock_now = MagicMock()
mock_now.strftime.return_value = "22:00"
mock_datetime.now.return_value = mock_now
mock_datetime.utcnow.return_value = datetime.utcnow()
# Set up async session mock
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_plug]
mock_db.execute = AsyncMock(return_value=mock_result)
mock_db.commit = AsyncMock()
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
mock_tasmota.turn_off = AsyncMock(return_value=True)
mock_pm.mark_printer_offline = MagicMock()
await manager._check_schedules()
mock_tasmota.turn_off.assert_called_once_with(mock_plug)
@pytest.mark.asyncio
async def test_check_schedules_skipped_when_disabled(self, manager):
"""Verify schedule is skipped when schedule_enabled is False."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.enabled = True
mock_plug.schedule_enabled = False # Disabled
with patch(
'backend.app.services.smart_plug_manager.datetime'
) as mock_datetime, \
patch(
'backend.app.core.database.async_session'
) as mock_session_ctx, \
patch(
'backend.app.services.smart_plug_manager.tasmota_service'
) as mock_tasmota:
mock_now = MagicMock()
mock_now.strftime.return_value = "08:00"
mock_datetime.now.return_value = mock_now
# Set up async session mock - returns no plugs (filtered by schedule_enabled)
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_db.commit = AsyncMock()
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
mock_tasmota.turn_on = AsyncMock()
await manager._check_schedules()
mock_tasmota.turn_on.assert_not_called()
class TestPendingAutoOffPersistence:
"""Tests for auto-off pending state persistence (restart recovery)."""
@pytest.fixture
def manager(self):
return SmartPlugManager()
@pytest.mark.asyncio
async def test_resume_pending_auto_offs_temperature_mode(self, manager):
"""Verify temperature-based pending auto-offs are resumed on startup."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.name = "Test Plug"
mock_plug.ip_address = "192.168.1.100"
mock_plug.username = None
mock_plug.password = None
mock_plug.printer_id = 1
mock_plug.auto_off_pending = True
mock_plug.auto_off_pending_since = datetime.utcnow()
mock_plug.off_delay_mode = "temperature"
mock_plug.off_temp_threshold = 70
with patch(
'backend.app.core.database.async_session'
) as mock_session_ctx, \
patch.object(
manager, '_schedule_temp_based_off'
) as mock_schedule:
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_plug]
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
await manager.resume_pending_auto_offs()
mock_schedule.assert_called_once_with(mock_plug, 1, 70)
@pytest.mark.asyncio
async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
"""Verify time-based pending auto-offs turn off immediately on resume."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.name = "Test Plug"
mock_plug.ip_address = "192.168.1.100"
mock_plug.username = None
mock_plug.password = None
mock_plug.printer_id = 1
mock_plug.auto_off_pending = True
mock_plug.auto_off_pending_since = datetime.utcnow()
mock_plug.off_delay_mode = "time"
with patch(
'backend.app.core.database.async_session'
) as mock_session_ctx, \
patch(
'backend.app.services.smart_plug_manager.tasmota_service'
) as mock_tasmota, \
patch.object(
manager, '_mark_auto_off_executed', new_callable=AsyncMock
) as mock_mark, \
patch(
'backend.app.services.smart_plug_manager.printer_manager'
) as mock_pm:
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_plug]
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
mock_tasmota.turn_off = AsyncMock(return_value=True)
await manager.resume_pending_auto_offs()
mock_tasmota.turn_off.assert_called_once()
mock_mark.assert_called_once_with(1)
+383
View File
@@ -0,0 +1,383 @@
"""Unit tests for TasmotaService.
Tests smart plug HTTP communication and error handling.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
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)