Files
NixReaperandClaude Sonnet 5 0a50e0ffb6
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
Fix two more test bugs surfaced by the full integration run
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]>
2026-09-05 20:17:02 -05:00

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()))
)