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
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:
co-authored by
Claude Sonnet 5
parent
892f78016c
commit
20f5e82d1b
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user