Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior.
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""Regression tests for AutomationEngine handler-error storage.
|
|
|
|
The Discord-reported "Clean Search History" error
|
|
(`'DownloadOrchestrator' object has no attribute 'base_url'`) stayed
|
|
visible on the automation card long after the underlying handler bug
|
|
was fixed because the engine only stored uncaught exceptions in
|
|
``last_error``. Handlers that report failure by RETURNING
|
|
``{'status': 'error', ...}`` were treated as successful from the
|
|
engine's perspective, so subsequent successful runs never cleared the
|
|
stale error.
|
|
|
|
These tests pin the new behaviour: every reported failure mode
|
|
(``status=error`` with any of ``error`` / ``reason`` / ``message``,
|
|
or no key at all) must surface to ``update_automation_run`` so the
|
|
DB row reflects reality and a successful next run clears it.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.automation_engine import AutomationEngine
|
|
|
|
|
|
@pytest.fixture
|
|
def engine_with_handler():
|
|
"""Build an AutomationEngine with a stub DB and a stub handler we can swap.
|
|
|
|
Returns a tuple of (engine, db_mock, set_handler) where set_handler
|
|
swaps in the handler that the next run_automation call will execute.
|
|
"""
|
|
db_mock = MagicMock()
|
|
db_mock.get_automation.return_value = {
|
|
'id': 1,
|
|
'name': 'Clean Search History',
|
|
'enabled': True,
|
|
'action_type': 'clean_search_history',
|
|
'action_config': '{}',
|
|
'trigger_type': 'interval_hours',
|
|
'trigger_config': '{"hours": 1}',
|
|
}
|
|
db_mock.update_automation_run = MagicMock(return_value=True)
|
|
|
|
engine = AutomationEngine(db_mock)
|
|
engine._running = True
|
|
|
|
handler_holder = {'fn': lambda config: {'status': 'completed'}}
|
|
|
|
def set_handler(fn):
|
|
handler_holder['fn'] = fn
|
|
|
|
engine._action_handlers['clean_search_history'] = {
|
|
'handler': lambda config: handler_holder['fn'](config),
|
|
'guard': None,
|
|
}
|
|
return engine, db_mock, set_handler
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_successful_run_clears_last_error(engine_with_handler) -> None:
|
|
"""A clean run must save error=None so any stale stored error clears."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'completed'})
|
|
engine.run_automation(1, skip_delay=True)
|
|
db_mock.update_automation_run.assert_called_once()
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') is None
|
|
|
|
|
|
def test_handler_returning_error_key_stores_it(engine_with_handler) -> None:
|
|
"""status=error with an 'error' key must populate last_error."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'error', 'error': "no attribute 'base_url'"})
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') == "no attribute 'base_url'"
|
|
|
|
|
|
def test_handler_returning_reason_key_stores_it(engine_with_handler) -> None:
|
|
"""Older handlers use 'reason' instead of 'error'. Must still surface."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'error', 'reason': 'slskd unreachable'})
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') == 'slskd unreachable'
|
|
|
|
|
|
def test_handler_returning_message_key_stores_it(engine_with_handler) -> None:
|
|
"""Some action handlers use 'message'. Must still surface."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'error', 'message': 'rate limited'})
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') == 'rate limited'
|
|
|
|
|
|
def test_handler_returning_error_status_with_no_message_stores_placeholder(
|
|
engine_with_handler,
|
|
) -> None:
|
|
"""status=error with no detail key must still record SOMETHING so
|
|
last_error is non-null and the UI can flag the run as failed."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'error'})
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') == 'Handler reported failure'
|
|
|
|
|
|
def test_handler_raising_exception_still_stores_error(engine_with_handler) -> None:
|
|
"""The original behaviour (uncaught exceptions get caught + stored)
|
|
must keep working — this is the case that originally surfaced the
|
|
Discord-reported AttributeError before the fix."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
|
|
def raising_handler(config):
|
|
raise AttributeError("'DownloadOrchestrator' object has no attribute 'base_url'")
|
|
set_handler(raising_handler)
|
|
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') and 'base_url' in kwargs['error']
|
|
|
|
|
|
def test_skipped_status_records_no_error(engine_with_handler) -> None:
|
|
"""status=skipped is a normal outcome, must not look like a failure."""
|
|
engine, db_mock, set_handler = engine_with_handler
|
|
set_handler(lambda config: {'status': 'skipped', 'reason': 'not configured'})
|
|
engine.run_automation(1, skip_delay=True)
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') is None
|
|
|
|
|
|
def test_guarded_scheduled_skip_retries_soon() -> None:
|
|
"""A busy guarded action should retry soon instead of waiting a full interval."""
|
|
db_mock = MagicMock()
|
|
db_mock.get_automation.return_value = {
|
|
'id': 1,
|
|
'name': 'Playlist Pipeline',
|
|
'enabled': True,
|
|
'action_type': 'playlist_pipeline',
|
|
'action_config': '{}',
|
|
'trigger_type': 'schedule',
|
|
'trigger_config': '{"interval": 6, "unit": "hours"}',
|
|
}
|
|
db_mock.update_automation_run = MagicMock(return_value=True)
|
|
|
|
engine = AutomationEngine(db_mock)
|
|
engine._running = True
|
|
engine.schedule_automation = MagicMock()
|
|
engine._action_handlers['playlist_pipeline'] = {
|
|
'handler': lambda config: {'status': 'completed'},
|
|
'guard': lambda: True,
|
|
}
|
|
|
|
engine.run_automation(1, skip_delay=True)
|
|
|
|
kwargs = db_mock.update_automation_run.call_args.kwargs
|
|
assert kwargs.get('error') is None
|
|
assert kwargs.get('next_run') is not None
|
|
# It should be scheduled for roughly five minutes, not the configured six hours.
|
|
from datetime import datetime, timezone
|
|
|
|
next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
|
delay = (next_run - datetime.now(timezone.utc)).total_seconds()
|
|
assert 0 < delay <= 360
|