feat: Requests / Add to Library Rework - Unmonitored album default + … (#25)

* feat: Requests / Add to Library Rework - Unmonitored album default + Resilience

* checking for source + refresh album logic

* artist monitoring + auto downloading + various request fixes

* synchronous album requests

* format
This commit is contained in:
Harvey
2026-04-06 23:08:58 +01:00
committed by GitHub
parent a3e0b2f65a
commit 343bafd7f4
44 changed files with 2360 additions and 271 deletions
@@ -25,11 +25,14 @@ async def test_jobs_survive_restart(store: QueueStore):
store.enqueue("job-1", "mbid-abc")
store.mark_processing("job-1")
q1._processor_task.cancel()
try:
await q1._processor_task
except asyncio.CancelledError:
pass
for task in q1._worker_tasks:
task.cancel()
for task in q1._worker_tasks:
try:
await task
except asyncio.CancelledError:
pass
q1._worker_tasks.clear()
fast_processed = []
@@ -0,0 +1,210 @@
"""Tests for MUS-14 queue changes: dedup, cancel, concurrency, atomic enqueue."""
import asyncio
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from infrastructure.queue.queue_store import QueueStore
from infrastructure.queue.request_queue import RequestQueue
@pytest.fixture
def store(tmp_path: Path) -> QueueStore:
return QueueStore(db_path=tmp_path / "test_queue.db")
@pytest.mark.asyncio
async def test_enqueue_dedup_rejects_duplicate(store: QueueStore):
"""Duplicate MBIDs are rejected by enqueue()."""
processed = []
async def processor(mbid: str) -> dict:
await asyncio.sleep(10)
processed.append(mbid)
return {"status": "ok"}
q = RequestQueue(processor=processor, store=store)
first = await q.enqueue("mbid-aaa")
second = await q.enqueue("mbid-aaa")
assert first is True
assert second is False
await q.stop()
@pytest.mark.asyncio
async def test_enqueue_dedup_is_atomic(store: QueueStore):
"""Concurrent enqueue calls for the same MBID should only succeed once."""
processed = []
async def slow_processor(mbid: str) -> dict:
await asyncio.sleep(10)
processed.append(mbid)
return {"status": "ok"}
q = RequestQueue(processor=slow_processor, store=store)
results = await asyncio.gather(
q.enqueue("mbid-race"),
q.enqueue("mbid-race"),
q.enqueue("mbid-race"),
)
assert sum(results) == 1
await q.stop()
@pytest.mark.asyncio
async def test_cancel_skips_queued_item(store: QueueStore):
"""Cancelled items are skipped by the worker."""
processed = []
gate = asyncio.Event()
async def gated_processor(mbid: str) -> dict:
await gate.wait()
processed.append(mbid)
return {"status": "ok"}
q = RequestQueue(processor=gated_processor, store=store, concurrency=1)
await q.enqueue("mbid-first")
await q.enqueue("mbid-second")
await q.cancel("mbid-second")
gate.set()
await asyncio.sleep(0.5)
assert "mbid-first" in processed
assert "mbid-second" not in processed
await q.stop()
@pytest.mark.asyncio
async def test_concurrent_workers_process_in_parallel(store: QueueStore):
"""Multiple workers process items concurrently."""
active = {"count": 0, "max": 0}
lock = asyncio.Lock()
async def tracking_processor(mbid: str) -> dict:
async with lock:
active["count"] += 1
active["max"] = max(active["max"], active["count"])
await asyncio.sleep(0.2)
async with lock:
active["count"] -= 1
return {"status": "ok"}
q = RequestQueue(processor=tracking_processor, store=store, concurrency=3)
await q.enqueue("mbid-a")
await q.enqueue("mbid-b")
await q.enqueue("mbid-c")
await asyncio.sleep(0.5)
assert active["max"] >= 2
await q.stop()
@pytest.mark.asyncio
async def test_store_persisted_before_memory_queue():
"""enqueue() persists to store before putting in asyncio.Queue."""
call_order: list[str] = []
class TrackedStore:
def has_active_mbid(self, mbid: str) -> bool:
return False
def enqueue(self, job_id: str, mbid: str) -> bool:
call_order.append("store_enqueue")
return True
def get_dead_letter_count(self) -> int:
return 0
def get_all(self) -> list:
return []
def reset_processing(self) -> None:
pass
def get_pending(self) -> list:
return []
def get_retryable_dead_letters(self) -> list:
return []
def mark_processing(self, job_id: str) -> None:
pass
def dequeue(self, job_id: str) -> None:
pass
original_put = asyncio.Queue.put
async def tracked_put(self, item):
call_order.append("queue_put")
return await original_put(self, item)
asyncio.Queue.put = tracked_put
try:
async def noop_processor(mbid: str) -> dict:
return {"status": "ok"}
q = RequestQueue(processor=noop_processor, store=TrackedStore())
await q.enqueue("mbid-order-test")
assert call_order.index("store_enqueue") < call_order.index("queue_put")
await q.stop()
finally:
asyncio.Queue.put = original_put
@pytest.mark.asyncio
async def test_cancel_then_re_request_processes_new_request(store: QueueStore):
"""A cancelled MBID can be re-requested and will be processed."""
processed = []
gate = asyncio.Event()
async def gated_processor(mbid: str) -> dict:
await gate.wait()
processed.append(mbid)
return {"status": "ok"}
q = RequestQueue(processor=gated_processor, store=store, concurrency=1)
# Enqueue, cancel, then re-enqueue the same MBID
first = await q.enqueue("mbid-bounce")
assert first is True
await q.cancel("mbid-bounce")
second = await q.enqueue("mbid-bounce")
assert second is True
gate.set()
await asyncio.sleep(0.5)
# The re-request should have been processed (not skipped by stale cancel)
assert "mbid-bounce" in processed
await q.stop()
@pytest.mark.asyncio
async def test_cancelled_mbids_bounded(store: QueueStore):
"""_cancelled_mbids set doesn't grow unbounded."""
async def noop(mbid: str) -> dict:
return {"status": "ok"}
q = RequestQueue(processor=noop, store=store, concurrency=1)
# Cancel 250 non-existent MBIDs (orphan cancels)
for i in range(250):
await q.cancel(f"orphan-{i}")
assert len(q._cancelled_mbids) == 250
# Process one item — triggers the cleanup threshold
await q.enqueue("mbid-trigger")
await asyncio.sleep(0.5)
assert len(q._cancelled_mbids) <= 200
await q.stop()
@@ -0,0 +1,101 @@
"""Tests for MUS-14 album.py changes: per-artist lock, safe LRU eviction."""
import asyncio
import pytest
from repositories.lidarr.album import _get_artist_lock, _artist_locks, _MAX_ARTIST_LOCKS
@pytest.fixture(autouse=True)
def _clear_locks():
"""Reset the module-level lock dict between tests."""
_artist_locks.clear()
yield
_artist_locks.clear()
def test_get_artist_lock_returns_same_lock_for_same_mbid():
lock1 = _get_artist_lock("artist-aaa")
lock2 = _get_artist_lock("artist-aaa")
assert lock1 is lock2
def test_get_artist_lock_returns_different_locks_for_different_mbids():
lock1 = _get_artist_lock("artist-aaa")
lock2 = _get_artist_lock("artist-bbb")
assert lock1 is not lock2
def test_lru_eviction_respects_max():
for i in range(_MAX_ARTIST_LOCKS + 10):
_get_artist_lock(f"artist-{i}")
assert len(_artist_locks) <= _MAX_ARTIST_LOCKS
@pytest.mark.asyncio
async def test_lru_eviction_skips_held_locks():
"""A lock that is currently held must not be evicted."""
first_lock = _get_artist_lock("artist-held")
await first_lock.acquire()
try:
for i in range(_MAX_ARTIST_LOCKS + 5):
_get_artist_lock(f"artist-fill-{i}")
assert "artist-held" in _artist_locks
assert _artist_locks["artist-held"] is first_lock
finally:
first_lock.release()
@pytest.mark.asyncio
async def test_per_artist_lock_serializes_concurrent_calls():
"""Concurrent calls for the same artist should be serialized."""
call_order: list[str] = []
async def simulated_add(artist_mbid: str, label: str):
lock = _get_artist_lock(artist_mbid)
async with lock:
call_order.append(f"{label}-start")
await asyncio.sleep(0.1)
call_order.append(f"{label}-end")
await asyncio.gather(
simulated_add("same-artist", "A"),
simulated_add("same-artist", "B"),
)
# One must complete fully before the other starts
a_start = call_order.index("A-start")
a_end = call_order.index("A-end")
b_start = call_order.index("B-start")
b_end = call_order.index("B-end")
serialized = (a_end < b_start) or (b_end < a_start)
assert serialized, f"Expected serialized execution, got: {call_order}"
@pytest.mark.asyncio
async def test_different_artists_run_concurrently():
"""Requests for different artists should NOT be serialized."""
active = {"count": 0, "max": 0}
lock = asyncio.Lock()
async def simulated_add(artist_mbid: str):
artist_lock = _get_artist_lock(artist_mbid)
async with artist_lock:
async with lock:
active["count"] += 1
active["max"] = max(active["max"], active["count"])
await asyncio.sleep(0.1)
async with lock:
active["count"] -= 1
await asyncio.gather(
simulated_add("artist-X"),
simulated_add("artist-Y"),
simulated_add("artist-Z"),
)
assert active["max"] >= 2, f"Expected concurrent execution, max active was {active['max']}"
@@ -166,14 +166,32 @@ class TestSharedRawAlbumCache:
},
]
library_mbids, requested_mbids = await asyncio.gather(
library_mbids, monitored_no_files = await asyncio.gather(
repo.get_library_mbids(include_release_ids=False),
repo.get_requested_mbids(),
repo.get_monitored_no_files_mbids(),
)
assert mock_get.await_count == 1
assert library_mbids == {"aaaa"}
assert requested_mbids == {"bbbb"}
assert monitored_no_files == {"bbbb"}
@pytest.mark.asyncio
async def test_get_requested_mbids_uses_history_store(self, repo):
"""get_requested_mbids delegates to RequestHistoryStore."""
mock_store = AsyncMock()
mock_store.async_get_active_mbids = AsyncMock(return_value={"cccc", "dddd"})
repo._request_history_store = mock_store
result = await repo.get_requested_mbids()
assert result == {"cccc", "dddd"}
mock_store.async_get_active_mbids.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_requested_mbids_returns_empty_without_store(self, repo):
"""get_requested_mbids returns empty set when no history store."""
repo._request_history_store = None
result = await repo.get_requested_mbids()
assert result == set()
@pytest.mark.asyncio
async def test_explicit_album_cache_invalidation_forces_refetch(self, repo):
@@ -0,0 +1,90 @@
import os
import tempfile
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# Ensure config uses a writable temp dir for tests
_test_dir = tempfile.mkdtemp()
os.environ.setdefault("ROOT_APP_DIR", _test_dir)
VALID_MBID = "77434d0b-1c5f-48c3-8694-050cb378ebd2"
UNKNOWN_MBID = "00000000-0000-0000-0000-000000000000"
def _make_basic_info():
return MagicMock(
release_group_id=VALID_MBID,
title="Test Album",
artist_name="Test Artist",
in_library=True,
)
@pytest.fixture
def mock_album_service():
svc = MagicMock()
svc.refresh_album = AsyncMock()
svc.get_album_basic_info = AsyncMock(return_value=_make_basic_info())
return svc
@pytest.fixture
def mock_navidrome_service():
svc = MagicMock()
svc.invalidate_album_cache = MagicMock()
return svc
@pytest.fixture
def client(mock_album_service, mock_navidrome_service):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.albums import router
from core.dependencies import get_album_service, get_navidrome_library_service
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_album_service] = lambda: mock_album_service
app.dependency_overrides[get_navidrome_library_service] = lambda: mock_navidrome_service
return TestClient(app)
def test_refresh_calls_invalidate_and_refresh(client, mock_album_service, mock_navidrome_service):
response = client.post(f"/albums/{VALID_MBID}/refresh")
assert response.status_code == 200
mock_navidrome_service.invalidate_album_cache.assert_called_once_with(VALID_MBID)
mock_album_service.refresh_album.assert_called_once_with(VALID_MBID)
mock_album_service.get_album_basic_info.assert_called_once_with(VALID_MBID)
def test_refresh_returns_basic_info(client):
response = client.post(f"/albums/{VALID_MBID}/refresh")
assert response.status_code == 200
def test_refresh_rejects_unknown_mbid(client, mock_album_service, mock_navidrome_service):
response = client.post("/albums/unknown_test/refresh")
assert response.status_code == 400
assert "Invalid or unknown" in response.json()["detail"]
mock_album_service.refresh_album.assert_not_called()
mock_navidrome_service.invalidate_album_cache.assert_not_called()
def test_refresh_rejects_empty_album_id(client, mock_album_service):
response = client.post("/albums/%20/refresh")
assert response.status_code == 400
mock_album_service.refresh_album.assert_not_called()
def test_refresh_propagates_service_value_error(client, mock_album_service, mock_navidrome_service):
mock_navidrome_service.invalidate_album_cache = MagicMock(side_effect=ValueError("bad"))
response = client.post(f"/albums/{VALID_MBID}/refresh")
assert response.status_code == 400
@@ -0,0 +1,65 @@
import pytest
import time
from unittest.mock import MagicMock, patch
from services.navidrome_library_service import NavidromeLibraryService
@pytest.fixture
def service():
svc = NavidromeLibraryService.__new__(NavidromeLibraryService)
svc._mbid_to_navidrome_id = {}
svc._album_mbid_cache = {}
svc._dirty = False
return svc
ALBUM_MBID = "77434d0b-1c5f-48c3-8694-050cb378ebd2"
NAVIDROME_ID = "nav-12345"
def test_invalidate_clears_mbid_to_navidrome_id(service):
service._mbid_to_navidrome_id[ALBUM_MBID] = NAVIDROME_ID
service.invalidate_album_cache(ALBUM_MBID)
assert ALBUM_MBID not in service._mbid_to_navidrome_id
def test_invalidate_clears_positive_album_mbid_cache_entries(service):
cache_key = "test album:test artist"
service._album_mbid_cache[cache_key] = ALBUM_MBID
service.invalidate_album_cache(ALBUM_MBID)
assert cache_key not in service._album_mbid_cache
assert service._dirty is True
def test_invalidate_clears_multiple_matching_entries(service):
service._album_mbid_cache["key1:artist1"] = ALBUM_MBID
service._album_mbid_cache["key2:artist2"] = ALBUM_MBID
service._album_mbid_cache["other:other"] = "different-mbid"
service.invalidate_album_cache(ALBUM_MBID)
assert "key1:artist1" not in service._album_mbid_cache
assert "key2:artist2" not in service._album_mbid_cache
assert "other:other" in service._album_mbid_cache
def test_invalidate_noop_when_not_cached(service):
service.invalidate_album_cache(ALBUM_MBID)
assert service._dirty is False
def test_invalidate_leaves_unrelated_entries(service):
other_mbid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
service._mbid_to_navidrome_id[other_mbid] = "nav-other"
service._album_mbid_cache["other:artist"] = other_mbid
service.invalidate_album_cache(ALBUM_MBID)
assert service._mbid_to_navidrome_id[other_mbid] == "nav-other"
assert service._album_mbid_cache["other:artist"] == other_mbid
+10 -20
View File
@@ -11,9 +11,10 @@ def _make_service(queue_add_result: dict | None = None) -> tuple[RequestService,
request_queue = MagicMock()
request_history = MagicMock()
request_queue.add = AsyncMock(return_value=queue_add_result or {"message": "queued", "payload": {}})
request_queue.enqueue = AsyncMock(return_value=True)
request_queue.get_status = MagicMock(return_value={"queue_size": 0, "processing": False})
request_history.async_record_request = AsyncMock()
request_history.async_get_record = AsyncMock(return_value=None)
service = RequestService(lidarr_repo, request_queue, request_history)
return service, request_queue, request_history
@@ -21,37 +22,26 @@ def _make_service(queue_add_result: dict | None = None) -> tuple[RequestService,
@pytest.mark.asyncio
async def test_request_album_records_history_and_returns_response():
service, request_queue, request_history = _make_service(
{
"message": "Album queued",
"payload": {
"id": 42,
"title": "Album X",
"foreignAlbumId": "rg-123",
"artist": {"artistName": "Artist X", "foreignArtistId": "artist-123"},
},
}
)
service, request_queue, request_history = _make_service()
response = await service.request_album("rg-123", artist="Fallback Artist", album="Fallback Album", year=2024)
assert response.success is True
assert response.message == "Album queued"
assert isinstance(response.lidarr_response, dict)
assert response.lidarr_response["id"] == 42
assert response.message == "Request accepted"
assert response.musicbrainz_id == "rg-123"
assert response.status == "pending"
request_queue.add.assert_awaited_once_with("rg-123")
request_queue.enqueue.assert_awaited_once_with("rg-123")
request_history.async_record_request.assert_awaited_once()
kwargs = request_history.async_record_request.await_args.kwargs
assert kwargs["artist_name"] == "Artist X"
assert kwargs["album_title"] == "Album X"
assert kwargs["artist_mbid"] == "artist-123"
assert kwargs["artist_name"] == "Fallback Artist"
assert kwargs["album_title"] == "Fallback Album"
@pytest.mark.asyncio
async def test_request_album_wraps_errors():
service, request_queue, _ = _make_service()
request_queue.add.side_effect = RuntimeError("boom")
request_queue.enqueue = AsyncMock(side_effect=RuntimeError("boom"))
with pytest.raises(ExternalServiceError):
await service.request_album("rg-123")
+295
View File
@@ -0,0 +1,295 @@
"""Tests for MUS-15B: Artist monitoring API and integration."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from services.artist_service import ArtistService
from core.exceptions import ExternalServiceError
@pytest.fixture
def mock_lidarr_repo():
repo = AsyncMock()
repo.is_configured = MagicMock(return_value=True)
repo.get_artist_details.return_value = {
"id": 42,
"monitored": False,
"monitorNewItems": "none",
"monitor_new_items": "none",
"name": "Test Artist",
"overview": "A test artist",
"genres": ["rock"],
"links": [],
"poster_url": None,
"fanart_url": None,
"banner_url": None,
}
repo.update_artist_monitoring.return_value = {"monitored": False, "auto_download": False}
repo.get_library_mbids.return_value = set()
repo.get_artist_albums.return_value = []
repo.get_requested_mbids.return_value = set()
return repo
@pytest.fixture
def mock_mb_repo():
repo = AsyncMock()
return repo
@pytest.fixture
def mock_wikidata_repo():
repo = AsyncMock()
return repo
@pytest.fixture
def mock_preferences():
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(
primary_types=["Album"],
secondary_types=[],
)
return prefs
@pytest.fixture
def mock_cache():
cache = AsyncMock()
cache.get.return_value = None
cache.delete.return_value = None
cache.clear_prefix.return_value = 0
return cache
@pytest.fixture
def mock_disk_cache():
return AsyncMock()
@pytest.fixture
def artist_service(
mock_lidarr_repo, mock_mb_repo, mock_wikidata_repo,
mock_preferences, mock_cache, mock_disk_cache,
):
return ArtistService(
mb_repo=mock_mb_repo,
lidarr_repo=mock_lidarr_repo,
wikidata_repo=mock_wikidata_repo,
preferences_service=mock_preferences,
memory_cache=mock_cache,
disk_cache=mock_disk_cache,
)
class TestSetArtistMonitoring:
@pytest.mark.asyncio
async def test_set_monitoring_on(self, artist_service, mock_lidarr_repo, mock_cache):
result = await artist_service.set_artist_monitoring(
"abc-123", monitored=True, auto_download=False,
)
mock_lidarr_repo.update_artist_monitoring.assert_awaited_once_with(
"abc-123", monitored=True, monitor_new_items="none",
)
mock_cache.delete.assert_awaited()
assert result == {"monitored": False, "auto_download": False}
@pytest.mark.asyncio
async def test_set_monitoring_with_auto_download(self, artist_service, mock_lidarr_repo):
await artist_service.set_artist_monitoring(
"abc-123", monitored=True, auto_download=True,
)
mock_lidarr_repo.update_artist_monitoring.assert_awaited_once_with(
"abc-123", monitored=True, monitor_new_items="all",
)
@pytest.mark.asyncio
async def test_auto_download_false_when_unmonitored(self, artist_service, mock_lidarr_repo):
await artist_service.set_artist_monitoring(
"abc-123", monitored=False, auto_download=True,
)
mock_lidarr_repo.update_artist_monitoring.assert_awaited_once_with(
"abc-123", monitored=False, monitor_new_items="none",
)
@pytest.mark.asyncio
async def test_raises_when_lidarr_not_configured(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.is_configured = MagicMock(return_value=False)
with pytest.raises(ExternalServiceError, match="not configured"):
await artist_service.set_artist_monitoring("abc-123", monitored=True)
@pytest.mark.asyncio
async def test_invalidates_artist_cache(self, artist_service, mock_cache):
await artist_service.set_artist_monitoring("abc-123", monitored=True)
delete_calls = [str(c) for c in mock_cache.delete.await_args_list]
assert any("artist_info:abc-123" in c for c in delete_calls)
class TestGetArtistMonitoringStatus:
@pytest.mark.asyncio
async def test_returns_status_from_lidarr(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_details.return_value = {
"id": 42, "monitored": True, "monitor_new_items": "all",
}
result = await artist_service.get_artist_monitoring_status("abc-123")
assert result == {"in_lidarr": True, "monitored": True, "auto_download": True}
@pytest.mark.asyncio
async def test_returns_defaults_when_not_in_lidarr(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_details.return_value = None
result = await artist_service.get_artist_monitoring_status("abc-123")
assert result == {"in_lidarr": False, "monitored": False, "auto_download": False}
@pytest.mark.asyncio
async def test_returns_defaults_when_lidarr_not_configured(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.is_configured = MagicMock(return_value=False)
result = await artist_service.get_artist_monitoring_status("abc-123")
assert result == {"in_lidarr": False, "monitored": False, "auto_download": False}
class TestArtistInfoMonitoringFields:
@pytest.mark.asyncio
async def test_monitoring_fields_set_from_lidarr(self, artist_service, mock_lidarr_repo, mock_cache):
mock_lidarr_repo.get_artist_details.return_value = {
"id": 42,
"monitored": True,
"monitor_new_items": "all",
"name": "Test Artist",
"overview": "A test artist",
"genres": ["rock"],
"links": [],
"poster_url": None,
"fanart_url": None,
"banner_url": None,
}
info = await artist_service._do_get_artist_info("abc-123", None, None)
assert info.monitored is True
assert info.auto_download is True
assert info.in_lidarr is True
@pytest.mark.asyncio
async def test_monitoring_fields_default_when_no_lidarr(self, artist_service, mock_lidarr_repo, mock_cache):
mock_lidarr_repo.is_configured = MagicMock(return_value=False)
mock_lidarr_repo.get_artist_details.return_value = None
with patch.object(artist_service, '_build_artist_from_musicbrainz') as mock_build:
from models.artist import ArtistInfo
mock_build.return_value = ArtistInfo(
name="Test", musicbrainz_id="abc-123",
tags=[], aliases=[], external_links=[],
)
info = await artist_service._do_get_artist_info("abc-123", None, None)
assert info.monitored is False
assert info.auto_download is False
assert info.in_lidarr is False
class TestUpdateAlbumHelper:
"""Tests for Aurral-aligned _update_album helper (PUT /album/{id} instead of PUT /album/monitor)."""
@pytest.mark.asyncio
async def test_update_album_returns_synchronous_result(self):
"""_update_album GETs full album, merges updates, PUTs back, returns updated object."""
repo = AsyncMock()
from repositories.lidarr.album import LidarrAlbumRepository
repo._ALBUM_MUTABLE_FIELDS = LidarrAlbumRepository._ALBUM_MUTABLE_FIELDS
original_album = {"id": 10, "title": "Test", "monitored": False, "statistics": {}}
updated_album = {"id": 10, "title": "Test", "monitored": True, "statistics": {}}
repo._get = AsyncMock(return_value=original_album)
repo._put = AsyncMock(return_value=updated_album)
result = await LidarrAlbumRepository._update_album(repo, 10, {"monitored": True})
repo._get.assert_awaited_once_with("/api/v1/album/10")
repo._put.assert_awaited_once()
put_args = repo._put.await_args
assert put_args[0][0] == "/api/v1/album/10"
assert put_args[0][1]["monitored"] is True
assert result["monitored"] is True
@pytest.mark.asyncio
async def test_update_album_preserves_other_fields(self):
"""_update_album only merges specified fields, preserving the rest."""
repo = AsyncMock()
from repositories.lidarr.album import LidarrAlbumRepository
repo._ALBUM_MUTABLE_FIELDS = LidarrAlbumRepository._ALBUM_MUTABLE_FIELDS
original = {"id": 10, "title": "Original", "monitored": False, "anyReleaseOk": True}
repo._get = AsyncMock(return_value=original.copy())
repo._put = AsyncMock(return_value={**original, "monitored": True})
result = await LidarrAlbumRepository._update_album(repo, 10, {"monitored": True})
put_payload = repo._put.await_args[0][1]
assert put_payload["title"] == "Original"
assert put_payload["anyReleaseOk"] is True
assert put_payload["monitored"] is True
assert result is not None
@pytest.mark.asyncio
async def test_update_album_rejects_disallowed_fields(self):
"""_update_album silently drops fields not in the allowlist."""
repo = AsyncMock()
from repositories.lidarr.album import LidarrAlbumRepository
repo._ALBUM_MUTABLE_FIELDS = LidarrAlbumRepository._ALBUM_MUTABLE_FIELDS
original = {"id": 10, "title": "Original", "monitored": False, "rootFolderPath": "/music"}
repo._get = AsyncMock(return_value=original.copy())
repo._put = AsyncMock(return_value={**original, "monitored": True})
result = await LidarrAlbumRepository._update_album(
repo, 10, {"monitored": True, "rootFolderPath": "/evil", "qualityProfileId": 999}
)
put_payload = repo._put.await_args[0][1]
assert put_payload["monitored"] is True
assert put_payload["rootFolderPath"] == "/music"
assert "qualityProfileId" not in put_payload or put_payload.get("qualityProfileId") != 999
assert result is not None
class TestProcessorMonitoringSignal:
"""Tests for the queue processor's belt-and-suspenders monitoring check (structured boolean)."""
def _check_monitored(self, result: dict) -> bool:
"""Replicate the processor's monitoring check logic."""
payload = result.get("payload", {})
is_monitored = payload.get("monitored", False) if isinstance(payload, dict) else False
if not is_monitored:
is_monitored = bool(result.get("monitored"))
return is_monitored
@pytest.mark.asyncio
async def test_processor_trusts_structured_flag_when_payload_stale(self):
"""Processor should treat album as monitored via structured boolean, even if payload is stale."""
result = {
"message": "Album monitored & search triggered: Test Album",
"monitored": True,
"payload": {"monitored": False, "id": 10},
}
assert self._check_monitored(result) is True
@pytest.mark.asyncio
async def test_processor_trusts_added_and_monitored_flag(self):
"""Processor should detect structured monitored=True for add+monitor path."""
result = {
"message": "Album added & monitored: New Album",
"monitored": True,
"payload": {"monitored": False, "id": 20},
}
assert self._check_monitored(result) is True
@pytest.mark.asyncio
async def test_processor_does_not_false_positive_without_flag(self):
"""Processor should NOT flag as monitored when structured boolean is absent."""
result = {
"message": "Album already downloaded: Some Album",
"payload": {"monitored": False, "id": 30},
}
assert self._check_monitored(result) is False
@pytest.mark.asyncio
async def test_processor_uses_payload_when_already_monitored(self):
"""When payload correctly has monitored=True, structured flag is not needed."""
result = {
"message": "Album already downloaded: Some Album",
"payload": {"monitored": True, "id": 40},
}
assert self._check_monitored(result) is True
+209
View File
@@ -0,0 +1,209 @@
"""Tests for MUS-15: album status race condition fixes."""
from unittest.mock import AsyncMock, MagicMock, patch
import asyncio
import pytest
# ---------- Fix 1: library_service.get_library_mbids merges library_db ----------
@pytest.mark.asyncio
async def test_get_library_mbids_merges_library_db():
"""Library mbids should union Lidarr API results with library_db MBIDs."""
from services.library_service import LibraryService
lidarr_repo = MagicMock()
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_library_mbids = AsyncMock(return_value={"aaa", "bbb"})
library_db = MagicMock()
library_db.get_all_album_mbids = AsyncMock(return_value={"CCC", "DDD"})
svc = LibraryService(
lidarr_repo=lidarr_repo,
library_db=library_db,
cover_repo=MagicMock(),
preferences_service=MagicMock(),
)
result = await svc.get_library_mbids()
result_set = {m.lower() for m in result}
assert result_set == {"aaa", "bbb", "ccc", "ddd"}, "Should contain both Lidarr and library_db MBIDs"
assert len(result) == 4
@pytest.mark.asyncio
async def test_get_library_mbids_handles_library_db_overlap():
"""Overlapping MBIDs should be deduplicated."""
from services.library_service import LibraryService
lidarr_repo = MagicMock()
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_library_mbids = AsyncMock(return_value={"aaa", "bbb"})
library_db = MagicMock()
library_db.get_all_album_mbids = AsyncMock(return_value={"AAA", "bbb"})
svc = LibraryService(
lidarr_repo=lidarr_repo,
library_db=library_db,
cover_repo=MagicMock(),
preferences_service=MagicMock(),
)
result = await svc.get_library_mbids()
assert len(result) == 2, "Overlapping MBIDs should be deduplicated (case-insensitive)"
@pytest.mark.asyncio
async def test_get_library_mbids_recently_imported_visible():
"""Album upserted to library_db (by on_import) appears even when Lidarr cache is stale."""
from services.library_service import LibraryService
# Lidarr API returns old cached data — doesn't include the newly imported album
lidarr_repo = MagicMock()
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_library_mbids = AsyncMock(return_value={"old-album"})
# library_db has the newly imported album (upserted by on_import callback)
library_db = MagicMock()
library_db.get_all_album_mbids = AsyncMock(return_value={"old-album", "newly-imported"})
svc = LibraryService(
lidarr_repo=lidarr_repo,
library_db=library_db,
cover_repo=MagicMock(),
preferences_service=MagicMock(),
)
result = await svc.get_library_mbids()
result_set = set(result)
assert "newly-imported" in result_set, "Recently imported album must appear in library mbids"
@pytest.mark.asyncio
async def test_get_library_mbids_degrades_when_library_db_fails():
"""If library_db fails, endpoint should degrade to Lidarr-only MBIDs."""
from services.library_service import LibraryService
lidarr_repo = MagicMock()
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_library_mbids = AsyncMock(return_value={"aaa", "bbb"})
library_db = MagicMock()
library_db.get_all_album_mbids = AsyncMock(side_effect=RuntimeError("DB locked"))
svc = LibraryService(
lidarr_repo=lidarr_repo,
library_db=library_db,
cover_repo=MagicMock(),
preferences_service=MagicMock(),
)
result = await svc.get_library_mbids()
result_set = set(result)
assert result_set == {"aaa", "bbb"}, "Should fall back to Lidarr-only when library_db fails"
# ---------- Fix 2: queue worker fires import callback ----------
@pytest.mark.asyncio
async def test_queue_worker_fires_import_callback_when_has_files():
"""When the queue worker detects has_files, the import callback should fire."""
from infrastructure.queue.request_queue import RequestQueue
from infrastructure.persistence.request_history import RequestHistoryRecord
callback_called = asyncio.Event()
callback_record = {}
async def on_import(record):
callback_record["mbid"] = record.musicbrainz_id
callback_record["artist_mbid"] = record.artist_mbid
callback_called.set()
history = MagicMock()
# First call: cancel check (returns original pending record)
# Second call: enriched record for callback (after field updates)
original_record = RequestHistoryRecord(
musicbrainz_id="test-mbid",
artist_name="Test Artist",
album_title="Test Album",
requested_at="2026-01-01T00:00:00Z",
status="pending",
)
enriched_record = RequestHistoryRecord(
musicbrainz_id="test-mbid",
artist_name="Test Artist",
album_title="Test Album",
requested_at="2026-01-01T00:00:00Z",
status="imported",
artist_mbid="artist-mbid",
)
history.async_get_record = AsyncMock(side_effect=[original_record, enriched_record])
history.async_update_status = AsyncMock()
history.async_update_lidarr_album_id = AsyncMock()
history.async_update_cover_url = AsyncMock()
history.async_update_artist_mbid = AsyncMock()
q = RequestQueue(
processor=AsyncMock(),
request_history=history,
on_import_callback=on_import,
)
result = {
"payload": {
"id": 42,
"statistics": {"trackFileCount": 5},
"artist": {"foreignArtistId": "artist-mbid"},
}
}
await q._update_history_on_result("test-mbid", result)
assert callback_called.is_set(), "Import callback should have been called"
assert callback_record["mbid"] == "test-mbid"
assert callback_record["artist_mbid"] == "artist-mbid", "Callback should receive enriched record"
@pytest.mark.asyncio
async def test_queue_worker_no_callback_when_downloading():
"""When the queue worker detects no files, import callback should NOT fire."""
from infrastructure.queue.request_queue import RequestQueue
from infrastructure.persistence.request_history import RequestHistoryRecord
callback_called = asyncio.Event()
async def on_import(record):
callback_called.set()
history = MagicMock()
original_record = RequestHistoryRecord(
musicbrainz_id="test-mbid",
artist_name="Test Artist",
album_title="Test Album",
requested_at="2026-01-01T00:00:00Z",
status="pending",
)
history.async_get_record = AsyncMock(return_value=original_record)
history.async_update_status = AsyncMock()
history.async_update_lidarr_album_id = AsyncMock()
history.async_update_cover_url = AsyncMock()
history.async_update_artist_mbid = AsyncMock()
q = RequestQueue(
processor=AsyncMock(),
request_history=history,
on_import_callback=on_import,
)
result = {
"payload": {
"id": 42,
"statistics": {"trackFileCount": 0},
"artist": {"foreignArtistId": "artist-mbid"},
}
}
await q._update_history_on_result("test-mbid", result)
assert not callback_called.is_set(), "Import callback should NOT fire when trackFileCount=0"