"""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 _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 def _prefs(values): """A MusicDatabase mock whose get_preference reads from `values`.""" db = MagicMock() db.get_preference.side_effect = lambda key, *a, **k: values.get(key) return db def _connect_with(nav_client, fake_db): 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 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.""" fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Audiobooks'}) _connect_with(nav_client, fake_db) assert nav_client.music_folder_id == '2' # Nothing drifted → no self-heal writes. fake_db.set_preference.assert_not_called() def test_setup_client_restores_by_id_after_folder_rename(nav_client): """Hardening: the id is the primary key, so a folder renamed in Navidrome (stored name no longer matches its title) still resolves by id — and the stale name is healed so the settings dropdown stays correct.""" fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Old Stale Name'}) _connect_with(nav_client, fake_db) assert nav_client.music_folder_id == '2' fake_db.set_preference.assert_any_call('navidrome_music_folder', 'Audiobooks') def test_setup_client_name_fallback_self_heals_id(nav_client): """Back-compat: installs saved before the id was persisted match by name, and the id is written back so a later rename can't break the match.""" fake_db = _prefs({'navidrome_music_folder_id': None, 'navidrome_music_folder': 'Audiobooks'}) _connect_with(nav_client, fake_db) assert nav_client.music_folder_id == '2' fake_db.set_preference.assert_any_call('navidrome_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'