mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(library): persisted wishlist / "wanted" list (#640)
Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack was missing. A wishlist entry is a song the user does NOT own yet, so unlike a playlist (which references owned local songs by filename) it can't reuse the playlist subsystem; it lives in a new `wanted` table keyed by descriptive identity (artist, title, source, source_ref, note, created_at). - New table + a UNIQUE index on (artist NOCASE, title NOCASE, source, source_ref); additive + idempotent (CREATE … IF NOT EXISTS). - MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock, so a re-run of an ownership-diff returns the existing row, never a dup), list_wanted (newest first), remove_wanted, count_wanted. - Routes GET/POST/DELETE /api/wanted. POST requires artist or title and defaults source to "manual"; idempotent on identity so producers (the find_more ownership-diff, or a manual add) can re-post freely. This is the core persistence primitive the charrette flagged as the missing piece; the consuming UI lives in the producing plugin (find_more / the_daily). Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl. case-insensitive, distinct source_ref, ordering, validation, additive schema). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07ab902604
commit
8ca7ea4002
@@ -0,0 +1,111 @@
|
||||
"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4).
|
||||
|
||||
A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored
|
||||
analogue), so it lives in its own `wanted` table keyed by descriptive identity
|
||||
rather than a local filename. Producers (the find_more ownership-diff, or a
|
||||
manual add) POST entries; the API is idempotent on identity so a re-run of an
|
||||
ownership-diff can't duplicate.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def test_add_list_remove_round_trip(client):
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
|
||||
r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus",
|
||||
"source": "find_more", "source_ref": "cf:123"})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["wanted"]
|
||||
assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more")
|
||||
wid = row["id"]
|
||||
|
||||
listed = client.get("/api/wanted").json()["wanted"]
|
||||
assert [w["title"] for w in listed] == ["Lateralus"]
|
||||
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True}
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
# Deleting an already-gone id is a no-op, not an error.
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False}
|
||||
|
||||
|
||||
def test_add_is_idempotent_on_identity(client, server_mod):
|
||||
payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"}
|
||||
first = client.post("/api/wanted", json=payload).json()["wanted"]
|
||||
# Same identity (case-insensitive on artist/title) → no duplicate, same row.
|
||||
again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"]
|
||||
assert first["id"] == again["id"]
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
|
||||
# A different source_ref is a distinct entry.
|
||||
client.post("/api/wanted", json={**payload, "source_ref": "x2"})
|
||||
assert server_mod.meta_db.count_wanted() == 2
|
||||
|
||||
|
||||
def test_newest_first_ordering(client, server_mod):
|
||||
for t in ("First", "Second", "Third"):
|
||||
server_mod.meta_db.add_wanted(artist="A", title=t, source="manual")
|
||||
titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]]
|
||||
assert titles == ["Third", "Second", "First"]
|
||||
|
||||
|
||||
def test_add_requires_artist_or_title(client):
|
||||
r = client.post("/api/wanted", json={"source": "manual"})
|
||||
assert r.status_code == 400
|
||||
r2 = client.post("/api/wanted", json={"artist": "", "title": " "})
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_add_defaults_source_to_manual(client):
|
||||
row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"]
|
||||
assert row["source"] == "manual"
|
||||
assert row["artist"] == ""
|
||||
|
||||
|
||||
def test_non_dict_body_rejected(client):
|
||||
# FastAPI's `data: dict` validation rejects a JSON array (422) before the
|
||||
# handler's own defensive isinstance guard; either way it's not a 2xx.
|
||||
assert client.post("/api/wanted", json=[]).status_code in (400, 422)
|
||||
|
||||
|
||||
def test_table_creation_is_idempotent(server_mod):
|
||||
# Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows —
|
||||
# pin the additive + idempotent migration guarantee (constitution IV).
|
||||
server_mod.meta_db.add_wanted(artist="Keep", title="Me")
|
||||
server_mod.meta_db.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wanted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '', created_at TEXT
|
||||
)
|
||||
""")
|
||||
server_mod.meta_db.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
|
||||
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
|
||||
)
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
Reference in New Issue
Block a user