diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index 981f489..4b175bf 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -2,7 +2,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +16,7 @@ from app.schemas.movie import ( MovieCreditsOut, MovieDetailOut, MovieImagesOut, + MovieListOut, MovieRecommendationsOut, MovieVideosOut, ) @@ -23,6 +26,32 @@ from app.services.cache import build_key router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enforce_rate_limit)]) +@router.get("", response_model=MovieListOut) +async def list_movies( + genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."), + year: int | None = Query(None, description="Primary release year"), + status_: str | None = Query(None, alias="status", description="Movie status, e.g. released"), + sort_by: Literal["popularity", "release_date", "vote_average", "title"] = Query("popularity"), + sort_dir: Literal["asc", "desc"] = Query("desc"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=50), + session: AsyncSession = Depends(get_db), +) -> MovieListOut: + try: + return await movie_service.list_movies( + session, + genre_aurora_id=genre, + year=year, + status=status_, + sort_by=sort_by, + sort_dir=sort_dir, + page=page, + page_size=page_size, + ) + except movie_service.InvalidMovieFilterError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + @router.get("/{aurora_id}", response_model=MovieDetailOut) async def get_movie( aurora_id: str, diff --git a/app/api/v1/series.py b/app/api/v1/series.py index 603bc6d..3422e8e 100644 --- a/app/api/v1/series.py +++ b/app/api/v1/series.py @@ -2,7 +2,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession @@ -10,13 +12,39 @@ from app.api.deps import cached_response, not_found from app.core.database import get_db from app.core.redis import get_redis from app.core.security import enforce_rate_limit -from app.schemas.tv import SeriesDetailOut, SeriesSeasonsOut +from app.schemas.tv import SeriesDetailOut, SeriesListOut, SeriesSeasonsOut from app.services import tv_service from app.services.cache import build_key router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enforce_rate_limit)]) +@router.get("", response_model=SeriesListOut) +async def list_series( + genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."), + year: int | None = Query(None, description="First air date year"), + status_: str | None = Query(None, alias="status", description="Series status, e.g. ended"), + sort_by: Literal["popularity", "first_air_date", "vote_average", "title"] = Query("popularity"), + sort_dir: Literal["asc", "desc"] = Query("desc"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=50), + session: AsyncSession = Depends(get_db), +) -> SeriesListOut: + try: + return await tv_service.list_series( + session, + genre_aurora_id=genre, + year=year, + status=status_, + sort_by=sort_by, + sort_dir=sort_dir, + page=page, + page_size=page_size, + ) + except tv_service.InvalidSeriesFilterError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + @router.get("/{aurora_id}", response_model=SeriesDetailOut) async def get_series( aurora_id: str, diff --git a/app/repositories/movie_repository.py b/app/repositories/movie_repository.py index 772d810..86cea9d 100644 --- a/app/repositories/movie_repository.py +++ b/app/repositories/movie_repository.py @@ -4,15 +4,23 @@ from __future__ import annotations import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.company import Company, MovieCompany +from app.models.enums import MovieStatus from app.models.genre import Genre, MovieGenre from app.models.keyword import Keyword, MovieKeyword from app.models.movie import Movie, MovieRecommendation +_MOVIE_SORT_COLUMNS = { + "popularity": Movie.popularity, + "release_date": Movie.primary_release_date, + "vote_average": Movie.vote_average, + "title": Movie.sort_title, +} + async def get_movie_by_id(session: AsyncSession, movie_id: uuid.UUID) -> Movie | None: stmt = ( @@ -76,3 +84,44 @@ async def get_movie_recommendation_ids( ) result = await session.execute(stmt) return list(result.scalars().all()) + + +async def list_movies( + session: AsyncSession, + *, + genre_id: uuid.UUID | None, + year: int | None, + status: MovieStatus | None, + sort_by: str, + sort_dir: str, + limit: int, + offset: int, +) -> tuple[list[Movie], int]: + """Browse movies with optional filters (section 17: additive beyond the + spec's minimum endpoint list). Returns (page of movies, total matching + count) -- the count is computed via a window function in the same query + rather than a second round trip, since it must reflect the filtered set + before LIMIT/OFFSET are applied.""" + + column = _MOVIE_SORT_COLUMNS[sort_by] + order = column.desc() if sort_dir == "desc" else column.asc() + + stmt = select(Movie, func.count().over().label("total_count")) + if genre_id is not None: + stmt = stmt.join(MovieGenre, MovieGenre.movie_id == Movie.id).where( + MovieGenre.genre_id == genre_id + ) + if year is not None: + stmt = stmt.where(func.extract("year", Movie.primary_release_date) == year) + if status is not None: + stmt = stmt.where(Movie.status == status) + + # A secondary tiebreak on id keeps ordering stable across pages when + # many rows share the same value on the primary sort column (e.g. a lot + # of untouched-by-TMDb popularity=None rows). + stmt = stmt.order_by(order.nulls_last(), Movie.id.asc()).limit(limit).offset(offset) + + rows = (await session.execute(stmt)).all() + if not rows: + return [], 0 + return [row[0] for row in rows], rows[0].total_count diff --git a/app/repositories/tv_repository.py b/app/repositories/tv_repository.py index 3098a19..08aa41f 100644 --- a/app/repositories/tv_repository.py +++ b/app/repositories/tv_repository.py @@ -4,15 +4,22 @@ from __future__ import annotations import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.company import Company, SeriesCompany -from app.models.enums import CompanyType +from app.models.enums import CompanyType, SeriesStatus from app.models.genre import Genre, SeriesGenre from app.models.keyword import Keyword, SeriesKeyword from app.models.tv import Episode, Season, Series +_SERIES_SORT_COLUMNS = { + "popularity": Series.popularity, + "first_air_date": Series.first_air_date, + "vote_average": Series.vote_average, + "title": Series.sort_title, +} + async def get_series_by_id(session: AsyncSession, series_id: uuid.UUID) -> Series | None: result = await session.execute(select(Series).where(Series.id == series_id)) @@ -149,3 +156,38 @@ async def get_series_companies( stmt = stmt.where(Company.company_type == company_type) result = await session.execute(stmt) return [(company, role) for company, role in result.all()] + + +async def list_series( + session: AsyncSession, + *, + genre_id: uuid.UUID | None, + year: int | None, + status: SeriesStatus | None, + sort_by: str, + sort_dir: str, + limit: int, + offset: int, +) -> tuple[list[Series], int]: + """Browse series with optional filters -- see movie_repository.list_movies + for the window-function total-count approach this mirrors.""" + + column = _SERIES_SORT_COLUMNS[sort_by] + order = column.desc() if sort_dir == "desc" else column.asc() + + stmt = select(Series, func.count().over().label("total_count")) + if genre_id is not None: + stmt = stmt.join(SeriesGenre, SeriesGenre.series_id == Series.id).where( + SeriesGenre.genre_id == genre_id + ) + if year is not None: + stmt = stmt.where(func.extract("year", Series.first_air_date) == year) + if status is not None: + stmt = stmt.where(Series.status == status) + + stmt = stmt.order_by(order.nulls_last(), Series.id.asc()).limit(limit).offset(offset) + + rows = (await session.execute(stmt)).all() + if not rows: + return [], 0 + return [row[0] for row in rows], rows[0].total_count diff --git a/app/schemas/movie.py b/app/schemas/movie.py index 957c173..de5d6a5 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -83,3 +83,11 @@ class MovieVideosOut(BaseModel): class MovieRecommendationsOut(BaseModel): id: str recommendations: list[MovieSummaryOut] = [] + + +class MovieListOut(BaseModel): + page: int + page_size: int + total_results: int + total_pages: int + results: list[MovieSummaryOut] = [] diff --git a/app/schemas/tv.py b/app/schemas/tv.py index 63cd5e6..4a456a3 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -76,6 +76,14 @@ class SeriesSeasonsOut(BaseModel): seasons: list[SeasonBriefOut] = [] +class SeriesListOut(BaseModel): + page: int + page_size: int + total_results: int + total_pages: int + results: list[SeriesSummaryOut] = [] + + class EpisodeBriefOut(BaseModel): id: str episode_number: int diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 3ddd973..616cfc7 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -7,13 +7,15 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id -from app.models.enums import ArtworkType +from app.models.enums import ArtworkType, MovieStatus +from app.models.movie import Movie from app.repositories import movie_repository, polymorphic from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut from app.schemas.movie import ( MovieCreditsOut, MovieDetailOut, MovieImagesOut, + MovieListOut, MovieRecommendationsOut, MovieSummaryOut, MovieVideosOut, @@ -25,6 +27,10 @@ class MovieNotFoundError(Exception): pass +class InvalidMovieFilterError(Exception): + pass + + async def _resolve_movie_id(movie_aurora_id: str) -> uuid.UUID: try: return uuid.UUID(parse_aurora_id(movie_aurora_id, expected_type="movie")) @@ -136,11 +142,8 @@ async def get_movie_recommendations( return MovieRecommendationsOut(id=aurora_id("movie", str(movie.id)), recommendations=summaries) -async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None: - movie = await movie_repository.get_movie_by_id(session, movie_id) - if movie is None: - return None - posters = await polymorphic.get_artwork(session, "movie", movie_id, ArtworkType.POSTER) +async def _movie_summary_from_row(session: AsyncSession, movie: Movie) -> MovieSummaryOut: + posters = await polymorphic.get_artwork(session, "movie", movie.id, ArtworkType.POSTER) return MovieSummaryOut( id=aurora_id("movie", str(movie.id)), title=movie.title, @@ -153,6 +156,13 @@ async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> Mov ) +async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None: + movie = await movie_repository.get_movie_by_id(session, movie_id) + if movie is None: + return None + return await _movie_summary_from_row(session, movie) + + async def build_movie_summaries( session: AsyncSession, movie_ids: list[uuid.UUID] ) -> list[MovieSummaryOut]: @@ -162,3 +172,46 @@ async def build_movie_summaries( if summary is not None: out.append(summary) return out + + +async def list_movies( + session: AsyncSession, + *, + genre_aurora_id: str | None, + year: int | None, + status: str | None, + sort_by: str, + sort_dir: str, + page: int, + page_size: int, +) -> MovieListOut: + genre_id: uuid.UUID | None = None + if genre_aurora_id is not None: + try: + genre_id = uuid.UUID(parse_aurora_id(genre_aurora_id, expected_type="genre")) + except ValueError as exc: + raise InvalidMovieFilterError(f"invalid genre id: {genre_aurora_id!r}") from exc + + status_enum: MovieStatus | None = None + if status is not None: + try: + status_enum = MovieStatus(status) + except ValueError as exc: + raise InvalidMovieFilterError(f"unknown movie status: {status!r}") from exc + + movies, total = await movie_repository.list_movies( + session, + genre_id=genre_id, + year=year, + status=status_enum, + sort_by=sort_by, + sort_dir=sort_dir, + limit=page_size, + offset=(page - 1) * page_size, + ) + results = [await _movie_summary_from_row(session, m) for m in movies] + total_pages = (total + page_size - 1) // page_size if total else 0 + + return MovieListOut( + page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results + ) diff --git a/app/services/tv_service.py b/app/services/tv_service.py index d6e2d63..16bbd96 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -7,7 +7,8 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id -from app.models.enums import ArtworkType, CompanyType +from app.models.enums import ArtworkType, CompanyType, SeriesStatus +from app.models.tv import Series from app.repositories import polymorphic, tv_repository from app.schemas.common import CompanyBriefOut, GenreOut, KeywordOut from app.schemas.tv import ( @@ -16,6 +17,7 @@ from app.schemas.tv import ( SeasonBriefOut, SeasonDetailOut, SeriesDetailOut, + SeriesListOut, SeriesSeasonsOut, SeriesSummaryOut, ) @@ -34,6 +36,10 @@ class EpisodeNotFoundError(Exception): pass +class InvalidSeriesFilterError(Exception): + pass + + def _resolve_id(aid: str, expected_type: str, error_cls: type[Exception]) -> uuid.UUID: try: return uuid.UUID(parse_aurora_id(aid, expected_type=expected_type)) @@ -201,13 +207,8 @@ async def get_episode_detail(session: AsyncSession, episode_aurora_id: str) -> E ) -async def build_series_summary( - session: AsyncSession, series_id: uuid.UUID -) -> SeriesSummaryOut | None: - series = await tv_repository.get_series_by_id(session, series_id) - if series is None: - return None - posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER) +async def _series_summary_from_row(session: AsyncSession, series: Series) -> SeriesSummaryOut: + posters = await polymorphic.get_artwork(session, "series", series.id, ArtworkType.POSTER) return SeriesSummaryOut( id=aurora_id("series", str(series.id)), title=series.title, @@ -220,6 +221,15 @@ async def build_series_summary( ) +async def build_series_summary( + session: AsyncSession, series_id: uuid.UUID +) -> SeriesSummaryOut | None: + series = await tv_repository.get_series_by_id(session, series_id) + if series is None: + return None + return await _series_summary_from_row(session, series) + + async def build_series_summaries( session: AsyncSession, series_ids: list[uuid.UUID] ) -> list[SeriesSummaryOut]: @@ -229,3 +239,46 @@ async def build_series_summaries( if summary is not None: out.append(summary) return out + + +async def list_series( + session: AsyncSession, + *, + genre_aurora_id: str | None, + year: int | None, + status: str | None, + sort_by: str, + sort_dir: str, + page: int, + page_size: int, +) -> SeriesListOut: + genre_id: uuid.UUID | None = None + if genre_aurora_id is not None: + try: + genre_id = uuid.UUID(parse_aurora_id(genre_aurora_id, expected_type="genre")) + except ValueError as exc: + raise InvalidSeriesFilterError(f"invalid genre id: {genre_aurora_id!r}") from exc + + status_enum: SeriesStatus | None = None + if status is not None: + try: + status_enum = SeriesStatus(status) + except ValueError as exc: + raise InvalidSeriesFilterError(f"unknown series status: {status!r}") from exc + + series_rows, total = await tv_repository.list_series( + session, + genre_id=genre_id, + year=year, + status=status_enum, + sort_by=sort_by, + sort_dir=sort_dir, + limit=page_size, + offset=(page - 1) * page_size, + ) + results = [await _series_summary_from_row(session, s) for s in series_rows] + total_pages = (total + page_size - 1) // page_size if total else 0 + + return SeriesListOut( + page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results + ) diff --git a/docs/api.md b/docs/api.md index e94719f..ca95c5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -43,12 +43,32 @@ GET /api/v1/search?q=the%20matrix | Method | Path | |---|---| +| GET | `/api/v1/movies` | | GET | `/api/v1/movies/{aurora_id}` | | GET | `/api/v1/movies/{aurora_id}/credits` | | GET | `/api/v1/movies/{aurora_id}/images` | | GET | `/api/v1/movies/{aurora_id}/videos` | | GET | `/api/v1/movies/{aurora_id}/recommendations` | +`GET /api/v1/movies` browses/discovers without a search query — the +complement to `/search` (section 17, additive beyond the spec's minimum +endpoint list). Query params: `genre` (an Aurora genre id), `year` +(primary release year), `status` (e.g. `released`), `sort_by` +(`popularity` default | `release_date` | `vote_average` | `title`), +`sort_dir` (`desc` default | `asc`), `page` (default 1), `page_size` +(default 20, max 50). An invalid filter value (a malformed genre id, an +unknown status) is a 422, not a silently-empty result. Response: + +```json +{ + "page": 1, + "page_size": 20, + "total_results": 142, + "total_pages": 8, + "results": [ { "id": "aur_movie_...", "type": "movie", "title": "The Matrix", "release_date": "1999-03-30", "poster_path": "https://...", "popularity": 45.5, "vote_average": 8.2 } ] +} +``` + `GET /api/v1/movies/{aurora_id}` (cached, see `docs/architecture.md#caching`): ```json @@ -103,11 +123,18 @@ ingested: | Method | Path | |---|---| +| GET | `/api/v1/series` | | GET | `/api/v1/series/{aurora_id}` | | GET | `/api/v1/series/{aurora_id}/seasons` | | GET | `/api/v1/seasons/{aurora_id}` | | GET | `/api/v1/episodes/{aurora_id}` | +`GET /api/v1/series` is the series equivalent of `GET /api/v1/movies` +above, with `sort_by` values `popularity` (default) | `first_air_date` | +`vote_average` | `title` (`release_date`/`year` become `first_air_date`/ +`year` filtered against it) — otherwise the same params, response shape, +and validation behavior. + Series detail includes `networks` and `companies` as separate lists (a TV network and a production company are both `Company` rows distinguished by `company_type` — see `docs/database.md`), plus `creators` (series-level @@ -160,10 +187,14 @@ you want network-level isolation too. ## Known limitations -- No pagination on list-shaped responses yet beyond search's `limit` - (movie/series lists like recommendations return everything). Cursor-based - pagination using the Aurora ID's inherent creation-order sort (see - `docs/database.md#aurora-ids-vs-primary-keys`) is the natural next step. +- `GET /movies` and `GET /series` (browse/discover) paginate with + `page`/`page_size`, offset-based rather than the Aurora ID cursor scheme + `docs/database.md#aurora-ids-vs-primary-keys` describes, since sorting by + popularity/rating/date doesn't line up with creation-order cursors. Other + list-shaped responses (recommendations, a collection's `movies`, a + person's `credits`) still return everything with no pagination at all — + fine at today's scale, but the natural next thing to revisit if any of + them grow large. - `/people/{id}/credits` covers movies and series (episode guest-star credits rolled up per series). It does not yet expose a per-episode breakdown for those guest appearances (just the aggregate diff --git a/tests/integration/test_movie_browse.py b/tests/integration/test_movie_browse.py new file mode 100644 index 0000000..2e426b6 --- /dev/null +++ b/tests/integration/test_movie_browse.py @@ -0,0 +1,259 @@ +"""Integration tests for GET /api/v1/movies (browse/discover, section 17 -- +additive beyond the spec's minimum endpoint list): sorting, filtering, and +pagination against real PostgreSQL, exercised through movie_service.list_movies +directly rather than over HTTP.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.genre import Genre +from app.services import movie_service +from app.services.ingestion.movie_ingestion import ingest_movie +from tests.integration.test_movie_ingestion import FakeTMDbProvider + + +def _movie_payload(**overrides: Any) -> dict[str, Any]: + base = { + "id": 9000, + "title": "Untitled", + "status": "Released", + "genres": [{"id": 28, "name": "Action"}], + "release_date": "2020-01-01", + "popularity": 10.0, + "vote_average": 5.0, + } + base.update(overrides) + return base + + +async def _seed_movies(db_session: AsyncSession) -> None: + movies = [ + _movie_payload(id=9001, title="A Action Movie", popularity=10.0, vote_average=5.0), + _movie_payload( + id=9002, + title="B Action Movie", + popularity=90.0, + vote_average=8.0, + release_date="2021-01-01", + ), + _movie_payload( + id=9003, + title="C Comedy Movie", + genres=[{"id": 35, "name": "Comedy"}], + popularity=50.0, + vote_average=6.0, + ), + _movie_payload( + id=9004, + title="D Planned Movie", + status="Planned", + popularity=20.0, + vote_average=None, + release_date="2022-01-01", + ), + ] + for payload in movies: + await ingest_movie(db_session, FakeTMDbProvider(payload), str(payload["id"])) + await db_session.commit() + + +async def _genre_aurora_id(db_session: AsyncSession, name: str) -> str: + genre = ( + await db_session.execute(select(Genre).where(Genre.name == name)) + ).scalar_one() + return aurora_id("genre", str(genre.id)) + + +@pytest.mark.asyncio +async def test_list_movies_defaults_to_popularity_descending(db_session: AsyncSession): + await _seed_movies(db_session) + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert out.total_results == 4 + assert out.total_pages == 1 + assert [m.title for m in out.results] == [ + "B Action Movie", + "C Comedy Movie", + "D Planned Movie", + "A Action Movie", + ] + + +@pytest.mark.asyncio +async def test_list_movies_filters_by_genre(db_session: AsyncSession): + await _seed_movies(db_session) + action_id = await _genre_aurora_id(db_session, "Action") + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=action_id, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert out.total_results == 3 + assert {m.title for m in out.results} == { + "B Action Movie", + "D Planned Movie", + "A Action Movie", + } + + +@pytest.mark.asyncio +async def test_list_movies_filters_by_year(db_session: AsyncSession): + await _seed_movies(db_session) + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=2020, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [m.title for m in out.results] == ["C Comedy Movie", "A Action Movie"] + + +@pytest.mark.asyncio +async def test_list_movies_filters_by_status(db_session: AsyncSession): + await _seed_movies(db_session) + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status="planned", + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [m.title for m in out.results] == ["D Planned Movie"] + + +@pytest.mark.asyncio +async def test_list_movies_sorts_by_title_ascending(db_session: AsyncSession): + await _seed_movies(db_session) + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="title", + sort_dir="asc", + page=1, + page_size=20, + ) + + assert [m.title for m in out.results] == [ + "A Action Movie", + "B Action Movie", + "C Comedy Movie", + "D Planned Movie", + ] + + +@pytest.mark.asyncio +async def test_list_movies_paginates(db_session: AsyncSession): + await _seed_movies(db_session) + + page1 = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=2, + ) + page2 = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=2, + page_size=2, + ) + + assert page1.total_results == 4 + assert page1.total_pages == 2 + assert [m.title for m in page1.results] == ["B Action Movie", "C Comedy Movie"] + assert [m.title for m in page2.results] == ["D Planned Movie", "A Action Movie"] + + +@pytest.mark.asyncio +async def test_list_movies_rejects_malformed_genre_filter(db_session: AsyncSession): + with pytest.raises(movie_service.InvalidMovieFilterError): + await movie_service.list_movies( + db_session, + genre_aurora_id="not-an-aurora-id", + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + +@pytest.mark.asyncio +async def test_list_movies_rejects_unknown_status_filter(db_session: AsyncSession): + with pytest.raises(movie_service.InvalidMovieFilterError): + await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=None, + status="not_a_real_status", + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + +@pytest.mark.asyncio +async def test_list_movies_returns_empty_page_when_nothing_matches(db_session: AsyncSession): + await _seed_movies(db_session) + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + year=1899, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert out.results == [] + assert out.total_results == 0 + assert out.total_pages == 0 diff --git a/tests/integration/test_series_browse.py b/tests/integration/test_series_browse.py new file mode 100644 index 0000000..e54833e --- /dev/null +++ b/tests/integration/test_series_browse.py @@ -0,0 +1,214 @@ +"""Integration tests for GET /api/v1/series (browse/discover, section 17 -- +additive beyond the spec's minimum endpoint list): sorting, filtering, and +pagination against real PostgreSQL, exercised through tv_service.list_series +directly rather than over HTTP. Mirrors test_movie_browse.py.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.genre import Genre +from app.services import tv_service +from app.services.ingestion.series_ingestion import ingest_series +from tests.integration.test_series_ingestion import FakeTMDbSeriesProvider + + +def _series_payload(**overrides: Any) -> dict[str, Any]: + base = { + "id": 9000, + "name": "Untitled", + "status": "Ended", + "genres": [{"id": 18, "name": "Drama"}], + "first_air_date": "2020-01-01", + "popularity": 10.0, + "vote_average": 5.0, + } + base.update(overrides) + return base + + +async def _seed_series(db_session: AsyncSession) -> None: + shows = [ + _series_payload(id=9101, name="A Drama Show", popularity=10.0, vote_average=5.0), + _series_payload( + id=9102, + name="B Drama Show", + popularity=90.0, + vote_average=8.0, + first_air_date="2021-01-01", + ), + _series_payload( + id=9103, + name="C Comedy Show", + genres=[{"id": 35, "name": "Comedy"}], + popularity=50.0, + vote_average=6.0, + ), + _series_payload( + id=9104, + name="D Planned Show", + status="Planned", + popularity=20.0, + vote_average=None, + first_air_date="2022-01-01", + ), + ] + for payload in shows: + await ingest_series( + db_session, FakeTMDbSeriesProvider(payload, {}), str(payload["id"]) + ) + await db_session.commit() + + +async def _genre_aurora_id(db_session: AsyncSession, name: str) -> str: + genre = ( + await db_session.execute(select(Genre).where(Genre.name == name)) + ).scalar_one() + return aurora_id("genre", str(genre.id)) + + +@pytest.mark.asyncio +async def test_list_series_defaults_to_popularity_descending(db_session: AsyncSession): + await _seed_series(db_session) + + out = await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert out.total_results == 4 + assert out.total_pages == 1 + assert [s.title for s in out.results] == [ + "B Drama Show", + "C Comedy Show", + "D Planned Show", + "A Drama Show", + ] + + +@pytest.mark.asyncio +async def test_list_series_filters_by_genre(db_session: AsyncSession): + await _seed_series(db_session) + drama_id = await _genre_aurora_id(db_session, "Drama") + + out = await tv_service.list_series( + db_session, + genre_aurora_id=drama_id, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert out.total_results == 3 + assert {s.title for s in out.results} == {"B Drama Show", "D Planned Show", "A Drama Show"} + + +@pytest.mark.asyncio +async def test_list_series_filters_by_year(db_session: AsyncSession): + await _seed_series(db_session) + + out = await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=2020, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [s.title for s in out.results] == ["C Comedy Show", "A Drama Show"] + + +@pytest.mark.asyncio +async def test_list_series_filters_by_status(db_session: AsyncSession): + await _seed_series(db_session) + + out = await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=None, + status="planned", + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [s.title for s in out.results] == ["D Planned Show"] + + +@pytest.mark.asyncio +async def test_list_series_paginates(db_session: AsyncSession): + await _seed_series(db_session) + + page1 = await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=2, + ) + page2 = await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=2, + page_size=2, + ) + + assert page1.total_results == 4 + assert page1.total_pages == 2 + assert [s.title for s in page1.results] == ["B Drama Show", "C Comedy Show"] + assert [s.title for s in page2.results] == ["D Planned Show", "A Drama Show"] + + +@pytest.mark.asyncio +async def test_list_series_rejects_malformed_genre_filter(db_session: AsyncSession): + with pytest.raises(tv_service.InvalidSeriesFilterError): + await tv_service.list_series( + db_session, + genre_aurora_id="not-an-aurora-id", + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + +@pytest.mark.asyncio +async def test_list_series_rejects_unknown_status_filter(db_session: AsyncSession): + with pytest.raises(tv_service.InvalidSeriesFilterError): + await tv_service.list_series( + db_session, + genre_aurora_id=None, + year=None, + status="not_a_real_status", + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + )