mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
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>
208 lines
9.1 KiB
Python
208 lines
9.1 KiB
Python
"""Tests for the curated album (P6, metadata-design §7.2): a playlists row with
|
|
kind='album' + per-slot pinned chart/arrangement, work_key stamped at add,
|
|
slot chart-swap validated to the same work, and orphan-at-read self-heal."""
|
|
|
|
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 _put(server, fn, title, artist, arrangements=("Lead",)):
|
|
arr = [{"name": n, "index": i} for i, n in enumerate(arrangements)]
|
|
server.meta_db.put(fn, 0, 0, {"title": title, "artist": artist, "arrangements": arr})
|
|
|
|
|
|
def _album(client, name="My Album"):
|
|
return client.post("/api/playlists", json={"name": name, "kind": "album"}).json()
|
|
|
|
|
|
def _add(client, pid, fn):
|
|
return client.post(f"/api/playlists/{pid}/songs", json={"filename": fn}).json()
|
|
|
|
|
|
# ── kind discriminator ────────────────────────────────────────────────────────
|
|
|
|
def test_create_album_kind(client):
|
|
pl = _album(client)
|
|
assert pl["kind"] == "album"
|
|
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
|
|
assert listed[pl["id"]]["kind"] == "album"
|
|
|
|
|
|
def test_regular_playlist_payload_has_no_album_fields(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
pl = client.post("/api/playlists", json={"name": "Mix"}).json()
|
|
assert "kind" not in pl
|
|
body = _add(client, pl["id"], "a.archive")
|
|
assert "arrangement" not in body["songs"][0]
|
|
assert "work_key" not in body["songs"][0]
|
|
|
|
|
|
def test_bad_kind_rejected(client):
|
|
r = client.post("/api/playlists", json={"name": "X", "kind": "boss-fight"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── add stamps work identity; slot payload carries the album fields ──────────
|
|
|
|
def test_album_add_stamps_work_key(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
pid = _album(client)["id"]
|
|
slot = _add(client, pid, "a.archive")["songs"][0]
|
|
assert slot["work_key"] == server.meta_db.work_key_for("a.archive")
|
|
assert slot["arrangement"] is None
|
|
assert [a["name"] for a in slot["arrangements"]] == ["Lead"]
|
|
|
|
|
|
# ── slot arrangement pin ──────────────────────────────────────────────────────
|
|
|
|
def test_slot_arrangement_pin_and_clear(client, server):
|
|
_put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Bass"))
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive")
|
|
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
|
|
json={"arrangement": "Bass"}).json()
|
|
assert body["songs"][0]["arrangement"] == "Bass"
|
|
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
|
|
json={"arrangement": None}).json()
|
|
assert body["songs"][0]["arrangement"] is None
|
|
|
|
|
|
def test_slot_edit_rejected_for_mix(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
|
|
_add(client, pid, "a.archive")
|
|
r = client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── slot chart swap (same work only; position + pin kept) ────────────────────
|
|
|
|
def test_slot_chart_swap_same_work(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
_put(server, "b.archive", "Song", "Artist")
|
|
_put(server, "z.archive", "Closer", "Artist")
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive")
|
|
_add(client, pid, "z.archive")
|
|
client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"})
|
|
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
|
|
json={"chart_filename": "b.archive"}).json()
|
|
slots = body["songs"]
|
|
assert [s["filename"] for s in slots] == ["b.archive", "z.archive"] # position kept
|
|
assert slots[0]["arrangement"] == "Lead" # pin kept
|
|
assert slots[0]["work_key"] == server.meta_db.work_key_for("b.archive")
|
|
|
|
|
|
def test_slot_chart_swap_rejects_other_work(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
_put(server, "x.archive", "Other", "Artist")
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive")
|
|
r = client.patch(f"/api/playlists/{pid}/songs/a.archive",
|
|
json={"chart_filename": "x.archive"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_slot_chart_swap_rejects_duplicate_member(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
_put(server, "b.archive", "Song", "Artist")
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive")
|
|
_add(client, pid, "b.archive")
|
|
r = client.patch(f"/api/playlists/{pid}/songs/a.archive",
|
|
json={"chart_filename": "b.archive"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── orphan-at-read self-heal (§7.2) ──────────────────────────────────────────
|
|
|
|
def test_orphan_slot_resolves_to_current_keeper(client, server):
|
|
_put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Rhythm"))
|
|
_put(server, "b.archive", "Song", "Artist")
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "b.archive") # pin the 1-arr chart
|
|
server.meta_db.delete_missing({"a.archive"}) # rescan: b's file is gone
|
|
slot = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
|
assert slot["filename"] == "b.archive" # membership NOT rewritten
|
|
assert slot["resolved_from_orphan"] is True
|
|
assert slot["resolved_filename"] == "a.archive" # the work's current keeper
|
|
assert slot["title"] == "Song"
|
|
assert "missing" not in slot
|
|
|
|
|
|
def test_orphan_slot_missing_when_work_gone(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive")
|
|
server.meta_db.delete_missing(set()) # library emptied
|
|
slot = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
|
assert slot["missing"] is True
|
|
assert "resolved_filename" not in slot
|
|
|
|
|
|
def test_mix_still_hides_dead_songs(client, server):
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
|
|
_add(client, pid, "a.archive")
|
|
server.meta_db.delete_missing(set())
|
|
assert client.get(f"/api/playlists/{pid}").json()["songs"] == []
|
|
|
|
|
|
# ── list-card count parity: albums count ALL slots (list vs detail) ──────────
|
|
|
|
def test_album_list_count_includes_orphaned_and_missing_slots(client, server):
|
|
# The detail view renders / plays EVERY album slot — a self-healing orphan
|
|
# and a fully-missing work both stay in the denominator (§7.2) — so the
|
|
# list-card count must agree. Guards against the mix "dead-filter" (count
|
|
# only songs still in `songs`) leaking into albums, which undercounted a
|
|
# slot the moment its pinned file was deleted.
|
|
_put(server, "a.archive", "Song", "Artist") # work A keeper (survives)
|
|
_put(server, "b.archive", "Song", "Artist") # work A, pinned then deleted
|
|
_put(server, "c.archive", "Closer", "Artist") # work C, deleted whole
|
|
pid = _album(client)["id"]
|
|
_add(client, pid, "a.archive") # live slot
|
|
_add(client, pid, "b.archive") # → orphan (self-heals to a)
|
|
_add(client, pid, "c.archive") # → fully missing
|
|
server.meta_db.delete_missing({"a.archive"}) # rescan: only a survives
|
|
detail = client.get(f"/api/playlists/{pid}").json()["songs"]
|
|
assert len(detail) == 3 # detail keeps all 3 slots
|
|
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
|
|
assert listed[pid]["count"] == 3 # list card agrees (was 1)
|
|
|
|
|
|
def test_mix_list_count_still_dead_filters(client, server):
|
|
# The other side of the discriminator: a mix's list count keeps hiding dead
|
|
# songs, so the album fix doesn't regress non-album playlists.
|
|
_put(server, "a.archive", "Song", "Artist")
|
|
_put(server, "b.archive", "Closer", "Artist")
|
|
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
|
|
_add(client, pid, "a.archive")
|
|
_add(client, pid, "b.archive")
|
|
server.meta_db.delete_missing({"a.archive"}) # b's file gone
|
|
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
|
|
assert listed[pid]["count"] == 1 # dead b not counted
|