`MediaServerEngine` reads the active server from config + dispatches to the corresponding registered client. Per-server reaches still work through `engine.client(name)`. Required-method dispatch (is_connected, ensure_connection, get_all_artists, get_all_album_ids) returns safe defaults when the active client failed to initialize OR when the method raises. Optional-method dispatch (search_tracks, trigger_library_scan, is_library_scanning, get_library_stats, get_recently_added_albums) checks hasattr first — SoulSync standalone has no trigger_library_scan or get_library_stats, engine no-ops with appropriate defaults instead of forcing every client to declare stub methods. 10 new engine tests pin: active-server resolution, required dispatch routing, exception safety, missing-optional-method fallback shape. Suite still green (1951 passed). Engine isn't on any production code path yet — Phase C migrates the 33 web_server.py dispatch sites to call engine.method() instead of hand-branching by active_server name.
192 lines
6.7 KiB
Python
192 lines
6.7 KiB
Python
"""Tests for MediaServerEngine cross-server dispatch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.media_server import MediaServerEngine, MediaServerRegistry
|
|
from core.media_server.registry import ServerSpec
|
|
|
|
|
|
class _FakeClient:
|
|
"""Stand-in client supporting all required + optional methods.
|
|
Tests selectively override per-test to assert dispatch behavior."""
|
|
|
|
def __init__(self, name='fake', connected=True):
|
|
self.name = name
|
|
self._connected = connected
|
|
self._artists = []
|
|
self._album_ids = set()
|
|
self._search_results = []
|
|
self._scan_triggered = False
|
|
|
|
def is_connected(self):
|
|
return self._connected
|
|
|
|
def ensure_connection(self):
|
|
return self._connected
|
|
|
|
def get_all_artists(self):
|
|
return self._artists
|
|
|
|
def get_all_album_ids(self):
|
|
return self._album_ids
|
|
|
|
def search_tracks(self, title, artist, limit=15):
|
|
return self._search_results
|
|
|
|
def trigger_library_scan(self):
|
|
self._scan_triggered = True
|
|
return True
|
|
|
|
def is_library_scanning(self):
|
|
return False
|
|
|
|
def get_library_stats(self):
|
|
return {'artists': 0, 'albums': 0, 'tracks': 0}
|
|
|
|
def get_recently_added_albums(self, max_results=400):
|
|
return []
|
|
|
|
|
|
@pytest.fixture
|
|
def make_engine():
|
|
"""Build an engine with mock clients. Test passes a dict of
|
|
name → client; engine wires them via a registry + custom
|
|
active-server resolver."""
|
|
|
|
def _make(clients_by_name, active='plex'):
|
|
registry = MediaServerRegistry()
|
|
for name, client in clients_by_name.items():
|
|
registry.register(ServerSpec(
|
|
name=name,
|
|
factory=lambda c=client: c,
|
|
display_name=name.title(),
|
|
))
|
|
return MediaServerEngine(
|
|
registry=registry,
|
|
active_server_resolver=lambda: active,
|
|
)
|
|
|
|
return _make
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active-server resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_active_server_property_reflects_resolver(make_engine):
|
|
plex = _FakeClient('plex')
|
|
jelly = _FakeClient('jellyfin')
|
|
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='jellyfin')
|
|
assert engine.active_server == 'jellyfin'
|
|
assert engine.active_client() is jelly
|
|
|
|
|
|
def test_client_lookup_by_name(make_engine):
|
|
plex = _FakeClient('plex')
|
|
engine = make_engine({'plex': plex}, active='plex')
|
|
assert engine.client('plex') is plex
|
|
assert engine.client('made_up') is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Required-method dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_connected_routes_to_active_client(make_engine):
|
|
plex = _FakeClient('plex', connected=True)
|
|
jelly = _FakeClient('jellyfin', connected=False)
|
|
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='jellyfin')
|
|
assert engine.is_connected() is False # follows jellyfin
|
|
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='plex')
|
|
assert engine.is_connected() is True # follows plex
|
|
|
|
|
|
def test_get_all_album_ids_returns_active_clients_set(make_engine):
|
|
plex = _FakeClient('plex')
|
|
plex._album_ids = {'p-1', 'p-2'}
|
|
jelly = _FakeClient('jellyfin')
|
|
jelly._album_ids = {'j-1'}
|
|
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='plex')
|
|
assert engine.get_all_album_ids() == {'p-1', 'p-2'}
|
|
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='jellyfin')
|
|
assert engine.get_all_album_ids() == {'j-1'}
|
|
|
|
|
|
def test_engine_returns_safe_defaults_when_active_client_failed_to_init(make_engine):
|
|
"""When the active client failed to initialize (registry stored
|
|
None), the engine returns safe defaults instead of raising."""
|
|
registry = MediaServerRegistry()
|
|
registry.register(ServerSpec(
|
|
name='broken',
|
|
factory=lambda: (_ for _ in ()).throw(RuntimeError("init failed")),
|
|
display_name='Broken',
|
|
))
|
|
registry.initialize() # captures the exception
|
|
engine = MediaServerEngine(registry=registry, active_server_resolver=lambda: 'broken')
|
|
|
|
assert engine.is_connected() is False
|
|
assert engine.get_all_artists() == []
|
|
assert engine.get_all_album_ids() == set()
|
|
assert engine.search_tracks('t', 'a') == []
|
|
assert engine.trigger_library_scan() is False
|
|
assert engine.is_library_scanning() is False
|
|
|
|
|
|
def test_engine_swallows_per_method_exceptions(make_engine):
|
|
"""A method that raises must NOT propagate to the dispatch
|
|
site — engine returns the safe default instead, mirroring the
|
|
legacy web_server.py defensive try/except chains."""
|
|
plex = _FakeClient('plex')
|
|
plex.is_connected = MagicMock(side_effect=RuntimeError("boom"))
|
|
plex.get_all_album_ids = MagicMock(side_effect=RuntimeError("boom"))
|
|
engine = make_engine({'plex': plex}, active='plex')
|
|
|
|
assert engine.is_connected() is False
|
|
assert engine.get_all_album_ids() == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional-method dispatch (engine returns safe default when missing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _MinimalClient:
|
|
"""Stand-in for SoulSync standalone — only the required methods,
|
|
NO optional methods. Used to assert engine routes around missing
|
|
optional methods with safe defaults."""
|
|
|
|
def is_connected(self): return True
|
|
def ensure_connection(self): return True
|
|
def get_all_artists(self): return []
|
|
def get_all_album_ids(self): return set()
|
|
|
|
|
|
def test_search_tracks_returns_empty_when_client_lacks_method(make_engine):
|
|
"""SoulSync standalone has no search_tracks — engine returns
|
|
[] instead of raising AttributeError."""
|
|
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
assert engine.search_tracks('t', 'a') == []
|
|
|
|
|
|
def test_trigger_library_scan_returns_true_when_client_lacks_method(make_engine):
|
|
"""SoulSync has no trigger_library_scan (filesystem walks
|
|
happen in-process). Engine no-ops with True so callers don't
|
|
treat it as a failure."""
|
|
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
assert engine.trigger_library_scan() is True
|
|
|
|
|
|
def test_is_library_scanning_returns_false_when_client_lacks_method(make_engine):
|
|
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
assert engine.is_library_scanning() is False
|
|
|
|
|
|
def test_get_library_stats_returns_empty_dict_when_client_lacks_method(make_engine):
|
|
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
assert engine.get_library_stats() == {}
|