From c1da56b2c2cbd7e79d2318e5134b13cadf5a853c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 4 May 2026 20:53:00 -0700 Subject: [PATCH] A1: Pin PlexClient observable behavior 6 tests pin the Plex client surface the engine will dispatch through after Phase B/C migrations: - is_connected returns False on no-server, True on server-present - is_fully_configured requires BOTH server AND music_library - get_all_artists empty list on not-connected, iterates music_library.searchArtists() when connected - get_all_album_ids returns a set of STRING ratingKey values (coerced from Plex ints so semantics match Jellyfin GUIDs + Navidrome string ids) Phase A pinning catches behavior drift during web_server.py dispatch-site migrations (Phase C) and engine adapter wiring (Phase B). --- tests/media_server/test_plex_pinning.py | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/media_server/test_plex_pinning.py diff --git a/tests/media_server/test_plex_pinning.py b/tests/media_server/test_plex_pinning.py new file mode 100644 index 00000000..7f314f73 --- /dev/null +++ b/tests/media_server/test_plex_pinning.py @@ -0,0 +1,113 @@ +"""Phase A pinning tests for PlexClient. + +Pin the OBSERVABLE BEHAVIOR the engine will dispatch through after +Phase B/C. The web_server.py dispatch sites + DatabaseUpdateWorker +read these methods generically — they must keep their current shape +through the engine refactor. + +Plex's surface is wider than the contract requires (additional +playlist / metadata writeback methods), but pinning ALL of them +turns into ~30 tests. Focused on the methods the dispatch sites +actually call: is_connected, is_fully_configured, ensure_connection, +get_all_artists, get_all_album_ids, search_tracks, trigger_library_scan, +is_library_scanning, get_library_stats. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from core.plex_client import PlexClient + + +@pytest.fixture +def plex_client(): + """A bare PlexClient with no real connection. Tests that need + a connected state set client.server = MagicMock() directly.""" + client = PlexClient.__new__(PlexClient) + client.server = None + client.music_library = None + client._connection_attempted = False + client._is_connecting = False + client._last_connection_check = 0 + client._connection_check_interval = 30 + return client + + +# --------------------------------------------------------------------------- +# is_connected +# --------------------------------------------------------------------------- + + +def test_is_connected_returns_false_when_no_server(plex_client): + """Pinning: no server object → False. Dashboard status indicators + + endpoint guards depend on this.""" + plex_client.server = None + # Patch ensure_connection to avoid network call + with patch.object(plex_client, 'ensure_connection', return_value=False): + assert plex_client.is_connected() is False + + +def test_is_connected_returns_true_when_server_present(plex_client): + """Pinning: a non-None server object → True (even if music_library + is unset). is_fully_configured() is the stricter check.""" + plex_client.server = MagicMock() + plex_client._connection_attempted = True + assert plex_client.is_connected() is True + + +def test_is_fully_configured_requires_server_and_music_library(plex_client): + """Pinning: is_fully_configured == True only when BOTH server AND + music_library are set. Used by playlist-sync gating.""" + plex_client.server = MagicMock() + plex_client.music_library = None + assert plex_client.is_fully_configured() is False + plex_client.music_library = MagicMock() + assert plex_client.is_fully_configured() is True + + +# --------------------------------------------------------------------------- +# get_all_artists / get_all_album_ids +# --------------------------------------------------------------------------- + + +def test_get_all_artists_returns_empty_when_not_connected(plex_client): + """Pinning: ensure_connection returning False → empty list (not + exception). Caller iterates the result, never raising.""" + with patch.object(plex_client, 'ensure_connection', return_value=False): + assert plex_client.get_all_artists() == [] + + +def test_get_all_artists_iterates_searchartists_when_connected(plex_client): + """Pinning: when connected, get_all_artists calls + music_library.searchArtists() and returns the result list.""" + fake_artists = [MagicMock(title='A'), MagicMock(title='B')] + plex_client.server = MagicMock() + plex_client.music_library = MagicMock() + plex_client.music_library.searchArtists.return_value = fake_artists + + with patch.object(plex_client, 'ensure_connection', return_value=True): + result = plex_client.get_all_artists() + + assert result == fake_artists + + +def test_get_all_album_ids_returns_set_of_string_ratingkeys(plex_client): + """Pinning: returns a set (not a list) of string ratingKey values. + DatabaseUpdateWorker uses set membership for diff-detection. + NOTE: Plex stores ratingKey as int but the method coerces to str + so the set semantics match other servers (Jellyfin GUIDs, Navidrome + string ids). Engine refactor must preserve.""" + fake_albums = [MagicMock(ratingKey=1), MagicMock(ratingKey=2), + MagicMock(ratingKey=3)] + plex_client.server = MagicMock() + plex_client.music_library = MagicMock() + plex_client.music_library.albums.return_value = fake_albums + + with patch.object(plex_client, 'ensure_connection', return_value=True): + result = plex_client.get_all_album_ids() + + assert isinstance(result, set) + assert result == {'1', '2', '3'}