feedBack/tests/test_song_user_meta.py
Byron Gamatos 97a941c45d
fix(tests): join background scan/enrich workers before closing the DB (#735)
Root cause of the flaky pytest segfault (exit 139): the background scan and enrichment daemon threads (_scan_runner/_enrich_runner) use the shared MetadataDB connection, but test fixtures closed that connection in teardown without stopping them. A daemon thread mid-query on a freed SQLite conn is a native use-after-free → SIGSEGV. The app's startup kicks a scan, so almost any app-booting fixture was vulnerable. It only surfaced now because got-feedback/feedBack#728 added a push trigger, so ci/test runs on every push to main.

Fix: server.py retains the scan/enrich thread handles and adds _join_background_db_threads(); every test fixture now joins the workers before conn.close(). Verified: the full suite runs to completion (no segfault) where it previously crashed at ~25%.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:15:43 +02:00

208 lines
9.2 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:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: 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"] == []