From 8759b5860db87441364a78063b054e10b7ef952e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 17:26:07 +0200 Subject: [PATCH] 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. --- backend/tests/test_channel_backfill.py | 52 ++---- backend/tests/test_channel_tags.py | 13 +- backend/tests/test_feed_cursor.py | 21 +-- backend/tests/test_hls_reaper.py | 28 ++- backend/tests/test_override_dep_convention.py | 23 +++ backend/tests/test_plex_clear_resume_push.py | 25 ++- backend/tests/test_plex_collection_sort.py | 47 +++-- backend/tests/test_plex_collections_filter.py | 171 ++++++++---------- backend/tests/test_plex_detail.py | 31 ++-- backend/tests/test_plex_discover.py | 79 ++++---- backend/tests/test_plex_facets.py | 24 +-- .../tests/test_plex_field_scoped_search.py | 65 ++++--- backend/tests/test_plex_hls_file_route.py | 9 +- backend/tests/test_plex_keyword_filter.py | 25 ++- backend/tests/test_plex_lang.py | 49 ++--- backend/tests/test_plex_last_watched_sort.py | 35 ++-- backend/tests/test_plex_person.py | 19 +- backend/tests/test_plex_person_bio.py | 23 +-- backend/tests/test_plex_playback_class.py | 89 ++++----- backend/tests/test_plex_playback_session.py | 23 +-- backend/tests/test_plex_query_conversion.py | 121 ++++++++----- backend/tests/test_saved_views_source.py | 83 ++++----- backend/tests/test_search_scrape_ingest.py | 11 +- 23 files changed, 537 insertions(+), 529 deletions(-) create mode 100644 backend/tests/test_override_dep_convention.py diff --git a/backend/tests/test_channel_backfill.py b/backend/tests/test_channel_backfill.py index d1244641..5520a3a1 100644 --- a/backend/tests/test_channel_backfill.py +++ b/backend/tests/test_channel_backfill.py @@ -18,7 +18,7 @@ from app.sync import runner, videos _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). ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB) 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). monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: me - try: - resp = TestClient(app).get(f"/api/channels/{ch.id}") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: me) + resp = TestClient(app).get(f"/api/channels/{ch.id}") assert resp.status_code == 200, resp.text body = resp.json() @@ -56,7 +53,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch): @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 # 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) @@ -68,18 +65,15 @@ def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidde db.commit() monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: me - try: - resp = TestClient(app).get(f"/api/channels/{ch.id}") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: me) + resp = TestClient(app).get(f"/api/channels/{ch.id}") assert resp.status_code == 200, resp.text 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. ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB) me = User(email="hidns@example.com") @@ -87,12 +81,9 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc db.commit() monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: me - try: - resp = TestClient(app).get(f"/api/channels/{ch.id}") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: me) + resp = TestClient(app).get(f"/api/channels/{ch.id}") assert resp.status_code == 200, resp.text assert resp.json()["hidden"] is False @@ -115,33 +106,30 @@ def _setup_channel_and_sub(db, *, email, is_demo=False): return ch, user -def _patch_deep_requested(db, monkeypatch, user): +def _patch_deep_requested(db, monkeypatch, override_dep, user): calls: list[tuple] = [] # 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(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True}) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True}) 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") - 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.json()["deep_requested"] is True # scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs. 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). _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 calls == [], "demo must not schedule an immediate backfill" # …and deep_requested is not persisted on the shared demo subscription either. diff --git a/backend/tests/test_channel_tags.py b/backend/tests/test_channel_tags.py index de7e9643..49bad533 100644 --- a/backend/tests/test_channel_tags.py +++ b/backend/tests/test_channel_tags.py @@ -17,7 +17,7 @@ from app.main import app 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). ch = Channel( 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). monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: me - try: - client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) - resp = client.get(f"/api/channels/{ch.id}") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: me) + client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) + resp = client.get(f"/api/channels/{ch.id}") assert resp.status_code == 200, resp.text assert resp.json()["tag_ids"] == [mine.id] diff --git a/backend/tests/test_feed_cursor.py b/backend/tests/test_feed_cursor.py index f90c470f..0c1fcd5a 100644 --- a/backend/tests/test_feed_cursor.py +++ b/backend/tests/test_feed_cursor.py @@ -15,34 +15,31 @@ from app.main import app from app.models import User -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr( state, "_configured_cache", True ) # the request gate 503s an unconfigured app - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) - return client.get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) + return client.get(path) -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") db.add(user) 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.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 # cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed. user = User(email="feednocursor@example.com") db.add(user) 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.json()["items"] == [] diff --git a/backend/tests/test_hls_reaper.py b/backend/tests/test_hls_reaper.py index b42b2ee4..81a396f5 100644 --- a/backend/tests/test_hls_reaper.py +++ b/backend/tests/test_hls_reaper.py @@ -142,13 +142,15 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"): return it -def _client(db, user): - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user +def _client(db, user, override_dep): + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) 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 `last_access` never moved on its own — but it does keep checkpointing its position.""" monkeypatch.setattr(state, "_configured_cache", True) @@ -158,20 +160,17 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, db.commit() s = _Session(tmp_path, "paused", finished=True, idle_s=90) sessions[(user.id, "paused1")] = s - try: - resp = _client(db, user).post( - f"/api/plex/item/{it.rating_key}/progress", - json={"position_seconds": 600, "duration_seconds": 3000, "final": False}, - ) - finally: - app.dependency_overrides.clear() + resp = _client(db, user, override_dep).post( + f"/api/plex/item/{it.rating_key}/progress", + json={"position_seconds": 600, "duration_seconds": 3000, "final": False}, + ) assert resp.status_code == 200, resp.text 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 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 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) - try: - resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session") - finally: - app.dependency_overrides.clear() + resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session") assert resp.status_code == 200, resp.text assert (999, "someone-else") in sessions and not paused.proc.killed diff --git a/backend/tests/test_override_dep_convention.py b/backend/tests/test_override_dep_convention.py new file mode 100644 index 00000000..d631bfd0 --- /dev/null +++ b/backend/tests/test_override_dep_convention.py @@ -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) diff --git a/backend/tests/test_plex_clear_resume_push.py b/backend/tests/test_plex_clear_resume_push.py index b088a84d..c594bb87 100644 --- a/backend/tests/test_plex_clear_resume_push.py +++ b/backend/tests/test_plex_clear_resume_push.py @@ -42,7 +42,7 @@ def _seed(db, *, status: str, position: int): 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.""" pushes: list[tuple] = [] 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", lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)), ) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"}) - assert resp.status_code == 200, resp.text - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"}) + assert resp.status_code == 200, resp.text 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. 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 -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. 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 # endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on # Plex that the user had merely hidden. it, user = _seed(db, status="hidden", position=0) - assert _clear(db, user, it, monkeypatch) == [] + assert _clear(db, user, it, monkeypatch, override_dep) == [] diff --git a/backend/tests/test_plex_collection_sort.py b/backend/tests/test_plex_collection_sort.py index 431e69f0..255d66a3 100644 --- a/backend/tests/test_plex_collection_sort.py +++ b/backend/tests/test_plex_collection_sort.py @@ -231,61 +231,70 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _order(r) -> list[str]: return [i["title"] for i in r.json()["items"]] -def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch): +def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch, override_dep): """The order the page had before it grew a control — a franchise reads in release order.""" user = _seed(db) - r = _get(db, user, monkeypatch, "/api/plex/collections/col1") + r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/col1") assert r.status_code == 200 assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"] -def test_collection_detail_honours_sort_and_direction(db, monkeypatch): +def test_collection_detail_honours_sort_and_direction(db, monkeypatch, override_dep): 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"] - 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"] - 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". 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 every show as never-watched.""" 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. 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 filter chip have nothing to show but `17045` until these are resolved.""" 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 [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 200 KB answer to a question about zero ids.""" 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.json()["collections"] == [] diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index f8b49bbd..dbfb04b9 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -58,44 +58,44 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _titles(resp): 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) - 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"} -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) - 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"} -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) resp = _get( db, user, monkeypatch, + override_dep, "/api/plex/library?scope=movie&collections=A,B&collection_mode=all", ) 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. lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) @@ -127,14 +127,16 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch): db.commit() card = next( 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" ) assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0) 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 # rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it). 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") db.add(user) db.commit() - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - app.dependency_overrides[admin_user] = lambda: user - try: - r = TestClient(app).post( - "/api/plex/collections", - json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, - ) - assert r.status_code == 409, r.text - assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"} - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + override_dep(admin_user, lambda: user) + r = TestClient(app).post( + "/api/plex/collections", + json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, + ) + assert r.status_code == 409, r.text + assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"} -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) + # members ordered by release year then title. 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") db.add(user) 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["year_min"] == 1992 and d["year_max"] == 1996 assert d["content_rating"] == "PG" # most restrictive of {G, PG} @@ -270,38 +269,32 @@ def _seed_editable(db): 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(coll_routes, "PlexClient", lambda db: stub) monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - app.dependency_overrides[admin_user] = lambda: user + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + override_dep(admin_user, lambda: user) 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) stub = _CollStub() - client = _admin_client(db, user, monkeypatch, stub) - try: - r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) - assert r.status_code == 200, r.text - assert "rename_collection" in stub.calls - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub) + r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) + assert r.status_code == 200, r.text + assert "rename_collection" in stub.calls -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) stub = _CollStub() - client = _admin_client(db, user, monkeypatch, stub) - try: - r = client.delete("/api/plex/collections/A/items/m1") - assert r.status_code == 200, r.text - assert "remove_collection_item" in stub.calls - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub) + r = client.delete("/api/plex/collections/A/items/m1") + assert r.status_code == 200, r.text + assert "remove_collection_item" in stub.calls def _resync_create(db, plex, lib, rk): @@ -312,25 +305,22 @@ def _resync_create(db, plex, lib, rk): 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) stub = _CollStub() - client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create) - try: - r = client.post( - "/api/plex/collections", - json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, - ) - assert r.status_code == 200, r.text - assert "create_collection" in stub.calls - body = r.json() - assert body["id"] == "NEW" # the stub's new ratingKey - assert body["editable"] is True # create marks the new collection editable - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create) + r = client.post( + "/api/plex/collections", + json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, + ) + assert r.status_code == 200, r.text + assert "create_collection" in stub.calls + body = r.json() + assert body["id"] == "NEW" # the stub's new ratingKey + assert body["editable"] is True # create marks the new collection editable -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 honest "reconcile on the next sync" detail, not a false "write failed". _lib, user = _seed_editable(db) @@ -339,34 +329,28 @@ def test_create_resync_failure_is_422(db, monkeypatch): def _boom(db_, plex, lib_, rk): raise PlexError("read failed") - client = _admin_client(db, user, monkeypatch, stub, resync=_boom) - try: - r = client.post( - "/api/plex/collections", - json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, - ) - assert r.status_code == 422, r.text - assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} - assert "create_collection" in stub.calls - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_boom) + r = client.post( + "/api/plex/collections", + json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, + ) + assert r.status_code == 422, r.text + assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} + assert "create_collection" in stub.calls -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 # is never reached (stub.calls holds only the mutating call). _lib, user = _seed_editable(db) stub = _CollStub(fail_on="rename_collection") - client = _admin_client(db, user, monkeypatch, stub) - try: - r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) - assert r.status_code == 422, r.text - assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"} - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub) + r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) + assert r.status_code == 422, r.text + assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"} -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 # 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. @@ -385,21 +369,18 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp db_.flush() raise PlexError("read failed") - client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom) - try: - r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) - assert r.status_code == 422, r.text - assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} - assert "rename_collection" in stub.calls # the write DID happen - finally: - app.dependency_overrides.clear() + client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_boom) + r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) + assert r.status_code == 422, r.text + assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"} + assert "rename_collection" in stub.calls # the write DID happen row = ( db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first() ) 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 # 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) @@ -431,7 +412,9 @@ def test_show_collection_gets_montage_art_from_shows(db, monkeypatch): db.commit() card = next( 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" ) assert card["art"] == ["s2", "s1"] # show members, best-rated first diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index 1cb0d584..a0f47c4e 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -191,14 +191,11 @@ def test_enrich_people_batch_persists_detail(db): # ---- 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 - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _user(db, email): @@ -208,7 +205,7 @@ def _user(db, email): 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 — only markers/streams (which need the live meta) degrade.""" 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() 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 body = resp.json() 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 -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).""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) @@ -261,7 +258,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch): db.commit() 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.json()["imdb_id"] == "tt0068646" 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"] -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 live meta the endpoint fetches for markers/streams.""" 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() 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 body = resp.json() 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 -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.""" lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(lib) @@ -324,7 +321,7 @@ def test_show_detail_serves_from_row(db, monkeypatch): raise RuntimeError("plex unavailable") 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 body = resp.json()["show"] assert body["imdb_rating"] == 9.1 @@ -334,7 +331,7 @@ def test_show_detail_serves_from_row(db, monkeypatch): 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 + stamped) so subsequent views serve from the row.""" 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. 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.json()["show"]["imdb_id"] == "tt0068646" db.refresh(show) diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index f6d52f50..7ad4cc57 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -120,24 +120,21 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - client = TestClient(app) # no context manager: skip lifespan - return client.get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + client = TestClient(app) # no context manager: skip lifespan + return client.get(path) def _ids(cards): return [c["id"] for c in cards] -def test_discover_shape_and_recently_added_order(db, monkeypatch): +def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep): 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 d = resp.json() 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 -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 # movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded. 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"]} assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first 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) - 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"]) # 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). @@ -168,7 +165,7 @@ def test_continue_watching_movies_and_shows(db, monkeypatch): 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 # (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. @@ -207,13 +204,15 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch): db.commit() 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 "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 # 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) @@ -248,14 +247,16 @@ def test_continue_watching_excludes_fully_watched_show(db, monkeypatch): db.commit() 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 -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) - 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"] assert bw is not None 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"] -def test_because_you_watched_null_without_history(db, monkeypatch): +def test_because_you_watched_null_without_history(db, monkeypatch, override_dep): _seed(db) fresh = User(email="fresh-discover@example.com") db.add(fresh) db.commit() - d = _get(db, fresh, monkeypatch, "/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. assert d["because_you_watched"] is None 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) - 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 items = resp.json()["items"] assert len(items) <= 2 assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows -def test_recently_watched_movies_and_episodes(db, monkeypatch): +def test_recently_watched_movies_and_episodes(db, monkeypatch, override_dep): # The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as # the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode. user = _seed(db) ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one() db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW)) db.commit() - rw = _get(db, user, monkeypatch, "/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} assert "m1" in by_id and by_id["m1"]["type"] == "movie" 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"} -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 # in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind. user = _seed(db) - items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"] + items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[ + "items" + ] ids = _ids(items) assert "m1" not in ids # watched → excluded assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles @@ -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 -def test_discover_scope_movie_excludes_shows(db, monkeypatch): +def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep): 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"]) # The show drops out of continue watching under the movie scope. assert _ids(d["continue_watching"]) == ["m3"] -def test_filters_narrow_every_discover_rail(db, monkeypatch): +def test_filters_narrow_every_discover_rail(db, monkeypatch, override_dep): # The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama # filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop. user = _seed(db) - d = _get(db, user, monkeypatch, "/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. assert set(_ids(d["recently_added"])) == {"m1", "m3"} # 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 -def test_filters_narrow_surprise(db, monkeypatch): +def test_filters_narrow_surprise(db, monkeypatch, override_dep): user = _seed(db) - items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[ - "items" - ] + items = _get( + db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy" + ).json()["items"] # Only the one Comedy movie qualifies under the filter. assert _ids(items) == ["m2"] diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py index aad23c77..3d4c87e4 100644 --- a/backend/tests/test_plex_facets.py +++ b/backend/tests/test_plex_facets.py @@ -16,7 +16,7 @@ from app.main import app 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.""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) @@ -33,18 +33,15 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch): db.add(user) db.commit() monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - resp = TestClient(app).get("/api/plex/facets?scope=movie") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + resp = TestClient(app).get("/api/plex/facets?scope=movie") assert resp.status_code == 200, resp.text studios = {s["value"]: s["count"] for s in resp.json()["studios"]} 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) db.add(lib) 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.commit() - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) - resp = client.get("/api/plex/facets?scope=show") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) + resp = client.get("/api/plex/facets?scope=show") assert resp.status_code == 200, resp.text # The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint. diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py index 00fefac9..9c590838 100644 --- a/backend/tests/test_plex_field_scoped_search.py +++ b/backend/tests/test_plex_field_scoped_search.py @@ -100,14 +100,11 @@ def _seed(db): return {"user": user} -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _ids(body): @@ -115,55 +112,75 @@ def _ids(body): 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) - 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 assert _ids(body) == ["mp", "ms", "mt"] 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) - default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() - scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + default = _get( + 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 default["total"] == scoped["total"] 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) - 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 body["total"] == 1 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) - 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"] - 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) - 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"] - 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) - 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["total"] == 0 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) 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() # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty assert body["items"] == [] 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) - 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"] diff --git a/backend/tests/test_plex_hls_file_route.py b/backend/tests/test_plex_hls_file_route.py index a7a680c7..127086bb 100644 --- a/backend/tests/test_plex_hls_file_route.py +++ b/backend/tests/test_plex_hls_file_route.py @@ -62,14 +62,11 @@ def sessions(monkeypatch): @pytest.fixture -def client(monkeypatch): +def client(monkeypatch, override_dep): monkeypatch.setattr(state, "_configured_cache", True) user = User(id=VIEWER, email="hls@example.com") - app.dependency_overrides[current_user] = lambda: user - try: - yield TestClient(app) - finally: - app.dependency_overrides.pop(current_user, None) + override_dep(current_user, lambda: user) + return TestClient(app) def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions): diff --git a/backend/tests/test_plex_keyword_filter.py b/backend/tests/test_plex_keyword_filter.py index c0ffc3a0..930d3407 100644 --- a/backend/tests/test_plex_keyword_filter.py +++ b/backend/tests/test_plex_keyword_filter.py @@ -41,34 +41,33 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _titles(resp): 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) - 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 _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) - 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"} -def test_unknown_keyword_matches_nothing(db, monkeypatch): +def test_unknown_keyword_matches_nothing(db, monkeypatch, override_dep): 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"] == [] diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 657cfc4a..9600cbfc 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -56,54 +56,55 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr( state, "_configured_cache", True ) # the request gate 503s an unconfigured app - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - client = TestClient(app) # no context manager: skip lifespan - return client.get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + client = TestClient(app) # no context manager: skip lifespan + return client.get(path) def _titles(resp): 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) - hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu") - en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en") + hu = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu") + 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. assert _titles(hu) == {"Magyar Cím", "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) - hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu") - en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en") + hu = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu") + en = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=en") hu_genres = {g["value"] for g in hu.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 "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) # 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") - miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma") + hit = _get( + 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 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) - 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() assert body["title"] == "Magyar Cím" assert body["summary"] == "Magyar összefoglaló." @@ -112,15 +113,15 @@ def test_item_detail_language_and_rating_split(db, monkeypatch): 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) # 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. - 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) -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) db.add(lib) db.flush() @@ -142,7 +143,7 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch): user = User(email="rating@example.com") db.add(user) 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) - 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) diff --git a/backend/tests/test_plex_last_watched_sort.py b/backend/tests/test_plex_last_watched_sort.py index 60060591..6839c9ee 100644 --- a/backend/tests/test_plex_last_watched_sort.py +++ b/backend/tests/test_plex_last_watched_sort.py @@ -111,44 +111,53 @@ def _seed(db): return user -def _get(db, user, monkeypatch, path): +def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) def _ids(resp): 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) 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 # 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"] -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) 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 # Recent resume first, then the watched-only show, then the never-watched one (nullslast). 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) 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 ids = _ids(resp) diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 760aac7c..2d1b4bad 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -25,17 +25,14 @@ from app.plex.query import content_libs 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 - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep) -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) shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) 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.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 body = resp.json() @@ -106,7 +103,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch): 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) db.add(lib) db.flush() @@ -123,7 +120,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch): db.add(user) 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 body = resp.json() # An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null). diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index 92985d66..532a9e19 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -168,17 +168,14 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch): 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) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).get(path) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).get(path) -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 # 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). @@ -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)) 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.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 @@ -260,7 +257,7 @@ def test_should_schedule_enrich_dedups_within_cooldown(): 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. from datetime import datetime, timezone @@ -284,13 +281,13 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch): scheduled: list[int] = [] 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.json()["biography"] == "Bio." 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) db.add(lib) db.flush() @@ -327,7 +324,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch): db.add(user) 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 j = resp.json() assert j["biography"] == "Bio here." diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py index 627c09c2..07788930 100644 --- a/backend/tests/test_plex_playback_class.py +++ b/backend/tests/test_plex_playback_class.py @@ -347,47 +347,41 @@ def _seed_classes(db): return lib -def _client(db, user, monkeypatch): +def _client(db, user, monkeypatch, override_dep): monkeypatch.setattr(state, "_configured_cache", True) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) 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) user = User(email="pclass@example.com") db.add(user) db.commit() - client = _client(db, user, monkeypatch) - try: - # Software-only transcodes. - sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") - assert sw.status_code == 200, sw.text - assert {i["id"] for i in sw.json()["items"]} == {"tsw"} - # HW transcode + Direct Play, OR-combined. - both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct") - assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"} - # 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") - assert card["transcode_hw"] is True - # No filter → everything. - allm = client.get("/api/plex/library?scope=movie") - assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"} - finally: - app.dependency_overrides.clear() + client = _client(db, user, monkeypatch, override_dep) + # Software-only transcodes. + sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") + assert sw.status_code == 200, sw.text + assert {i["id"] for i in sw.json()["items"]} == {"tsw"} + # HW transcode + Direct Play, OR-combined. + both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct") + assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"} + # 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") + assert card["transcode_hw"] is True + # No filter → everything. + allm = client.get("/api/plex/library?scope=movie") + assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"} -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) user = User(email="pclassfacet@example.com") db.add(user) db.commit() - client = _client(db, user, monkeypatch) - try: - resp = client.get("/api/plex/facets?scope=movie") - finally: - app.dependency_overrides.clear() + client = _client(db, user, monkeypatch, override_dep) + resp = client.get("/api/plex/facets?scope=movie") assert resp.status_code == 200, resp.text playable = resp.json()["playable"] # 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 # 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 @@ -422,20 +416,17 @@ def test_unprobed_transcode_folds_into_sw(db, monkeypatch): user = User(email="pnull@example.com") db.add(user) db.commit() - client = _client(db, user, monkeypatch) - try: - sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") - assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket - hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw") - assert hw.json()["items"] == [] # never HW - facets = client.get("/api/plex/facets?scope=movie") - counts = {p["value"]: p["count"] for p in facets.json()["playable"]} - assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0 - finally: - app.dependency_overrides.clear() + client = _client(db, user, monkeypatch, override_dep) + sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") + assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket + hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw") + assert hw.json()["items"] == [] # never HW + facets = client.get("/api/plex/facets?scope=movie") + counts = {p["value"]: p["count"] for p in facets.json()["playable"]} + assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0 -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), # 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. @@ -443,17 +434,14 @@ def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch): user = User(email="pself@example.com") db.add(user) db.commit() - client = _client(db, user, monkeypatch) - try: - resp = client.get("/api/plex/facets?scope=movie&playable=direct") - finally: - app.dependency_overrides.clear() + client = _client(db, user, monkeypatch, override_dep) + resp = client.get("/api/plex/facets?scope=movie&playable=direct") assert resp.status_code == 200, resp.text counts = {p["value"]: p["count"] for p in resp.json()["playable"]} 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 # 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. @@ -485,11 +473,8 @@ def test_active_playable_narrows_the_other_facets(db, monkeypatch): user = User(email="pnarrow@example.com") db.add(user) db.commit() - client = _client(db, user, monkeypatch) - try: - wide = client.get("/api/plex/facets?scope=movie").json() - narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json() - finally: - app.dependency_overrides.clear() + client = _client(db, user, monkeypatch, override_dep) + wide = client.get("/api/plex/facets?scope=movie").json() + narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json() 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 diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index c868e329..ef22ad07 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -48,7 +48,7 @@ def _user(db, email="play@example.com"): 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 was called with into `captured` and returns a stand-in session echoing it back.""" 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) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - return TestClient(app).post(f"/api/plex/stream/{rating_key}/session") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + return TestClient(app).post(f"/api/plex/stream/{rating_key}/session") -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. it = _item(db, playable="transcode") user = _user(db) 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 body = resp.json() 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 -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. it = _item(db, playable="remux", rating_key="m2") user = _user(db, "remux@example.com") 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 body = resp.json() assert body["mode"] == "hls" @@ -101,12 +98,12 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch): 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. it = _item(db, playable="direct", rating_key="m3") user = _user(db, "direct@example.com") 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.json()["mode"] == "direct" assert "transcode" not in captured # start_session not called diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index b8e63db9..a0300882 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -96,27 +96,26 @@ def _seed(db): 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( state, "_configured_cache", True ) # the request gate 503s an unconfigured app - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - try: - client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) - return client.request(method, path, json=json) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) + client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) + return client.request(method, path, json=json) -def _get(db, user, monkeypatch, path): - return _request(db, user, monkeypatch, "GET", path) +def _get(db, user, monkeypatch, override_dep, path): + return _request(db, user, monkeypatch, override_dep, "GET", path) class TestUnifiedLibrary: - def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): + def test_both_scope_unions_movies_and_shows_ordered_by_added( + self, db, monkeypatch, override_dep + ): s = _seed(db) - r = _get(db, s["user"], monkeypatch, "/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 body = r.json() assert body["total"] == 3 # 2 movies + 1 show, unioned @@ -127,43 +126,53 @@ class TestUnifiedLibrary: assert show_item["season_count"] == 1 assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" - def test_scope_movie_only(self, db, monkeypatch): + def test_scope_movie_only(self, db, monkeypatch, override_dep): 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 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) - 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 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) # 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"] # 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"] # unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.) - unwatched = _get(db, s["user"], monkeypatch, "/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"] - 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) - 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" assert [i["id"] for i in body["items"]] == ["s1"] assert body["total"] == 1 # matching episodes come back grouped in the separate episodes list assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] - def test_pagination_caps_items_not_total(self, db, monkeypatch): + def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep): 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 [i["id"] for i in body["items"]] == [ "s1", @@ -267,7 +276,7 @@ class TestFacetsWithData: JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only covers the null-genre guard; this locks the aggregated numbers with real data.""" - def test_facet_aggregation(self, db, monkeypatch): + def test_facet_aggregation(self, db, monkeypatch, override_dep): movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add_all([movie_lib, show_lib]) @@ -311,7 +320,7 @@ class TestFacetsWithData: db.add(user) 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 {c["value"]: c["count"] for c in body["content_ratings"]} == { "PG": 1, @@ -332,7 +341,7 @@ class TestPlaylistAndCollectionEndpoints: def _movie(self, db, lib_id, rk, title): return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title) - def test_playlist_crud_roundtrip(self, db, monkeypatch): + def test_playlist_crud_roundtrip(self, db, monkeypatch, override_dep): lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() @@ -348,6 +357,7 @@ class TestPlaylistAndCollectionEndpoints: db, user, monkeypatch, + override_dep, "POST", "/api/plex/playlists", {"title": "Faves", "item_rating_key": "m1"}, @@ -357,7 +367,9 @@ class TestPlaylistAndCollectionEndpoints: assert created.json()["item_count"] == 1 # 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)] # add m2, then reorder to [m2, m1] @@ -365,6 +377,7 @@ class TestPlaylistAndCollectionEndpoints: db, user, monkeypatch, + override_dep, "POST", f"/api/plex/playlists/{pid}/items", {"item_rating_key": "m2"}, @@ -374,24 +387,30 @@ class TestPlaylistAndCollectionEndpoints: db, user, monkeypatch, + override_dep, "PUT", f"/api/plex/playlists/{pid}/order", {"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 # 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 - 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"] # delete the playlist -> gone from the list - _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") - assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] + _request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}") + assert ( + _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"] + == [] + ) - def test_playlist_bulk_add_and_remove(self, db, monkeypatch): + def test_playlist_bulk_add_and_remove(self, db, monkeypatch, override_dep): # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's # synchronize_session=False path, which the single add/remove roundtrip never exercises. lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) @@ -402,13 +421,16 @@ class TestPlaylistAndCollectionEndpoints: db.add(user) 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"] added = _request( db, user, monkeypatch, + override_dep, "POST", f"/api/plex/playlists/{pid}/items/bulk", {"item_rating_keys": ["m1", "m2", "m3"]}, @@ -420,6 +442,7 @@ class TestPlaylistAndCollectionEndpoints: db, user, monkeypatch, + override_dep, "POST", f"/api/plex/playlists/{pid}/items/remove-bulk", {"item_rating_keys": ["m2"]}, @@ -427,13 +450,13 @@ class TestPlaylistAndCollectionEndpoints: assert removed.status_code == 200, removed.text 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"]] == [ "m1", "m3", ] # 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 lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) @@ -450,13 +473,13 @@ class TestPlaylistAndCollectionEndpoints: db.commit() # 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"] # 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"] - 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 # is what the conversion touched (the st lookup + the delete on ->new). lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) @@ -468,7 +491,13 @@ class TestPlaylistAndCollectionEndpoints: db.commit() 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.json()["status"] == "watched" @@ -476,7 +505,15 @@ class TestPlaylistAndCollectionEndpoints: assert row.status == "watched" # -> 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 ( db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py index ae8b0f7d..7db013f9 100644 --- a/backend/tests/test_saved_views_source.py +++ b/backend/tests/test_saved_views_source.py @@ -11,12 +11,12 @@ from app.main import app from app.models import User -def _client(db, user, monkeypatch): +def _client(db, user, monkeypatch, override_dep): monkeypatch.setattr( state, "_configured_cache", True ) # the request gate 503s an unconfigured app - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user # require_human resolves through this + override_dep(get_db, lambda: db) + override_dep(current_user, lambda: user) # require_human resolves through this return TestClient(app) @@ -27,56 +27,43 @@ def _user(db): 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) - try: - c = _client(db, user, monkeypatch) - # 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"} - ) - 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) + c = _client(db, user, monkeypatch, override_dep) + # 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"}) + 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() - plex = c.get("/api/saved-views?source=plex").json() - assert [v["name"] for v in feed] == ["Fresh"] - assert [v["name"] for v in plex] == ["Fresh"] - assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2} - # 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] - finally: - app.dependency_overrides.clear() + feed = c.get("/api/saved-views?source=feed").json() + plex = c.get("/api/saved-views?source=plex").json() + assert [v["name"] for v in feed] == ["Fresh"] + assert [v["name"] for v in plex] == ["Fresh"] + assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2} + # 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] -def test_default_is_per_source(db, monkeypatch): +def test_default_is_per_source(db, monkeypatch, override_dep): user = _user(db) - try: - c = _client(db, user, monkeypatch) - fid = c.post( - "/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"} - ).json()["id"] - pid = c.post( - "/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/{pid}", json={"is_default": True}) - # 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=plex").json()[0]["is_default"] is True - finally: - app.dependency_overrides.clear() + c = _client(db, user, monkeypatch, override_dep) + fid = c.post("/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}).json()[ + "id" + ] + pid = c.post("/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/{pid}", json={"is_default": True}) + # 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=plex").json()[0]["is_default"] is True -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) - try: - c = _client(db, user, monkeypatch) - a = 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 b.status_code == 409 # same (user, source, name) - finally: - app.dependency_overrides.clear() + c = _client(db, user, monkeypatch, override_dep) + a = 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 b.status_code == 409 # same (user, source, name) diff --git a/backend/tests/test_search_scrape_ingest.py b/backend/tests/test_search_scrape_ingest.py index 5ab47a07..e4f30a43 100644 --- a/backend/tests/test_search_scrape_ingest.py +++ b/backend/tests/test_search_scrape_ingest.py @@ -96,7 +96,7 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db): 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 # + 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 @@ -145,12 +145,9 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch): monkeypatch.setattr(search_mod, "_ingest_candidates", _boom) - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[require_human] = lambda: user - try: - resp = TestClient(app).get("/api/search/youtube?q=test+term") - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + override_dep(require_human, lambda: user) + resp = TestClient(app).get("/api/search/youtube?q=test+term") assert resp.status_code == 422, resp.text assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}