soulsync/tests/test_watchlist_scan_history.py
BoulderBadgeDad eddaea2f93 watchlist history: record automatic scans too (#933)
save_watchlist_scan_run had a single caller — the manual scan endpoint. the automatic/
scheduled path (process_watchlist_scan_automatically) ran the full scan but never wrote a
history row, so nightly scans never showed up in the History modal — only manual ones.

- new shared helper persist_scan_run(database, state, ...) extracts the run from the
  finished watchlist_scan_state and writes one history row
- the automatic path now stamps scan_run_id/scan_track_events and calls it
- the manual path is refactored onto the same helper so the two can't drift apart again
- history is global (no profile filter), so the all-profiles nightly scan records one
  aggregate row (profile_id None → 1, never NULL)

tests: 4 new persist_scan_run seam tests (real DB) + 2 new auto-scan integration tests
proving the auto path actually records (completed + cancelled, exactly once). 420
watchlist/automation tests green.
2026-06-27 00:42:58 -07:00

124 lines
4.7 KiB
Python

"""Watchlist scan history persistence (#831 round 2).
Every scan run is saved to watchlist_scan_runs with its track ledger so the
Watchlist History modal can show what each past run added — wishlist rows
erode as tracks download, so this table is the durable record.
"""
from __future__ import annotations
import pytest
from database.music_database import MusicDatabase
@pytest.fixture()
def db(tmp_path):
return MusicDatabase(str(tmp_path / 'm.db'))
def _events(n_added=2, n_skipped=1):
evs = [{'track_name': f'Added {i}', 'artist_name': 'A', 'album_name': 'Al',
'album_image_url': '', 'status': 'added'} for i in range(n_added)]
evs += [{'track_name': f'Skipped {i}', 'artist_name': 'A', 'album_name': 'Al',
'album_image_url': '', 'status': 'skipped'} for i in range(n_skipped)]
return evs
def test_save_and_fetch_run_with_ledger(db):
assert db.save_watchlist_scan_run(
'run-1', status='completed',
started_at='2026-06-09T20:00:00', completed_at='2026-06-09T20:05:00',
total_artists=63, artists_scanned=63, tracks_found=19, tracks_added=10,
track_events=_events())
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1
r = runs[0]
assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \
('run-1', 'completed', 19, 10)
events = db.get_watchlist_scan_run_events('run-1')
assert [e['status'] for e in events] == ['added', 'added', 'skipped']
def test_resave_is_idempotent_on_run_id(db):
db.save_watchlist_scan_run('run-1', tracks_added=10)
db.save_watchlist_scan_run('run-1', tracks_added=11)
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1 and runs[0]['tracks_added'] == 11
# ── persist_scan_run: the shared seam both scan paths use (#933) ──
from datetime import datetime
from core.watchlist.scan_history import persist_scan_run
def _state(**over):
"""A watchlist_scan_state as the scanner leaves it when a scan finishes."""
s = {
'scan_run_id': 'auto-run-1',
'started_at': datetime(2026, 6, 26, 2, 0, 0),
'completed_at': datetime(2026, 6, 26, 2, 4, 0),
'total_artists': 40,
'tracks_found_this_scan': 7,
'tracks_added_this_scan': 3,
'scan_track_events': _events(n_added=3, n_skipped=1),
'summary': {'total_artists': 40, 'successful_scans': 40,
'new_tracks_found': 7, 'tracks_added_to_wishlist': 3},
}
s.update(over)
return s
def test_persist_scan_run_records_a_history_row(db):
# the #933 fix: an automatic (all-profiles → profile_id=None) scan must land in History.
assert persist_scan_run(db, _state(), profile_id=None, was_cancelled=False) is True
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1
r = runs[0]
assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \
('auto-run-1', 'completed', 7, 3)
assert r['profile_id'] == 1 # None coerced to a concrete profile, never NULL
# the per-run ledger came through too
assert [e['status'] for e in db.get_watchlist_scan_run_events('auto-run-1')] == \
['added', 'added', 'added', 'skipped']
def test_persist_scan_run_cancelled_status(db):
persist_scan_run(db, _state(scan_run_id='c1'), profile_id=2, was_cancelled=True)
assert db.get_watchlist_scan_runs()[0]['status'] == 'cancelled'
def test_persist_scan_run_accepts_datetime_or_iso_string(db):
# state timestamps may be datetime (auto path) or already-iso strings — both must persist.
persist_scan_run(db, _state(scan_run_id='dt', completed_at='2026-06-26T02:09:00'),
profile_id=1, was_cancelled=False)
assert db.get_watchlist_scan_runs()[0]['run_id'] == 'dt'
def test_persist_scan_run_tolerates_sparse_state(db):
# a bare/early-finished state must not raise — history-write must never break a scan.
assert persist_scan_run(db, {'scan_run_id': 'sparse'}, profile_id=None, was_cancelled=False)
assert db.get_watchlist_scan_runs()[0]['tracks_added'] == 0
def test_prune_keeps_most_recent(db):
for i in range(1, 8):
db.save_watchlist_scan_run(
f'run-{i}', completed_at=f'2026-06-09T20:0{i}:00', keep_last=5)
runs = db.get_watchlist_scan_runs()
assert len(runs) == 5
assert runs[0]['run_id'] == 'run-7' # newest first
assert all(r['run_id'] != 'run-1' for r in runs) # oldest pruned
def test_events_for_unknown_run_empty(db):
assert db.get_watchlist_scan_run_events('nope') == []
def test_cancelled_run_recorded(db):
db.save_watchlist_scan_run('run-c', status='cancelled', tracks_added=3,
track_events=_events(1, 0))
r = db.get_watchlist_scan_runs()[0]
assert r['status'] == 'cancelled'