Add GET /movies and GET /series browse/discover endpoints
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

The complement to /search for browsing without a query: paginated,
filterable (genre, year, status) and sortable (popularity,
release/air date, rating, title) listings. Offset-based pagination
rather than the Aurora ID cursor scheme, since sorting by
popularity/rating/date doesn't line up with creation-order cursors.

Total count is computed via a window function in the same query as
the page of results, avoiding a second round trip per request. An
invalid filter (malformed genre id, unknown status) is a 422 via a
per-service InvalidMovieFilterError/InvalidSeriesFilterError, caught
before any DB query runs.

build_movie_summary/build_series_summary refactored to share their
row-to-summary logic with the new list path, avoiding a redundant
get_by_id lookup when the row is already in hand.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 09:01:11 -05:00
co-authored by Claude Sonnet 5
parent 892f78016c
commit 20f5e82d1b
11 changed files with 798 additions and 24 deletions
+30 -1
View File
@@ -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,
+30 -2
View File
@@ -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,
+50 -1
View File
@@ -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
+44 -2
View File
@@ -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
+8
View File
@@ -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] = []
+8
View File
@@ -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
+59 -6
View File
@@ -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
)
+61 -8
View File
@@ -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
)
+35 -4
View File
@@ -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
+259
View File
@@ -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
+214
View File
@@ -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,
)