df779c9e6d
* fix: Sync issues - AudioDB warmig + automatic sync skips * progress ui/ux + discovery and album fixes * artist fixes * several request level fixes and improvements * handle request fails + artist refresh + resilience fixes * fix format * fix stop sync fail + last.fn mbid issues + failures/validation reworks
220 lines
7.6 KiB
Python
220 lines
7.6 KiB
Python
"""Tests for MUS-19: sync generation counter, false-failed status, cancel, progress clamp."""
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from services.cache_status_service import CacheStatusService, CacheSyncProgress
|
|
|
|
|
|
def _make_status_service() -> CacheStatusService:
|
|
store = AsyncMock()
|
|
store.save_sync_state = AsyncMock()
|
|
svc = CacheStatusService(store)
|
|
svc._sse_subscribers = []
|
|
return svc
|
|
|
|
|
|
class TestGenerationCounter:
|
|
"""Generation counter rejects stale writes from old syncs."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_sync_returns_generation(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
assert gen1 >= 1
|
|
gen2 = await svc.start_sync('artists', 5)
|
|
assert gen2 == gen1 + 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_update_progress_rejected(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
gen2 = await svc.start_sync('artists', 5)
|
|
|
|
await svc.update_progress(3, 'old item', generation=gen1)
|
|
progress = svc.get_progress()
|
|
assert progress.processed_items == 0, "Stale generation write should be rejected"
|
|
|
|
await svc.update_progress(2, 'new item', generation=gen2)
|
|
progress = svc.get_progress()
|
|
assert progress.processed_items == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_update_phase_rejected(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
gen2 = await svc.start_sync('albums', 5)
|
|
|
|
await svc.update_phase('audiodb_prewarm', 100, generation=gen1)
|
|
progress = svc.get_progress()
|
|
assert progress.phase == 'albums', "Stale generation should not change phase"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_complete_sync_rejected(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
_gen2 = await svc.start_sync('artists', 5)
|
|
|
|
await svc.complete_sync(generation=gen1)
|
|
progress = svc.get_progress()
|
|
assert progress.is_syncing is True, "Stale complete_sync should not stop current sync"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_skip_phase_rejected(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
gen2 = await svc.start_sync('albums', 5)
|
|
|
|
await svc.skip_phase('albums', generation=gen1)
|
|
progress = svc.get_progress()
|
|
assert progress.phase == 'albums', "Stale skip_phase should be rejected"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_persist_progress_rejected(self):
|
|
svc = _make_status_service()
|
|
gen1 = await svc.start_sync('artists', 10)
|
|
_gen2 = await svc.start_sync('artists', 5)
|
|
|
|
svc._sync_state_store.save_sync_state.reset_mock()
|
|
await svc.persist_progress(generation=gen1)
|
|
svc._sync_state_store.save_sync_state.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generation_zero_bypasses_guard(self):
|
|
"""generation=0 (default) always passes through, for backward compatibility."""
|
|
svc = _make_status_service()
|
|
_gen = await svc.start_sync('artists', 10)
|
|
|
|
await svc.update_progress(5, 'item', generation=0)
|
|
progress = svc.get_progress()
|
|
assert progress.processed_items == 5
|
|
|
|
|
|
class TestProgressClamp:
|
|
"""progress_percent is clamped to 100."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_percent_clamped_to_100(self):
|
|
svc = _make_status_service()
|
|
await svc.start_sync('artists', 5)
|
|
await svc.update_progress(20, 'overflow')
|
|
progress = svc.get_progress()
|
|
assert progress.progress_percent <= 100
|
|
|
|
|
|
class TestSkippedAutoSync:
|
|
"""Skipped auto-sync must not flip last_sync_success to False."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_sync_does_not_update_status(self):
|
|
from core.tasks import sync_library_periodically
|
|
from api.v1.schemas.library import SyncLibraryResponse
|
|
|
|
mock_lib = AsyncMock()
|
|
mock_lib._lidarr_repo = MagicMock()
|
|
mock_lib._lidarr_repo.is_configured.return_value = True
|
|
mock_lib.sync_library.return_value = SyncLibraryResponse(
|
|
status="skipped", artists=0, albums=0
|
|
)
|
|
|
|
mock_prefs = MagicMock()
|
|
lidarr_settings = MagicMock()
|
|
lidarr_settings.sync_frequency = "5min"
|
|
mock_prefs.get_lidarr_settings.return_value = lidarr_settings
|
|
|
|
call_count = 0
|
|
|
|
original_sleep = asyncio.sleep
|
|
async def fake_sleep(duration):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= 2:
|
|
raise asyncio.CancelledError()
|
|
await original_sleep(0)
|
|
|
|
with patch('asyncio.sleep', side_effect=fake_sleep):
|
|
try:
|
|
await sync_library_periodically(mock_lib, mock_prefs)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
mock_prefs.save_lidarr_settings.assert_not_called()
|
|
|
|
|
|
class TestCancelSync:
|
|
"""Cancel endpoint and cancellation behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_always_sets_event(self):
|
|
svc = _make_status_service()
|
|
assert not svc.is_cancelled()
|
|
await svc.cancel_current_sync()
|
|
assert svc.is_cancelled()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_works_when_not_syncing(self):
|
|
"""Cancel should work even when is_syncing is False (post-completion AudioDB)."""
|
|
svc = _make_status_service()
|
|
await svc.cancel_current_sync()
|
|
assert svc.is_cancelled()
|
|
|
|
|
|
class TestCancelRoute:
|
|
"""Cancel sync API endpoint."""
|
|
|
|
@pytest.mark.skipif(
|
|
not os.access('/app', os.W_OK),
|
|
reason="Route tests require /app to be writable (Docker environment)",
|
|
)
|
|
def test_cancel_endpoint_calls_service_and_registry(self):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from api.v1.routes.cache_status import router
|
|
from core.dependencies import get_cache_status_service
|
|
|
|
mock_svc = MagicMock()
|
|
mock_svc.cancel_current_sync = AsyncMock()
|
|
mock_svc.wait_for_completion = AsyncMock()
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[get_cache_status_service] = lambda: mock_svc
|
|
|
|
with patch("core.task_registry.TaskRegistry") as MockRegistry:
|
|
mock_registry_instance = MagicMock()
|
|
MockRegistry.get_instance.return_value = mock_registry_instance
|
|
|
|
client = TestClient(app)
|
|
resp = client.post("/cache/sync/cancel")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "cancelled"}
|
|
mock_svc.cancel_current_sync.assert_awaited_once()
|
|
mock_registry_instance.cancel.assert_called_once_with("precache-library")
|
|
mock_svc.wait_for_completion.assert_awaited_once()
|
|
|
|
|
|
class TestRestoreAudioDBPhase:
|
|
"""restore_from_persistence handles audiodb_prewarm phase."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audiodb_prewarm_phase_restores(self):
|
|
svc = _make_status_service()
|
|
svc._sync_state_store.get_sync_state = AsyncMock(return_value={
|
|
'status': 'running',
|
|
'phase': 'audiodb_prewarm',
|
|
'total_artists': 100,
|
|
'processed_artists': 100,
|
|
'total_albums': 50,
|
|
'processed_albums': 50,
|
|
'started_at': 1000,
|
|
})
|
|
await svc.restore_from_persistence()
|
|
progress = svc.get_progress()
|
|
assert progress.is_syncing is True
|
|
assert progress.phase == 'audiodb_prewarm'
|
|
assert progress.total_items == 0
|