soulsync/tests/blocklist/test_matching.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

130 lines
5.9 KiB
Python

"""Pure blocklist matching — the cascade + ID/name rules.
Block an artist/album/track by metadata-source ID; a candidate track is
blocked if its track, album, or any artist matches (cascade). Same-source ID
match is primary; artist NAME is a fallback (covers the backfill window);
albums/tracks are ID-only to avoid common-title false positives.
"""
from __future__ import annotations
from core.blocklist.matching import (
ENTITY_ALBUM,
ENTITY_ARTIST,
ENTITY_TRACK,
build_index,
candidate_block_reason,
)
def _row(entity_type, name="", **ids):
return {"entity_type": entity_type, "name": name, **ids}
def _check(index, source="spotify", **kw):
return candidate_block_reason(index, source=source, **kw)
# ── empty / no-match ─────────────────────────────────────────────────────────
def test_empty_blocklist_blocks_nothing():
idx = build_index([])
assert idx.is_empty
assert _check(idx, track_id="t1", album_id="al1",
artists=[{"id": "ar1", "name": "X"}]) is None
def test_unrelated_candidate_not_blocked():
idx = build_index([_row(ENTITY_ARTIST, "Drake", spotify_id="drake-sp")])
assert _check(idx, artists=[{"id": "other", "name": "Adele"}]) is None
# ── artist level + cascade ───────────────────────────────────────────────────
def test_artist_blocked_by_id_blocks_their_track():
idx = build_index([_row(ENTITY_ARTIST, "Drake", spotify_id="drake-sp")])
reason = _check(idx, track_id="t9", album_id="al9",
artists=[{"id": "drake-sp", "name": "Drake"}])
assert reason == (ENTITY_ARTIST, "Drake")
def test_artist_blocked_by_name_fallback():
# No id resolved for this source yet (backfill window) — name still catches it.
idx = build_index([_row(ENTITY_ARTIST, "Drake", deezer_id="drake-dz")])
reason = _check(idx, source="spotify", artists=[{"id": "drake-sp", "name": "drake"}])
assert reason == (ENTITY_ARTIST, "drake")
def test_artist_name_match_is_case_insensitive():
idx = build_index([_row(ENTITY_ARTIST, "Tyler, The Creator", spotify_id="x")])
assert _check(idx, artists=[{"id": None, "name": "tyler, the creator"}]) is not None
# ── album level ──────────────────────────────────────────────────────────────
def test_album_blocked_by_id_blocks_its_track():
idx = build_index([_row(ENTITY_ALBUM, "Scorpion", spotify_id="scorp-sp")])
reason = _check(idx, track_id="t1", album_id="scorp-sp", album_name="Scorpion",
artists=[{"id": "drake-sp", "name": "Drake"}])
assert reason == (ENTITY_ALBUM, "Scorpion")
def test_album_name_does_not_match_without_id():
# Common title must NOT block across artists on name alone.
idx = build_index([_row(ENTITY_ALBUM, "Greatest Hits", spotify_id="gh-queen")])
reason = _check(idx, album_id="gh-abba", album_name="Greatest Hits",
artists=[{"id": "abba", "name": "ABBA"}])
assert reason is None
# ── track level ──────────────────────────────────────────────────────────────
def test_track_blocked_by_id():
idx = build_index([_row(ENTITY_TRACK, "Hotline Bling", spotify_id="hb-sp")])
reason = _check(idx, track_id="hb-sp", track_name="Hotline Bling",
album_id="al", artists=[{"id": "drake", "name": "Drake"}])
assert reason == (ENTITY_TRACK, "Hotline Bling")
def test_track_name_alone_does_not_block():
idx = build_index([_row(ENTITY_TRACK, "Intro", spotify_id="intro-1")])
assert _check(idx, track_id="intro-2", track_name="Intro",
artists=[{"id": "z", "name": "Z"}]) is None
# ── source isolation (numeric id collision guard) ────────────────────────────
def test_same_id_different_source_does_not_collide():
# Deezer artist 12246 is blocked; an iTunes artist that happens to be 12246
# is a DIFFERENT entity and must NOT match (candidate source = itunes).
idx = build_index([_row(ENTITY_ARTIST, "Some Deezer Artist", deezer_id="12246")])
reason = _check(idx, source="itunes", artists=[{"id": "12246", "name": "Other"}])
assert reason is None
def test_same_id_same_source_matches():
idx = build_index([_row(ENTITY_ARTIST, "A", deezer_id="12246")])
reason = _check(idx, source="deezer", artists=[{"id": "12246", "name": "A"}])
assert reason is not None
# ── multi-source row ─────────────────────────────────────────────────────────
def test_row_with_multiple_source_ids_matches_each():
idx = build_index([_row(ENTITY_ARTIST, "Drake",
spotify_id="sp", itunes_id="it", deezer_id="dz")])
assert _check(idx, source="spotify", artists=[{"id": "sp", "name": "Drake"}])
assert _check(idx, source="itunes", artists=[{"id": "it", "name": "Drake"}])
assert _check(idx, source="deezer", artists=[{"id": "dz", "name": "Drake"}])
# ── cascade precedence ───────────────────────────────────────────────────────
def test_track_hit_reported_before_artist():
idx = build_index([
_row(ENTITY_ARTIST, "Drake", spotify_id="drake"),
_row(ENTITY_TRACK, "God's Plan", spotify_id="gp"),
])
reason = _check(idx, track_id="gp", track_name="God's Plan",
artists=[{"id": "drake", "name": "Drake"}])
assert reason[0] == ENTITY_TRACK # most specific hit wins the reason