feat: add public index page with censored playlist and live reveals
Public-facing page at / shows the current show's playlist with tracks obscured until the admin marks them as announced. Tracks reveal in real-time via a new unauthenticated /ws/public WebSocket. Server-side censorship on /public/playlist strips track details from unannounced items and sanitizes announced tracks to only expose frontend-needed fields (no raw_json, track_id, etc). Past episodes are browsable with fully revealed but sanitized tracklists. Also fixes RuntimeError on backfill shutdown by closing the httpx client on the same event loop that created it. Made-with: Cursor
This commit is contained in:
@@ -152,3 +152,72 @@ def test_no_dashboard_routes_without_config(client):
|
||||
def test_no_login_route_without_config(client):
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- Public endpoint tests ---
|
||||
|
||||
|
||||
def test_index_page(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "NtR Playlist" in resp.text
|
||||
|
||||
|
||||
def test_public_playlist_censors_unannounced(client, db):
|
||||
show = _seed_show(db)
|
||||
db.set_track_announced(show.id, 1, True)
|
||||
|
||||
resp = client.get("/public/playlist")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["tracks"]) == 2
|
||||
|
||||
revealed = data["tracks"][0]
|
||||
assert revealed["announced"] == 1
|
||||
assert revealed["title"] == "Song A"
|
||||
assert revealed["artist"] == "Artist A"
|
||||
assert "raw_json" not in revealed
|
||||
assert "track_id" not in revealed
|
||||
assert "show_id" not in revealed
|
||||
|
||||
hidden = data["tracks"][1]
|
||||
assert hidden["announced"] == 0
|
||||
assert hidden["position"] == 2
|
||||
assert "title" not in hidden
|
||||
assert "artist" not in hidden
|
||||
|
||||
|
||||
def test_public_playlist_all_hidden(client, db):
|
||||
_seed_show(db)
|
||||
resp = client.get("/public/playlist")
|
||||
data = resp.json()
|
||||
for t in data["tracks"]:
|
||||
assert t["announced"] == 0
|
||||
assert "title" not in t
|
||||
|
||||
|
||||
def test_public_shows_list(client, db):
|
||||
_seed_show(db)
|
||||
resp = client.get("/public/shows")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
assert "id" in data[0]
|
||||
assert "episode_number" in data[0]
|
||||
|
||||
|
||||
def test_public_show_detail_fully_revealed(client, db):
|
||||
show = _seed_show(db)
|
||||
resp = client.get(f"/public/shows/{show.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["tracks"]) == 2
|
||||
assert data["tracks"][0]["title"] == "Song A"
|
||||
assert data["tracks"][1]["title"] == "Song B"
|
||||
assert "raw_json" not in data["tracks"][0]
|
||||
assert "track_id" not in data["tracks"][0]
|
||||
|
||||
|
||||
def test_public_show_detail_not_found(client):
|
||||
resp = client.get("/public/shows/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
||||
from ntr_fetcher.dashboard import create_dashboard_router
|
||||
from ntr_fetcher.db import Database
|
||||
from ntr_fetcher.models import Track
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -23,11 +23,17 @@ def manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db, manager):
|
||||
def public_manager():
|
||||
return PublicManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db, manager, public_manager):
|
||||
a = FastAPI()
|
||||
router = create_dashboard_router(
|
||||
db=db,
|
||||
manager=manager,
|
||||
public_manager=public_manager,
|
||||
admin_token="test-token",
|
||||
web_user="nick",
|
||||
web_password="secret",
|
||||
@@ -250,3 +256,59 @@ def test_set_announced_invalid_position(client, db):
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- Public WebSocket tests ---
|
||||
|
||||
|
||||
def test_ws_public_connects_without_auth(app):
|
||||
with TestClient(app) as c:
|
||||
with c.websocket_connect("/ws/public") as ws:
|
||||
pass
|
||||
|
||||
|
||||
def test_ws_public_receives_reveal_on_announce(app, db, public_manager):
|
||||
_seed_show(db)
|
||||
with TestClient(app) as c:
|
||||
with c.websocket_connect("/ws/public") as ws:
|
||||
c.post(
|
||||
"/admin/announce",
|
||||
json={"show_id": 1, "position": 1},
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "reveal"
|
||||
assert msg["position"] == 1
|
||||
assert msg["track"]["title"] == "Song A"
|
||||
assert msg["track"]["artist"] == "Artist A"
|
||||
|
||||
|
||||
def test_ws_public_receives_reveal_on_announced_toggle(app, db, public_manager):
|
||||
_seed_show(db)
|
||||
with TestClient(app) as c:
|
||||
with c.websocket_connect("/ws/public") as ws:
|
||||
c.post(
|
||||
"/admin/announced",
|
||||
json={"show_id": 1, "position": 1, "announced": True},
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "reveal"
|
||||
assert msg["position"] == 1
|
||||
assert msg["track"]["title"] == "Song A"
|
||||
|
||||
|
||||
def test_ws_public_receives_hide_on_unannounce(app, db, public_manager):
|
||||
show = _seed_show(db)
|
||||
db.set_track_announced(show.id, 1, True)
|
||||
with TestClient(app) as c:
|
||||
with c.websocket_connect("/ws/public") as ws:
|
||||
c.post(
|
||||
"/admin/announced",
|
||||
json={"show_id": 1, "position": 1, "announced": False},
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "hide"
|
||||
assert msg["position"] == 1
|
||||
assert "track" not in msg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -98,3 +98,61 @@ async def test_status_broadcast_includes_clients(manager):
|
||||
assert msg["subscribers"] == 1
|
||||
assert len(msg["clients"]) == 1
|
||||
assert msg["clients"][0]["client_id"] == "my-bot"
|
||||
|
||||
|
||||
# --- PublicManager tests ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_manager():
|
||||
return PublicManager()
|
||||
|
||||
|
||||
def test_public_no_clients_initially(public_manager):
|
||||
assert public_manager.client_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_add_remove_client(public_manager):
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
pass
|
||||
|
||||
ws = FakeWS()
|
||||
public_manager.add_client(ws)
|
||||
assert public_manager.client_count == 1
|
||||
|
||||
public_manager.remove_client(ws)
|
||||
assert public_manager.client_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_broadcast(public_manager):
|
||||
received = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
received.append(data)
|
||||
|
||||
ws1 = FakeWS()
|
||||
ws2 = FakeWS()
|
||||
public_manager.add_client(ws1)
|
||||
public_manager.add_client(ws2)
|
||||
|
||||
await public_manager.broadcast({"type": "reveal", "position": 1})
|
||||
assert len(received) == 2
|
||||
assert all(m["type"] == "reveal" for m in received)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_broadcast_removes_dead_clients(public_manager):
|
||||
class DeadWS:
|
||||
async def send_json(self, data):
|
||||
raise Exception("closed")
|
||||
|
||||
ws = DeadWS()
|
||||
public_manager.add_client(ws)
|
||||
assert public_manager.client_count == 1
|
||||
|
||||
await public_manager.broadcast({"type": "reveal", "position": 1})
|
||||
assert public_manager.client_count == 0
|
||||
|
||||
Reference in New Issue
Block a user