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
1. test_ingestion_cache_invalidation.py: the job was created through
the test's db_session fixture, whose commit() only ends a savepoint
that gets rolled back at teardown (see tests/conftest.py) -- it's
never a real commit. _run_ingestion looks the job up through its
own session_scope() (a genuinely separate connection), which could
never see it, so the very first _require_job call raised "job
vanished mid-run" every time. Worse, test_failed_ingestion_does_not_
invalidate_the_cache still passed, coincidentally -- it expected a
RuntimeError and got one, just the wrong one, masking the bug.
Fixed by having _make_job create the job through session_scope()
itself, matching test_ingestion_retry.py's existing pattern.
2. Same class of bug as the video-collision fix in the last commit:
FAKE_MOVIE's fixed imdb_id ("tt0133093"), not just its video, also
collides across two different fake movies sharing it unmodified --
uq_external_id_source is a real unique constraint on
(provider_id, entity_type, external_id). Fixed by also overriding
imdb_id on the second movie in both affected tests.
All caught by running the actual integration suite against Postgres --
these paths were previously only collect-only'd locally (no local DB).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
226 lines
8.2 KiB
Python
226 lines
8.2 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.collection_ingestion import ingest_collection
|
|
from app.services.ingestion.movie_ingestion import ingest_movie
|
|
from tests.integration.test_collection_ingestion import FakeTMDbCollectionProvider
|
|
from tests.unit.test_normalizer import FAKE_COLLECTION, 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_detail_surfaces_collection_poster_path(db_session: AsyncSession):
|
|
"""Same class of bug as company logo_path: movie_service built the
|
|
embedded collection field without ever looking up its poster artwork,
|
|
so poster_path stayed None even after a full ingest_collection run
|
|
populated one. The bare belongs_to_collection stub created by
|
|
ingest_movie alone has no poster -- only ingest_collection provides it."""
|
|
|
|
provider = FakeTMDbCollectionProvider(
|
|
movie_payloads={"603": FAKE_MOVIE}, collection_payload=FAKE_COLLECTION
|
|
)
|
|
movie_id = await ingest_movie(db_session, provider, "603")
|
|
await db_session.flush()
|
|
await ingest_collection(db_session, provider, "2344")
|
|
await db_session.commit()
|
|
|
|
detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id)))
|
|
|
|
assert detail.collection is not None
|
|
assert detail.collection.poster_path is not None
|
|
assert detail.collection.poster_path.endswith("/collection-poster.jpg")
|
|
|
|
|
|
@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."""
|
|
|
|
# videos and imdb_id overridden: FAKE_MOVIE's embedded video and imdb_id
|
|
# are both fixed identifiers, and two different movies both ingesting
|
|
# them unmodified would collide on the real uq_video_provider_source /
|
|
# uq_external_id_source unique constraints -- each belongs to exactly
|
|
# one entity, unlike genres/companies which are legitimately shared.
|
|
sequel_payload = {
|
|
**FAKE_MOVIE,
|
|
"id": 604,
|
|
"title": "The Matrix Reloaded",
|
|
"videos": {"results": []},
|
|
"imdb_id": "tt0234215",
|
|
}
|
|
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()))
|
|
)
|