feedBack/tests/test_context_menu_api.py
Byron Gamatos 165475d115
refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3) (#861)
* refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3)

MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and
the background enrichment worker (~930 lines, 61 defs) leave server.py as one
cohesive unit. Bodies are verbatim; the only changes are seam reads:

  meta_db / config_dir / sloppak_cache_dir / art_cache_dir -> appstate.<slot>
  _song_pack_art_exists / _art_override_paths (stay in server.py for the art +
    delete routes) -> appstate.<callable> (new seam slots, injected by reference)
  _env_flag -> env_compat.env_flag_compat (the existing identical helper)
  _artist_title_from_filename -> imported from metadata_db (its home)
  the User-Agent VERSION lookup: Path(__file__).parent ->
    Path(__file__).resolve().parents[1] (lib/enrichment.py -> app root)

server.py drives the worker through the module (import enrichment; the routes +
scan lifecycle call enrichment.X). Tests that faked the network on `server`
(_mb_http_get, _enrich_network_enabled, _caa_http_get, ...) now patch the same
names on `enrichment` — the module attribute is resolved at call time, so one
setattr reaches both the routes and the worker's internal callers. Acyclic:
enrichment imports appstate/appconfig/dlc_paths/metadata_db/mb_match/
acoustid_match/sloppak/loosefolder, never server.

server.py: 6,917 -> 5,988 (-929).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2400 passed (140 enrichment/art cases
incl the offline-safety + transport-error-pauses-pass contracts that fake the
network); test_packaging 44 passed (enrichment.py resolves under lib/); eslint 0.
Boot smoke: /api/enrichment/status + POST /kick serve from the new module.

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

* test: reset enrichment worker state between tests (CodeRabbit)

lib/enrichment.py now owns the worker, and it stays imported for the whole
session while the `server` fixtures pop-and-reimport `server` — so the cancel
Event / status dict / caches would leak across tests, and a stale `_enrich_cancel`
could short-circuit a later direct `_background_enrich()`. An autouse conftest
fixture clears that process-global state before each test.

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

* test: tighten the enrichment-reset fixture (CodeRabbit)

Narrow the import guard to ImportError (not blind Exception, BLE001), and stop
clearing _caa_index_locks — it's guarded by _caa_index_locks_guard, so an
unlocked clear() would race a still-alive worker, and its per-release mutexes
carry no test state.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:32:21 +02:00

144 lines
6.1 KiB
Python

"""Tests for the R2 context-menu backend: per-song "Refresh metadata"
(explicit re-match reset + kick) and "Get info" (file location + pack
contents). The refresh flow reuses the P8 fake-transport pattern — nothing
here opens a socket."""
import importlib
import enrichment
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 / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
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="Thunderstruck", artist="AC/DC", album="", year="1990",
duration=292):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": year,
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
})
def make_sloppak(server, name, extra_yaml="", title="Thunderstruck", artist="AC/DC"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 292\n"
"arrangements:\n - name: Lead\n id: lead\n"
"stems:\n - id: full\n file: stems/full.ogg\n" + extra_yaml,
encoding="utf-8")
_put(server, name, title=title, artist=artist)
return d
# ── Refresh metadata ──────────────────────────────────────────────────────────
def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypatch):
_put(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "old-pin", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
# The reset itself is synchronous; the kicked pass runs on a daemon
# thread, so assert the reset here and drive the re-match inline below.
r = client.post("/api/enrichment/refresh/a.sloppak")
assert r.status_code == 200
for _ in range(200):
if not client.get("/api/enrichment/status").json()["running"]:
break
import time as _t
_t.sleep(0.02)
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # the pin was discarded
assert row["mb_recording_id"] is None
assert row["attempts"] == 0
# …and the normal pass re-matches it (fake transport, network flag on).
def fake(path, params):
return {"recordings": [{
"id": "rec-new", "score": 100, "title": "Thunderstruck",
"length": 292000,
"artist-credit": [{"name": "AC/DC", "artist": {
"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}}],
"releases": [{"id": "rel-1", "title": "The Razors Edge",
"status": "Official", "date": "1990-09-24",
"release-group": {"primary-type": "Album"}}],
}]}
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
def test_refresh_unknown_song_404(server, client):
assert client.post("/api/enrichment/refresh/ghost.sloppak").status_code == 404
# ── Get info ──────────────────────────────────────────────────────────────────
def test_fileinfo_sloppak_contents(server, client):
make_sloppak(server, "a.sloppak",
extra_yaml="mbid: 12345678-abcd-4ef0-9876-0123456789ab\n"
"genres: [rock, hard rock]\ntrack: 3\n")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["format"] == "sloppak"
assert body["filename"] == "a.sloppak"
assert body["path"].endswith("a.sloppak")
assert body["size"] > 0
m = body["manifest"]
assert m["title"] == "Thunderstruck"
assert m["arrangements"] == ["Lead"]
assert m["stems"] == ["full"]
assert m["has_cover"] is False
assert m["identity"]["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert m["identity"]["genres"] == ["rock", "hard rock"]
assert m["identity"]["track"] == 3
assert "isrc" not in m["identity"] # only keys actually present
# No enrichment row yet → no match block (the panel shows "Not scanned").
assert "match" not in body
def test_fileinfo_includes_match_verdict(server, client):
make_sloppak(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "rec-1", "title": "Thunderstruck",
"artist": "AC/DC", "album": "The Razors Edge"},
source="search")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["match"]["match_state"] == "manual"
assert body["match"]["canon_album"] == "The Razors Edge"
def test_fileinfo_missing_and_traversal(server, client):
assert client.get("/api/chart/ghost.sloppak/fileinfo").status_code == 404
assert client.get("/api/chart/..%2f..%2fetc%2fpasswd/fileinfo").status_code in (403, 404)
def test_fileinfo_non_chart_file_is_404(server, client):
"""A stray non-song file the user keeps under DLC_DIR must not have its
path/size/mtime exposed — the route is charts only, not a filesystem stat."""
(server.DLC_DIR / "private-notes.txt").write_text("secret", encoding="utf-8")
assert client.get("/api/chart/private-notes.txt/fileinfo").status_code == 404