Helpers flush and handlers commit — but every test request shared the test's session, so a follow-up GET or an assertion read the flushed write and the suite stayed green over a handler whose commit was missing (production's get_db closes the session: the write is rolled back behind a 200). The db fixture now hands out `db.info["request"]`: the same session, recording each request that ends normally with a flushed, uncommitted write, and failing the test on it at teardown. All 28 get_db overrides use it (a convention test keeps it so); the full suite passes under it, and dropping the commit from add_item turns a playlist test red.
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail
|
||
payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB,
|
||
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
|
||
without Postgres.
|
||
"""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import 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, PlexShow, PlexState, User
|
||
|
||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||
|
||
|
||
def _seed(db):
|
||
"""A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2
|
||
(in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode."""
|
||
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()
|
||
|
||
m1 = PlexItem(
|
||
rating_key="m1",
|
||
library_id=lib_m.id,
|
||
kind="movie",
|
||
title="Anchor Movie",
|
||
guid="tmdb:111",
|
||
genres=["Drama"],
|
||
year=2001,
|
||
added_at=NOW - timedelta(days=1),
|
||
tmdb_enriched_at=NOW - timedelta(days=1),
|
||
recommendations=[
|
||
{"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"}
|
||
],
|
||
)
|
||
m2 = PlexItem(
|
||
rating_key="m2",
|
||
library_id=lib_m.id,
|
||
kind="movie",
|
||
title="Rec Movie",
|
||
guid="tmdb:555",
|
||
genres=["Comedy"],
|
||
year=2002,
|
||
added_at=NOW - timedelta(days=2),
|
||
)
|
||
m3 = PlexItem(
|
||
rating_key="m3",
|
||
library_id=lib_m.id,
|
||
kind="movie",
|
||
title="Half-Watched Movie",
|
||
guid="tmdb:333",
|
||
genres=["Drama"],
|
||
year=2003,
|
||
duration_s=6000,
|
||
added_at=NOW - timedelta(days=3),
|
||
)
|
||
sh1 = PlexShow(
|
||
rating_key="sh1",
|
||
library_id=lib_s.id,
|
||
title="A Show",
|
||
child_count=1,
|
||
added_at=NOW - timedelta(hours=1),
|
||
) # newest → leads recently-added
|
||
db.add_all([m1, m2, m3, sh1])
|
||
db.flush()
|
||
ep1 = PlexItem(
|
||
rating_key="sh1e1",
|
||
library_id=lib_s.id,
|
||
kind="episode",
|
||
title="Ep 1",
|
||
show_id=sh1.id,
|
||
season_number=1,
|
||
episode_number=1,
|
||
)
|
||
ep2 = PlexItem(
|
||
rating_key="sh1e2",
|
||
library_id=lib_s.id,
|
||
kind="episode",
|
||
title="Ep 2",
|
||
show_id=sh1.id,
|
||
season_number=1,
|
||
episode_number=2,
|
||
)
|
||
db.add_all([ep1, ep2])
|
||
|
||
user = User(email="discover@example.com")
|
||
db.add(user)
|
||
db.flush()
|
||
|
||
db.add_all(
|
||
[
|
||
# m1 watched most recently → the because-you-watched anchor.
|
||
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
|
||
# m3 in-progress → continue watching.
|
||
PlexState(
|
||
user_id=user.id,
|
||
item_id=m3.id,
|
||
status="new",
|
||
position_seconds=1200,
|
||
progress_updated_at=NOW - timedelta(hours=1),
|
||
),
|
||
# sh1's ep1 in-progress → the show is in continue watching.
|
||
PlexState(
|
||
user_id=user.id,
|
||
item_id=ep1.id,
|
||
status="new",
|
||
position_seconds=300,
|
||
progress_updated_at=NOW - timedelta(minutes=30),
|
||
),
|
||
]
|
||
)
|
||
db.commit()
|
||
return user
|
||
|
||
|
||
def _get(db, user, monkeypatch, override_dep, path):
|
||
monkeypatch.setattr(state, "_configured_cache", True)
|
||
override_dep(get_db, db.info["request"])
|
||
override_dep(current_user, lambda: user)
|
||
client = TestClient(app) # no context manager: skip lifespan
|
||
return client.get(path)
|
||
|
||
|
||
def _ids(cards):
|
||
return [c["id"] for c in cards]
|
||
|
||
|
||
def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both")
|
||
assert resp.status_code == 200, resp.text
|
||
d = resp.json()
|
||
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
|
||
# Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3.
|
||
assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"]
|
||
# Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2).
|
||
genres = {g["value"]: g["count"] for g in d["genres"]}
|
||
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
||
|
||
|
||
def test_genre_tiles_carry_poster_art(db, monkeypatch, override_dep):
|
||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||
assert art["Comedy"] == ["m2"]
|
||
|
||
|
||
def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||
ids = _ids(d["continue_watching"])
|
||
# The in-progress movie and the show with an in-progress episode are both present; the watched
|
||
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
|
||
assert ids == ["sh1", "m3"]
|
||
assert "m1" not in ids
|
||
|
||
|
||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||
# A hidden started movie must be excluded.
|
||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||
db.add(lib)
|
||
db.flush()
|
||
m_null = PlexItem(
|
||
rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1"
|
||
)
|
||
m_hidden = PlexItem(
|
||
rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2"
|
||
)
|
||
db.add_all([m_null, m_hidden])
|
||
db.flush()
|
||
user = User(email="cw-null@example.com")
|
||
db.add(user)
|
||
db.flush()
|
||
db.add_all(
|
||
[
|
||
PlexState(
|
||
user_id=user.id,
|
||
item_id=m_null.id,
|
||
status=None,
|
||
position_seconds=500,
|
||
progress_updated_at=NOW,
|
||
),
|
||
PlexState(
|
||
user_id=user.id,
|
||
item_id=m_hidden.id,
|
||
status="hidden",
|
||
position_seconds=500,
|
||
progress_updated_at=NOW - timedelta(hours=1),
|
||
),
|
||
]
|
||
)
|
||
db.commit()
|
||
|
||
ids = _ids(
|
||
_get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()[
|
||
"continue_watching"
|
||
]
|
||
)
|
||
assert "mn" in ids # NULL-status started movie IS in progress
|
||
assert "mh" not in ids # hidden is excluded
|
||
|
||
|
||
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch, override_dep):
|
||
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
|
||
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
|
||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||
db.add(lib_s)
|
||
db.flush()
|
||
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
|
||
db.add(sh)
|
||
db.flush()
|
||
ep = PlexItem(
|
||
rating_key="shwe1",
|
||
library_id=lib_s.id,
|
||
kind="episode",
|
||
title="E1",
|
||
show_id=sh.id,
|
||
season_number=1,
|
||
episode_number=1,
|
||
)
|
||
db.add(ep)
|
||
db.flush()
|
||
user = User(email="fullywatched@example.com")
|
||
db.add(user)
|
||
db.flush()
|
||
db.add(
|
||
PlexState(
|
||
user_id=user.id,
|
||
item_id=ep.id,
|
||
status="watched",
|
||
position_seconds=0,
|
||
progress_updated_at=NOW,
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
ids = _ids(
|
||
_get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=show").json()[
|
||
"continue_watching"
|
||
]
|
||
)
|
||
assert "shw" not in ids # fully watched → excluded from continue-watching
|
||
|
||
|
||
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||
bw = d["because_you_watched"]
|
||
assert bw is not None
|
||
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
|
||
# m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card.
|
||
assert _ids(bw["items"]) == ["m2"]
|
||
|
||
|
||
def test_because_you_watched_null_without_history(db, monkeypatch, override_dep):
|
||
_seed(db)
|
||
fresh = User(email="fresh-discover@example.com")
|
||
db.add(fresh)
|
||
db.commit()
|
||
d = _get(db, fresh, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||
# A user with no watched-and-enriched movie gets no recommendation rail.
|
||
assert d["because_you_watched"] is None
|
||
assert d["continue_watching"] == []
|
||
|
||
|
||
def test_surprise_scope_and_bound(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&limit=2")
|
||
assert resp.status_code == 200, resp.text
|
||
items = resp.json()["items"]
|
||
assert len(items) <= 2
|
||
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
||
|
||
|
||
def test_recently_watched_movies_and_episodes(db, monkeypatch, override_dep):
|
||
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
|
||
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
|
||
user = _seed(db)
|
||
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one()
|
||
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
|
||
db.commit()
|
||
rw = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()[
|
||
"recently_watched"
|
||
]
|
||
by_id = {c["id"]: c for c in rw}
|
||
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
||
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
|
||
assert by_id["sh1e2"]["show_title"] == "A Show" # episode carries its show's title
|
||
# ep2 was watched at NOW (newest) → it leads over m1 (watched earlier in _seed... same NOW ok).
|
||
assert set(_ids(rw)) == {"m1", "sh1e2"}
|
||
|
||
|
||
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_dep):
|
||
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
|
||
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||
user = _seed(db)
|
||
items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[
|
||
"items"
|
||
]
|
||
ids = _ids(items)
|
||
assert "m1" not in ids # watched → excluded
|
||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
||
assert len([c for c in items if c["type"] == "movie"]) <= 3
|
||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||
|
||
|
||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||
# The show drops out of continue watching under the movie scope.
|
||
assert _ids(d["continue_watching"]) == ["m3"]
|
||
|
||
|
||
def test_filters_narrow_every_discover_rail(db, monkeypatch, override_dep):
|
||
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
|
||
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||
user = _seed(db)
|
||
d = _get(
|
||
db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both&genres=Drama"
|
||
).json()
|
||
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
|
||
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
||
assert _ids(d["continue_watching"]) == ["m3"]
|
||
# Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
|
||
assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
|
||
# Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
|
||
assert d["because_you_watched"] is None
|
||
|
||
|
||
def test_filters_narrow_surprise(db, monkeypatch, override_dep):
|
||
user = _seed(db)
|
||
items = _get(
|
||
db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||
).json()["items"]
|
||
# Only the one Comedy movie qualifies under the filter.
|
||
assert _ids(items) == ["m2"]
|