"""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