soulsync/tests/wishlist/test_wishlist_ignore.py
BoulderBadgeDad 15ea87a154 #897: surface the ignore-list on the wishlist page + stop blocking manual re-adds
Two issues behind #897:

1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
   shipped with #874) was only reachable from the wishlist *overview modal*
   footer, which most users never open. Add the same button to the wishlist
   page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().

2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
   wishlist" endpoint passes source_type=album, but the ignore gate only
   bypasses+clears for source_type=manual, so re-adding a previously-cancelled
   track failed. We cannot just send manual: source_type drives Albums/Singles
   categorisation and repair_worker legitimately uses album too. Thread an
   explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
   that bypasses+clears the ignore while preserving the real source_type.

Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
2026-06-21 20:21:44 -07:00

210 lines
9 KiB
Python

"""#874 — wishlist ignore-list: TTL skip-gate for user-removed/cancelled tracks.
Two layers:
* pure logic (core.wishlist.ignore) — TTL, id-normalization, display extract
* DB seam (MusicDatabase on a temp db) — add/check/remove/list/clear, the
add_to_wishlist gate, the manual-add bypass, and the regression that the
success-cleanup removal path does NOT ignore.
"""
from datetime import datetime, timedelta
import pytest
from core.wishlist.ignore import (
IGNORE_TTL_DAYS,
REASON_CANCELLED,
REASON_REMOVED,
active_ignored_ids,
extract_display,
is_expired,
is_ignored,
normalize_ignore_id,
)
from database.music_database import MusicDatabase
# ── pure logic ──────────────────────────────────────────────────────────
def test_normalize_strips_composite_album_suffix():
assert normalize_ignore_id("track123::album456") == "track123"
assert normalize_ignore_id(" track123 ") == "track123"
assert normalize_ignore_id("") == ""
assert normalize_ignore_id(None) == ""
def test_is_expired_true_past_ttl_false_within():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
assert is_expired(fresh, now) is False
assert is_expired(stale, now) is True
def test_is_expired_unparseable_is_treated_expired_fail_open():
# Corrupt timestamp must lapse (never wedge a track out of the wishlist).
assert is_expired("not-a-date", datetime(2026, 6, 15)) is True
assert is_expired("", datetime(2026, 6, 15)) is True
assert is_expired(None, datetime(2026, 6, 15)) is True
def test_is_ignored_matches_composite_and_bare_ids():
now = datetime(2026, 6, 15, 12, 0, 0)
created = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
rows = [{"track_id": "abc", "created_at": created}]
# Stored bare, queried with composite (and vice-versa) — both match.
assert is_ignored(rows, "abc::album9", now) is True
rows2 = [{"track_id": "abc", "created_at": created}]
assert is_ignored(rows2, "abc", now) is True
assert is_ignored(rows2, "different", now) is False
def test_active_ignored_ids_drops_expired():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=99)).strftime("%Y-%m-%d %H:%M:%S")
rows = [
{"track_id": "keep", "created_at": fresh},
{"track_id": "drop", "created_at": stale},
]
assert active_ignored_ids(rows, now) == {"keep"}
def test_extract_display_handles_dict_and_string_artists():
assert extract_display({"name": "Song", "artists": [{"name": "A"}]}) == ("Song", "A")
assert extract_display({"name": "Song", "artists": ["B"]}) == ("Song", "B")
assert extract_display({}) == ("", "")
assert extract_display(None) == ("", "")
# ── DB seam (temp database — never the live db) ─────────────────────────
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "m.db"))
def _track(track_id="t1", name="Some Song", artist="Some Artist", album_id="alb1"):
return {
"id": track_id,
"name": name,
"artists": [{"name": artist}],
"album": {"id": album_id, "name": "Some Album", "images": []},
}
def test_add_check_remove_roundtrip(db):
assert db.is_track_ignored("t1") is False
assert db.add_to_wishlist_ignore("t1", "Song", "Artist", REASON_REMOVED) is True
assert db.is_track_ignored("t1") is True
# Composite id of the same base track is also considered ignored.
assert db.is_track_ignored("t1::albX") is True
assert db.remove_from_wishlist_ignore("t1") is True
assert db.is_track_ignored("t1") is False
def test_get_and_clear_ignore_list(db):
db.add_to_wishlist_ignore("a", "SongA", "ArtA", REASON_REMOVED)
db.add_to_wishlist_ignore("b", "SongB", "ArtB", REASON_CANCELLED)
entries = db.get_wishlist_ignore()
assert {e["track_id"] for e in entries} == {"a", "b"}
assert any(e["reason"] == "cancelled" for e in entries)
assert db.clear_wishlist_ignore() == 2
assert db.get_wishlist_ignore() == []
def test_expired_entry_is_not_active_and_gets_purged(db):
db.add_to_wishlist_ignore("old", "Old", "Art", REASON_REMOVED)
# Backdate it well past the TTL.
with db._get_connection() as conn:
conn.execute(
"UPDATE wishlist_ignore SET created_at = ? WHERE track_id = ?",
((datetime.now() - timedelta(days=IGNORE_TTL_DAYS + 5)).strftime("%Y-%m-%d %H:%M:%S"), "old"),
)
conn.commit()
assert db.is_track_ignored("old") is False # lapsed
assert db.get_wishlist_ignore() == [] # and purged on read
with db._get_connection() as conn:
remaining = conn.execute("SELECT COUNT(*) c FROM wishlist_ignore").fetchone()["c"]
assert remaining == 0
# ── the gate: add_to_wishlist honours the ignore-list ───────────────────
def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
track = _track("t1")
# Auto add works first time.
assert db.add_to_wishlist(track, source_type="playlist") is True
# User removes + ignores it.
db.remove_from_wishlist("t1")
db.add_to_wishlist_ignore("t1", "Some Song", "Some Artist", REASON_REMOVED)
# Auto re-add (watchlist / failed-capture / cancel) is now blocked.
assert db.add_to_wishlist(track, source_type="playlist") is False
assert db.is_track_ignored("t1") is True
# A MANUAL add bypasses the gate AND clears the ignore so it sticks.
assert db.add_to_wishlist(track, source_type="manual") is True
assert db.is_track_ignored("t1") is False
def test_user_initiated_add_bypasses_and_clears_keeping_source_type(db):
# #897 / carlosjfcasero: a user manually adds an album track they had
# previously cancelled. It must bypass the gate AND clear the ignore — but
# WITHOUT pretending to be source_type='manual' (the album modal sends
# source_type='album', which the Albums/Singles categorisation relies on,
# and which an automatic path like repair_worker also legitimately uses).
track = _track("t7")
db.add_to_wishlist_ignore("t7", "Owned Song", "Owned Artist", REASON_CANCELLED)
# An automatic 'album' add (e.g. repair_worker) is still correctly blocked.
assert db.add_to_wishlist(track, source_type="album") is False
assert db.is_track_ignored("t7") is True
# The explicit user click (user_initiated) goes through and clears the ignore,
# while the stored source_type stays 'album'.
assert db.add_to_wishlist(track, source_type="album", user_initiated=True) is True
assert db.is_track_ignored("t7") is False
# Provenance preserved: the stored row is still source_type='album', NOT 'manual'.
row = next(r for r in db.get_wishlist_tracks() if str(r.get("spotify_track_id")) == "t7")
assert row.get("source_type") == "album"
def test_gate_failopen_when_ignore_table_errors(db, monkeypatch):
# If the ignore check raises, the add must still succeed (never block).
monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert db.add_to_wishlist(_track("t9"), source_type="playlist") is True
def test_route_remove_track_records_ignore(db, monkeypatch):
# Pin the route → ignore wiring (not just the DB layer): a user-initiated
# remove via the route function must drop the row AND ignore the track.
import types
from core.wishlist import routes as routes_module
db.add_to_wishlist(_track("rt1", name="Route Song", artist="Route Artist"),
source_type="playlist")
class _Svc:
database = db
def remove_track_from_wishlist(self, tid, profile_id=1):
return db.remove_from_wishlist(tid, profile_id=profile_id)
monkeypatch.setattr(routes_module, "get_wishlist_service", lambda: _Svc())
runtime = types.SimpleNamespace(profile_id=1, logger=routes_module.module_logger)
payload, status = routes_module.remove_track_from_wishlist(runtime, "rt1")
assert status == 200
assert db.is_track_ignored("rt1") is True
# The ignore carries the captured label.
entry = db.get_wishlist_ignore()[0]
assert entry["track_name"] == "Route Song"
assert entry["reason"] == REASON_REMOVED
def test_regression_success_cleanup_does_not_ignore(db):
# The post-download success path calls remove_from_wishlist directly — it
# must NOT add anything to the ignore-list (only user remove/cancel do).
track = _track("t5")
assert db.add_to_wishlist(track, source_type="playlist") is True
db.remove_from_wishlist("t5") # simulate success cleanup
assert db.is_track_ignored("t5") is False # NOT ignored
# And so a later legitimate auto-add still works.
assert db.add_to_wishlist(track, source_type="playlist") is True