soulsync/tests/automation/test_engine_profile_context.py
BoulderBadgeDad 6980253a96 Automations: run each as its OWNER profile in the background (part 1 of per-profile sync)
Background automations had no session, so get_current_profile_id() fell back to
admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the
automation's owner around handler execution via a contextvar
(core/profile_context.py), and get_current_profile_id() consults it only when
there's NO web request. So:
- a real logged-in request always wins (foreground unchanged),
- admin + system automations are profile 1 → resolve to admin exactly as before
  (the 8 admin-owned auto-sync pipelines behave identically),
- only non-admin-owned automations gain their correct identity, deep through the
  whole call chain (incl. the per-profile client resolvers) — no threading
  profile_id through dozens of signatures.

Reset in a finally so a pooled thread can't leak the override to the next job.

Tests: contextvar set/reset/nested; get_current_profile_id honours the override
only outside a request (a real session still wins); and end-to-end — the engine
runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger
profile overrides the owner, and the context resets even when the handler raises.
27 + 4 tests pass.

Part 2 (next): point the sync handlers' source-playlist READ at
get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist.
2026-06-10 15:37:56 -07:00

64 lines
2.4 KiB
Python

"""The engine runs each automation AS its owner profile (per-profile automations).
A non-admin's scheduled job must execute with their profile in the background
context, so get_current_profile_id() / the per-profile clients act as them — not
admin. Admin/system automations (profile 1) are unchanged. The context must be
reset after every run so a pooled thread can't leak it.
"""
from unittest.mock import MagicMock
import pytest
from core.automation_engine import AutomationEngine
from core.profile_context import get_background_profile
def _engine(owner_profile_id):
db = MagicMock()
db.get_automation.return_value = {
'id': 1, 'name': 'Auto-Sync', 'enabled': True,
'action_type': 'sync_playlist', 'action_config': '{}',
'trigger_type': 'interval_hours', 'trigger_config': '{"hours": 1}',
'profile_id': owner_profile_id,
}
db.update_automation_run = MagicMock(return_value=True)
eng = AutomationEngine(db)
eng._running = True
seen = {}
eng._action_handlers['sync_playlist'] = {
'handler': lambda config: seen.update(profile=get_background_profile()) or {'status': 'completed'},
'guard': None,
}
return eng, seen
def test_nonadmin_owned_automation_runs_as_owner():
eng, seen = _engine(owner_profile_id=4)
eng.run_automation(1, skip_delay=True)
assert seen['profile'] == 4 # handler ran AS profile 4
assert get_background_profile() is None # reset after the run
def test_admin_owned_automation_runs_as_admin():
eng, seen = _engine(owner_profile_id=1)
eng.run_automation(1, skip_delay=True)
assert seen['profile'] == 1 # unchanged for admin/system
assert get_background_profile() is None
def test_explicit_trigger_profile_overrides_owner():
# A manual trigger (run_automation(profile_id=...)) wins over the owner.
eng, seen = _engine(owner_profile_id=4)
eng.run_automation(1, skip_delay=True, profile_id=9)
assert seen['profile'] == 9
def test_context_reset_even_when_handler_raises():
eng, _ = _engine(owner_profile_id=4)
eng._action_handlers['sync_playlist'] = {
'handler': lambda config: (_ for _ in ()).throw(RuntimeError('boom')),
'guard': None,
}
eng.run_automation(1, skip_delay=True) # error is caught + stored
assert get_background_profile() is None # finally reset it