2026-09-06 05:18:02 -05:00
|
|
|
"""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]:
|
2026-09-06 07:45:39 -05:00
|
|
|
# FAKE_MOVIE's embedded video id and imdb_id are fixed identifiers
|
|
|
|
|
# meant to be globally unique per real TMDb movie -- reusing them
|
|
|
|
|
# unmodified across multiple synthetic movies here would collide
|
|
|
|
|
# on uq_video_provider_source / uq_external_id_source. Unlike
|
|
|
|
|
# every other test file (db_session rolls back per test),
|
|
|
|
|
# refresh_trending's session_scope() commits are never rolled
|
|
|
|
|
# back, so that collision would otherwise permanently poison the
|
|
|
|
|
# shared test database for every later test in the same run that
|
|
|
|
|
# also ingests FAKE_MOVIE unmodified (see test_watch_providers.py).
|
|
|
|
|
return {
|
|
|
|
|
**FAKE_MOVIE,
|
|
|
|
|
"id": int(external_id),
|
|
|
|
|
"title": f"Movie {external_id}",
|
|
|
|
|
"videos": {"results": []},
|
|
|
|
|
"imdb_id": None,
|
|
|
|
|
"external_ids": {},
|
|
|
|
|
}
|
2026-09-06 05:18:02 -05:00
|
|
|
|
|
|
|
|
async def get_series(self, external_id: str) -> dict[str, Any]:
|
2026-09-06 07:45:39 -05:00
|
|
|
# Same reasoning as get_movie above, for FAKE_SERIES's imdb_id/tvdb_id.
|
|
|
|
|
return {
|
|
|
|
|
**FAKE_SERIES,
|
|
|
|
|
"id": int(external_id),
|
|
|
|
|
"name": f"Series {external_id}",
|
|
|
|
|
"external_ids": {},
|
|
|
|
|
}
|
2026-09-06 05:18:02 -05:00
|
|
|
|
|
|
|
|
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
|