soulsync/tests/blocklist/test_blocklist_api.py
BoulderBadgeDad b6d78d015d Blocklist Phase 1 (backfill + API + modal): the Blocklist button on the watchlist page
Completes Phase 1 on top of the backend (43c798a7):

- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
  core (resolve only missing sources, never raises); core/blocklist/runtime.py
  wires the real metadata clients with a confident name-match (exact
  significant-token equality; album/track also require the parent artist when
  both expose one — no wrong IDs hung on an entry). Resolution runs
  synchronously at add time, so a ban is cross-source from the first scan;
  the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
  (thin wrapper over the manual-match service search on the active source, so
  the modal needn't know the source). Add resolves the other sources before
  storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
  revamp design language (accent light-edge, pill tabs, debounced search with
  spinner + out-of-order guard, per-result Block, "currently blocked" list
  with a match-status star and per-row remove). Opened by a new "Blocklist"
  button on the watchlist page, next to Download Origins.

Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
2026-06-07 15:25:52 -07:00

61 lines
2.3 KiB
Python

"""Blocklist HTTP API: search / add (+ synchronous cross-source backfill) /
list / delete, end to end through the Flask test client."""
from __future__ import annotations
from unittest.mock import patch
import pytest
web_server = pytest.importorskip("web_server")
@pytest.fixture()
def client(monkeypatch):
web_server.app.config["TESTING"] = True
monkeypatch.setattr(web_server, "get_current_profile_id", lambda: 1)
return web_server.app.test_client()
def test_search_proxies_active_source(client):
with patch.object(web_server, "_search_service", return_value=[
{"id": "drake-sp", "name": "Drake", "image": None, "extra": "", "provider": "spotify"}]):
r = client.get("/api/blocklist/search?type=artist&q=drake")
assert r.status_code == 200
body = r.get_json()
assert body["success"] and body["results"][0]["name"] == "Drake"
def test_add_resolves_other_sources_then_list_and_delete(client):
resolvers = {
"itunes": lambda et, n, p: "drake-it",
"deezer": lambda et, n, p: None,
"spotify": lambda et, n, p: None,
"musicbrainz": lambda et, n, p: None,
}
with patch("core.blocklist.runtime.build_resolvers", return_value=resolvers):
r = client.post("/api/blocklist", json={
"entity_type": "artist", "name": "Drake Test One",
"source": "spotify", "source_id": "drake-sp1"})
assert r.status_code == 200 and r.get_json()["success"]
eid = r.get_json()["id"]
rows = client.get("/api/blocklist?entity_type=artist").get_json()["entries"]
row = next(x for x in rows if x["id"] == eid)
assert row["spotify_id"] == "drake-sp1"
assert row["itunes_id"] == "drake-it" # resolved at add time
assert row["match_status"] == "matched"
assert client.delete(f"/api/blocklist/{eid}").get_json()["success"] is True
rows = client.get("/api/blocklist?entity_type=artist").get_json()["entries"]
assert all(x["id"] != eid for x in rows)
def test_add_requires_type_and_name(client):
r = client.post("/api/blocklist", json={"entity_type": "artist"})
assert r.status_code == 400
def test_invalid_entity_type_rejected(client):
assert client.get("/api/blocklist?entity_type=bogus").status_code == 400
assert client.get("/api/blocklist/search?type=bogus&q=x").status_code == 400