soulsync/tests/media_server/test_navidrome_pinning.py
BoulderBadgeDad cf655c5009 Fix #789: Navidrome library selection ignored (all libraries imported)
The saved music-folder selection was silently dropped on every reconnect.
_setup_client's restore step called the public get_music_folders(), which
starts with ensure_connection() — but we're already inside ensure_connection()
at that point (_is_connecting=True, _connection_attempted not yet set), so the
re-entrant call bailed and returned []. The restore matched nothing,
music_folder_id stayed None, and the per-call musicFolderId filters all
no-op'd → scans imported every library regardless of the user's choice.
Surfaces after any restart or settings save (reload_config resets the state).

Split get_music_folders() into the public method (does the connection check)
and a non-reentrant _fetch_music_folders() seam; the restore now calls the
seam directly (connection is already established + ping succeeded by then).

Regression + seam tests added.
2026-06-04 11:41:03 -07:00

145 lines
5.3 KiB
Python

"""Phase A pinning tests for NavidromeClient.
Pin the OBSERVABLE BEHAVIOR the engine will dispatch through. Auth
shape uses base_url + username + password (no token like Plex).
Both get_all_artists + get_all_album_ids return empty when the
client isn't connected.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from core.navidrome_client import NavidromeAlbum, NavidromeClient
@pytest.fixture
def nav_client():
"""A bare NavidromeClient with no real connection."""
client = NavidromeClient.__new__(NavidromeClient)
client.base_url = None
client.username = None
client.password = None
client._connection_attempted = False
client._is_connecting = False
client._artist_cache = {}
client._album_cache = {}
client._track_cache = {}
client._folder_album_ids = None
client._progress_callback = None
client.music_folder_id = None
return client
def test_is_connected_returns_false_without_credentials(nav_client):
with patch.object(nav_client, 'ensure_connection', return_value=False):
assert nav_client.is_connected() is False
def test_is_connected_returns_true_when_all_three_set(nav_client):
"""Pinning: requires base_url AND username AND password.
No token model (vs Plex). Salt is generated per-request."""
nav_client._connection_attempted = True
nav_client.base_url = 'http://nav'
nav_client.username = 'u'
nav_client.password = 'p'
assert nav_client.is_connected() is True
def test_get_all_artists_returns_empty_when_not_connected(nav_client):
with patch.object(nav_client, 'ensure_connection', return_value=False):
assert nav_client.get_all_artists() == []
def test_get_all_album_ids_returns_set(nav_client):
"""Pinning: returns a set of string Navidrome ids. Same shape
semantic as Plex/Jellyfin (set of strings) — engine extraction
depends on uniform set type."""
nav_client._connection_attempted = True
nav_client.base_url = 'http://nav'
nav_client.username = 'u'
nav_client.password = 'p'
# Navidrome uses paginated getAlbumList2 — first page returns
# 2 albums, second returns empty (terminates loop).
# _make_request unwraps the subsonic-response envelope and
# returns the body. get_all_album_ids reads response.albumList2.album.
page_responses = [
{'albumList2': {'album': [{'id': 'nav-1'}, {'id': 'nav-2'}]}},
{'albumList2': {'album': []}},
]
with patch.object(nav_client, 'ensure_connection', return_value=True), \
patch.object(nav_client, '_make_request', side_effect=page_responses):
result = nav_client.get_all_album_ids()
assert isinstance(result, set)
assert result == {'nav-1', 'nav-2'}
def test_fetch_music_folders_parses_without_ensure_connection(nav_client):
"""The seam: _fetch_music_folders does its own _make_request + parse and
does NOT gate on ensure_connection, so it is safe to call mid-connect."""
nav_client.base_url = 'http://nav'
nav_client.username = 'u'
nav_client.password = 'p'
folders_envelope = {'musicFolders': {'musicFolder': [
{'id': 1, 'name': 'Music'},
{'id': 2, 'name': 'Audiobooks'},
]}}
with patch.object(nav_client, '_make_request', return_value=folders_envelope):
folders = nav_client._fetch_music_folders()
assert folders == [
{'title': 'Music', 'key': '1'},
{'title': 'Audiobooks', 'key': '2'},
]
def test_setup_client_restores_saved_music_folder(nav_client):
"""Regression for #789: a saved music-folder selection must survive
_setup_client. The restore runs while still inside ensure_connection()
(_is_connecting=True); the old code called the public get_music_folders(),
which re-entered the guard, got [], and left music_folder_id=None — so
every scan imported all libraries regardless of the user's selection."""
def fake_request(endpoint, params=None):
if endpoint == 'ping':
return {'status': 'ok', 'version': '1.16.1'}
if endpoint == 'getMusicFolders':
return {'musicFolders': {'musicFolder': [
{'id': 1, 'name': 'Music'},
{'id': 2, 'name': 'Audiobooks'},
]}}
return None
fake_db = MagicMock()
fake_db.get_preference.return_value = 'Audiobooks'
with patch('core.navidrome_client.config_manager.get_navidrome_config',
return_value={'base_url': 'http://nav', 'username': 'u', 'password': 'p'}), \
patch('database.music_database.MusicDatabase', return_value=fake_db), \
patch.object(nav_client, '_make_request', side_effect=fake_request):
assert nav_client.ensure_connection() is True
# Selection ('Audiobooks') was restored to its folder id, not left None.
assert nav_client.music_folder_id == '2'
def test_navidrome_album_exposes_cover_art_url(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
'coverArt': 'cover-123',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=cover-123'
def test_navidrome_album_cover_art_falls_back_to_album_id(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=album-1'