feedBack/tests/test_wanted_api.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

113 lines
4.4 KiB
Python

"""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:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: 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