Files
musicseerr/backend/tests/test_sync_coordinator.py
T

142 lines
4.7 KiB
Python

"""Tests for sync coordinator: cooldown on success only, future dedup, race safety."""
import asyncio
import time
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_library_service(**overrides):
"""Build a minimal LibraryService with mocked deps."""
from services.library_service import LibraryService
lidarr = overrides.get("lidarr", AsyncMock())
lidarr.is_configured = MagicMock(return_value=True)
lidarr.get_library = overrides.get("get_library", AsyncMock(return_value=[]))
lidarr.get_artists_from_library = overrides.get(
"get_artists_from_library", AsyncMock(return_value=[])
)
library_db = overrides.get("library_db", AsyncMock())
library_db.save_library = AsyncMock()
prefs = overrides.get("prefs", MagicMock())
prefs.get_advanced_settings.return_value = MagicMock(
cache_ttl_library_sync=30,
)
svc = LibraryService(
lidarr_repo=lidarr,
library_db=library_db,
cover_repo=MagicMock(),
preferences_service=prefs,
)
return svc
class TestCooldownOnlyOnSuccess:
@pytest.mark.asyncio
async def test_failed_sync_does_not_set_cooldown(self):
svc = _make_library_service()
svc._lidarr_repo.get_library = AsyncMock(side_effect=RuntimeError("DNS fail"))
with patch("services.library_service.CacheStatusService") as mock_css:
mock_css.return_value.is_syncing.return_value = False
with pytest.raises(Exception, match="DNS fail"):
await svc.sync_library()
assert svc._last_sync_time == 0.0, "cooldown must NOT activate on failure"
@pytest.mark.asyncio
async def test_successful_sync_sets_cooldown(self):
svc = _make_library_service()
with patch("services.library_service.CacheStatusService") as mock_css:
mock_css.return_value.is_syncing.return_value = False
before = time.time()
result = await svc.sync_library()
after = time.time()
assert result.status == "success"
assert before <= svc._last_sync_time <= after
@pytest.mark.asyncio
async def test_retry_after_failed_sync_is_not_cooldown_blocked(self):
call_count = 0
async def fail_then_succeed():
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("temporary failure")
return []
svc = _make_library_service()
svc._lidarr_repo.get_library = fail_then_succeed
with patch("services.library_service.CacheStatusService") as mock_css:
mock_css.return_value.is_syncing.return_value = False
with pytest.raises(Exception, match="temporary failure"):
await svc.sync_library()
result = await svc.sync_library()
assert result.status == "success"
class TestSyncFutureDedup:
@pytest.mark.asyncio
async def test_concurrent_syncs_deduplicated(self):
"""Two concurrent sync calls should result in exactly one Lidarr call."""
call_count = 0
sync_event = asyncio.Event()
async def slow_get_library():
nonlocal call_count
call_count += 1
sync_event.set()
await asyncio.sleep(0.05)
return []
svc = _make_library_service()
svc._lidarr_repo.get_library = slow_get_library
with patch("services.library_service.CacheStatusService") as mock_css:
mock_css.return_value.is_syncing.return_value = False
results = await asyncio.gather(
svc.sync_library(),
svc.sync_library(),
)
assert all(r.status == "success" for r in results)
assert call_count == 1, f"Expected 1 Lidarr call, got {call_count}"
@pytest.mark.asyncio
async def test_concurrent_sync_failure_propagates_to_waiter(self):
"""When the producer fails, deduped waiters get the real exception."""
async def failing_get_library():
await asyncio.sleep(0.05)
raise RuntimeError("Lidarr DNS failure")
svc = _make_library_service()
svc._lidarr_repo.get_library = failing_get_library
with patch("services.library_service.CacheStatusService") as mock_css:
mock_css.return_value.is_syncing.return_value = False
results = await asyncio.gather(
svc.sync_library(),
svc.sync_library(),
return_exceptions=True,
)
# Both should get an error, not hang or get CancelledError
for r in results:
assert isinstance(r, Exception)
assert not isinstance(r, asyncio.CancelledError), \
"Waiter got CancelledError instead of the real exception"