"""Characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion. These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conversion can be proven behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane (`db` fixture) — skipped without Postgres. """ from datetime import datetime, timezone from fastapi.testclient import TestClient from sqlalchemy import delete, select import app.state as state from app.auth import current_user from app.db import get_db from app.main import app from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.plex import rollup, sync from app.plex.cards import ShowState def _dt(day: int) -> datetime: return datetime(2024, 1, day, tzinfo=timezone.utc) def _seed(db): """A minimal cross-library corpus: 2 movies, 1 show with 2 episodes, and a per-user watch-state where movie m1 is watched and episode e1 is in-progress (so the show rolls up to in_progress).""" 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() m1 = PlexItem( rating_key="m1", library_id=movie_lib.id, kind="movie", title="Alpha Movie", year=2001, added_at=_dt(1), ) m2 = PlexItem( rating_key="m2", library_id=movie_lib.id, kind="movie", title="Bravo Movie", year=2002, added_at=_dt(2), ) show = PlexShow( rating_key="s1", library_id=show_lib.id, title="Charlie Show", year=2003, added_at=_dt(3), child_count=1, ) db.add_all([m1, m2, show]) db.flush() season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) db.add(season) db.flush() e1 = PlexItem( rating_key="e1", library_id=show_lib.id, kind="episode", title="Charlie Pilot", show_id=show.id, season_id=season.id, season_number=1, episode_number=1, added_at=_dt(4), ) e2 = PlexItem( rating_key="e2", library_id=show_lib.id, kind="episode", title="Charlie Finale", show_id=show.id, season_id=season.id, season_number=1, episode_number=2, added_at=_dt(5), ) db.add_all([e1, e2]) user = User(email="plexbrowse@example.com") db.add(user) db.flush() db.add_all( [ PlexState(user_id=user.id, item_id=m1.id, status="watched"), PlexState(user_id=user.id, item_id=e1.id, position_seconds=120), ] ) db.commit() return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} def _request(db, user, monkeypatch, override_dep, method, path, json=None): monkeypatch.setattr( state, "_configured_cache", True ) # the request gate 503s an unconfigured app override_dep(get_db, db.info["request"]) override_dep(current_user, lambda: user) client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) return client.request(method, path, json=json) def _get(db, user, monkeypatch, override_dep, path): return _request(db, user, monkeypatch, override_dep, "GET", path) class TestUnifiedLibrary: def test_both_scope_unions_movies_and_shows_ordered_by_added( self, db, monkeypatch, override_dep ): s = _seed(db) r = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=both") assert r.status_code == 200, r.text body = r.json() assert body["total"] == 3 # 2 movies + 1 show, unioned # default sort = added_at desc: show (day 3) > bravo (day 2) > alpha (day 1) assert [i["id"] for i in body["items"]] == ["s1", "m2", "m1"] show_item = next(i for i in body["items"] if i["type"] == "show") assert show_item["status"] == "in_progress" # e1 in-progress -> show rollup assert show_item["season_count"] == 1 assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" def test_scope_movie_only(self, db, monkeypatch, override_dep): s = _seed(db) body = _get( db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=movie" ).json() assert body["total"] == 2 assert all(i["type"] == "movie" for i in body["items"]) def test_scope_show_only(self, db, monkeypatch, override_dep): s = _seed(db) body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=show").json() assert [i["id"] for i in body["items"]] == ["s1"] assert body["items"][0]["season_count"] == 1 def test_watch_state_branches(self, db, monkeypatch, override_dep): s = _seed(db) # watched: only movie m1 (the show is in_progress, not fully watched) watched = _get( db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=watched" ).json() assert [i["id"] for i in watched["items"]] == ["m1"] # in_progress: only the show (no movie is in progress) inprog = _get( db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=in_progress" ).json() assert [i["id"] for i in inprog["items"]] == ["s1"] # unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.) unwatched = _get( db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=unwatched" ).json() assert [i["id"] for i in unwatched["items"]] == ["m2"] def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch, override_dep): s = _seed(db) body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=charlie").json() # only the show title matches in the item grid; the movies don't contain "charlie" assert [i["id"] for i in body["items"]] == ["s1"] assert body["total"] == 1 # matching episodes come back grouped in the separate episodes list assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep): s = _seed(db) body = _get( db, s["user"], monkeypatch, override_dep, "/api/plex/library?limit=2&offset=0" ).json() assert body["total"] == 3 assert [i["id"] for i in body["items"]] == [ "s1", "m2", ] # first page of the added-desc order class TestRollup: def test_state_map_new_inprogress_watched(self, db): s = _seed(db) show, user = s["show"], s["user"] # e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)} # empty input short-circuits assert rollup.state_map(db, user.id, []) == {} # a fresh user with no state -> new other = User(email="rollup-other@example.com") db.add(other) db.commit() assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)} # e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart # would both play e2 from its start db.execute(delete(PlexState).where(PlexState.user_id == user.id)) e1, e2 = db.execute( select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number) ).scalars() db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched")) db.commit() assert rollup.state_map(db, user.id, [show.id]) == { show.id: ShowState("in_progress", False) } # mark every episode watched for `user` -> watched db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched")) db.commit() assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)} def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume( self, db, monkeypatch, override_dep ): s = _seed(db) user = s["user"] cards = { i["id"]: i for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"] } assert cards["s1"]["resumable"] is True # e1 finished, e2 untouched: in progress, but nothing to resume e1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "e1")).scalar_one() db.execute( delete(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == e1.id) ) db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched")) db.commit() cards = { i["id"]: i for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"] } assert (cards["s1"]["status"], cards["s1"]["resumable"]) == ("in_progress", False) def _fake_paginate(by_type): def _p(_plex, _key, typ, progress=None): if progress is not None: progress["complete"] = True # the fake returns the whole list — a complete listing return list(by_type.get(typ, [])) return _p class TestSyncUpsertIdempotency: def test_movies_upsert_in_place_no_duplicates(self, db, monkeypatch): lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.commit() metas = [ {"ratingKey": "100", "title": "First", "year": 2000, "addedAt": 1700000000}, {"ratingKey": "101", "title": "Second", "year": 2001, "addedAt": 1700000001}, ] monkeypatch.setattr(sync, "_paginate", _fake_paginate({sync._TYPE_MOVIE: metas})) sync._sync_movies(db, object(), lib, {"movies": 0}) db.commit() # second run with one title changed: same rows, updated in place (upsert, not re-insert) metas[0]["title"] = "First Renamed" sync._sync_movies(db, object(), lib, {"movies": 0}) db.commit() rows = ( db.execute( select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") ) .scalars() .all() ) assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows assert next(r for r in rows if r.rating_key == "100").title == "First Renamed" def test_shows_upsert_links_episodes_and_is_idempotent(self, db, monkeypatch): lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(lib) db.commit() by_type = { sync._TYPE_SHOW: [ {"ratingKey": "s1", "title": "Show One", "childCount": 1, "addedAt": 1700000000} ], sync._TYPE_SEASON: [ {"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1} ], sync._TYPE_EPISODE: [ { "ratingKey": "e1", "title": "Ep One", "grandparentRatingKey": "s1", "parentRatingKey": "se1", "parentIndex": 1, "index": 1, "addedAt": 1700000001, } ], } monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type)) for _ in range(2): # run twice — must be idempotent sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0}) db.commit() shows = db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars().all() assert {sh.rating_key for sh in shows} == {"s1"} # no duplicate show ep = db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars().one() 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, override_dep): 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, override_dep, "/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, override_dep): 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, override_dep, "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, override_dep, "/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, override_dep, "POST", f"/api/plex/playlists/{pid}/items", {"item_rating_key": "m2"}, ) assert add.status_code == 200, add.text _request( db, user, monkeypatch, override_dep, "PUT", f"/api/plex/playlists/{pid}/order", {"item_rating_keys": ["m2", "m1"]}, ) detail = _get(db, user, monkeypatch, override_dep, 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, override_dep, "DELETE", f"/api/plex/playlists/{pid}/items/m1" ) assert rm.status_code == 200, rm.text after = _get(db, user, monkeypatch, override_dep, 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, override_dep, "DELETE", f"/api/plex/playlists/{pid}") assert ( _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"] == [] ) def test_playlist_bulk_add_and_remove(self, db, monkeypatch, override_dep): # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's # synchronize_session=False path, which the single add/remove roundtrip never exercises. lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")]) user = User(email="bulk@example.com") db.add(user) db.commit() created = _request( db, user, monkeypatch, override_dep, "POST", "/api/plex/playlists", {"title": "Bulk"} ) pid = created.json()["id"] added = _request( db, user, monkeypatch, override_dep, "POST", f"/api/plex/playlists/{pid}/items/bulk", {"item_rating_keys": ["m1", "m2", "m3"]}, ) assert added.status_code == 200, added.text assert added.json() == {"ok": True, "added": 3, "item_count": 3} removed = _request( db, user, monkeypatch, override_dep, "POST", f"/api/plex/playlists/{pid}/items/remove-bulk", {"item_rating_keys": ["m2"]}, ) assert removed.status_code == 200, removed.text assert removed.json() == {"ok": True, "removed": 1, "item_count": 2} detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json() assert [it["id"] for it in detail["items"]] == [ "m1", "m3", ] # input order preserved, m2 gone def test_collections_list(self, db, monkeypatch, override_dep): 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, override_dep, "/api/plex/collections").json() assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"] # name filter filtered = _get(db, user, monkeypatch, override_dep, "/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, override_dep): # 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, override_dep, "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, override_dep, "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 )