Files
feedBack/tests/test_song_user_meta.py
T
58e7407c38 v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1 (#691)
* v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1

The local "your relationship to the song" layer: a per-song user-difficulty
(1–5, planning-only, distinct from the authored 1–10 difficulty bands),
free-form notes, and free-form practice tags. All kept OUT of the shared
feedpak file and OUT of the `songs` table, in two new never-clobbered tables
so a rescan's `INSERT OR REPLACE INTO songs` can't wipe them. Likes stay the
existing favorites heart — no rating column.

Backend only (the Details drawer + tag/difficulty filter UI come next).

Schema (additive, idempotent):
- song_user_meta(filename PK, user_difficulty INTEGER, notes TEXT, updated_at)
- song_tags(filename, tag, created_at, PK(filename, tag)) + idx on tag

API (DB-only — distinct from POST /api/song/{f}/meta, which writes catalog
fields back into the file):
- GET  /api/song/{f}/user-meta  → {user_difficulty, notes, tags}
- PUT  /api/song/{f}/user-meta  → partial update; user_difficulty (1–5 or
  null), notes (string or null), tags (full-replace array). Tag removal is a
  full-replace array rather than a DELETE sub-route because the greedy
  DELETE /api/song/{filename:path} already owns every DELETE under /api/song
  and would shadow it.
- GET  /api/tags → tags in use with counts (for the filter UI)

Read path:
- query_page rows embed user_difficulty + tags (like `favorite`); notes stay
  out of the list payload (per-song GET — they can be long).
- Read-time filters ?user_difficulty= and ?tags= threaded through _build_where
  exactly like the mastery filter — EXISTS-style predicates, so keyset paging,
  counts, and the A–Z rail are unaffected.
- delete_song purges both personal tables inside the existing lock.

Tags are normalized (trim + lowercase + collapse whitespace) so "Rock"/"rock"
don't split. New tests cover defaults, difficulty validation (rejects out-of-
range / non-integral / bool), notes trim, tag normalize/dedupe, grid embed,
both filters, never-clobber-on-rescan, and purge (21 tests). Neighboring
library tests (filters/keyset/providers/playlists/collections/stats) stay
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): cap per-song tags at 50 to bound writes (PR #691 review)

set_song_tags capped each tag at 60 chars but not the number of tags,
so one PUT could write unbounded rows. Cap the normalized-unique tag
list to the first 50 after dedup. Adds a test asserting >50 stores 50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:25:18 +02:00

207 lines
9.1 KiB
Python

"""Tests for the personal per-song metadata layer (P1): user-difficulty,
notes, and tags. This is the LOCAL, never-shared layer — distinct from
/api/song/{f}/meta (which writes catalog fields back into the feedpak file).
Likes stay the existing favorites heart and are not touched here."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, *filenames):
for fn in filenames:
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": "A"})
# ── Read defaults ────────────────────────────────────────────────────────────
def test_defaults_for_untouched_song(client, server):
_seed(server, "a.archive")
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# ── Difficulty + notes write/read ────────────────────────────────────────────
def test_set_difficulty_and_notes(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": " bar chord section "})
assert r.status_code == 200
m = r.json()
assert m["user_difficulty"] == 3
assert m["notes"] == "bar chord section" # trimmed
# persisted
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] == 3
def test_partial_update_preserves_other_fields(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 4, "notes": "keep"})
# update only notes -> difficulty preserved
client.put("/api/song/a.archive/user-meta", json={"notes": "changed"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 4 and m["notes"] == "changed"
def test_clear_difficulty_with_null(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": None})
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None
@pytest.mark.parametrize("bad", [0, 6, 99, -1, "x", 2.5])
def test_difficulty_out_of_range_is_400(client, server, bad):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": bad}).status_code == 400
def test_empty_body_is_400(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta", json={}).status_code == 400
# ── Tags (full-replace) + normalization ──────────────────────────────────────
def test_tags_replace_and_normalize(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"tags": ["Warm-ups", " RIFFS ", "warm-ups", ""]})
# lowercased, whitespace-trimmed, case-dupes + blanks dropped; returned
# sorted alphabetically (deterministic chip order, matches reads)
assert r.json()["tags"] == ["riffs", "warm-ups"]
# replace with a smaller set
client.put("/api/song/a.archive/user-meta", json={"tags": ["gig"]})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["gig"]
# clear
client.put("/api/song/a.archive/user-meta", json={"tags": []})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == []
def test_tags_capped_at_50(client, server):
_seed(server, "a.archive")
# Submit 80 distinct tags; only the first 50 normalized-unique are stored.
many = [f"tag{i}" for i in range(80)]
r = client.put("/api/song/a.archive/user-meta", json={"tags": many})
assert r.status_code == 200
assert len(r.json()["tags"]) == 50
assert len(client.get("/api/song/a.archive/user-meta").json()["tags"]) == 50
def test_tags_must_be_array(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"tags": "warmups"}).status_code == 400
def test_tags_untouched_when_key_absent(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["keep"]})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 1})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["keep"]
def test_list_all_tags_with_counts(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups", "riffs"]})
client.put("/api/song/b.archive/user-meta", json={"tags": ["warmups"]})
tags = client.get("/api/tags").json()["tags"]
# most-used first
assert tags[0] == {"tag": "warmups", "count": 2}
assert {"tag": "riffs", "count": 1} in tags
# ── Grid embed (query_page) ──────────────────────────────────────────────────
def test_library_row_embeds_difficulty_and_tags_not_notes(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 5, "notes": "secret", "tags": ["gig"]})
row = next(s for s in client.get("/api/library").json()["songs"]
if s["filename"] == "a.archive")
assert row["user_difficulty"] == 5
assert row["tags"] == ["gig"]
assert "notes" not in row # notes stay out of the list payload
# ── Read-time filters ────────────────────────────────────────────────────────
def test_filter_by_user_difficulty(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 4})
got = {s["filename"] for s in client.get("/api/library?user_difficulty=2").json()["songs"]}
assert got == {"a.archive"}
# any-of set
got2 = {s["filename"] for s in client.get("/api/library?user_difficulty=2,4").json()["songs"]}
assert got2 == {"a.archive", "b.archive"}
def test_filter_by_tag(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups"]})
got = {s["filename"] for s in client.get("/api/library?tags=warmups").json()["songs"]}
assert got == {"a.archive"}
# case-insensitive match on the query side (normalized)
got_ci = {s["filename"] for s in client.get("/api/library?tags=WARMUPS").json()["songs"]}
assert got_ci == {"a.archive"}
def test_filter_total_count_reflects_filter(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 3})
body = client.get("/api/library?user_difficulty=3").json()
assert body["total"] == 1
# ── Never-clobber on rescan ──────────────────────────────────────────────────
def test_personal_data_survives_rescan(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["t"]})
# Simulate the scanner re-indexing the song (INSERT OR REPLACE INTO songs).
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "A"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 3 and m["notes"] == "n" and m["tags"] == ["t"]
# ── Purge on delete ──────────────────────────────────────────────────────────
def test_purge_removes_all_personal_rows(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["x", "y"]})
# delete_song calls this inside meta_db._lock after removing the file.
with server.meta_db._lock:
server.meta_db.purge_song_user_data("a.archive")
server.meta_db.conn.commit()
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# and the tag drops out of the global vocabulary
assert client.get("/api/tags").json()["tags"] == []