fix(plex): address S1 code-review findings

- Restore synchronize_session=False on the remove-bulk Core delete (the 2.0
  default is 'auto'; keep the pre-conversion contract explicit).
- _episode_nav: list(...scalars()) instead of [r[0] for r in ...all()], matching
  the house style used across the conversion.
- Extract app/plex/lookups.by_rating_key() and migrate the get-by-rating_key
  pattern at 13 sites (imgcache x4, _shared.item_or_404, playback show/season,
  browse x6).
- Coverage: add TestFacetsWithData (the converted wsq aggregation with real
  data — the highest-risk untested builder), TestPlaylistAndCollectionEndpoints
  (playlist CRUD + collections list), and an item-state playback test.

Full suite 256 passed, 2 skipped; ruff clean.
This commit is contained in:
2026-08-16 07:06:19 +02:00
parent f8df504d18
commit e008c48284
6 changed files with 166 additions and 50 deletions
+132 -2
View File
@@ -57,16 +57,21 @@ def _seed(db):
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
def _get(db, user, monkeypatch, path):
def _request(db, user, monkeypatch, method, path, json=None):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path) # no context manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
return client.request(method, path, json=json)
finally:
app.dependency_overrides.clear()
def _get(db, user, monkeypatch, path):
return _request(db, user, monkeypatch, "GET", path)
class TestUnifiedLibrary:
def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch):
s = _seed(db)
@@ -197,3 +202,128 @@ class TestSyncUpsertIdempotency:
assert ep.rating_key == "e1"
assert ep.show_id == shows[0].id # episode linked to its show
assert (ep.season_number, ep.episode_number) == (1, 1)
class TestFacetsWithData:
"""The facets endpoint's wsq builder is the most complex converted query (genre/content-rating
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
covers the null-genre guard; this locks the aggregated numbers with real data."""
def test_facet_aggregation(self, db, monkeypatch):
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib])
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", title="M1",
genres=["Comedy", "Drama"], content_rating="PG", year=2000, rating=7.5,
duration_s=6000),
PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", title="M2",
genres=["Comedy"], content_rating="R", year=2010, rating=8.0, duration_s=7200),
PlexShow(rating_key="s1", library_id=show_lib.id, title="S1",
genres=["Drama"], content_rating="TV-14", year=2005, rating=9.0),
])
user = User(email="facetdata@example.com")
db.add(user)
db.commit()
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json()
assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
"PG": 1, "R": 1, "TV-14": 1
}
assert (body["year_min"], body["year_max"]) == (2000, 2010)
assert body["rating_max"] == 9.0
# Duration bounds are movie-only.
assert (body["duration_min"], body["duration_max"]) == (6000, 7200)
class TestPlaylistAndCollectionEndpoints:
"""Smoke coverage for the converted CRUD endpoints that had no tests: playlist create/list/detail/
add/reorder/remove/delete, and the collections list. A 1.x->2.0 conversion slip in any of these
(a lost .scalars(), a wrong .all(), a broken order_by) surfaces here instead of shipping green."""
def _movie(self, db, lib_id, rk, title):
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
def test_playlist_crud_roundtrip(self, db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")])
user = User(email="pl@example.com")
db.add(user)
db.commit()
# create (seeded with m1) -> item_count 1
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists",
{"title": "Faves", "item_rating_key": "m1"})
assert created.status_code == 200, created.text
pid = created.json()["id"]
assert created.json()["item_count"] == 1
# list -> the playlist with its count
listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"]
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
# add m2, then reorder to [m2, m1]
add = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items",
{"item_rating_key": "m2"})
assert add.status_code == 200, add.text
_request(db, user, monkeypatch, "PUT", f"/api/plex/playlists/{pid}/order",
{"item_rating_keys": ["m2", "m1"]})
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
# remove m1 -> one item left
rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1")
assert rm.status_code == 200, rm.text
after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in after["items"]] == ["m2"]
# delete the playlist -> gone from the list
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}")
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == []
def test_collections_list(self, db, monkeypatch):
from app.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12),
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5),
])
user = User(email="col@example.com")
db.add(user)
db.commit()
# unified scope (no library param) -> most-populated first
body = _get(db, user, monkeypatch, "/api/plex/collections").json()
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
# name filter
filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json()
assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
def test_item_state_watched_then_new(self, db, monkeypatch):
# No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which
# is what the conversion touched (the st lookup + the delete on ->new).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
user = User(email="state@example.com")
db.add(user)
db.commit()
watched = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state",
{"status": "watched"})
assert watched.status_code == 200, watched.text
assert watched.json()["status"] == "watched"
row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one()
assert row.status == "watched"
# -> new deletes the state row
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"})
assert back.status_code == 200, back.text
assert db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() is None