test(be): every dependency override goes through override_dep

22 test files set `app.dependency_overrides[...]` by hand and tore down with a blanket `.clear()`
in a `finally`, which wipes every override rather than the test's own and stops fixtures composing.
They now take the `override_dep` fixture (helpers get it passed in), whose teardown restores each
key's prior value; no clear() carried meaning between two phases of a test. A convention test fails
any test file that writes `dependency_overrides` directly.
This commit is contained in:
2026-09-19 17:26:07 +02:00
parent dc902d2c0e
commit 8759b5860d
23 changed files with 537 additions and 529 deletions
+20 -32
View File
@@ -18,7 +18,7 @@ from app.sync import runner, videos
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc) _PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch): def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, override_dep):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests). # published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB) ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB)
db.add(ch) db.add(ch)
@@ -40,12 +40,9 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
@@ -56,7 +53,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
@pytest.mark.parametrize("hidden", [True, False]) @pytest.mark.parametrize("hidden", [True, False])
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden): def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep):
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer # S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
@@ -68,18 +65,15 @@ def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidde
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is hidden assert resp.json()["hidden"] is hidden
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch): def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch, override_dep):
# No subscription row → the flag can't be True; it must serialize as False, not raise. # No subscription row → the flag can't be True; it must serialize as False, not raise.
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB) ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
me = User(email="hidns@example.com") me = User(email="hidns@example.com")
@@ -87,12 +81,9 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is False assert resp.json()["hidden"] is False
@@ -115,33 +106,30 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
return ch, user return ch, user
def _patch_deep_requested(db, monkeypatch, user): def _patch_deep_requested(db, monkeypatch, override_dep, user):
calls: list[tuple] = [] calls: list[tuple] = []
# background.add_task runs after the response in TestClient; record what would be scheduled. # background.add_task runs after the response in TestClient; record what would be scheduled.
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a)) monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
finally:
app.dependency_overrides.clear()
return resp, calls return resp, calls
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch): def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch, override_dep):
ch, me = _setup_channel_and_sub(db, email="wire@example.com") ch, me = _setup_channel_and_sub(db, email="wire@example.com")
resp, calls = _patch_deep_requested(db, monkeypatch, me) resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["deep_requested"] is True assert resp.json()["deep_requested"] is True
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs. # scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
assert calls == [(ch.id, me.id)] assert calls == [(ch.id, me.id)]
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch): def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch, override_dep):
# The SHARED demo account must not be able to kick immediate full backfills (budget drain). # The SHARED demo account must not be able to kick immediate full backfills (budget drain).
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True) _ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
resp, calls = _patch_deep_requested(db, monkeypatch, demo) resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, demo)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert calls == [], "demo must not schedule an immediate backfill" assert calls == [], "demo must not schedule an immediate backfill"
# …and deep_requested is not persisted on the shared demo subscription either. # …and deep_requested is not persisted on the shared demo subscription either.
+5 -8
View File
@@ -17,7 +17,7 @@ from app.main import app
from app.models import Channel, ChannelTag, Tag, User from app.models import Channel, ChannelTag, Tag, User
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch): def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, override_dep):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests). # published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel( ch = Channel(
id="UC_tags_test", id="UC_tags_test",
@@ -43,13 +43,10 @@ def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) resp = client.get(f"/api/channels/{ch.id}")
resp = client.get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["tag_ids"] == [mine.id] assert resp.json()["tag_ids"] == [mine.id]
+9 -12
View File
@@ -15,34 +15,31 @@ from app.main import app
from app.models import User from app.models import User
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def test_malformed_feed_cursor_returns_400(db, monkeypatch): def test_malformed_feed_cursor_returns_400(db, monkeypatch, override_dep):
user = User(email="feedcursor@example.com") user = User(email="feedcursor@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor") resp = _get(db, user, monkeypatch, override_dep, "/api/feed?cursor=not-a-real-cursor")
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"} assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
def test_feed_without_cursor_is_200(db, monkeypatch): def test_feed_without_cursor_is_200(db, monkeypatch, override_dep):
# Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the # Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the
# cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed. # cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed.
user = User(email="feednocursor@example.com") user = User(email="feednocursor@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/feed") resp = _get(db, user, monkeypatch, override_dep, "/api/feed")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["items"] == [] assert resp.json()["items"] == []
+12 -16
View File
@@ -142,13 +142,15 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"):
return it return it
def _client(db, user): def _client(db, user, override_dep):
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, tmp_path, sessions): def test_a_paused_viewers_checkpoint_keeps_their_session_alive(
db, monkeypatch, tmp_path, sessions, override_dep
):
"""The whole reason the short rule is safe again: a paused player fetches nothing, so """The whole reason the short rule is safe again: a paused player fetches nothing, so
`last_access` never moved on its own — but it does keep checkpointing its position.""" `last_access` never moved on its own — but it does keep checkpointing its position."""
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
@@ -158,20 +160,17 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
db.commit() db.commit()
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions[(user.id, "paused1")] = s sessions[(user.id, "paused1")] = s
try: resp = _client(db, user, override_dep).post(
resp = _client(db, user).post( f"/api/plex/item/{it.rating_key}/progress",
f"/api/plex/item/{it.rating_key}/progress", json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
json={"position_seconds": 600, "duration_seconds": 3000, "final": False}, )
)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there" assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
assert (user.id, "paused1") in sessions and not s.proc.killed assert (user.id, "paused1") in sessions and not s.proc.killed
def test_starting_a_session_leaves_another_viewers_paused_one_alone( def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db, monkeypatch, tmp_path, sessions db, monkeypatch, tmp_path, sessions, override_dep
): ):
"""The opportunistic sweep this route used to run was not scoped to the key being started: it """The opportunistic sweep this route used to run was not scoped to the key being started: it
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
@@ -196,9 +195,6 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
) )
monkeypatch.setattr(stream, "start_session", fake_start) monkeypatch.setattr(stream, "start_session", fake_start)
try: resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session")
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert (999, "someone-else") in sessions and not paused.proc.killed assert (999, "someone-else") in sessions and not paused.proc.killed
@@ -0,0 +1,23 @@
"""Dependency overrides go through `conftest.override_dep`, never `app.dependency_overrides` directly.
The hand-rolled shape — assign, then `app.dependency_overrides.clear()` in a `finally` — wipes EVERY
override, not the ones the test set, so it stops fixtures composing: one test's teardown silently
clobbers an override another fixture installed. `override_dep` restores each key's prior value
instead. 22 files had drifted to the hand-rolled shape (it was the house norm at ~20 files to 3), and
the sweep that converted them is only as good as the check that keeps it converted — this one.
"""
from pathlib import Path
TESTS = Path(__file__).parent
def test_no_test_writes_dependency_overrides_directly():
offenders = [
f"{p.name}:{n}"
for p in sorted(TESTS.rglob("*.py"))
if p.name not in {"conftest.py", Path(__file__).name}
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if "dependency_overrides" in line
]
assert offenders == [], "use the override_dep fixture: " + ", ".join(offenders)
+11 -14
View File
@@ -42,7 +42,7 @@ def _seed(db, *, status: str, position: int):
return it, user return it, user
def _clear(db, user, it, monkeypatch) -> list[tuple]: def _clear(db, user, it, monkeypatch, override_dep) -> list[tuple]:
"""Drive the endpoint with the Plex link satisfied and the background push recorded.""" """Drive the endpoint with the Plex link satisfied and the background push recorded."""
pushes: list[tuple] = [] pushes: list[tuple] = []
monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate
@@ -52,32 +52,29 @@ def _clear(db, user, it, monkeypatch) -> list[tuple]:
"push_state_to_plex", "push_state_to_plex",
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)), lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
) )
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"}) assert resp.status_code == 200, resp.text
assert resp.status_code == 200, resp.text
finally:
app.dependency_overrides.clear()
return pushes return pushes
def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch): def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch, override_dep):
# The regression: status "new" + a position IS the in-progress shape, and it must push. # The regression: status "new" + a position IS the in-progress shape, and it must push.
it, user = _seed(db, status="new", position=92) it, user = _seed(db, status="new", position=92)
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")] assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
assert db.query(PlexState).count() == 0 assert db.query(PlexState).count() == 0
def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch): def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch, override_dep):
# The case the old guard did cover — kept, so the fix cannot narrow into a new hole. # The case the old guard did cover — kept, so the fix cannot narrow into a new hole.
it, user = _seed(db, status="watched", position=0) it, user = _seed(db, status="watched", position=0)
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")] assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
def test_unhiding_touches_plex_not_at_all(db, monkeypatch): def test_unhiding_touches_plex_not_at_all(db, monkeypatch, override_dep):
# `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the # `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the
# endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on # endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on
# Plex that the user had merely hidden. # Plex that the user had merely hidden.
it, user = _seed(db, status="hidden", position=0) it, user = _seed(db, status="hidden", position=0)
assert _clear(db, user, it, monkeypatch) == [] assert _clear(db, user, it, monkeypatch, override_dep) == []
+28 -19
View File
@@ -231,61 +231,70 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _order(r) -> list[str]: def _order(r) -> list[str]:
return [i["title"] for i in r.json()["items"]] return [i["title"] for i in r.json()["items"]]
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch): 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.""" """The order the page had before it grew a control — a franchise reads in release order."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/col1")
assert r.status_code == 200 assert r.status_code == 200
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"] assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
def test_collection_detail_honours_sort_and_direction(db, monkeypatch): def test_collection_detail_honours_sort_and_direction(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=year&sort_dir=desc") r = _get(
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=year&sort_dir=desc"
)
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"] assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=title&sort_dir=asc") r = _get(
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=title&sort_dir=asc"
)
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"] assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=rating&sort_dir=desc") 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". # The show has no rating, so it sinks — it is not "rated 0".
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"] assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch): 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 """The show leg needs the rollup's `resumed_at`; with only a status map this key would order
every show as never-watched.""" every show as never-watched."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc") 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. # Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"] assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
def test_collections_list_resolves_titles_by_key(db, monkeypatch): 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 """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.""" filter chip have nothing to show but `17045` until these are resolved."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=col1") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=col1")
assert r.status_code == 200 assert r.status_code == 200
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"] assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch): 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 """`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.""" 200 KB answer to a question about zero ids."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["collections"] == [] assert r.json()["collections"] == []
+77 -94
View File
@@ -58,44 +58,44 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_single_collection_filters_to_its_members(db, monkeypatch): def test_single_collection_filters_to_its_members(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A")
assert _titles(resp) == {"Only A", "A and B"} assert _titles(resp) == {"Only A", "A and B"}
def test_multiple_collections_any_is_the_union(db, monkeypatch): def test_multiple_collections_any_is_the_union(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B") resp = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A,B"
)
assert _titles(resp) == {"Only A", "Only B", "A and B"} assert _titles(resp) == {"Only A", "Only B", "A and B"}
def test_multiple_collections_all_is_the_intersection(db, monkeypatch): def test_multiple_collections_all_is_the_intersection(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all", "/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
) )
assert _titles(resp) == {"A and B"} assert _titles(resp) == {"A and B"}
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch): def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch, override_dep):
# The browse-tile montage (top films, best-rated first) + the collection's total runtime. # The browse-tile montage (top films, best-rated first) + the collection's total runtime.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -127,14 +127,16 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
db.commit() db.commit()
card = next( card = next(
c c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"] for c in _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()[
"collections"
]
if c["id"] == "A" if c["id"] == "A"
) )
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0) assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
assert card["total_runtime_s"] == 5400 # 3600 + 1800 assert card["total_runtime_s"] == 5400 # 3600 + 1800
def test_create_collection_rejects_duplicate_name(db, monkeypatch): def test_create_collection_rejects_duplicate_name(db, monkeypatch, override_dep):
# Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is # Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it). # rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
@@ -146,21 +148,18 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch):
user = User(email="dup@example.com") user = User(email="dup@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
app.dependency_overrides[admin_user] = lambda: user override_dep(admin_user, lambda: user)
try: r = TestClient(app).post(
r = TestClient(app).post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, )
) assert r.status_code == 409, r.text
assert r.status_code == 409, r.text assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
finally:
app.dependency_overrides.clear()
def test_collection_detail_hero_and_ordered_members(db, monkeypatch): def test_collection_detail_hero_and_ordered_members(db, monkeypatch, override_dep):
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) + # The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
# members ordered by release year then title. # members ordered by release year then title.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -195,7 +194,7 @@ def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
user = User(email="cd@example.com") user = User(email="cd@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
d = _get(db, user, monkeypatch, "/api/plex/collections/A").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/A").json()
assert d["title"] == "Coll A" and d["summary"] == "S" assert d["title"] == "Coll A" and d["summary"] == "S"
assert d["year_min"] == 1992 and d["year_max"] == 1996 assert d["year_min"] == 1992 and d["year_max"] == 1996
assert d["content_rating"] == "PG" # most restrictive of {G, PG} assert d["content_rating"] == "PG" # most restrictive of {G, PG}
@@ -270,38 +269,32 @@ def _seed_editable(db):
return lib, user return lib, user
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok): def _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_ok):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub) monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync) monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
app.dependency_overrides[admin_user] = lambda: user override_dep(admin_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_rename_writes_through_to_plex(db, monkeypatch): def test_rename_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "rename_collection" in stub.calls
assert "rename_collection" in stub.calls
finally:
app.dependency_overrides.clear()
def test_remove_item_writes_through_to_plex(db, monkeypatch): def test_remove_item_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.delete("/api/plex/collections/A/items/m1")
r = client.delete("/api/plex/collections/A/items/m1") assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "remove_collection_item" in stub.calls
assert "remove_collection_item" in stub.calls
finally:
app.dependency_overrides.clear()
def _resync_create(db, plex, lib, rk): def _resync_create(db, plex, lib, rk):
@@ -312,25 +305,22 @@ def _resync_create(db, plex, lib, rk):
return col return col
def test_create_writes_through_to_plex(db, monkeypatch): def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
try: r = client.post(
r = client.post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, )
) assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "create_collection" in stub.calls
assert "create_collection" in stub.calls body = r.json()
body = r.json() assert body["id"] == "NEW" # the stub's new ratingKey
assert body["id"] == "NEW" # the stub's new ratingKey assert body["editable"] is True # create marks the new collection editable
assert body["editable"] is True # create marks the new collection editable
finally:
app.dependency_overrides.clear()
def test_create_resync_failure_is_422(db, monkeypatch): def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with # The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
# the honest "reconcile on the next sync" detail, not a false "write failed". # the honest "reconcile on the next sync" detail, not a false "write failed".
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
@@ -339,34 +329,28 @@ def test_create_resync_failure_is_422(db, monkeypatch):
def _boom(db_, plex, lib_, rk): def _boom(db_, plex, lib_, rk):
raise PlexError("read failed") raise PlexError("read failed")
client = _admin_client(db, user, monkeypatch, stub, resync=_boom) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_boom)
try: r = client.post(
r = client.post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, )
) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} assert "create_collection" in stub.calls
assert "create_collection" in stub.calls
finally:
app.dependency_overrides.clear()
def test_mutation_failure_returns_422_write_failed(db, monkeypatch): def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync # The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
# is never reached (stub.calls holds only the mutating call). # is never reached (stub.calls holds only the mutating call).
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub(fail_on="rename_collection") stub = _CollStub(fail_on="rename_collection")
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
finally:
app.dependency_overrides.clear()
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch): def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch, override_dep):
# The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a # The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
# write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile # write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
# on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed. # on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
@@ -385,21 +369,18 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
db_.flush() db_.flush()
raise PlexError("read failed") raise PlexError("read failed")
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_boom)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} assert "rename_collection" in stub.calls # the write DID happen
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
row = ( row = (
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first() db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
) )
assert row.title == "My Coll" # the partial resync upsert was rolled back assert row.title == "My Coll" # the partial resync upsert was rolled back
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch): def test_show_collection_gets_montage_art_from_shows(db, monkeypatch, override_dep):
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the # A show-library collection must get its montage from PlexShow members (else it renders blank) — the
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration). # A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -431,7 +412,9 @@ def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
db.commit() db.commit()
card = next( card = next(
c c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"] for c in _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()[
"collections"
]
if c["id"] == "S" if c["id"] == "S"
) )
assert card["art"] == ["s2", "s1"] # show members, best-rated first assert card["art"] == ["s2", "s1"] # show members, best-rated first
+14 -17
View File
@@ -191,14 +191,11 @@ def test_enrich_people_batch_persists_detail(db):
# ---- item_detail serving (DB lane) --------------------------------------------------------------- # ---- item_detail serving (DB lane) ---------------------------------------------------------------
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _user(db, email): def _user(db, email):
@@ -208,7 +205,7 @@ def _user(db, email):
return u return u
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch): def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, override_dep):
"""An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises — """An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises —
only markers/streams (which need the live meta) degrade.""" only markers/streams (which need the live meta) degrade."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -237,7 +234,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
resp = _get(db, user, monkeypatch, "/api/plex/item/m1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["imdb_rating"] == 8.7 assert body["imdb_rating"] == 8.7
@@ -249,7 +246,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch): def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""A not-yet-enriched movie is filled from the live meta on view (and persisted for next time).""" """A not-yet-enriched movie is filled from the live meta on view (and persisted for next time)."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -261,7 +258,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, "/api/plex/item/m1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["imdb_id"] == "tt0068646" assert resp.json()["imdb_id"] == "tt0068646"
db.refresh(mv) db.refresh(mv)
@@ -270,7 +267,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch): def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep):
"""Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the """Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the
live meta the endpoint fetches for markers/streams.""" live meta the endpoint fetches for markers/streams."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -288,7 +285,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, "/api/plex/item/e1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/e1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
@@ -297,7 +294,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
assert ep.full_cast is None # episodes are not persisted/enriched assert ep.full_cast is None # episodes are not persisted/enriched
def test_show_detail_serves_from_row(db, monkeypatch): def test_show_detail_serves_from_row(db, monkeypatch, override_dep):
"""A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail.""" """A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib) db.add(lib)
@@ -324,7 +321,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
raise RuntimeError("plex unavailable") raise RuntimeError("plex unavailable")
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom) monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
resp = _get(db, user, monkeypatch, "/api/plex/show/s1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json()["show"] body = resp.json()["show"]
assert body["imdb_rating"] == 9.1 assert body["imdb_rating"] == 9.1
@@ -334,7 +331,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
assert resp.json()["related"] == [] assert resp.json()["related"] == []
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch): def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted + """A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted +
stamped) so subsequent views serve from the row.""" stamped) so subsequent views serve from the row."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -348,7 +345,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
# _show_enrichment returns (live meta, no related) → the fill branch runs. # _show_enrichment returns (live meta, no related) → the fill branch runs.
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, [])) monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
resp = _get(db, user, monkeypatch, "/api/plex/show/s1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["show"]["imdb_id"] == "tt0068646" assert resp.json()["show"]["imdb_id"] == "tt0068646"
db.refresh(show) db.refresh(show)
+43 -36
View File
@@ -120,24 +120,21 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan
client = TestClient(app) # no context manager: skip lifespan return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def _ids(cards): def _ids(cards):
return [c["id"] for c in cards] return [c["id"] for c in cards]
def test_discover_shape_and_recently_added_order(db, monkeypatch): def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
d = resp.json() d = resp.json()
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"} assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
@@ -148,19 +145,19 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1 assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
def test_genre_tiles_carry_poster_art(db, monkeypatch): 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 # 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. # movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
art = {g["value"]: g["art"] for g in d["genres"]} art = {g["value"]: g["art"] for g in d["genres"]}
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
assert art["Comedy"] == ["m2"] assert art["Comedy"] == ["m2"]
def test_continue_watching_movies_and_shows(db, monkeypatch): def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
ids = _ids(d["continue_watching"]) ids = _ids(d["continue_watching"])
# The in-progress movie and the show with an in-progress episode are both present; the watched # 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). # anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
@@ -168,7 +165,7 @@ def test_continue_watching_movies_and_shows(db, monkeypatch):
assert "m1" not in ids assert "m1" not in ids
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch): 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 # 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. # (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. # A hidden started movie must be excluded.
@@ -207,13 +204,15 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
db.commit() db.commit()
ids = _ids( ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"] _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 "mn" in ids # NULL-status started movie IS in progress
assert "mh" not in ids # hidden is excluded assert "mh" not in ids # hidden is excluded
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch): 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 # 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). # 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) lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -248,14 +247,16 @@ def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
db.commit() db.commit()
ids = _ids( ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"] _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 assert "shw" not in ids # fully watched → excluded from continue-watching
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch): def test_because_you_watched_resolves_in_library_recs(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
bw = d["because_you_watched"] bw = d["because_you_watched"]
assert bw is not None assert bw is not None
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie" assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
@@ -263,34 +264,36 @@ def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
assert _ids(bw["items"]) == ["m2"] assert _ids(bw["items"]) == ["m2"]
def test_because_you_watched_null_without_history(db, monkeypatch): def test_because_you_watched_null_without_history(db, monkeypatch, override_dep):
_seed(db) _seed(db)
fresh = User(email="fresh-discover@example.com") fresh = User(email="fresh-discover@example.com")
db.add(fresh) db.add(fresh)
db.commit() db.commit()
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json() 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. # A user with no watched-and-enriched movie gets no recommendation rail.
assert d["because_you_watched"] is None assert d["because_you_watched"] is None
assert d["continue_watching"] == [] assert d["continue_watching"] == []
def test_surprise_scope_and_bound(db, monkeypatch): def test_surprise_scope_and_bound(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&limit=2")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
items = resp.json()["items"] items = resp.json()["items"]
assert len(items) <= 2 assert len(items) <= 2
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
def test_recently_watched_movies_and_episodes(db, monkeypatch): 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 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. # the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
user = _seed(db) user = _seed(db)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one() 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.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
db.commit() db.commit()
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"] rw = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()[
"recently_watched"
]
by_id = {c["id"]: c for c in rw} by_id = {c["id"]: c for c in rw}
assert "m1" in by_id and by_id["m1"]["type"] == "movie" assert "m1" in by_id and by_id["m1"]["type"] == "movie"
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode" assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
@@ -299,11 +302,13 @@ def test_recently_watched_movies_and_episodes(db, monkeypatch):
assert set(_ids(rw)) == {"m1", "sh1e2"} assert set(_ids(rw)) == {"m1", "sh1e2"}
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch): 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 # 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. # in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
user = _seed(db) user = _seed(db)
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"] items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[
"items"
]
ids = _ids(items) ids = _ids(items)
assert "m1" not in ids # watched → excluded assert "m1" not in ids # watched → excluded
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
@@ -311,19 +316,21 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
assert len([c for c in items if c["type"] == "show"]) <= 3 assert len([c for c in items if c["type"] == "show"]) <= 3
def test_discover_scope_movie_excludes_shows(db, monkeypatch): def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
assert all(c["type"] == "movie" for c in d["recently_added"]) assert all(c["type"] == "movie" for c in d["recently_added"])
# The show drops out of continue watching under the movie scope. # The show drops out of continue watching under the movie scope.
assert _ids(d["continue_watching"]) == ["m3"] assert _ids(d["continue_watching"]) == ["m3"]
def test_filters_narrow_every_discover_rail(db, monkeypatch): 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 # 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. # filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json() 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. # 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"} assert set(_ids(d["recently_added"])) == {"m1", "m3"}
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded). # Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
@@ -334,10 +341,10 @@ def test_filters_narrow_every_discover_rail(db, monkeypatch):
assert d["because_you_watched"] is None assert d["because_you_watched"] is None
def test_filters_narrow_surprise(db, monkeypatch): def test_filters_narrow_surprise(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[ items = _get(
"items" db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
] ).json()["items"]
# Only the one Comedy movie qualifies under the filter. # Only the one Comedy movie qualifies under the filter.
assert _ids(items) == ["m2"] assert _ids(items) == ["m2"]
+9 -15
View File
@@ -16,7 +16,7 @@ from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User from app.models import PlexItem, PlexLibrary, PlexShow, User
def test_facets_returns_studio_vocabulary(db, monkeypatch): def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" """E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -33,18 +33,15 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).get("/api/plex/facets?scope=movie")
resp = TestClient(app).get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
studios = {s["value"]: s["count"] for s in resp.json()["studios"]} studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
assert studios == {"Pixar": 2, "A24": 1} assert studios == {"Pixar": 2, "A24": 1}
def test_facets_survives_json_null_genres(db, monkeypatch): def test_facets_survives_json_null_genres(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -61,13 +58,10 @@ def test_facets_survives_json_null_genres(db, monkeypatch):
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'")) db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
db.commit() db.commit()
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) resp = client.get("/api/plex/facets?scope=show")
resp = client.get("/api/plex/facets?scope=show")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint. # The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
+41 -24
View File
@@ -100,14 +100,11 @@ def _seed(db):
return {"user": user} return {"user": user}
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(body): def _ids(body):
@@ -115,55 +112,75 @@ def _ids(body):
class TestFieldScopedSearch: class TestFieldScopedSearch:
def test_everywhere_matches_every_facet(self, db, monkeypatch): def test_everywhere_matches_every_facet(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
).json()
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out # title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
assert _ids(body) == ["mp", "ms", "mt"] assert _ids(body) == ["mp", "ms", "mt"]
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_field_all_is_identical_to_default(self, db, monkeypatch): def test_field_all_is_identical_to_default(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() default = _get(
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr"
).json()
scoped = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
).json()
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"] assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
assert default["total"] == scoped["total"] assert default["total"] == scoped["total"]
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]] assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
def test_title_scope_narrows_to_title_match(self, db, monkeypatch): def test_title_scope_narrows_to_title_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=title"
).json()
assert _ids(body) == ["mt"] assert _ids(body) == ["mt"]
assert body["total"] == 1 assert body["total"] == 1
assert body["episodes"] == [] # non-episode field scopes suppress the episode section assert body["episodes"] == [] # non-episode field scopes suppress the episode section
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch): def test_people_scope_narrows_to_cast_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=people"
).json()
assert _ids(body) == ["mp"] assert _ids(body) == ["mp"]
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch): def test_summary_scope_narrows_to_description_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=summary"
).json()
assert _ids(body) == ["ms"] assert _ids(body) == ["ms"]
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch): def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=episode"
).json()
assert body["items"] == [] assert body["items"] == []
assert body["total"] == 0 assert body["total"] == 0
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch): def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get( body = _get(
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie" db,
s["user"],
monkeypatch,
override_dep,
"/api/plex/library?q=zephyr&field=episode&scope=movie",
).json() ).json()
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
assert body["items"] == [] assert body["items"] == []
assert body["episodes"] == [] assert body["episodes"] == []
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch): def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=bogus"
).json()
assert _ids(body) == ["mp", "ms", "mt"] assert _ids(body) == ["mp", "ms", "mt"]
+3 -6
View File
@@ -62,14 +62,11 @@ def sessions(monkeypatch):
@pytest.fixture @pytest.fixture
def client(monkeypatch): def client(monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
user = User(id=VIEWER, email="hls@example.com") user = User(id=VIEWER, email="hls@example.com")
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app)
yield TestClient(app)
finally:
app.dependency_overrides.pop(current_user, None)
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions): def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
+12 -13
View File
@@ -41,34 +41,33 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch): def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=heist")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert _titles(resp) == {"Heist Movie"} assert _titles(resp) == {"Heist Movie"}
def test_no_keyword_filter_returns_all(db, monkeypatch): def test_no_keyword_filter_returns_all(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie")
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"} assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
def test_unknown_keyword_matches_nothing(db, monkeypatch): def test_unknown_keyword_matches_nothing(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch") resp = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=nonesuch"
)
assert resp.json()["items"] == [] assert resp.json()["items"] == []
+25 -24
View File
@@ -56,54 +56,55 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan
client = TestClient(app) # no context manager: skip lifespan return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_library_serves_active_language_titles(db, monkeypatch): def test_library_serves_active_language_titles(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu") hu = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en") en = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en")
# HU serves the twin's Hungarian title; the EN-only row falls back to its English title. # HU serves the twin's Hungarian title; the EN-only row falls back to its English title.
assert _titles(hu) == {"Magyar Cím", "EN Only"} assert _titles(hu) == {"Magyar Cím", "EN Only"}
assert _titles(en) == {"English Title", "EN Only"} assert _titles(en) == {"English Title", "EN Only"}
def test_facets_vocabulary_is_language_specific(db, monkeypatch): def test_facets_vocabulary_is_language_specific(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu") hu = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en") en = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=en")
hu_genres = {g["value"] for g in hu.json()["genres"]} hu_genres = {g["value"] for g in hu.json()["genres"]}
en_genres = {g["value"] for g in en.json()["genres"]} en_genres = {g["value"] for g in en.json()["genres"]}
assert "Dráma" in hu_genres and "Drama" not in hu_genres assert "Dráma" in hu_genres and "Drama" not in hu_genres
assert "Drama" in en_genres and "Dráma" not in en_genres assert "Drama" in en_genres and "Dráma" not in en_genres
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch): def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
# The HU genre chip filters the HU column; the same string against the EN column matches nothing. # The HU genre chip filters the HU column; the same string against the EN column matches nothing.
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma") hit = _get(
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma") db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&genres=Dráma"
)
miss = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en&genres=Dráma"
)
assert _titles(hit) == {"Magyar Cím"} assert _titles(hit) == {"Magyar Cím"}
assert miss.json()["items"] == [] assert miss.json()["items"] == []
def test_item_detail_language_and_rating_split(db, monkeypatch): def test_item_detail_language_and_rating_split(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1?lang=hu")
body = resp.json() body = resp.json()
assert body["title"] == "Magyar Cím" assert body["title"] == "Magyar Cím"
assert body["summary"] == "Magyar összefoglaló." assert body["summary"] == "Magyar összefoglaló."
@@ -112,15 +113,15 @@ def test_item_detail_language_and_rating_split(db, monkeypatch):
assert body["rating"] == 6.5 assert body["rating"] == 6.5
def test_hu_search_still_finds_en_only_titles(db, monkeypatch): def test_hu_search_still_finds_en_only_titles(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search # m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
# must still match it via the EN vector (vector_match's union) — findable by its English title. # must still match it via the EN vector (vector_match's union) — findable by its English title.
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&q=Only")
assert "EN Only" in _titles(resp) assert "EN Only" in _titles(resp)
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch): def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -142,7 +143,7 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
user = User(email="rating@example.com") user = User(email="rating@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7") hit = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=7")
assert "High IMDb" in _titles(hit) assert "High IMDb" in _titles(hit)
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9") miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9")
assert "High IMDb" not in _titles(miss) assert "High IMDb" not in _titles(miss)
+22 -13
View File
@@ -111,44 +111,53 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(resp): def _ids(resp):
return [i["id"] for i in resp.json()["items"]] return [i["id"] for i in resp.json()["items"]]
def test_movies_ordered_by_last_watched_desc(db, monkeypatch): def test_movies_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never. # Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"] assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
def test_shows_ordered_by_last_watched_desc(db, monkeypatch): def test_shows_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# Recent resume first, then the watched-only show, then the never-watched one (nullslast). # Recent resume first, then the watched-only show, then the never-watched one (nullslast).
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"] assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
def test_last_watched_mixed_scope_nullslast(db, monkeypatch): def test_last_watched_mixed_scope_nullslast(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
ids = _ids(resp) ids = _ids(resp)
+8 -11
View File
@@ -25,17 +25,14 @@ from app.plex.query import content_libs
NAME = "Robert De Niro" NAME = "Robert De Niro"
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
finally:
app.dependency_overrides.clear()
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch): def test_person_detail_returns_cast_and_director_credits(db, monkeypatch, override_dep):
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False) off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False)
@@ -89,7 +86,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched")) db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
db.commit() db.commit()
resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}") resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{NAME.replace(' ', '%20')}")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
@@ -106,7 +103,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
assert body["shows"][0]["type"] == "show" assert body["shows"][0]["type"] == "show"
def test_person_detail_unknown_name_is_empty(db, monkeypatch): def test_person_detail_unknown_name_is_empty(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -123,7 +120,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Ghost%20Actor")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null). # An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
+10 -13
View File
@@ -168,17 +168,14 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
assert plex_tmdb.enrich_person(db, person) is False assert plex_tmdb.enrich_person(db, person) is False
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch): def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, override_dep):
# The bio fetch runs OFF the request path (a background task after the response), so the serve never # The bio fetch runs OFF the request path (a background task after the response), so the serve never
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules # makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
# the enrich and the page still renders immediately with whatever's in the row (bio still null). # the enrich and the page still renders immediately with whatever's in the row (bio still null).
@@ -196,7 +193,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
assert len(scheduled) == 1 # the enrich was scheduled off the request path assert len(scheduled) == 1 # the enrich was scheduled off the request path
@@ -260,7 +257,7 @@ def test_should_schedule_enrich_dedups_within_cooldown():
people._enrich_inflight.clear() people._enrich_inflight.clear()
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch): def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override_dep):
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch. # An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -284,13 +281,13 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
scheduled: list[int] = [] scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, "/api/plex/person/Done P") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["biography"] == "Bio." assert resp.json()["biography"] == "Bio."
assert scheduled == [] # already enriched → no background work assert scheduled == [] # already enriched → no background work
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch): def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -327,7 +324,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Actor X")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
j = resp.json() j = resp.json()
assert j["biography"] == "Bio here." assert j["biography"] == "Bio here."
+37 -52
View File
@@ -347,47 +347,41 @@ def _seed_classes(db):
return lib return lib
def _client(db, user, monkeypatch): def _client(db, user, monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_library_filters_by_playback_class(db, monkeypatch): def test_library_filters_by_playback_class(db, monkeypatch, override_dep):
_seed_classes(db) _seed_classes(db)
user = User(email="pclass@example.com") user = User(email="pclass@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: # Software-only transcodes.
# Software-only transcodes. sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") assert sw.status_code == 200, sw.text
assert sw.status_code == 200, sw.text assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
assert {i["id"] for i in sw.json()["items"]} == {"tsw"} # HW transcode + Direct Play, OR-combined.
# HW transcode + Direct Play, OR-combined. both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct") assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"} # The HW card carries the transcode_hw flag for the 4-way badge.
# The HW card carries the transcode_hw flag for the 4-way badge. card = next(i for i in both.json()["items"] if i["id"] == "thw")
card = next(i for i in both.json()["items"] if i["id"] == "thw") assert card["transcode_hw"] is True
assert card["transcode_hw"] is True # No filter → everything.
# No filter → everything. allm = client.get("/api/plex/library?scope=movie")
allm = client.get("/api/plex/library?scope=movie") assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
finally:
app.dependency_overrides.clear()
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch): def test_facets_count_playback_classes_in_severity_order(db, monkeypatch, override_dep):
_seed_classes(db) _seed_classes(db)
user = User(email="pclassfacet@example.com") user = User(email="pclassfacet@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: resp = client.get("/api/plex/facets?scope=movie")
resp = client.get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
playable = resp.json()["playable"] playable = resp.json()["playable"]
# Fixed good→heavy order (not by count), one per seeded class. # Fixed good→heavy order (not by count), one per seeded class.
@@ -400,7 +394,7 @@ def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
} }
def test_unprobed_transcode_folds_into_sw(db, monkeypatch): def test_unprobed_transcode_folds_into_sw(db, monkeypatch, override_dep):
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's # transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with # CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the # software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
@@ -422,20 +416,17 @@ def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
user = User(email="pnull@example.com") user = User(email="pnull@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw") assert hw.json()["items"] == [] # never HW
assert hw.json()["items"] == [] # never HW facets = client.get("/api/plex/facets?scope=movie")
facets = client.get("/api/plex/facets?scope=movie") counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
counts = {p["value"]: p["count"] for p in facets.json()["playable"]} assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
finally:
app.dependency_overrides.clear()
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch): def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch, override_dep):
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion), # A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting # otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
# 'direct' must still return all four classes at their full, unnarrowed counts. # 'direct' must still return all four classes at their full, unnarrowed counts.
@@ -443,17 +434,14 @@ def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
user = User(email="pself@example.com") user = User(email="pself@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: resp = client.get("/api/plex/facets?scope=movie&playable=direct")
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
counts = {p["value"]: p["count"] for p in resp.json()["playable"]} counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1} assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
def test_active_playable_narrows_the_other_facets(db, monkeypatch): def test_active_playable_narrows_the_other_facets(db, monkeypatch, override_dep):
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so # The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a # e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000. # software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
@@ -485,11 +473,8 @@ def test_active_playable_narrows_the_other_facets(db, monkeypatch):
user = User(email="pnarrow@example.com") user = User(email="pnarrow@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: wide = client.get("/api/plex/facets?scope=movie").json()
wide = client.get("/api/plex/facets?scope=movie").json() narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
finally:
app.dependency_overrides.clear()
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
+10 -13
View File
@@ -48,7 +48,7 @@ def _user(db, email="play@example.com"):
return u return u
def _post(db, user, monkeypatch, rating_key, *, captured): def _post(db, user, monkeypatch, override_dep, rating_key, *, captured):
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it """Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
was called with into `captured` and returns a stand-in session echoing it back.""" was called with into `captured` and returns a stand-in session echoing it back."""
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
@@ -67,20 +67,17 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
) )
monkeypatch.setattr(plex_stream, "start_session", fake_start) monkeypatch.setattr(plex_stream, "start_session", fake_start)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
finally:
app.dependency_overrides.clear()
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch): def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch, override_dep):
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session. # A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
it = _item(db, playable="transcode") it = _item(db, playable="transcode")
user = _user(db) user = _user(db)
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["mode"] == "hls" assert body["mode"] == "hls"
@@ -88,12 +85,12 @@ def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
assert captured["transcode"] is True # start_session was told to re-encode assert captured["transcode"] is True # start_session was told to re-encode
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch): def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
# A remux (h264) item takes the SAME HLS path but must NOT re-encode. # A remux (h264) item takes the SAME HLS path but must NOT re-encode.
it = _item(db, playable="remux", rating_key="m2") it = _item(db, playable="remux", rating_key="m2")
user = _user(db, "remux@example.com") user = _user(db, "remux@example.com")
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["mode"] == "hls" assert body["mode"] == "hls"
@@ -101,12 +98,12 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
assert captured["transcode"] is False assert captured["transcode"] is False
def test_direct_item_never_opens_a_session(db, monkeypatch): def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
# A browser-native file is served raw — start_session is never reached, so no transcode decision. # A browser-native file is served raw — start_session is never reached, so no transcode decision.
it = _item(db, playable="direct", rating_key="m3") it = _item(db, playable="direct", rating_key="m3")
user = _user(db, "direct@example.com") user = _user(db, "direct@example.com")
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["mode"] == "direct" assert resp.json()["mode"] == "direct"
assert "transcode" not in captured # start_session not called assert "transcode" not in captured # start_session not called
+79 -42
View File
@@ -96,27 +96,26 @@ def _seed(db):
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
def _request(db, user, monkeypatch, method, path, json=None): def _request(db, user, monkeypatch, override_dep, method, path, json=None):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # 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)
return client.request(method, path, json=json)
finally:
app.dependency_overrides.clear()
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
return _request(db, user, monkeypatch, "GET", path) return _request(db, user, monkeypatch, override_dep, "GET", path)
class TestUnifiedLibrary: class TestUnifiedLibrary:
def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): def test_both_scope_unions_movies_and_shows_ordered_by_added(
self, db, monkeypatch, override_dep
):
s = _seed(db) s = _seed(db)
r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both") r = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=both")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
assert body["total"] == 3 # 2 movies + 1 show, unioned assert body["total"] == 3 # 2 movies + 1 show, unioned
@@ -127,43 +126,53 @@ class TestUnifiedLibrary:
assert show_item["season_count"] == 1 assert show_item["season_count"] == 1
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
def test_scope_movie_only(self, db, monkeypatch): def test_scope_movie_only(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=movie"
).json()
assert body["total"] == 2 assert body["total"] == 2
assert all(i["type"] == "movie" for i in body["items"]) assert all(i["type"] == "movie" for i in body["items"])
def test_scope_show_only(self, db, monkeypatch): def test_scope_show_only(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json() body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=show").json()
assert [i["id"] for i in body["items"]] == ["s1"] assert [i["id"] for i in body["items"]] == ["s1"]
assert body["items"][0]["season_count"] == 1 assert body["items"][0]["season_count"] == 1
def test_watch_state_branches(self, db, monkeypatch): def test_watch_state_branches(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
# watched: only movie m1 (the show is in_progress, not fully watched) # watched: only movie m1 (the show is in_progress, not fully watched)
watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json() watched = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=watched"
).json()
assert [i["id"] for i in watched["items"]] == ["m1"] assert [i["id"] for i in watched["items"]] == ["m1"]
# in_progress: only the show (no movie is in progress) # in_progress: only the show (no movie is in progress)
inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json() inprog = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=in_progress"
).json()
assert [i["id"] for i in inprog["items"]] == ["s1"] assert [i["id"] for i in inprog["items"]] == ["s1"]
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means # 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.) # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.)
unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json() unwatched = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=unwatched"
).json()
assert [i["id"] for i in unwatched["items"]] == ["m2"] assert [i["id"] for i in unwatched["items"]] == ["m2"]
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch): def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json() 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" # 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 [i["id"] for i in body["items"]] == ["s1"]
assert body["total"] == 1 assert body["total"] == 1
# matching episodes come back grouped in the separate episodes list # matching episodes come back grouped in the separate episodes list
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
def test_pagination_caps_items_not_total(self, db, monkeypatch): def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?limit=2&offset=0"
).json()
assert body["total"] == 3 assert body["total"] == 3
assert [i["id"] for i in body["items"]] == [ assert [i["id"] for i in body["items"]] == [
"s1", "s1",
@@ -267,7 +276,7 @@ class TestFacetsWithData:
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only 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.""" covers the null-genre guard; this locks the aggregated numbers with real data."""
def test_facet_aggregation(self, db, monkeypatch): def test_facet_aggregation(self, db, monkeypatch, override_dep):
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib]) db.add_all([movie_lib, show_lib])
@@ -311,7 +320,7 @@ class TestFacetsWithData:
db.add(user) db.add(user)
db.commit() db.commit()
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json() 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 {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
assert {c["value"]: c["count"] for c in body["content_ratings"]} == { assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
"PG": 1, "PG": 1,
@@ -332,7 +341,7 @@ class TestPlaylistAndCollectionEndpoints:
def _movie(self, db, lib_id, rk, title): def _movie(self, db, lib_id, rk, title):
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title) return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
def test_playlist_crud_roundtrip(self, db, monkeypatch): def test_playlist_crud_roundtrip(self, db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -348,6 +357,7 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
"/api/plex/playlists", "/api/plex/playlists",
{"title": "Faves", "item_rating_key": "m1"}, {"title": "Faves", "item_rating_key": "m1"},
@@ -357,7 +367,9 @@ class TestPlaylistAndCollectionEndpoints:
assert created.json()["item_count"] == 1 assert created.json()["item_count"] == 1
# list -> the playlist with its count # list -> the playlist with its count
listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] listed = _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()[
"playlists"
]
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)] assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
# add m2, then reorder to [m2, m1] # add m2, then reorder to [m2, m1]
@@ -365,6 +377,7 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items", f"/api/plex/playlists/{pid}/items",
{"item_rating_key": "m2"}, {"item_rating_key": "m2"},
@@ -374,24 +387,30 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"PUT", "PUT",
f"/api/plex/playlists/{pid}/order", f"/api/plex/playlists/{pid}/order",
{"item_rating_keys": ["m2", "m1"]}, {"item_rating_keys": ["m2", "m1"]},
) )
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() 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 assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
# remove m1 -> one item left # remove m1 -> one item left
rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1") rm = _request(
db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}/items/m1"
)
assert rm.status_code == 200, rm.text assert rm.status_code == 200, rm.text
after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() after = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in after["items"]] == ["m2"] assert [it["id"] for it in after["items"]] == ["m2"]
# delete the playlist -> gone from the list # delete the playlist -> gone from the list
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") _request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}")
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] assert (
_get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"]
== []
)
def test_playlist_bulk_add_and_remove(self, db, monkeypatch): 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 # 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. # synchronize_session=False path, which the single add/remove roundtrip never exercises.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -402,13 +421,16 @@ class TestPlaylistAndCollectionEndpoints:
db.add(user) db.add(user)
db.commit() db.commit()
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"}) created = _request(
db, user, monkeypatch, override_dep, "POST", "/api/plex/playlists", {"title": "Bulk"}
)
pid = created.json()["id"] pid = created.json()["id"]
added = _request( added = _request(
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items/bulk", f"/api/plex/playlists/{pid}/items/bulk",
{"item_rating_keys": ["m1", "m2", "m3"]}, {"item_rating_keys": ["m1", "m2", "m3"]},
@@ -420,6 +442,7 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items/remove-bulk", f"/api/plex/playlists/{pid}/items/remove-bulk",
{"item_rating_keys": ["m2"]}, {"item_rating_keys": ["m2"]},
@@ -427,13 +450,13 @@ class TestPlaylistAndCollectionEndpoints:
assert removed.status_code == 200, removed.text assert removed.status_code == 200, removed.text
assert removed.json() == {"removed": 1, "item_count": 2} assert removed.json() == {"removed": 1, "item_count": 2}
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == [ assert [it["id"] for it in detail["items"]] == [
"m1", "m1",
"m3", "m3",
] # input order preserved, m2 gone ] # input order preserved, m2 gone
def test_collections_list(self, db, monkeypatch): def test_collections_list(self, db, monkeypatch, override_dep):
from app.models import PlexCollection from app.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -450,13 +473,13 @@ class TestPlaylistAndCollectionEndpoints:
db.commit() db.commit()
# unified scope (no library param) -> most-populated first # unified scope (no library param) -> most-populated first
body = _get(db, user, monkeypatch, "/api/plex/collections").json() body = _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"] assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
# name filter # name filter
filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json() filtered = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?q=pix").json()
assert [c["title"] for c in filtered["collections"]] == ["Pixar"] assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
def test_item_state_watched_then_new(self, db, monkeypatch): 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 # 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). # is what the conversion touched (the st lookup + the delete on ->new).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -468,7 +491,13 @@ class TestPlaylistAndCollectionEndpoints:
db.commit() db.commit()
watched = _request( watched = _request(
db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "watched"} db,
user,
monkeypatch,
override_dep,
"POST",
"/api/plex/item/m1/state",
{"status": "watched"},
) )
assert watched.status_code == 200, watched.text assert watched.status_code == 200, watched.text
assert watched.json()["status"] == "watched" assert watched.json()["status"] == "watched"
@@ -476,7 +505,15 @@ class TestPlaylistAndCollectionEndpoints:
assert row.status == "watched" assert row.status == "watched"
# -> new deletes the state row # -> new deletes the state row
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"}) back = _request(
db,
user,
monkeypatch,
override_dep,
"POST",
"/api/plex/item/m1/state",
{"status": "new"},
)
assert back.status_code == 200, back.text assert back.status_code == 200, back.text
assert ( assert (
db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first()
+35 -48
View File
@@ -11,12 +11,12 @@ from app.main import app
from app.models import User from app.models import User
def _client(db, user, monkeypatch): def _client(db, user, monkeypatch, override_dep):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this override_dep(current_user, lambda: user) # require_human resolves through this
return TestClient(app) return TestClient(app)
@@ -27,56 +27,43 @@ def _user(db):
return u return u
def test_views_are_scoped_by_source(db, monkeypatch): def test_views_are_scoped_by_source(db, monkeypatch, override_dep):
user = _user(db) user = _user(db)
try: c = _client(db, user, monkeypatch, override_dep)
c = _client(db, user, monkeypatch) # Same NAME under two sources is allowed (unique is per user+source now).
# Same NAME under two sources is allowed (unique is per user+source now). f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
f = c.post( p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
"/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"} assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
)
p = c.post(
"/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"}
)
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
feed = c.get("/api/saved-views?source=feed").json() feed = c.get("/api/saved-views?source=feed").json()
plex = c.get("/api/saved-views?source=plex").json() plex = c.get("/api/saved-views?source=plex").json()
assert [v["name"] for v in feed] == ["Fresh"] assert [v["name"] for v in feed] == ["Fresh"]
assert [v["name"] for v in plex] == ["Fresh"] assert [v["name"] for v in plex] == ["Fresh"]
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2} assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
# A source-less GET keeps the old feed-only behaviour. # A source-less GET keeps the old feed-only behaviour.
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed] assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
finally:
app.dependency_overrides.clear()
def test_default_is_per_source(db, monkeypatch): def test_default_is_per_source(db, monkeypatch, override_dep):
user = _user(db) user = _user(db)
try: c = _client(db, user, monkeypatch, override_dep)
c = _client(db, user, monkeypatch) fid = c.post("/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}).json()[
fid = c.post( "id"
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"} ]
).json()["id"] pid = c.post("/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}).json()[
pid = c.post( "id"
"/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"} ]
).json()["id"] c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
c.patch(f"/api/saved-views/{fid}", json={"is_default": True}) c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
c.patch(f"/api/saved-views/{pid}", json={"is_default": True}) # Making the Plex view default must NOT clear the feed view's default (different source).
# Making the Plex view default must NOT clear the feed view's default (different source). assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
finally:
app.dependency_overrides.clear()
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch): def test_duplicate_name_within_a_source_conflicts(db, monkeypatch, override_dep):
user = _user(db) user = _user(db)
try: c = _client(db, user, monkeypatch, override_dep)
c = _client(db, user, monkeypatch) a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"}) b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"}) assert a.status_code == 200
assert a.status_code == 200 assert b.status_code == 409 # same (user, source, name)
assert b.status_code == 409 # same (user, source, name)
finally:
app.dependency_overrides.clear()
+4 -7
View File
@@ -96,7 +96,7 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
assert c.enriched_at is None assert c.enriched_at is None
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch): def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422 # Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry, # + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds # burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
@@ -145,12 +145,9 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom) monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, lambda: db)
app.dependency_overrides[require_human] = lambda: user override_dep(require_human, lambda: user)
try: resp = TestClient(app).get("/api/search/youtube?q=test+term")
resp = TestClient(app).get("/api/search/youtube?q=test+term")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text assert resp.status_code == 422, resp.text
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"} assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}