soulsync/tests/test_artist_sync_stale_guard.py
BoulderBadgeDad d82d02b921 Artist Sync: unify with deep scan — server-diff stale removal, scoped to one artist
Per the original intent, "Sync" is now a single-artist deep scan: it uses the SAME
reconciliation source as the whole-library deep scan instead of a separate
disk-existence check.

- Phase 1 already calls the deep-scan worker's _process_artist_with_content; now it
  passes seen_track_ids so the pull collects the server's current track IDs for the
  artist (existing + new), exactly as the library deep scan does.
- Phase 2 stale = (artist's DB tracks for this server) − seen, then
  delete_stale_tracks(server_source) — identical mechanism to deep scan, scoped to
  one artist. The old os.path.exists disk check (which could mass-delete on an
  unreachable mount) is gone.
- Removal only runs when the server pull SUCCEEDED — no trustworthy 'seen' set
  (no server, unreachable, or a failed pull) → skip, never delete. The
  is_implausible_stale_removal guard (>50% unseen) stays as the same safety net
  deep scan has for a flaky response. @admin_only retained.

Tests rewritten for the server-diff model: removes only tracks the server no longer
has; guard skips when most are unseen; a failed pull skips removal entirely;
admin-only. 8 tests pass.
2026-06-10 19:43:25 -07:00

107 lines
4.3 KiB
Python

"""Artist 'Sync' = a single-artist deep scan: stale removal is a server-diff
(tracks the media server no longer has), with the same safety net + admin gate as
the whole-library deep scan. #828 pattern."""
from __future__ import annotations
import os
import tempfile
from types import SimpleNamespace
import pytest
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-sync2-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 's.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
@pytest.fixture
def client():
return web_server.app.test_client()
def _seed(artist_id, track_ids):
"""Plex artist + album + tracks (server_source='plex')."""
db = web_server.get_database()
aid, album_id = str(artist_id), artist_id * 10
with db._get_connection() as conn:
conn.execute("INSERT OR REPLACE INTO artists (id, name, server_source) VALUES (?, ?, 'plex')",
(aid, f'Artist {aid}'))
conn.execute("INSERT OR REPLACE INTO albums (id, title, artist_id) VALUES (?, 'Alb', ?)",
(album_id, aid))
for tid in track_ids:
conn.execute("INSERT OR REPLACE INTO tracks (id, album_id, artist_id, title, track_number, "
"duration, file_path, server_source) VALUES (?, ?, ?, ?, 1, 100, ?, 'plex')",
(tid, album_id, aid, f'T{tid}', f'/m/{tid}.flac'))
conn.commit()
def _track_ids(artist_id):
db = web_server.get_database()
with db._get_connection() as conn:
return {r['id'] for r in conn.execute("SELECT id FROM tracks WHERE artist_id = ?", (str(artist_id),))}
def _mock_server_pull(monkeypatch, *, seen, success=True):
"""Fake the media server returning `seen` track IDs for the artist."""
class _FakeServer:
def fetchItem(self, _id):
return SimpleNamespace(title=None) # truthy artist, no name change
class _FakePlex:
server = _FakeServer()
class _FakeEngine:
def client(self, name):
return _FakePlex() if name == 'plex' else None
monkeypatch.setattr(web_server, 'media_server_engine', _FakeEngine())
class _FakeWorker:
def __init__(self, *a, **k):
self.database = None
def _process_artist_with_content(self, server_artist, skip_existing_tracks=False, seen_track_ids=None):
if seen_track_ids is not None:
seen_track_ids.update(seen)
return (success, 'ok', 0, 0)
monkeypatch.setattr('core.database_update_worker.DatabaseUpdateWorker', _FakeWorker)
def test_removes_tracks_the_server_no_longer_has(client, monkeypatch):
_seed(7001, [f't{i}' for i in range(1, 11)]) # t1..t10 in DB
_mock_server_pull(monkeypatch, seen={f't{i}' for i in range(1, 9)}) # server has t1..t8
body = client.post('/api/library/artist/7001/sync').get_json()
assert body['success'] and body['removal_skipped'] is False
assert body['stale_removed'] == 2 # t9,t10 gone from server
assert _track_ids(7001) == {f't{i}' for i in range(1, 9)}
def test_guard_skips_when_most_tracks_unseen(client, monkeypatch):
_seed(7002, [f't{i}' for i in range(1, 11)])
_mock_server_pull(monkeypatch, seen={'t1'}) # 9/10 unseen → flaky response
body = client.post('/api/library/artist/7002/sync').get_json()
assert body['removal_skipped'] is True
assert body['stale_removed'] == 0
assert len(_track_ids(7002)) == 10 # nothing deleted
def test_failed_pull_skips_removal(client, monkeypatch):
_seed(7003, [f't{i}' for i in range(1, 11)])
_mock_server_pull(monkeypatch, seen=set(), success=False) # pull failed → no trustworthy view
body = client.post('/api/library/artist/7003/sync').get_json()
assert body['removal_skipped'] is True
assert body['stale_removed'] == 0
assert len(_track_ids(7003)) == 10
def test_sync_is_admin_only(client, monkeypatch):
_seed(7004, ['t1', 't2'])
_mock_server_pull(monkeypatch, seen={'t1', 't2'})
nonadmin = web_server.get_database().create_profile(name=f'u_{os.urandom(3).hex()}')
with client.session_transaction() as sess:
sess['profile_id'] = nonadmin
assert client.post('/api/library/artist/7004/sync').status_code == 403
assert len(_track_ids(7004)) == 2 # untouched