soulsync/tests/blocklist/test_blocklist_db.py
BoulderBadgeDad 45badf588c Blocklist Phase 2a: gate the download queue (playlist sync / album / discography)
Phase 1 guarded the wishlist; Phase 2a closes the other auto-acquisition path.
Playlist sync, album download, and discography backfill all flow through
run_full_missing_tracks_process, which queues missing tracks at one point —
right where the explicit-content filter already drops tracks. The blocklist
filter slots in beside it: each missing track is checked and a banned
artist/album/track is dropped before queueing (logged with a count), so a
blocked item can't slip in via these flows.

Same brain as Phase 1: the wishlist guard's matcher is generalized to
db.blocklist_reason_for_track(profile_id, track_data, source=None) — the new
`source` param lets the queue path supply the batch source, since an analysis
track dict may not carry a 'provider' field (artists still match by name
fallback regardless). One method, two callers (wishlist + queue), one cascade.

Manual single-track downloads (/api/download, candidate picker, redownload)
are deliberately NOT gated here — that's Phase 2b, pending a block-vs-warn-vs-
override policy decision.

Tests: source-fallback isolation (album id-only proves source drives the ID
match; artist name still matches sourceless), and a queue-filter simulation
mirroring master.py. 35 blocklist tests pass (the only failures in the
download family are the pre-existing soundcloud /app ones).
2026-06-07 15:49:59 -07:00

183 lines
7.8 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
# ── Phase 2a: shared guard with source fallback (download-queue path) ────────
def test_blocklist_reason_source_fallback(db):
"""The download-queue guard passes the batch source explicitly because the
analysis track dict may not carry a 'provider' field. Uses an ALBUM ban
(id-only, no name fallback) to isolate the source-driven ID match."""
db.add_blocklist_entry(1, "album", "Scorpion", deezer_id="scorp-dz")
track = {"id": "t1", "name": "Nonstop",
"artists": [{"id": "drake", "name": "Drake"}],
"album": {"id": "scorp-dz", "name": "Scorpion"}}
assert db.blocklist_reason_for_track(1, track) is None # no source → album id can't match
assert db.blocklist_reason_for_track(1, track, source="spotify") is None # wrong source
assert db.blocklist_reason_for_track(1, track, source="deezer") # right source → match
def test_artist_name_fallback_works_without_source(db):
# Artists DO fall back to name, so a ban matches even when the source is
# unknown (covers the cross-source backfill window).
db.add_blocklist_entry(1, "artist", "Drake", deezer_id="drake-dz")
track = {"id": "t1", "name": "Track", "artists": [{"id": "x", "name": "Drake"}],
"album": {"id": "al", "name": "Al"}}
assert db.blocklist_reason_for_track(1, track) is not None
def test_blocklist_reason_simulates_queue_filter(db):
"""Mirror what master.py does: filter a missing-tracks list by the guard."""
db.add_blocklist_entry(1, "artist", "Blocked Guy", spotify_id="bg-sp")
missing = [
{"track": {"id": "t1", "name": "Keep Me",
"artists": [{"id": "ok", "name": "Good Artist"}], "album": {"id": "a1", "name": "A"}}},
{"track": {"id": "t2", "name": "Drop Me",
"artists": [{"id": "bg-sp", "name": "Blocked Guy"}], "album": {"id": "a2", "name": "B"}}},
]
kept = [r for r in missing
if not db.blocklist_reason_for_track(1, r["track"], source="spotify")]
assert [r["track"]["id"] for r in kept] == ["t1"]