"""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