Follow-up hardening to #789. The selection was keyed purely by folder name, so renaming a music folder in Navidrome silently reverted the scan to all libraries. Now persist the folder id (stable across renames) as the primary key alongside the name (kept for display + back-compat), and restore by id first with a name fallback. Self-heals on reconnect: pre-id installs and drifted/renamed names get the id + fresh name written back, so the settings dropdown keeps highlighting the right folder. Tests: restore-by-id-after-rename (+ name heal), name-fallback self-heals id, no-drift writes nothing.
174 lines
6.6 KiB
Python
174 lines
6.6 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 _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'
|