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
Collections were bare stubs (name only) lazily created the moment a
movie's belongs_to_collection reference was seen. Adds a real
ingest_collection pipeline: TMDb's /collection/{id} for overview and
poster/backdrop artwork, plus ordered CollectionMovie membership --
linking only movies already ingested into Aurora, the same
never-fabricate-a-stub rule movie recommendations already follow.
get_or_create_collection now refreshes name/overview on an existing
stub instead of only creating, guarded so a bare movie-stub call
(overview=None) never clobbers real data a full ingestion already
filled in.
New POST /api/v1/admin/ingest/collection endpoint and
ingest_collection_task; GET /api/v1/collections/{id} needed no changes
since its read path (collection_service.py) already assembled the full
detail shape.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
"""Integration tests for the movie ingestion pipeline (section 15): a fake
|
|
provider stands in for TMDb so these run without network access, while
|
|
still exercising the real normalize -> match -> upsert -> provenance path
|
|
against PostgreSQL."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
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.artwork import Artwork
|
|
from app.models.company import Company
|
|
from app.models.credit import Credit
|
|
from app.models.genre import Genre
|
|
from app.models.movie import Movie
|
|
from app.models.provider import ProviderRecord
|
|
from app.providers.base import MetadataProvider
|
|
from app.services import movie_service
|
|
from app.services.ingestion.movie_ingestion import ingest_movie
|
|
from tests.unit.test_normalizer import FAKE_MOVIE
|
|
|
|
|
|
class FakeTMDbProvider(MetadataProvider):
|
|
slug = "tmdb"
|
|
name = "The Movie Database (TMDb)"
|
|
|
|
def __init__(self, payload: dict[str, Any]) -> None:
|
|
self._payload = payload
|
|
|
|
async def get_movie(self, external_id: str) -> dict[str, Any]:
|
|
return self._payload
|
|
|
|
async def get_series(self, external_id: str) -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_movie_creates_normalized_record(db_session: AsyncSession):
|
|
provider = FakeTMDbProvider(FAKE_MOVIE)
|
|
|
|
movie_id = await ingest_movie(db_session, provider, "603")
|
|
await db_session.commit()
|
|
|
|
movie = await db_session.get(Movie, movie_id)
|
|
assert movie is not None
|
|
assert movie.title == "The Matrix"
|
|
assert movie.runtime_minutes == 136
|
|
|
|
genres = (await db_session.execute(select(Genre.name))).scalars().all()
|
|
assert "Action" in genres
|
|
|
|
credits_ = (
|
|
(await db_session.execute(select(Credit).where(Credit.entity_id == movie_id)))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert any(c.character == "Neo" for c in credits_)
|
|
|
|
record = (
|
|
await db_session.execute(
|
|
select(ProviderRecord).where(ProviderRecord.external_id == "603")
|
|
)
|
|
).scalar_one()
|
|
assert record.aurora_entity_id == movie_id
|
|
assert record.match_confidence.value == "new"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_movie_is_idempotent(db_session: AsyncSession):
|
|
"""Re-running ingestion for the same provider + external_id must update
|
|
the same Aurora movie, never create a duplicate (section 2: zero
|
|
duplicate ingestion records)."""
|
|
|
|
provider = FakeTMDbProvider(FAKE_MOVIE)
|
|
|
|
first_id = await ingest_movie(db_session, provider, "603")
|
|
await db_session.commit()
|
|
|
|
updated_payload = {**FAKE_MOVIE, "title": "The Matrix (Updated Title)"}
|
|
second_provider = FakeTMDbProvider(updated_payload)
|
|
second_id = await ingest_movie(db_session, second_provider, "603")
|
|
await db_session.commit()
|
|
|
|
assert first_id == second_id
|
|
|
|
all_movies = (await db_session.execute(select(Movie))).scalars().all()
|
|
assert len(all_movies) == 1
|
|
assert all_movies[0].title == "The Matrix (Updated Title)"
|
|
|
|
credits_ = (
|
|
(await db_session.execute(select(Credit).where(Credit.entity_id == first_id)))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
# Credits are replaced wholesale on re-sync, not duplicated.
|
|
assert len(credits_) == 2 # 1 cast + 1 crew from FAKE_MOVIE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_movie_ingests_company_logo(db_session: AsyncSession):
|
|
provider = FakeTMDbProvider(FAKE_MOVIE)
|
|
|
|
await ingest_movie(db_session, provider, "603")
|
|
await db_session.commit()
|
|
|
|
company = (
|
|
await db_session.execute(
|
|
select(Company).where(Company.name == "Village Roadshow Pictures")
|
|
)
|
|
).scalar_one()
|
|
|
|
logo = (
|
|
await db_session.execute(
|
|
select(Artwork).where(Artwork.entity_type == "company", Artwork.entity_id == company.id)
|
|
)
|
|
).scalar_one()
|
|
assert logo.artwork_type.value == "logo"
|
|
assert logo.is_primary is True
|
|
assert logo.original_url.endswith("/village-roadshow-logo.png")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_movie_detail_surfaces_company_logo_path(db_session: AsyncSession):
|
|
"""The ingested logo must actually reach the API response -- movie_service
|
|
used to hardcode companies[].logo_path = None instead of looking up the
|
|
company's artwork, so ingestion could succeed while the API still showed
|
|
logo_path: None for every company."""
|
|
|
|
provider = FakeTMDbProvider(FAKE_MOVIE)
|
|
movie_id = await ingest_movie(db_session, provider, "603")
|
|
await db_session.commit()
|
|
|
|
detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id)))
|
|
|
|
roadshow = next(c for c in detail.companies if c.name == "Village Roadshow Pictures")
|
|
assert roadshow.logo_path is not None
|
|
assert roadshow.logo_path.endswith("/village-roadshow-logo.png")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_movie_recommendations_only_include_already_ingested_movies(
|
|
db_session: AsyncSession,
|
|
):
|
|
"""TMDb's recommendations list can reference movies Aurora hasn't
|
|
ingested yet; ingest_movie must link only the ones it can resolve by
|
|
external id (section 15: never fabricate a stub for an unseen entity),
|
|
and GET .../recommendations must return them in TMDb's ranked order."""
|
|
|
|
sequel_payload = {**FAKE_MOVIE, "id": 604, "title": "The Matrix Reloaded"}
|
|
await ingest_movie(db_session, FakeTMDbProvider(sequel_payload), "604")
|
|
await db_session.flush()
|
|
|
|
movie_with_recs = {
|
|
**FAKE_MOVIE,
|
|
"recommendations": {"results": [{"id": 604}, {"id": 999999}]},
|
|
}
|
|
movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_with_recs), "603")
|
|
await db_session.commit()
|
|
|
|
recs = await movie_service.get_movie_recommendations(
|
|
db_session, aurora_id("movie", str(movie_id))
|
|
)
|
|
|
|
assert [r.title for r in recs.recommendations] == ["The Matrix Reloaded"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_movie_recommendations_not_found_for_unknown_movie(db_session: AsyncSession):
|
|
with pytest.raises(movie_service.MovieNotFoundError):
|
|
await movie_service.get_movie_recommendations(
|
|
db_session, aurora_id("movie", str(uuid.uuid4()))
|
|
)
|