soulsync/tests/blocklist/test_blocklist_db.py
BoulderBadgeDad 43c798a76e Blocklist Phase 1 (backend): artist/album/track bans enforced at the wishlist chokepoint
A proper artist/album/track blacklist (distinct from download_blacklist, which
stays untouched). ID-keyed across metadata sources so a ban survives a source
switch; profile-scoped; cascade artist→album→track.

- core/blocklist/matching.py — pure decision core (no I/O): build an index from
  rows, candidate_block_reason() walks track→album→artist. Same-source ID match
  is primary; artist NAME is a fallback (covers the ID-backfill window);
  albums/tracks are ID-only (common titles like "Greatest Hits" must not
  false-positive across artists). Source-isolated so a numeric Deezer id can't
  collide with a numeric iTunes id of a different entity.
- DB: new `blocklist` table (profile_id, entity_type, name, 4 source-id cols,
  match_status) + CRUD, match-row fetch, backfill-pending query, id-backfill
  update (COALESCE — fills NULLs only).
- Guard: _wishlist_blocklist_reason at the top of add_to_wishlist — every
  auto-acquisition path funnels through it, so one check covers watchlist,
  discography backfill, repair, manual add. Fails OPEN (a guard error never
  blocks a legitimate add).
- Discovery unified IN: legacy discovery_artist_blacklist is migrated into the
  blocklist on upgrade (replicated to every profile so no global ban silently
  stops working; idempotent; legacy table kept for rollback). Discovery reads
  (hero + personalized-playlist SQL) now union the blocklist, so a new-modal
  ban filters discovery too.

Tests: 13 on the pure matcher (cascade, id-vs-name rules, source isolation,
precedence) + 10 on the DB/guard (CRUD, profile isolation, dedup, backfill,
end-to-end wishlist refusal + cascade + the discovery migration upgrade path).
50 blocklist/personalized tests pass.
2026-06-07 15:18:25 -07:00

145 lines
5.7 KiB
Python

"""Blocklist DB layer + the add_to_wishlist enforcement guard.
Verified against a real SQLite DB: CRUD + profile scoping + dedup + backfill
update, the discovery→blocklist migration, and the end-to-end wishlist guard
(a banned artist's track is refused; cascade; profile isolation).
"""
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 _track(track_id, name, artist_id, artist_name, album_id="al0", album_name="Al", source="spotify"):
return {
"id": track_id, "name": name, "source": source,
"artists": [{"id": artist_id, "name": artist_name}],
"album": {"id": album_id, "name": album_name, "artists": [{"id": artist_id, "name": artist_name}]},
}
# ── CRUD + scoping ───────────────────────────────────────────────────────────
def test_add_list_remove(db):
eid = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
assert eid
rows = db.get_blocklist(1)
assert len(rows) == 1 and rows[0]["name"] == "Drake"
assert db.remove_blocklist_entry(1, eid) is True
assert db.get_blocklist(1) == []
def test_dedup_by_id_and_name(db):
a = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp")
b = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp") # same id
c = db.add_blocklist_entry(1, "artist", "drake") # same name (NOCASE)
assert a == b == c
assert len(db.get_blocklist(1)) == 1
def test_profile_isolation(db):
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp")
assert len(db.get_blocklist(1)) == 1
assert db.get_blocklist(2) == []
# remove is profile-scoped — profile 2 can't delete profile 1's row
eid = db.get_blocklist(1)[0]["id"]
assert db.remove_blocklist_entry(2, eid) is False
assert len(db.get_blocklist(1)) == 1
def test_match_status_pending_vs_matched(db):
one = db.add_blocklist_entry(1, "artist", "A", spotify_id="sp")
two = db.add_blocklist_entry(1, "artist", "B", spotify_id="sp2", deezer_id="dz2")
rows = {r["id"]: r for r in db.get_blocklist(1)}
assert rows[one]["match_status"] == "pending" # single id → needs backfill
assert rows[two]["match_status"] == "matched" # 2+ ids → already cross-known
def test_backfill_update_fills_only_nulls(db):
eid = db.add_blocklist_entry(1, "artist", "A", spotify_id="sp")
db.update_blocklist_entry_ids(eid, deezer_id="dz", spotify_id="SHOULD-NOT-OVERWRITE")
row = db.get_blocklist(1)[0]
assert row["spotify_id"] == "sp" # COALESCE: existing kept
assert row["deezer_id"] == "dz"
assert row["match_status"] == "matched"
assert db.get_blocklist_entries_needing_backfill() == []
# ── the wishlist guard, end to end ───────────────────────────────────────────
def test_blocked_artist_track_refused_from_wishlist(db):
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
ok = db.add_to_wishlist(
spotify_track_data=_track("t1", "God's Plan", "drake-sp", "Drake"),
profile_id=1)
assert ok is False # refused
# And nothing landed in the wishlist.
assert db.get_wishlist_count(profile_id=1) == 0
def test_unblocked_artist_track_is_added(db):
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
ok = db.add_to_wishlist(
spotify_track_data=_track("t2", "Hello", "adele-sp", "Adele"),
profile_id=1)
assert ok is True
assert db.get_wishlist_count(profile_id=1) == 1
def test_block_is_profile_scoped_at_the_guard(db):
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
# Profile 2 has no such ban → the same track is allowed.
ok = db.add_to_wishlist(
spotify_track_data=_track("t3", "Nice For What", "drake-sp", "Drake"),
profile_id=2)
assert ok is True
def test_album_block_cascades_to_track_at_guard(db):
db.add_blocklist_entry(1, "album", "Scorpion", spotify_id="scorp-sp")
ok = db.add_to_wishlist(
spotify_track_data=_track("t4", "Nonstop", "drake-sp", "Drake",
album_id="scorp-sp", album_name="Scorpion"),
profile_id=1)
assert ok is False
def test_discovery_blacklist_migrated_into_blocklist(tmp_path):
import database.music_database as mdb
def _reinit(path):
# An app upgrade = a fresh process with an empty init memo. Clear the
# per-process "already initialized" set so init (and the migration)
# actually re-runs against the existing DB file.
mdb._database_initialized_paths.discard(str(mdb.Path(path).resolve()))
return MusicDatabase(path)
path = str(tmp_path / "mig.db")
db = MusicDatabase(path)
conn = db._get_connection()
cur = conn.cursor()
cur.execute("INSERT INTO discovery_artist_blacklist (artist_name, spotify_artist_id) "
"VALUES ('Nickelback', 'nb-sp')")
conn.commit()
conn.close()
db2 = _reinit(path) # upgrade → migration runs (committed)
rows = db2.get_blocklist(1)
assert any(r["name"] == "Nickelback" and r["entity_type"] == "artist" for r in rows)
db3 = _reinit(path) # idempotent — a second upgrade doesn't duplicate
nb = [r for r in db3.get_blocklist(1) if r["name"] == "Nickelback"]
assert len(nb) == 1
# The migrated ban actually enforces at the wishlist guard.
ok = db3.add_to_wishlist(
spotify_track_data=_track("t5", "Photograph", "nb-sp", "Nickelback"),
profile_id=1)
assert ok is False