mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +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>
204 lines
9.0 KiB
Python
204 lines
9.0 KiB
Python
"""Tests for the multi-chart grouping engine (P5a): several charts (feedpak rows)
|
||
of the same WORK collapse to one representative card via a materialized
|
||
work_display read-model + a `group=1` `WHERE is_group_representative=1` predicate.
|
||
Counting works (not charts), auto-pick, chart_group_pref, split, and keyset
|
||
paging under grouping all stay correct."""
|
||
|
||
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=1, mtime=0.0):
|
||
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
|
||
server.meta_db.put(fn, mtime, 0, {"title": title, "artist": artist, "arrangements": arr})
|
||
|
||
|
||
def _grouped(client, **params):
|
||
params["group"] = 1
|
||
return client.get("/api/library", params=params).json()
|
||
|
||
|
||
# ── Baseline: ungrouped shows every chart ────────────────────────────────────
|
||
|
||
def test_ungrouped_shows_all_charts(client, server):
|
||
_put(server, "a.archive", "Song", "Artist")
|
||
_put(server, "b.archive", "Song", "Artist")
|
||
body = client.get("/api/library").json()
|
||
assert body["total"] == 2
|
||
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
|
||
# ungrouped rows carry no chart_count (only the grouped path attaches it)
|
||
assert "chart_count" not in body["songs"][0]
|
||
|
||
|
||
# ── Grouping collapses a work to one representative ───────────────────────────
|
||
|
||
def test_grouping_collapses_to_one_representative(client, server):
|
||
_put(server, "a.archive", "Song", "Artist", arrangements=1)
|
||
_put(server, "b.archive", "Song", "Artist", arrangements=2)
|
||
body = _grouped(client)
|
||
assert body["total"] == 1
|
||
assert len(body["songs"]) == 1
|
||
rep = body["songs"][0]
|
||
assert rep["filename"] == "b.archive" # most arrangements wins auto-pick
|
||
assert rep["chart_count"] == 2
|
||
|
||
|
||
def test_autopick_plays_tiebreak(client, server):
|
||
_put(server, "a.archive", "S", "A", arrangements=1)
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
server.meta_db.record_session("a.archive", 0, score=100, accuracy=0.5) # a has a play
|
||
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
|
||
|
||
|
||
def test_autopick_filename_tiebreak(client, server):
|
||
_put(server, "b.archive", "S", "A")
|
||
_put(server, "a.archive", "S", "A")
|
||
# equal arrangements + plays + mtime → lowest filename
|
||
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
|
||
|
||
|
||
# ── chart_group_pref override ────────────────────────────────────────────────
|
||
|
||
def test_preferred_overrides_autopick(client, server):
|
||
_put(server, "a.archive", "S", "A", arrangements=2) # would auto-win
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
wk = server.meta_db.work_key_for("b.archive")
|
||
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||
|
||
|
||
def test_set_preferred_incremental_reflip(client, server):
|
||
_put(server, "a.archive", "S", "A", arrangements=2)
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # builds read-model
|
||
wk = server.meta_db.work_key_for("a.archive")
|
||
server.meta_db.set_chart_preferred(wk, "b.archive") # incremental re-flip
|
||
body = _grouped(client)
|
||
assert body["songs"][0]["filename"] == "b.archive"
|
||
assert body["songs"][0]["chart_count"] == 2 # group_size unchanged
|
||
|
||
|
||
def test_clear_preferred_returns_to_autopick(client, server):
|
||
_put(server, "a.archive", "S", "A", arrangements=2)
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
wk = server.meta_db.work_key_for("a.archive")
|
||
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||
server.meta_db.clear_chart_preferred(wk)
|
||
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # auto again
|
||
|
||
|
||
# ── Split / un-split ─────────────────────────────────────────────────────────
|
||
|
||
def test_split_makes_singleton(client, server):
|
||
_put(server, "a.archive", "S", "A")
|
||
_put(server, "b.archive", "S", "A")
|
||
assert _grouped(client)["total"] == 1
|
||
server.meta_db.split_chart("b.archive")
|
||
body = _grouped(client)
|
||
assert body["total"] == 2 # two works now
|
||
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
|
||
assert all(s["chart_count"] == 1 for s in body["songs"])
|
||
|
||
|
||
def test_unsplit_rejoins(client, server):
|
||
_put(server, "a.archive", "S", "A")
|
||
_put(server, "b.archive", "S", "A")
|
||
server.meta_db.split_chart("b.archive")
|
||
assert _grouped(client)["total"] == 2
|
||
server.meta_db.unsplit_chart("b.archive")
|
||
assert _grouped(client)["total"] == 1
|
||
|
||
|
||
# ── Stats + A–Z count works, not charts ──────────────────────────────────────
|
||
|
||
def test_stats_counts_works_not_charts(client, server):
|
||
_put(server, "a.archive", "Song", "Artist")
|
||
_put(server, "b.archive", "Song", "Artist") # same work as a
|
||
_put(server, "c.archive", "Other", "Artist")
|
||
assert client.get("/api/library/stats").json()["total_songs"] == 3
|
||
assert client.get("/api/library/stats", params={"group": 1}).json()["total_songs"] == 2
|
||
|
||
|
||
def test_sort_letters_counts_works(client, server):
|
||
_put(server, "a.archive", "Song", "Alpha")
|
||
_put(server, "b.archive", "Song", "Alpha") # same work
|
||
_put(server, "c.archive", "Track", "Beta")
|
||
sl = client.get("/api/library/stats",
|
||
params={"group": 1, "sort_letters": 1, "sort": "artist"}).json()["sort_letters"]
|
||
assert sl.get("A") == 1 and sl.get("B") == 1
|
||
|
||
|
||
# ── Keyset paging under grouping (the critical integration) ───────────────────
|
||
|
||
def test_grouped_keyset_pagination(client, server):
|
||
for i in range(5):
|
||
_put(server, f"w{i}_a.archive", f"Song {i}", "Artist")
|
||
_put(server, f"w{i}_b.archive", f"Song {i}", "Artist") # 2 charts per work
|
||
# Title sort: artist sorts page by OFFSET now (title-secondary ordering),
|
||
# and this test PROVES the grouped keyset, so it pins a keyset sort.
|
||
body = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title"}).json()
|
||
assert body["total"] == 5 and len(body["songs"]) == 3
|
||
cur = body["next_cursor"]
|
||
assert cur
|
||
body2 = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title", "after": cur}).json()
|
||
assert len(body2["songs"]) == 2
|
||
p1 = {s["filename"] for s in body["songs"]}
|
||
p2 = {s["filename"] for s in body2["songs"]}
|
||
assert not (p1 & p2) # no skip / dupe across pages
|
||
|
||
|
||
# ── Freshness: adding a chart regroups; overrides survive reindex ─────────────
|
||
|
||
def test_new_chart_updates_group_size(client, server):
|
||
_put(server, "a.archive", "S", "A")
|
||
assert _grouped(client)["songs"][0]["chart_count"] == 1
|
||
_put(server, "b.archive", "S", "A") # dirties read-model
|
||
body = _grouped(client)
|
||
assert body["total"] == 1 and body["songs"][0]["chart_count"] == 2
|
||
|
||
|
||
def test_preferred_survives_reindex(client, server):
|
||
_put(server, "a.archive", "S", "A", arrangements=2)
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
wk = server.meta_db.work_key_for("b.archive")
|
||
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||
# A rescan re-indexes both charts (INSERT OR REPLACE INTO songs).
|
||
_put(server, "a.archive", "S", "A", arrangements=2)
|
||
_put(server, "b.archive", "S", "A", arrangements=1)
|
||
assert _grouped(client)["songs"][0]["filename"] == "b.archive" # pref survived
|
||
|
||
|
||
# ── work_key normalization folds trivial differences ─────────────────────────
|
||
|
||
def test_work_key_folds_the_and_punctuation(server):
|
||
wk = server.meta_db._work_key
|
||
assert wk("The Beatles", "Hey Jude") == wk("beatles", "hey jude!")
|
||
assert wk("AC/DC", "T.N.T.") == wk("acdc", "tnt")
|
||
assert wk("Metallica", "One") != wk("Metallica", "Two")
|