mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -0,0 +1 @@
|
||||
"""Unit tests for BamBuddy backend services."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user