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.
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a
|
|
popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a
|
|
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
|
|
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
|
|
"""
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.state as state
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.main import app
|
|
from app.models import PlexItem, PlexLibrary, PlexPerson, User
|
|
from app.plex import tmdb as plex_tmdb
|
|
|
|
PAYLOAD = {
|
|
"biography": " A celebrated actor. ",
|
|
"birthday": "1970-01-01",
|
|
"deathday": None,
|
|
"place_of_birth": "Springfield",
|
|
"known_for_department": "Acting",
|
|
"also_known_as": ["AKA One", "AKA Two"],
|
|
"combined_credits": {
|
|
"cast": [
|
|
{"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50},
|
|
{"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80},
|
|
{"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id
|
|
{"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1},
|
|
{"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
def test_parse_person_shapes_bio_and_ranked_known_for():
|
|
p = plex_tmdb.parse_person(PAYLOAD)
|
|
assert p["biography"] == "A celebrated actor." # stripped
|
|
assert p["birthday"] == "1970-01-01"
|
|
assert p["known_for_department"] == "Acting"
|
|
assert p["also_known_as"] == ["AKA One", "AKA Two"]
|
|
# Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1).
|
|
assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30]
|
|
assert p["known_for"][0]["media_type"] == "tv"
|
|
|
|
|
|
def test_parse_person_empty_payload():
|
|
p = plex_tmdb.parse_person({})
|
|
assert p["biography"] is None and p["known_for"] is None
|
|
|
|
|
|
def test_parse_person_keeps_same_id_across_media_types():
|
|
# TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works
|
|
# and BOTH must survive the dedup (keyed on (id, media_type), not the bare id).
|
|
payload = {
|
|
"combined_credits": {
|
|
"cast": [
|
|
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
|
|
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
|
|
{
|
|
"id": 42,
|
|
"media_type": "movie",
|
|
"title": "dup movie 42",
|
|
"popularity": 5,
|
|
}, # real dup
|
|
]
|
|
}
|
|
}
|
|
kf = plex_tmdb.parse_person(payload)["known_for"]
|
|
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [
|
|
(42, "movie"),
|
|
(42, "tv"),
|
|
] # both kept, dup dropped
|
|
|
|
|
|
class _FakeTmdb:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def fetch_person(self, pid):
|
|
return self.payload
|
|
|
|
|
|
def test_enrich_person_persists_and_stamps(db, monkeypatch):
|
|
person = PlexPerson(name="Actor X", tmdb_person_id=555)
|
|
db.add(person)
|
|
db.commit()
|
|
monkeypatch.setattr(
|
|
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
|
|
)
|
|
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdb(PAYLOAD))
|
|
assert plex_tmdb.enrich_person(db, person) is True
|
|
assert person.biography == "A celebrated actor."
|
|
assert person.tmdb_enriched_at is not None
|
|
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
|
|
|
|
|
|
class _FakeTmdbResult:
|
|
"""Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload."""
|
|
|
|
def __init__(self, *, raises=False, payload=None):
|
|
self._raises = raises
|
|
self._payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def fetch_person(self, pid):
|
|
if self._raises:
|
|
raise plex_tmdb.TmdbError("429 rate limited")
|
|
return self._payload
|
|
|
|
|
|
def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
|
|
# A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently
|
|
# blank a person after one blip).
|
|
person = PlexPerson(name="Transient P", tmdb_person_id=555)
|
|
db.add(person)
|
|
db.commit()
|
|
monkeypatch.setattr(
|
|
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
|
|
)
|
|
monkeypatch.setattr(
|
|
plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True)
|
|
)
|
|
assert plex_tmdb.enrich_person(db, person) is False
|
|
assert person.tmdb_enriched_at is None # left NULL → retried next open
|
|
|
|
|
|
def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
|
|
# A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open.
|
|
person = PlexPerson(name="Gone P", tmdb_person_id=555)
|
|
db.add(person)
|
|
db.commit()
|
|
monkeypatch.setattr(
|
|
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
|
|
)
|
|
monkeypatch.setattr(
|
|
plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None)
|
|
)
|
|
assert plex_tmdb.enrich_person(db, person) is True
|
|
assert person.tmdb_enriched_at is not None # stamped
|
|
assert person.biography is None # nothing fetched
|
|
|
|
|
|
def test_enrich_person_noop_without_key(db, monkeypatch):
|
|
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
|
|
db.add(person)
|
|
db.commit()
|
|
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key
|
|
assert plex_tmdb.enrich_person(db, person) is False
|
|
assert person.tmdb_enriched_at is None
|
|
|
|
|
|
def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
|
|
person = PlexPerson(name="Actor Z", tmdb_person_id=None)
|
|
db.add(person)
|
|
db.commit()
|
|
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key")
|
|
assert plex_tmdb.enrich_person(db, person) is False
|
|
|
|
|
|
def _get(db, user, monkeypatch, override_dep, path):
|
|
monkeypatch.setattr(state, "_configured_cache", True)
|
|
override_dep(get_db, lambda: db)
|
|
override_dep(current_user, lambda: user)
|
|
return TestClient(app).get(path)
|
|
|
|
|
|
def 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).
|
|
from app.routes.plex import people
|
|
|
|
lib = PlexLibrary(plex_key="9", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add(PlexPerson(name="Lazy P", tmdb_person_id=777))
|
|
user = User(email="lazybg@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
scheduled: list[int] = []
|
|
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, 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
|
|
|
|
|
|
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
|
|
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
|
|
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
|
|
from app.routes.plex import people
|
|
|
|
calls = {"rollback": 0, "close": 0, "enriched": 0}
|
|
|
|
class _FakeSession:
|
|
def __init__(self, person):
|
|
self._person = person
|
|
|
|
def get(self, model, pid):
|
|
return self._person
|
|
|
|
def rollback(self):
|
|
calls["rollback"] += 1
|
|
|
|
def close(self):
|
|
calls["close"] += 1
|
|
|
|
# Success path: person found → enrich called, session closed, no rollback.
|
|
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
|
|
monkeypatch.setattr(
|
|
people.plex_tmdb,
|
|
"enrich_person",
|
|
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
|
|
)
|
|
people._enrich_person_bg(5)
|
|
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
|
|
|
|
# Error path: enrich raises → rollback + close, no exception escapes.
|
|
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
|
|
|
def _boom(db, p):
|
|
raise RuntimeError("enrich blew up")
|
|
|
|
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
|
|
people._enrich_person_bg(5) # must not raise
|
|
assert calls["rollback"] == 1 and calls["close"] == 1
|
|
|
|
# Missing person (deleted id): None-guard skips enrich, session still closed.
|
|
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
|
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
|
|
people._enrich_person_bg(9)
|
|
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
|
|
|
|
|
|
def test_should_schedule_enrich_dedups_within_cooldown():
|
|
# Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window.
|
|
from app.routes.plex import people
|
|
|
|
people._enrich_inflight.clear()
|
|
assert people._should_schedule_enrich(12345) is True # first open reserves
|
|
assert people._should_schedule_enrich(12345) is False # within cooldown → deduped
|
|
assert people._should_schedule_enrich(99999) is True # a different person is independent
|
|
people._enrich_inflight.clear()
|
|
|
|
|
|
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
|
|
|
|
from app.routes.plex import people
|
|
|
|
lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add(
|
|
PlexPerson(
|
|
name="Done P",
|
|
tmdb_person_id=888,
|
|
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
biography="Bio.",
|
|
)
|
|
)
|
|
user = User(email="donebg@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
scheduled: list[int] = []
|
|
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
|
|
|
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, override_dep):
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
|
|
db.add(
|
|
PlexItem(
|
|
rating_key="km",
|
|
library_id=lib.id,
|
|
kind="movie",
|
|
title="Known Movie",
|
|
guid="tmdb:100",
|
|
cast_names=["Actor X"],
|
|
)
|
|
)
|
|
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
|
|
from datetime import datetime, timezone
|
|
|
|
db.add(
|
|
PlexPerson(
|
|
name="Actor X",
|
|
tmdb_person_id=555,
|
|
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
biography="Bio here.",
|
|
birthday="1970-01-01",
|
|
known_for_department="Acting",
|
|
also_known_as=["Alt Name"],
|
|
known_for=[
|
|
{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
|
|
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"},
|
|
],
|
|
)
|
|
)
|
|
user = User(email="personbio@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
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."
|
|
assert j["birthday"] == "1970-01-01"
|
|
assert j["also_known_as"] == ["Alt Name"]
|
|
# Only the in-library known_for credit resolves to a card; the out-of-library one is dropped.
|
|
assert [c["title"] for c in j["known_for"]] == ["Known Movie"]
|