soulsync/tests/automation/test_automation_signals.py
Broque Thomas 6cdcf778f3 Lift /api/automations/* into core/automation/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
three focused modules under core/automation/. 436 lines deleted from
web_server.py; 53 added back as wrappers.

Module split:
- core/automation/api.py — CRUD + run + history helpers. Each function
  takes (database, automation_engine, ...) explicitly and returns
  (response_body, http_status). Includes signal cycle detection
  preflight checks for create + update.
- core/automation/progress.py — owns the in-memory progress state dict
  + lock (mirroring the original web_server.py globals as module-level
  shared state so all callers see one view), init/update/history
  helpers, and the WebSocket emit loop.
- core/automation/signals.py — collect_known_signals for the builder
  autocomplete.

Out of scope (deferred):
- _register_automation_handlers — the 23+ action handler closures stay
  in web_server.py because each one is tightly coupled to feature-
  specific implementations (wishlist, watchlist, library scan, etc.).
- Worker functions (_process_wishlist_automatically, etc.) — belong
  with their feature lifts.
- _run_sync_task / _run_playlist_discovery_worker — sync + discovery
  PRs.

Behavior preserved 1:1:
- Same route response shapes + status codes
- Same JSON field hydration (trigger_config, action_config,
  notify_config, last_result, then_actions)
- Same backward-compat: empty then_actions + notify_type set →
  synthesize then_actions from notify_type/notify_config
- Same signal cycle detection behavior on create + update
- Same system-automation protection on delete + duplicate
- Same reschedule/cancel logic on toggle + bulk-toggle + update
- Same progress state shape (status, progress, phase, current_item,
  log capped at 50, started_at/finished_at, action_type)
- Same emit-on-finish socketio push from update_progress
- Same emit loop semantics (1s tick, snapshot active states, reap
  finished after window)

Pre-existing bugs preserved (will fix in follow-up PRs):
- emit_progress_loop uses naive datetime.now() against tz-aware
  started_at/finished_at, so the timeout-zombie check raises
  TypeError → caught → never fires, and the cleanup-after-window
  check raises → caught → state is reaped on FIRST tick regardless
  of the window. Tests document this behavior so the next PR can
  flip them to the corrected expectation.

Tests: 72 new under tests/automation/ (signals 10, progress 24,
api 38). Full suite: 861 passing (was 789). Ruff clean.
2026-04-27 18:05:14 -07:00

92 lines
3.2 KiB
Python

"""Tests for core/automation/signals.py — known signal collection."""
from __future__ import annotations
import json
from core.automation import signals
class _FakeDB:
def __init__(self, automations):
self._autos = automations
def get_automations(self, profile_id=None):
return self._autos
def test_no_automations_returns_empty():
db = _FakeDB([])
assert signals.collect_known_signals(db) == []
def test_signal_received_trigger_collected():
db = _FakeDB([
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'job_done'}), 'then_actions': '[]'},
])
assert signals.collect_known_signals(db) == ['job_done']
def test_fire_signal_then_action_collected():
db = _FakeDB([
{'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': json.dumps([{'type': 'fire_signal', 'config': {'signal_name': 'cleanup_done'}}])},
])
assert signals.collect_known_signals(db) == ['cleanup_done']
def test_collected_signals_are_sorted_and_deduped():
db = _FakeDB([
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'zebra'}), 'then_actions': '[]'},
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'apple'}), 'then_actions': '[]'},
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'apple'}), 'then_actions': '[]'},
])
assert signals.collect_known_signals(db) == ['apple', 'zebra']
def test_empty_signal_name_skipped():
db = _FakeDB([
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': ''}), 'then_actions': '[]'},
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': ' '}), 'then_actions': '[]'},
])
assert signals.collect_known_signals(db) == []
def test_malformed_trigger_config_swallowed():
db = _FakeDB([
{'trigger_type': 'signal_received', 'trigger_config': 'not json', 'then_actions': '[]'},
])
assert signals.collect_known_signals(db) == []
def test_malformed_then_actions_swallowed():
db = _FakeDB([
{'trigger_type': 'schedule', 'trigger_config': '{}', 'then_actions': 'not json'},
])
assert signals.collect_known_signals(db) == []
def test_db_failure_returns_empty():
class _BrokenDB:
def get_automations(self, profile_id=None):
raise RuntimeError("db dead")
assert signals.collect_known_signals(_BrokenDB()) == []
def test_mixed_trigger_and_action_signals_merged():
db = _FakeDB([
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'sig_a'}),
'then_actions': json.dumps([{'type': 'fire_signal', 'config': {'signal_name': 'sig_b'}}])},
])
assert signals.collect_known_signals(db) == ['sig_a', 'sig_b']
def test_non_signal_then_action_ignored():
db = _FakeDB([
{'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': json.dumps([
{'type': 'discord', 'config': {'webhook_url': 'http://x'}},
{'type': 'fire_signal', 'config': {'signal_name': 'real_sig'}},
])},
])
assert signals.collect_known_signals(db) == ['real_sig']