"""The Collection info page's member ordering (`GET /api/plex/collections/{rk}?sort=&sort_dir=`) and the `keys=` lookup that gives the sidebar token / filter chip a title instead of a raw rating_key. Reported from daily use: "Collectionben nem működik a rendezés". The browse grid sorts fine — the collection page had no control at all and a hardcoded year-ascending order. Adding the control means the SAME vocabulary must actually work here, hence the pin against `PLEX_SORT_KEYS`. Pure-logic tests run everywhere; the endpoint tests use the `db` fixture (DB lane). """ from datetime import date, datetime, timedelta, timezone from fastapi.testclient import TestClient from sqlalchemy import literal_column 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 PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User from app.plex.query import PLEX_SORT_KEYS from app.routes.plex.browse import sort_column from app.routes.plex.collections import ( DEFAULT_MEMBER_SORT, MEMBER_SORT_KEYS, Member, sort_members, ) NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) def _m(title: str, **over) -> Member: base = dict( kind="movie", obj=None, title=title, year=None, rating=None, added=None, release=None, duration=None, last_watched=None, ) base.update(over) return Member(**base) # type: ignore[arg-type] def _titles(members: list[Member]) -> list[str]: return [m.title for m in members] # --- the vocabulary is ONE vocabulary --------------------------------------------------------- def test_every_sort_key_is_orderable_on_a_collection(): """The collection page shows the same Sort control as the browse grid, so a key it cannot order would be an option that silently does nothing.""" assert set(MEMBER_SORT_KEYS) == set(PLEX_SORT_KEYS) def test_every_sort_key_is_orderable_on_the_browse_grid(): """The other half of the same pin: browse falls back to `added_at` for an unknown key, so a key missing from its map would order by date while the control claimed otherwise.""" class _Cols: title = literal_column("title") year = literal_column("year") rating = literal_column("rating") duration_s = literal_column("duration_s") rel = literal_column("rel") last_watched = literal_column("last_watched") added_at = literal_column("added_at") class _U: c = _Cols() u = _U() fallback = u.c.added_at unhandled = [k for k in PLEX_SORT_KEYS if k != "added" and sort_column(u, k) is fallback] assert unhandled == [] # …and the fallback itself is still what an unknown key gets. assert sort_column(u, "no-such-key") is fallback # --- ordering behaviour ----------------------------------------------------------------------- def test_orders_by_the_requested_key_in_both_directions(): members = [_m("b", year=2006), _m("c", year=2017), _m("a", year=2003)] assert _titles(sort_members(members, "year", "asc")) == ["a", "b", "c"] assert _titles(sort_members(members, "year", "desc")) == ["c", "b", "a"] def test_members_without_a_value_sink_to_the_end_in_both_directions(): """A movie with no rating is not "the worst rated" — nullslast, like the browse grid.""" members = [_m("rated-low", rating=6.5), _m("unrated"), _m("rated-high", rating=8.1)] assert _titles(sort_members(members, "rating", "desc")) == [ "rated-high", "rated-low", "unrated", ] assert _titles(sort_members(members, "rating", "asc")) == ["rated-low", "rated-high", "unrated"] def test_ties_stay_title_ascending_when_the_direction_flips(): """Flipping the direction must reverse the RANKING, not the alphabet inside a tie.""" members = [_m("zulu", year=2003), _m("alpha", year=2003), _m("mike", year=2017)] assert _titles(sort_members(members, "year", "asc")) == ["alpha", "zulu", "mike"] assert _titles(sort_members(members, "year", "desc")) == ["mike", "alpha", "zulu"] def test_an_unknown_key_falls_back_to_the_pages_default(): members = [_m("b", year=2006), _m("a", year=2017)] assert _titles(sort_members(members, "no-such-key", "asc")) == _titles( sort_members(members, DEFAULT_MEMBER_SORT, "asc") ) def test_a_show_has_no_duration_and_therefore_sinks_on_that_key(): members = [ _m("short movie", duration=3600), _m("a show", kind="show", duration=None), _m("long movie", duration=9000), ] assert _titles(sort_members(members, "duration", "desc")) == [ "long movie", "short movie", "a show", ] # --- the endpoint (DB lane) ------------------------------------------------------------------- def _seed(db): lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add_all([lib_m, lib_s]) db.flush() col = PlexCollection(rating_key="col1", library_id=lib_m.id, title="Franchise", child_count=3) other = PlexCollection(rating_key="col2", library_id=lib_m.id, title="Elsewhere", child_count=1) db.add_all([col, other]) db.flush() # Titles deliberately alphabetical-opposite to the years, so a year sort can't accidentally pass # by falling through to the title tiebreak. old = PlexItem( rating_key="mo_old", library_id=lib_m.id, kind="movie", title="Zulu", year=2003, rating=8.1, duration_s=8000, added_at=NOW - timedelta(days=1), originally_available_at=date(2003, 7, 9), collection_keys=["col1"], ) mid = PlexItem( rating_key="mo_mid", library_id=lib_m.id, kind="movie", title="Mike", year=2006, rating=7.4, duration_s=9000, added_at=NOW - timedelta(days=3), originally_available_at=date(2006, 7, 7), collection_keys=["col1"], ) new = PlexItem( rating_key="mo_new", library_id=lib_m.id, kind="movie", title="Alpha", year=2017, rating=6.5, duration_s=7000, added_at=NOW, originally_available_at=date(2017, 5, 26), collection_keys=["col1"], ) # A show member with a resumed episode: `lastwatched` must work on this leg too, not just movies. show = PlexShow( rating_key="sh1", library_id=lib_s.id, title="Series", year=2010, child_count=1, collection_keys=["col1"], ) db.add_all([old, mid, new, show]) db.flush() ep = PlexItem( rating_key="sh1_e1", library_id=lib_s.id, kind="episode", title="E1", show_id=show.id, season_number=1, episode_number=1, ) db.add(ep) db.flush() user = User(email="colsort@example.com") db.add(user) db.flush() db.add_all( [ # The show's episode is the most recently touched thing in the collection… PlexState( user_id=user.id, item_id=ep.id, status="new", position_seconds=60, progress_updated_at=NOW, ), # …and one movie was watched earlier. PlexState( user_id=user.id, item_id=old.id, status="watched", watched_at=NOW - timedelta(days=2), ), ] ) db.commit() return user def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) override_dep(get_db, lambda: db) override_dep(current_user, lambda: user) return TestClient(app).get(path) def _order(r) -> list[str]: return [i["title"] for i in r.json()["items"]] def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch, override_dep): """The order the page had before it grew a control — a franchise reads in release order.""" user = _seed(db) r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/col1") assert r.status_code == 200 assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"] def test_collection_detail_honours_sort_and_direction(db, monkeypatch, override_dep): user = _seed(db) r = _get( db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=year&sort_dir=desc" ) assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"] r = _get( db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=title&sort_dir=asc" ) assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"] r = _get( db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=rating&sort_dir=desc" ) # The show has no rating, so it sinks — it is not "rated 0". assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"] def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch, override_dep): """The show leg needs the rollup's `resumed_at`; with only a status map this key would order every show as never-watched.""" user = _seed(db) r = _get( db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc", ) # Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc. assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"] def test_collections_list_resolves_titles_by_key(db, monkeypatch, override_dep): """What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the filter chip have nothing to show but `17045` until these are resolved.""" user = _seed(db) r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=col1") assert r.status_code == 200 assert [c["title"] for c in r.json()["collections"]] == ["Franchise"] def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch, override_dep): """`keys=` with nothing in it must not fall through to "every collection" — that would be a 200 KB answer to a question about zero ids.""" user = _seed(db) r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=") assert r.status_code == 200 assert r.json()["collections"] == []