Add trending movies/series, refreshed on a schedule
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
Mirrors TMDb's own /trending/{movie|tv}/{day|week} ranking rather than
Aurora computing trending itself from popularity deltas -- that would
require re-syncing the entire catalog on a schedule, infeasible at any
real scale, whereas TMDb already computes this well from signals (real
view/search velocity) Aurora has no access to.
New: TrendingEntry model + migration 0003, MetadataProvider.get_trending
(concrete/optional, like aclose(), so existing test fakes don't all need
updating), refresh_trending() which actually ingests each trending item
(only ~20 per window, cheap enough to fully ingest rather than apply the
never-fabricate-a-stub rule recommendations uses), a twice-daily arq cron
job (WorkerSettings.cron_jobs, new to this codebase), a manual admin
trigger for right after a deploy, and GET /movies/trending + GET
/series/trending.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6c95e18aad
commit
4403305da6
@@ -0,0 +1,165 @@
|
||||
"""Integration tests for trending (section 17, additive): Aurora mirrors
|
||||
TMDb's own /trending/{media_type}/{time_window} ranking rather than
|
||||
computing its own from popularity deltas (see app/models/trending.py) --
|
||||
refresh_trending ingests/refreshes each item and replaces the window's
|
||||
ranked snapshot.
|
||||
|
||||
refresh_trending manages its own per-item session_scope() (so one failing
|
||||
item doesn't roll back others already committed -- see its docstring),
|
||||
so these tests read back through session_scope() too rather than the
|
||||
db_session fixture, same convention as test_ingestion_retry.py: db_session
|
||||
is accepted only to trigger the Postgres-reachability skip, real reads and
|
||||
writes all go through session_scope() to stay on a connection that
|
||||
actually sees committed data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.providers.base import MetadataProvider
|
||||
from app.repositories.trending_repository import get_trending_entity_ids, replace_trending
|
||||
from app.services import movie_service, tv_service
|
||||
from app.services.ingestion.trending_ingestion import refresh_trending
|
||||
from tests.unit.test_normalizer import FAKE_MOVIE, FAKE_SERIES
|
||||
|
||||
|
||||
class FakeTrendingProvider(MetadataProvider):
|
||||
slug = "tmdb"
|
||||
name = "The Movie Database (TMDb)"
|
||||
|
||||
def __init__(self, movie_ids: list[int], series_ids: list[int]) -> None:
|
||||
self._movie_ids = movie_ids
|
||||
self._series_ids = series_ids
|
||||
|
||||
async def get_movie(self, external_id: str) -> dict[str, Any]:
|
||||
return {**FAKE_MOVIE, "id": int(external_id), "title": f"Movie {external_id}"}
|
||||
|
||||
async def get_series(self, external_id: str) -> dict[str, Any]:
|
||||
return {**FAKE_SERIES, "id": int(external_id), "name": f"Series {external_id}"}
|
||||
|
||||
async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_person(self, external_id: str) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_collection(self, external_id: str) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def search_movie(self, query: str) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_trending(self, media_type: str, time_window: str) -> list[dict[str, Any]]:
|
||||
ids = self._movie_ids if media_type == "movie" else self._series_ids
|
||||
return [{"id": i} for i in ids]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_trending_ingests_and_ranks_movies(db_session: AsyncSession):
|
||||
provider = FakeTrendingProvider(movie_ids=[901, 902, 903], series_ids=[])
|
||||
|
||||
count = await refresh_trending(provider, "movie", "day")
|
||||
assert count == 3
|
||||
|
||||
async with session_scope() as session:
|
||||
ids = await get_trending_entity_ids(session, entity_type="movie", time_window="day")
|
||||
assert len(ids) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_trending_preserves_tmdb_rank_order(db_session: AsyncSession):
|
||||
provider = FakeTrendingProvider(movie_ids=[901, 902, 903], series_ids=[])
|
||||
await refresh_trending(provider, "movie", "day")
|
||||
|
||||
async with session_scope() as session:
|
||||
out = await movie_service.list_trending(session, time_window="day", page=1, page_size=20)
|
||||
|
||||
assert [m.title for m in out.results] == ["Movie 901", "Movie 902", "Movie 903"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_trending_is_scoped_per_window(db_session: AsyncSession):
|
||||
"""A "week" refresh must not appear in "day" results, and vice versa."""
|
||||
|
||||
day_provider = FakeTrendingProvider(movie_ids=[901], series_ids=[])
|
||||
week_provider = FakeTrendingProvider(movie_ids=[902], series_ids=[])
|
||||
await refresh_trending(day_provider, "movie", "day")
|
||||
await refresh_trending(week_provider, "movie", "week")
|
||||
|
||||
async with session_scope() as session:
|
||||
day_out = await movie_service.list_trending(session, time_window="day", page=1, page_size=20)
|
||||
week_out = await movie_service.list_trending(session, time_window="week", page=1, page_size=20)
|
||||
|
||||
assert [m.title for m in day_out.results] == ["Movie 901"]
|
||||
assert [m.title for m in week_out.results] == ["Movie 902"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_trending_replaces_stale_entries(db_session: AsyncSession):
|
||||
"""A movie that falls off trending must not linger from a prior
|
||||
refresh -- this is a wholesale replace, not an accumulate."""
|
||||
|
||||
provider = FakeTrendingProvider(movie_ids=[901, 902], series_ids=[])
|
||||
await refresh_trending(provider, "movie", "day")
|
||||
|
||||
provider_v2 = FakeTrendingProvider(movie_ids=[903], series_ids=[])
|
||||
await refresh_trending(provider_v2, "movie", "day")
|
||||
|
||||
async with session_scope() as session:
|
||||
out = await movie_service.list_trending(session, time_window="day", page=1, page_size=20)
|
||||
assert [m.title for m in out.results] == ["Movie 903"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_trending_ingests_series(db_session: AsyncSession):
|
||||
provider = FakeTrendingProvider(movie_ids=[], series_ids=[1901, 1902])
|
||||
count = await refresh_trending(provider, "tv", "day")
|
||||
assert count == 2
|
||||
|
||||
async with session_scope() as session:
|
||||
out = await tv_service.list_trending(session, time_window="day", page=1, page_size=20)
|
||||
assert [s.title for s in out.results] == ["Series 1901", "Series 1902"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_trending_paginates(db_session: AsyncSession):
|
||||
provider = FakeTrendingProvider(movie_ids=[901, 902, 903, 904], series_ids=[])
|
||||
await refresh_trending(provider, "movie", "day")
|
||||
|
||||
async with session_scope() as session:
|
||||
page1 = await movie_service.list_trending(session, time_window="day", page=1, page_size=2)
|
||||
page2 = await movie_service.list_trending(session, time_window="day", page=2, page_size=2)
|
||||
|
||||
assert page1.total_results == 4
|
||||
assert page1.total_pages == 2
|
||||
assert [m.title for m in page1.results] == ["Movie 901", "Movie 902"]
|
||||
assert [m.title for m in page2.results] == ["Movie 903", "Movie 904"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_trending_rejects_unknown_window(db_session: AsyncSession):
|
||||
async with session_scope() as session:
|
||||
with pytest.raises(movie_service.InvalidMovieFilterError):
|
||||
await movie_service.list_trending(session, time_window="month", page=1, page_size=20)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_trending_returns_empty_when_never_refreshed(db_session: AsyncSession):
|
||||
# Unlike the other tests here, this one's precondition is "no trending
|
||||
# data exists yet" -- every other test calls refresh_trending, which
|
||||
# always wipes its own (entity_type, time_window) scope before writing,
|
||||
# making the prior state irrelevant. This one has no such call, so it
|
||||
# must clear the scope itself rather than assume a clean slate (these
|
||||
# tests commit for real via session_scope(), unlike db_session's
|
||||
# per-test rollback, so a prior test's rows would otherwise leak in).
|
||||
async with session_scope() as session:
|
||||
await replace_trending(session, entity_type="movie", time_window="day", entity_ids=[])
|
||||
|
||||
async with session_scope() as session:
|
||||
out = await movie_service.list_trending(session, time_window="day", page=1, page_size=20)
|
||||
assert out.results == []
|
||||
assert out.total_results == 0
|
||||
Reference in New Issue
Block a user