Files
bambuddy/backend/tests/unit/services/test_smart_plug_manager.py
T
maziggy 8266d225d2 fix(energy): date-range energy in total mode + restart-resilient per-print tracking (#941)
The Statistics page reported "Gesamt" (All Time) kWh correctly but showed
  zero for Today/Week/Month in total-consumption mode. Two bugs drove it:

  1. The starting plug counter was kept in an in-memory dict
     `_print_energy_start` that was lost on any backend restart mid-print, so
     the per-print `energy_kwh` delta silently never got computed. The stats
     endpoint's fallback path `SUM(PrintArchive.energy_kwh)` therefore summed
     to zero for users running in total mode.
  2. Total-consumption mode has no per-print delta by design — it includes
     idle/preheat/standby — so the fallback to archive rows was the wrong
     strategy even when the data existed.

  Fix, in two parts:

  - Persist `energy_start_kwh` on the archive row and read it back from a
    fresh session at print end. Deletes `_print_energy_start` and its 5
    call sites, replacing them with a single `_record_energy_start()` helper.
    Per-print tracking is now restart-resilient regardless of tracking mode.
  - Add hourly `smart_plug_energy_snapshots` table + `_snapshot_loop()` in
    SmartPlugManager. Rewrote the `/archives/stats` energy branch as
    `_sum_snapshot_deltas()` which computes per-plug
    `max(0, last-in-range - baseline)` where baseline is the latest snapshot
    at or before the range start, falling back to the earliest-ever snapshot
    and signalling `energy_data_warming_up` when no pre-range baseline
    exists (fresh upgrade). MQTT plugs are skipped from snapshots since they
    only report "today" and have no lifetime counter.

  Frontend: QuickStatsWidget renders an AlertTriangle next to Energy Used /
  Energy Cost with a tooltip when `energy_data_warming_up` is true, so the
  "low values right after upgrading" situation is explained in-product.
  Fully localised across 7 UI languages.

  Tests: new backend unit tests cover the snapshot delta arithmetic
  (baseline/endpoint, counter reset clamp, multi-plug, warming-up fallback,
  endpoint windowing), per-print restart resilience via expunge_all, and the
  snapshot task lifecycle (start idempotent, stop cancels). Frontend tests
  assert the warning icon appears only when the flag is set and only on the
  energy tiles.

  Docs: updated `CHANGELOG.md`, `README.md`, wiki `features/energy.md`,
  wiki `features/statistics.md`, and website `features.html` with the new
  behaviour and warming-up explanation.
2026-04-11 11:14:54 +02:00

739 lines
30 KiB
Python

"""Unit tests for SmartPlugManager service.
These tests specifically target the auto-off behavior and toggle functionality
that were identified as common regression points.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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_plugs_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_plugs_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_plugs_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_plugs_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_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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_plugs_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"):
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_schedule_task = MagicMock()
mock_snapshot_task = MagicMock()
manager._scheduler_task = mock_schedule_task
manager._snapshot_task = mock_snapshot_task
# Mock the loop coroutines to avoid unawaited coroutine warnings
with (
patch.object(manager, "_schedule_loop") as mock_loop,
patch.object(manager, "_snapshot_loop") as mock_snapshot,
patch("asyncio.create_task") as mock_create,
):
manager.start_scheduler()
mock_create.assert_not_called() # Should not create new tasks
mock_loop.assert_not_called()
mock_snapshot.assert_not_called()
def test_stop_scheduler_cancels_snapshot_task(self, manager):
"""Verify stopping scheduler also cancels the snapshot loop (#941)."""
mock_schedule_task = MagicMock()
mock_snapshot_task = MagicMock()
manager._scheduler_task = mock_schedule_task
manager._snapshot_task = mock_snapshot_task
manager.stop_scheduler()
mock_schedule_task.cancel.assert_called_once()
mock_snapshot_task.cancel.assert_called_once()
assert manager._scheduler_task is None
assert manager._snapshot_task is None
class TestGetPlugsForPrinter:
"""Tests for _get_plugs_for_printer — returns all plugs for a printer (#903)."""
@pytest.fixture
def manager(self):
return SmartPlugManager()
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_plugs(self, manager):
"""Verify empty list is returned when no plugs are linked to printer."""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
result = await manager._get_plugs_for_printer(1, mock_db)
assert result == []
@pytest.mark.asyncio
async def test_returns_single_plug_as_list(self, manager):
"""Verify single plug is returned in a list."""
plug = MagicMock()
plug.plug_type = "tasmota"
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [plug]
mock_db.execute = AsyncMock(return_value=mock_result)
result = await manager._get_plugs_for_printer(1, mock_db)
assert result == [plug]
@pytest.mark.asyncio
async def test_returns_all_plugs(self, manager):
"""Verify all plugs are returned when multiple exist (#903)."""
plug1 = MagicMock()
plug1.plug_type = "homeassistant"
plug1.ha_entity_id = "switch.printer"
plug2 = MagicMock()
plug2.plug_type = "homeassistant"
plug2.ha_entity_id = "switch.filter"
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [plug1, plug2]
mock_db.execute = AsyncMock(return_value=mock_result)
result = await manager._get_plugs_for_printer(1, mock_db)
assert result == [plug1, plug2]
class TestAutoOffPersistent:
"""Tests for persistent auto-off behavior (Issue #826).
When auto_off_persistent is True, auto_off should remain enabled after
execution instead of being disabled (one-shot default).
"""
@pytest.fixture
def manager(self):
return SmartPlugManager()
@pytest.mark.asyncio
async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
"""Default one-shot: auto_off should be set to False after execution."""
mock_plug = MagicMock()
mock_plug.id = 1
mock_plug.auto_off = True
mock_plug.auto_off_persistent = False
mock_plug.auto_off_executed = False
mock_plug.auto_off_pending = True
mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
with patch("backend.app.core.database.async_session") as mock_session_ctx:
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.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()
await manager._mark_auto_off_executed(1)
assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
assert mock_plug.auto_off_pending is False
assert mock_plug.auto_off_pending_since is None
mock_db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
"""Persistent mode: auto_off should remain True after execution."""
mock_plug = MagicMock()
mock_plug.id = 2
mock_plug.auto_off = True
mock_plug.auto_off_persistent = True
mock_plug.auto_off_executed = False
mock_plug.auto_off_pending = True
mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
with patch("backend.app.core.database.async_session") as mock_session_ctx:
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.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()
await manager._mark_auto_off_executed(2)
assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
assert mock_plug.auto_off_pending is False
assert mock_plug.auto_off_pending_since is None
mock_db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_persistent_auto_off_full_cycle(self, manager):
"""Verify persistent auto-off survives a full print cycle.
Simulates: print start → print complete → auto-off executes → next print start.
auto_off should remain True throughout for persistent plugs.
"""
mock_plug = MagicMock()
mock_plug.id = 3
mock_plug.name = "HA BentoBox Filter"
mock_plug.plug_type = "homeassistant"
mock_plug.ha_entity_id = "switch.bentobox_filter"
mock_plug.ip_address = None
mock_plug.username = None
mock_plug.password = None
mock_plug.enabled = True
mock_plug.auto_on = True
mock_plug.auto_off = True
mock_plug.auto_off_persistent = True
mock_plug.off_delay_mode = "time"
mock_plug.off_delay_minutes = 1
mock_plug.off_temp_threshold = 70
mock_plug.printer_id = 1
mock_plug.auto_off_executed = False
mock_plug.auto_off_pending = False
mock_plug.last_state = "OFF"
mock_plug.last_checked = None
mock_db = AsyncMock()
mock_db.commit = AsyncMock()
# Step 1: Print starts — plug turns on
with (
patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
):
mock_get.return_value = [mock_plug]
mock_service = AsyncMock()
mock_service.turn_on = AsyncMock(return_value=True)
mock_svc.return_value = mock_service
await manager.on_print_start(printer_id=1, db=mock_db)
assert mock_plug.auto_off_executed is False
assert mock_plug.auto_off is True # Still enabled
# Step 2: Print completes — auto-off is scheduled
with (
patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
patch.object(manager, "_schedule_delayed_off") as mock_schedule,
):
mock_get.return_value = [mock_plug]
await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
mock_schedule.assert_called_once()
assert mock_plug.auto_off is True # Still enabled after scheduling
# Step 3: Auto-off executes via _mark_auto_off_executed
with patch("backend.app.core.database.async_session") as mock_session_ctx:
mock_db2 = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_plug
mock_db2.execute = AsyncMock(return_value=mock_result)
mock_db2.commit = AsyncMock()
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
mock_session_ctx.return_value.__aexit__ = AsyncMock()
await manager._mark_auto_off_executed(3)
# KEY ASSERTION: auto_off stays True for persistent mode
assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
assert mock_plug.auto_off_pending is False
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.now(timezone.utc)
# 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.now(timezone.utc)
# 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.now(timezone.utc)
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.now(timezone.utc)
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"),
):
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)