soulsync/tests/automation/test_engine_schedule_integration.py
BoulderBadgeDad fd66390648 Video automations: reliably delete the obsolete 'Scan Video Library' seed
_fix_video_scan_default set its 'done' flag even on runs where it deleted
nothing, so once the flag latched True the standalone 'Scan Video Library'
system automation survived forever (the row the post-download chain replaced).
Drop the flag entirely — get_system_automation_by_action already matches only
the is_system-seeded row, so the cleanup is safe to run every startup and
no-ops once the row is gone.
2026-06-21 08:59:32 -07:00

455 lines
20 KiB
Python

"""Engine-level integration tests for the next_run_at refactor (PR 2).
PR 1 lifted next-run computation into ``core/automation/schedule.py``
as a pure function. PR 2 wires the engine through it — three setup
methods (daily / weekly / monthly) collapse to one ``_setup_timed_trigger``
helper, ``_finish_run`` drops its inline daily / weekly arithmetic,
and ``monthly_time`` becomes a real registered trigger type.
These tests pin the integration surface:
- ``_finish_run`` dispatches through ``next_run_at`` for every trigger
type with the right args (trigger_type, trigger_config, now_utc,
default_tz) and serialises the result into the DB ``next_run`` column.
- Retry-delay short-circuit bypasses ``next_run_at`` (immediate
reschedule on transient failure, not on the regular cadence).
- Error path swallows + writes None next_run instead of crashing.
- Backward-compat: existing daily / weekly rows without an explicit
``tz`` field use the engine's ``_default_tz`` (server-local IANA),
preserving "every Monday 09:00 server-local" behaviour.
- New ``monthly_time`` trigger registers in ``_trigger_handlers`` and
arms a timer correctly.
- ``_setup_timed_trigger`` honours an existing future ``next_run`` in
the DB (lets manual edits / restart-resume survive).
- ``_dt_to_db_str`` correctly normalises aware + naive datetimes to
the engine's naive-UTC string convention.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from core.automation_engine import (
AutomationEngine,
_dt_to_db_str,
_resolve_system_default_tz,
)
def _utc(year, month, day, hour=0, minute=0, second=0):
"""Aware UTC datetime for test clarity — matches the convention
used in tests/automation/test_schedule.py."""
return datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# _dt_to_db_str — engine-side serialiser for ``next_run_at`` results.
# ---------------------------------------------------------------------------
def test_dt_to_db_str_normalises_aware_utc():
"""Aware-UTC datetime → naive-UTC string the DB column expects.
Format matches the engine's existing ``_utc_after``."""
dt = _utc(2026, 5, 27, 14, 30, 0)
assert _dt_to_db_str(dt) == '2026-05-27 14:30:00'
def test_dt_to_db_str_converts_aware_non_utc_to_utc_first():
"""An aware datetime in a non-UTC tz must be converted to UTC
before stringifying — otherwise the DB column would silently
hold a tz-shifted instant. This is the bug class the PR 1
tests already cover at the next_run_at layer; pin it here so a
future refactor that drops the ``.astimezone(UTC)`` step fails
fast."""
from zoneinfo import ZoneInfo
la = ZoneInfo('America/Los_Angeles')
dt = datetime(2026, 5, 27, 9, 0, 0, tzinfo=la) # 09:00 PDT
# 09:00 PDT (UTC-7) → 16:00 UTC.
assert _dt_to_db_str(dt) == '2026-05-27 16:00:00'
def test_dt_to_db_str_assumes_naive_is_utc():
"""Defensive — naive inputs are assumed UTC (matches the engine's
convention when parsing the DB column back out)."""
dt = datetime(2026, 5, 27, 14, 30, 0) # naive
assert _dt_to_db_str(dt) == '2026-05-27 14:30:00'
# ---------------------------------------------------------------------------
# _resolve_system_default_tz — engine's tz fallback chain.
# ---------------------------------------------------------------------------
def test_resolve_system_default_tz_returns_iana_string():
"""The engine caches this at import time; the result must be a
string (not a ZoneInfo object) so it can flow into next_run_at's
``default_tz`` param."""
result = _resolve_system_default_tz()
assert isinstance(result, str)
assert len(result) > 0
def test_resolve_system_default_tz_falls_back_to_utc_when_tzlocal_missing():
"""tzlocal is in requirements but the engine should still boot
without it — minimal Docker images / dev environments where
tzlocal didn't install. Defensive fallback to UTC instead of
crashing the engine."""
with patch.dict('sys.modules', {'tzlocal': None}):
# Force ImportError on the in-function import.
import importlib
import core.automation_engine as engine_mod
importlib.reload(engine_mod)
result = engine_mod._resolve_system_default_tz()
assert result == 'UTC'
# Reload again to restore normal state for subsequent tests.
importlib.reload(engine_mod)
# ---------------------------------------------------------------------------
# Engine fixture — minimal AutomationEngine with mocked DB.
# ---------------------------------------------------------------------------
@pytest.fixture
def engine_with_db():
"""AutomationEngine wired to a mock DB. Used across the
integration tests below — each test sets ``trigger_type`` and
``trigger_config`` on the mock's ``get_automation`` return value."""
db_mock = MagicMock()
db_mock.update_automation_run = MagicMock(return_value=True)
db_mock.update_automation = MagicMock(return_value=True)
db_mock.get_automation.return_value = None # tests override
engine = AutomationEngine(db_mock)
engine._running = True
return engine, db_mock
# ---------------------------------------------------------------------------
# _finish_run — single integration point with next_run_at.
# ---------------------------------------------------------------------------
def test_finish_run_dispatches_interval_trigger_through_next_run_at(engine_with_db):
"""Interval trigger flows through the same next_run_at call as
daily/weekly/monthly — no special-case branch left in the engine
for the legacy ``schedule`` type."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'schedule',
'trigger_config': json.dumps({'interval': 6, 'unit': 'hours'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 18, 0)
engine._finish_run(auto, 1, {'status': 'completed'}, error=None)
assert mock_nra.called
call = mock_nra.call_args
assert call.args[0] == 'schedule'
assert call.args[1] == {'interval': 6, 'unit': 'hours'}
assert call.kwargs['default_tz'] == engine._default_tz
def test_finish_run_dispatches_daily_time_through_next_run_at(engine_with_db):
"""Daily trigger no longer has its own inline arithmetic — the
refactor must route through next_run_at with the unmodified
trigger_config so tz / time fields flow through cleanly."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00', 'tz': 'America/Los_Angeles'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'daily_time'
assert mock_nra.call_args.args[1] == {'time': '09:00', 'tz': 'America/Los_Angeles'}
def test_finish_run_dispatches_weekly_time_through_next_run_at(engine_with_db):
"""Weekly trigger same as daily — single integration point."""
engine, db_mock = engine_with_db
cfg = {'time': '09:00', 'days': ['mon', 'wed', 'fri'], 'tz': 'America/Los_Angeles'}
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'weekly_time',
'trigger_config': json.dumps(cfg),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'weekly_time'
assert mock_nra.call_args.args[1] == cfg
def test_finish_run_dispatches_monthly_time_through_next_run_at(engine_with_db):
"""New monthly_time trigger — added to _trigger_handlers in PR 2.
Without this entry, the if-trigger_type-in-handlers gate above
skips computation entirely and the DB next_run stays stale."""
engine, db_mock = engine_with_db
cfg = {'time': '09:00', 'day_of_month': 15, 'tz': 'America/Los_Angeles'}
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'monthly_time',
'trigger_config': json.dumps(cfg),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 6, 15, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'monthly_time'
def test_finish_run_retry_delay_short_circuits_next_run_at(engine_with_db):
"""When a transient failure asks for a retry-delay reschedule
(e.g. action handler returns ``status='retry'``), the next_run
is just now+delay — NOT the regular schedule cadence. The
refactor must preserve this short-circuit path."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
engine._finish_run(auto, 1, {}, error='boom', retry_delay_seconds=120)
# next_run_at NOT called — we used the retry delay instead.
mock_nra.assert_not_called()
# DB write happened (with a next_run computed from now + 120s).
assert db_mock.update_automation_run.called
written = db_mock.update_automation_run.call_args.kwargs.get('next_run')
assert written is not None
def test_finish_run_writes_none_when_next_run_at_returns_none(engine_with_db):
"""Defensive — next_run_at can return None for unknown trigger
types or completely broken configs. The engine must write
None to the DB rather than skip the update (which would leave
a stale next_run sitting in the row forever)."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at', return_value=None):
engine._finish_run(auto, 1, {}, error=None)
assert db_mock.update_automation_run.called
assert db_mock.update_automation_run.call_args.kwargs.get('next_run') is None
def test_finish_run_swallows_next_run_at_exception(engine_with_db):
"""next_run_at is pure so it shouldn't raise — but if it does
(programmer error in the helper, weird tz lookup), the engine
must not crash the finish-run cycle. Existing behaviour
swallows + logs at debug; the refactor preserves that."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at', side_effect=RuntimeError('boom')):
engine._finish_run(auto, 1, {}, error=None)
# DB write still happens, just with None next_run.
assert db_mock.update_automation_run.called
def test_finish_run_skips_next_run_for_event_triggers(engine_with_db):
"""Event-based triggers (not in _trigger_handlers) have no
scheduled next-run — the existing gate must still skip them
after the refactor."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'event',
'trigger_config': json.dumps({}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
engine._finish_run(auto, 1, {}, error=None)
mock_nra.assert_not_called()
# update_automation_run still fires but with next_run=None.
assert db_mock.update_automation_run.call_args.kwargs.get('next_run') is None
def test_finish_run_passes_engine_default_tz(engine_with_db):
"""Backward-compat: existing daily/weekly rows without ``tz`` in
their config must use the engine's ``_default_tz`` (server-local
IANA via tzlocal). Pre-fix, the engine implicitly used naive
``datetime.now()`` = server local; post-fix the explicit
default_tz preserves that behaviour."""
engine, db_mock = engine_with_db
engine._default_tz = 'America/Los_Angeles' # simulate server-local
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}), # NO tz field
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.kwargs['default_tz'] == 'America/Los_Angeles'
# ---------------------------------------------------------------------------
# Trigger handler registration.
# ---------------------------------------------------------------------------
def test_engine_registers_monthly_time_trigger(engine_with_db):
"""``monthly_time`` joins schedule / daily_time / weekly_time in
the _trigger_handlers registry — without this, calling
``schedule_automation`` on a monthly row falls through the
``trigger_type in self._trigger_handlers`` gate and the
automation never gets armed."""
engine, _ = engine_with_db
assert 'monthly_time' in engine._trigger_handlers
assert callable(engine._trigger_handlers['monthly_time'])
def test_engine_keeps_existing_trigger_registrations(engine_with_db):
"""Backward-compat: the refactor must not drop the historic
trigger types. schedule / daily_time / weekly_time stay
registered alongside the new monthly_time."""
engine, _ = engine_with_db
assert 'schedule' in engine._trigger_handlers
assert 'daily_time' in engine._trigger_handlers
assert 'weekly_time' in engine._trigger_handlers
# ---------------------------------------------------------------------------
# _setup_timed_trigger — shared skeleton for daily / weekly / monthly.
# ---------------------------------------------------------------------------
def test_setup_monthly_time_trigger_writes_next_run_and_arms_timer(engine_with_db):
"""Sanity check that the new monthly handler actually wires up
a timer (it's the new-shaped trigger so a "no timer armed"
regression would otherwise be silent — the automation just
never fires)."""
engine, db_mock = engine_with_db
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer = MagicMock()
mock_timer_cls.return_value = mock_timer
engine._setup_monthly_time_trigger(
1, {'time': '09:00', 'day_of_month': 15, 'tz': 'UTC'},
)
# Timer armed.
assert mock_timer.start.called
# next_run written to DB.
assert db_mock.update_automation.called
written = db_mock.update_automation.call_args.kwargs.get('next_run')
assert written is not None and isinstance(written, str)
def test_setup_timed_trigger_honours_future_db_next_run(engine_with_db):
"""If the DB row already has a future ``next_run`` (e.g. a
manual edit, or a process restart picking up where it left
off), the setup must keep that instant — not recompute from
scratch. Matches the existing interval-path behaviour and
prevents losing pending retries."""
engine, db_mock = engine_with_db
# Far-future next_run in the DB.
future = (datetime.now(timezone.utc) + timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
db_mock.get_automation.return_value = {'id': 1, 'next_run': future}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer_cls.return_value = MagicMock()
engine._setup_daily_time_trigger(1, {'time': '09:00', 'tz': 'UTC'})
# Engine writes the EXISTING next_run back (the if-future-in-DB
# branch overrides the freshly-computed delay).
written = db_mock.update_automation.call_args.kwargs.get('next_run')
assert written == future
def test_setup_timed_trigger_skips_when_next_run_at_returns_none(engine_with_db):
"""If next_run_at can't compute a valid next-run (e.g. broken
config that defeats every defensive fallback in the helper),
the setup must NOT arm a timer with bogus delay. Skip-with-log
is safer than scheduling-for-the-past or scheduling-immediately."""
engine, db_mock = engine_with_db
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.next_run_at', return_value=None), \
patch('core.automation_engine.threading.Timer') as mock_timer_cls:
engine._setup_monthly_time_trigger(1, {})
# No timer armed.
mock_timer_cls.assert_not_called()
# ---------------------------------------------------------------------------
# End-to-end: real next_run_at + engine wiring (no mocks).
# ---------------------------------------------------------------------------
def test_end_to_end_monthly_schedule_produces_valid_db_string(engine_with_db):
"""No-mock smoke: monthly_time config flows from engine through
the real next_run_at into a valid DB string. Catches any
serialisation drift between PR 1 (helper returns aware UTC) and
PR 2 (engine writes naive UTC string)."""
engine, db_mock = engine_with_db
engine._default_tz = 'UTC'
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer_cls.return_value = MagicMock()
engine._setup_monthly_time_trigger(
1, {'time': '09:00', 'day_of_month': 15},
)
written = db_mock.update_automation.call_args.kwargs['next_run']
# Format matches the engine's existing _utcnow_str / _utc_after.
parsed = datetime.strptime(written, '%Y-%m-%d %H:%M:%S')
# Day-of-month is 15 in the user's tz (UTC here).
assert parsed.day == 15
assert parsed.hour == 9
assert parsed.minute == 0
# ---------------------------------------------------------------------------
# System-automation seeding — owned_by / action_config (video side).
# ---------------------------------------------------------------------------
def test_ensure_system_automations_seeds_video_with_owned_by_and_mode():
"""The video post-download chain twins must seed with owned_by='video' so they
stay off the music page; the standalone 'Scan Video Library' is NO LONGER seeded
(superseded by the chain). Music system automations keep owned_by=None."""
db = MagicMock()
db.get_system_automation_by_action.return_value = None # nothing seeded yet
created = {}
def _create(**kw):
created[kw['action_type']] = kw
return 'id-' + kw['action_type']
db.create_automation.side_effect = _create
engine = AutomationEngine(db)
engine.ensure_system_automations()
# The standalone scheduled scan is gone; the chain twins are seeded, video-tagged.
assert 'video_scan_library' not in created
assert created['video_scan_server']['owned_by'] == 'video'
assert created['video_update_database']['owned_by'] == 'video'
assert json.loads(created['video_update_database']['action_config']) == {'mode': 'incremental'}
# Music automations stay owned_by=None (shown on the music page).
assert created['scan_library']['owned_by'] is None
assert json.loads(created['scan_library']['action_config']) == {}
def test_video_scan_library_system_automation_is_cleaned_up():
"""The obsolete standalone 'Scan Video Library' system automation is deleted when
present (superseded by the post-download chain). No stuck flag — it just keys off
the lookup, so once the row is gone it no-ops."""
# present → deleted (matches only the is_system-seeded row)
db = MagicMock()
db.get_system_automation_by_action.return_value = {'id': 99, 'is_system': 1}
AutomationEngine(db)._fix_video_scan_default()
db.delete_automation.assert_any_call(99)
# absent (already gone) → idempotent no-op, never errors or deletes
db2 = MagicMock()
db2.get_system_automation_by_action.return_value = None
AutomationEngine(db2)._fix_video_scan_default()
db2.delete_automation.assert_not_called()