Initial Aurora Core 0.1 implementation
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
Production-grade media metadata backend per docs/aurora-core-spec.md: Aurora-owned UUIDv7 identity system, normalized movie/TV/people/credits schema, provider abstraction with a TMDb adapter, an idempotent async ingestion pipeline (normalize -> match -> upsert -> provenance), a Postgres FTS/trigram search layer, a stateless FastAPI v1 API with Redis caching/rate-limiting, arq workers, dev/prod Docker Compose topologies, unit + integration + load tests, CI, and architecture/ database/API/ERD documentation. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
Integration/API tests need a real PostgreSQL instance (JSONB, trigram/FTS
|
||||
indexes, and native enums are Postgres-specific — see docs/database.md) and
|
||||
Redis. Point `TEST_DATABASE_URL` / `TEST_REDIS_URL` at throwaway instances,
|
||||
e.g. via `docker compose up -d postgres redis` and a separate test database:
|
||||
|
||||
createdb -h localhost -U aurora aurora_core_test
|
||||
|
||||
Unit tests (tests/unit/) never touch either and always run offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
TEST_DATABASE_URL = os.environ.get(
|
||||
"TEST_DATABASE_URL", "postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core_test"
|
||||
)
|
||||
TEST_REDIS_URL = os.environ.get("TEST_REDIS_URL", "redis://localhost:6379/15")
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", TEST_DATABASE_URL)
|
||||
os.environ.setdefault("REDIS_URL", TEST_REDIS_URL)
|
||||
os.environ.setdefault("AURORA_ENV", "test")
|
||||
os.environ.setdefault("AURORA_SECRET_KEY", "test-secret")
|
||||
os.environ.setdefault("AURORA_ADMIN_API_KEYS", "test-admin-key")
|
||||
|
||||
|
||||
def _requires_postgres() -> bool:
|
||||
return "--no-db" not in os.environ.get("PYTEST_ADDOPTS", "")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def engine():
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import Base
|
||||
|
||||
eng = create_async_engine(TEST_DATABASE_URL)
|
||||
try:
|
||||
async with eng.begin() as conn:
|
||||
# Tests build schema straight from ORM metadata for speed and
|
||||
# to catch model/migration drift; docs/database.md notes this
|
||||
# is deliberately *not* how the running application migrates.
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except Exception as exc: # pragma: no cover - environment guard
|
||||
pytest.skip(f"PostgreSQL not reachable at {TEST_DATABASE_URL}: {exc}")
|
||||
yield eng
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
connection = await engine.connect()
|
||||
transaction = await connection.begin()
|
||||
session_factory = async_sessionmaker(bind=connection, expire_on_commit=False)
|
||||
session = session_factory()
|
||||
|
||||
yield session
|
||||
|
||||
await session.close()
|
||||
await transaction.rollback()
|
||||
await connection.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
from app.core.database import get_db
|
||||
from app.main import app
|
||||
|
||||
async def _override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Minimal data builders for integration tests — inserts ORM rows directly
|
||||
so tests don't depend on network calls to a real provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id
|
||||
from app.models.artwork import Artwork
|
||||
from app.models.credit import Credit
|
||||
from app.models.enums import ArtworkType, MovieStatus
|
||||
from app.models.external_id import ExternalID
|
||||
from app.models.genre import Genre, MovieGenre
|
||||
from app.models.movie import Movie
|
||||
from app.models.person import Person
|
||||
from app.models.provider import Provider
|
||||
|
||||
|
||||
async def make_provider(session: AsyncSession, slug: str = "tmdb") -> Provider:
|
||||
provider = Provider(slug=slug, name=slug.upper())
|
||||
session.add(provider)
|
||||
await session.flush()
|
||||
return provider
|
||||
|
||||
|
||||
async def make_movie(
|
||||
session: AsyncSession, *, title: str = "The Matrix", provider: Provider | None = None
|
||||
) -> Movie:
|
||||
movie = Movie(
|
||||
title=title,
|
||||
sort_title=title,
|
||||
overview="A hacker discovers reality is a simulation.",
|
||||
status=MovieStatus.RELEASED,
|
||||
runtime_minutes=136,
|
||||
)
|
||||
session.add(movie)
|
||||
await session.flush()
|
||||
|
||||
genre = Genre(name="Science Fiction", slug="science-fiction")
|
||||
session.add(genre)
|
||||
await session.flush()
|
||||
session.add(MovieGenre(movie_id=movie.id, genre_id=genre.id))
|
||||
|
||||
person = Person(name="Keanu Reeves")
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Credit(
|
||||
person_id=person.id,
|
||||
entity_type="movie",
|
||||
entity_id=movie.id,
|
||||
department="Acting",
|
||||
character="Neo",
|
||||
billing_order=0,
|
||||
)
|
||||
)
|
||||
|
||||
session.add(
|
||||
Artwork(
|
||||
entity_type="movie",
|
||||
entity_id=movie.id,
|
||||
artwork_type=ArtworkType.POSTER,
|
||||
provider_id=(provider or await make_provider(session)).id,
|
||||
original_url="https://image.tmdb.org/t/p/original/poster.jpg",
|
||||
is_primary=True,
|
||||
)
|
||||
)
|
||||
|
||||
if provider is not None:
|
||||
session.add(
|
||||
ExternalID(
|
||||
entity_type="movie", entity_id=movie.id, provider_id=provider.id, external_id="603"
|
||||
)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return movie
|
||||
|
||||
|
||||
def movie_aurora_id(movie: Movie) -> str:
|
||||
return aurora_id("movie", str(movie.id))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Integration tests for liveness/readiness (section 24). Requires
|
||||
PostgreSQL and Redis — see tests/conftest.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("db_session")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_is_always_ok(client: AsyncClient):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_reports_postgresql_dependency(client: AsyncClient):
|
||||
response = await client.get("/ready")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
names = {dep["name"] for dep in body["dependencies"]}
|
||||
assert "postgresql" in names
|
||||
assert "redis" in names
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.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 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
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Integration tests for the Movie API (section 17/18). Requires
|
||||
PostgreSQL and Redis — see tests/conftest.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from tests.integration.factories import make_movie, make_provider, movie_aurora_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_returns_normalized_record(client: AsyncClient, db_session: AsyncSession):
|
||||
provider = await make_provider(db_session)
|
||||
movie = await make_movie(db_session, provider=provider)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(f"/api/v1/movies/{movie_aurora_id(movie)}")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert body["id"] == movie_aurora_id(movie)
|
||||
assert body["type"] == "movie"
|
||||
assert body["title"] == "The Matrix"
|
||||
assert body["runtime"] == 136
|
||||
assert {g["name"] for g in body["genres"]} == {"Science Fiction"}
|
||||
assert len(body["cast"]) == 1
|
||||
assert body["cast"][0]["character"] == "Neo"
|
||||
assert body["images"]["posters"][0]["is_primary"] is True
|
||||
assert body["external_ids"]["tmdb"] == "603"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_404_for_unknown_id(client: AsyncClient):
|
||||
from app.core.ids import aurora_id, new_uuid7
|
||||
|
||||
unknown_id = aurora_id("movie", new_uuid7())
|
||||
response = await client.get(f"/api/v1/movies/{unknown_id}")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error"]["code"] == "movie_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_rejects_malformed_id(client: AsyncClient):
|
||||
response = await client.get("/api/v1/movies/not-a-real-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_rejects_wrong_entity_type_id(
|
||||
client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
# A well-formed person ID must not resolve as a movie.
|
||||
from app.core.ids import aurora_id, new_uuid7
|
||||
|
||||
person_shaped_id = aurora_id("person", new_uuid7())
|
||||
response = await client.get(f"/api/v1/movies/{person_shaped_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_credits(client: AsyncClient, db_session: AsyncSession):
|
||||
movie = await make_movie(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(f"/api/v1/movies/{movie_aurora_id(movie)}/credits")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body["cast"]) == 1
|
||||
assert body["cast"][0]["person"]["name"] == "Keanu Reeves"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_images(client: AsyncClient, db_session: AsyncSession):
|
||||
movie = await make_movie(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(f"/api/v1/movies/{movie_aurora_id(movie)}/images")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["images"]["posters"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_movie_videos_empty(client: AsyncClient, db_session: AsyncSession):
|
||||
movie = await make_movie(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(f"/api/v1/movies/{movie_aurora_id(movie)}/videos")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["videos"] == []
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Load test for Aurora Core 0.1 (section 30/2).
|
||||
|
||||
Simulates the read-heavy interactive traffic Core 0.1 targets: searching,
|
||||
opening movie/series/person detail pages, and paging through search
|
||||
results. Run against a seeded environment (ingest a reasonable movie/series/
|
||||
person catalog first — an empty database will pass load tests trivially but
|
||||
tell you nothing about real query cost).
|
||||
|
||||
Usage:
|
||||
|
||||
pip install locust
|
||||
locust -f tests/load/locustfile.py --host http://localhost:8000
|
||||
|
||||
Then open http://localhost:8089 and start a run with up to 500 users to
|
||||
verify the section 2 targets (search p95 < 500ms, other endpoints p95 <
|
||||
250ms, 5xx rate < 0.1%). Locust reports p50/p95/p99 latency and failure
|
||||
rate directly; compare them against docs/architecture.md's targets.
|
||||
|
||||
See docs/architecture.md for how to seed sample data before running this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from locust import HttpUser, between, task
|
||||
|
||||
SEARCH_TERMS = [
|
||||
"matrix",
|
||||
"star wars",
|
||||
"alien",
|
||||
"the office",
|
||||
"breaking bad",
|
||||
"keanu reeves",
|
||||
"christopher nolan",
|
||||
"marvel",
|
||||
"batman",
|
||||
"inception",
|
||||
]
|
||||
|
||||
|
||||
class AuroraCoreUser(HttpUser):
|
||||
"""A typical interactive client: search, then drill into a result."""
|
||||
|
||||
wait_time = between(1, 3)
|
||||
|
||||
def on_start(self) -> None:
|
||||
self._known_movie_ids: list[str] = []
|
||||
self._known_series_ids: list[str] = []
|
||||
self._known_person_ids: list[str] = []
|
||||
|
||||
@task(5)
|
||||
def search(self) -> None:
|
||||
query = random.choice(SEARCH_TERMS)
|
||||
limit = random.choice([5, 10, 20])
|
||||
with self.client.get(
|
||||
f"/api/v1/search?q={query}&limit={limit}",
|
||||
name="/api/v1/search",
|
||||
catch_response=True,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
response.failure(f"search failed: {response.status_code}")
|
||||
return
|
||||
body = response.json()
|
||||
self._known_movie_ids.extend(m["id"] for m in body.get("movies", []))
|
||||
self._known_series_ids.extend(s["id"] for s in body.get("series", []))
|
||||
self._known_person_ids.extend(p["id"] for p in body.get("people", []))
|
||||
# Keep the pools bounded so memory doesn't grow unbounded over a
|
||||
# long run.
|
||||
self._known_movie_ids = self._known_movie_ids[-200:]
|
||||
self._known_series_ids = self._known_series_ids[-200:]
|
||||
self._known_person_ids = self._known_person_ids[-200:]
|
||||
|
||||
@task(4)
|
||||
def open_movie(self) -> None:
|
||||
if not self._known_movie_ids:
|
||||
return
|
||||
movie_id = random.choice(self._known_movie_ids)
|
||||
self.client.get(f"/api/v1/movies/{movie_id}", name="/api/v1/movies/[id]")
|
||||
self.client.get(
|
||||
f"/api/v1/movies/{movie_id}/credits", name="/api/v1/movies/[id]/credits"
|
||||
)
|
||||
|
||||
@task(2)
|
||||
def open_series(self) -> None:
|
||||
if not self._known_series_ids:
|
||||
return
|
||||
series_id = random.choice(self._known_series_ids)
|
||||
self.client.get(f"/api/v1/series/{series_id}", name="/api/v1/series/[id]")
|
||||
self.client.get(
|
||||
f"/api/v1/series/{series_id}/seasons", name="/api/v1/series/[id]/seasons"
|
||||
)
|
||||
|
||||
@task(2)
|
||||
def open_person(self) -> None:
|
||||
if not self._known_person_ids:
|
||||
return
|
||||
person_id = random.choice(self._known_person_ids)
|
||||
self.client.get(f"/api/v1/people/{person_id}", name="/api/v1/people/[id]")
|
||||
|
||||
@task(1)
|
||||
def health_probe(self) -> None:
|
||||
self.client.get("/health", name="/health")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for the Aurora ID system (app/core/ids.py) — section 3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.ids import (
|
||||
ENTITY_PREFIXES,
|
||||
aurora_id,
|
||||
is_valid_aurora_id,
|
||||
new_uuid7,
|
||||
parse_aurora_id,
|
||||
)
|
||||
|
||||
|
||||
def test_new_uuid7_is_unique_and_well_formed():
|
||||
ids = {new_uuid7() for _ in range(1000)}
|
||||
assert len(ids) == 1000
|
||||
for value in list(ids)[:5]:
|
||||
assert len(value) == 36 # canonical hyphenated form
|
||||
|
||||
|
||||
def test_aurora_id_roundtrips_through_parse():
|
||||
u = new_uuid7()
|
||||
aid = aurora_id("movie", u)
|
||||
assert aid.startswith("aur_movie_")
|
||||
assert parse_aurora_id(aid, expected_type="movie") == u
|
||||
|
||||
|
||||
def test_aurora_id_never_leaks_provider_identity():
|
||||
# Aurora IDs must not be derived from or embed provider IDs (section 3).
|
||||
u = new_uuid7()
|
||||
aid = aurora_id("movie", u)
|
||||
assert "550" not in aid # a plausible TMDb id, just shouldn't appear
|
||||
assert "tmdb" not in aid
|
||||
|
||||
|
||||
def test_every_entity_prefix_produces_a_parseable_id():
|
||||
for entity_type in ENTITY_PREFIXES:
|
||||
aid = aurora_id(entity_type, new_uuid7())
|
||||
assert is_valid_aurora_id(aid, expected_type=entity_type)
|
||||
|
||||
|
||||
def test_parse_rejects_wrong_entity_type():
|
||||
aid = aurora_id("movie", new_uuid7())
|
||||
with pytest.raises(ValueError):
|
||||
parse_aurora_id(aid, expected_type="person")
|
||||
assert is_valid_aurora_id(aid, expected_type="person") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
[
|
||||
"",
|
||||
"not-an-aurora-id",
|
||||
"aur_movie_",
|
||||
"aur_movie_tooshort",
|
||||
"aur_unknownprefix_" + "0" * 26,
|
||||
"aur_movie_" + "!" * 26,
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_malformed_ids(malformed: str):
|
||||
with pytest.raises(ValueError):
|
||||
parse_aurora_id(malformed)
|
||||
assert is_valid_aurora_id(malformed) is False
|
||||
|
||||
|
||||
def test_aurora_ids_sort_in_creation_order():
|
||||
# UUIDv7 is time-ordered, and the Crockford base32 encoding preserves
|
||||
# byte order, so IDs generated later should sort later.
|
||||
first = aurora_id("movie", new_uuid7())
|
||||
second = aurora_id("movie", new_uuid7())
|
||||
assert first < second
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Unit tests for the TMDb normalizer (app/services/metadata/normalizer.py)
|
||||
— section 13. Uses hand-built payload fragments shaped like real TMDb
|
||||
responses rather than calling the network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.metadata.normalizer import normalize_movie, normalize_person
|
||||
|
||||
FAKE_MOVIE = {
|
||||
"id": 603,
|
||||
"title": "The Matrix",
|
||||
"original_title": "The Matrix",
|
||||
"overview": "A computer hacker learns...",
|
||||
"tagline": "Welcome to the Real World",
|
||||
"status": "Released",
|
||||
"original_language": "en",
|
||||
"release_date": "1999-03-30",
|
||||
"runtime": 136,
|
||||
"budget": 63000000,
|
||||
"revenue": 463517383,
|
||||
"popularity": 45.5,
|
||||
"vote_average": 8.2,
|
||||
"vote_count": 24000,
|
||||
"adult": False,
|
||||
"imdb_id": "tt0133093",
|
||||
"genres": [{"id": 28, "name": "Action"}, {"id": 878, "name": "Science Fiction"}],
|
||||
"production_companies": [
|
||||
{"id": 79, "name": "Village Roadshow Pictures", "origin_country": "US"}
|
||||
],
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"belongs_to_collection": {"id": 2344, "name": "The Matrix Collection"},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 6384,
|
||||
"name": "Keanu Reeves",
|
||||
"character": "Neo",
|
||||
"order": 0,
|
||||
"profile_path": "/keanu.jpg",
|
||||
"gender": 2,
|
||||
}
|
||||
],
|
||||
"crew": [
|
||||
{
|
||||
"id": 9339,
|
||||
"name": "Lana Wachowski",
|
||||
"department": "Directing",
|
||||
"job": "Director",
|
||||
"gender": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
"images": {
|
||||
"posters": [
|
||||
{"file_path": "/poster.jpg", "width": 2000, "height": 3000, "aspect_ratio": 0.667, "iso_639_1": "en", "vote_average": 8.1}
|
||||
],
|
||||
"backdrops": [],
|
||||
"logos": [],
|
||||
},
|
||||
"videos": {
|
||||
"results": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"type": "Trailer",
|
||||
"site": "YouTube",
|
||||
"key": "vKQi3bBA1y8",
|
||||
"iso_639_1": "en",
|
||||
"iso_3166_1": "US",
|
||||
"official": True,
|
||||
"published_at": "1999-03-01T00:00:00.000Z",
|
||||
}
|
||||
]
|
||||
},
|
||||
"keywords": {"keywords": [{"id": 1, "name": "dystopia"}]},
|
||||
"alternative_titles": {"titles": [{"title": "Matrix", "iso_3166_1": "US"}]},
|
||||
"external_ids": {"imdb_id": "tt0133093"},
|
||||
"recommendations": {"results": [{"id": 604}]},
|
||||
}
|
||||
|
||||
FAKE_PERSON = {
|
||||
"id": 6384,
|
||||
"name": "Keanu Reeves",
|
||||
"biography": "Keanu Charles Reeves is a Canadian actor.",
|
||||
"birthday": "1964-09-02",
|
||||
"deathday": None,
|
||||
"place_of_birth": "Beirut, Lebanon",
|
||||
"gender": 2,
|
||||
"known_for_department": "Acting",
|
||||
"popularity": 30.2,
|
||||
"also_known_as": ["Keanu Charles Reeves"],
|
||||
"profile_path": "/keanu.jpg",
|
||||
"images": {"profiles": [{"file_path": "/keanu.jpg", "width": 1000, "height": 1500, "aspect_ratio": 0.667, "iso_639_1": None, "vote_average": 5.0}]},
|
||||
"external_ids": {"imdb_id": "nm0000206"},
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_movie_maps_core_fields():
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
assert movie.external_id == "603"
|
||||
assert movie.title == "The Matrix"
|
||||
assert movie.status == "released"
|
||||
assert movie.release_date == date(1999, 3, 30)
|
||||
assert movie.runtime_minutes == 136
|
||||
assert movie.budget == 63000000
|
||||
assert movie.adult is False
|
||||
|
||||
|
||||
def test_normalize_movie_maps_genres_and_keywords():
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
assert movie.genres == ["Action", "Science Fiction"]
|
||||
assert movie.keywords == ["dystopia"]
|
||||
assert movie.alternate_titles == ["Matrix"]
|
||||
|
||||
|
||||
def test_normalize_movie_maps_cast_and_crew():
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
assert len(movie.cast) == 1
|
||||
assert movie.cast[0].external_id == "6384"
|
||||
assert movie.cast[0].department == "Acting"
|
||||
assert movie.cast[0].character == "Neo"
|
||||
assert movie.cast[0].gender == "male"
|
||||
|
||||
assert len(movie.crew) == 1
|
||||
assert movie.crew[0].department == "Directing"
|
||||
assert movie.crew[0].job == "Director"
|
||||
assert movie.crew[0].gender == "female"
|
||||
|
||||
|
||||
def test_normalize_movie_maps_artwork_and_videos():
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
poster_types = [a.artwork_type for a in movie.artwork]
|
||||
assert poster_types.count("poster") >= 1
|
||||
assert all(a.url.startswith("https://image.tmdb.org/t/p/original/") for a in movie.artwork)
|
||||
|
||||
assert len(movie.videos) == 1
|
||||
assert movie.videos[0].url == "https://www.youtube.com/watch?v=vKQi3bBA1y8"
|
||||
assert movie.videos[0].is_official is True
|
||||
|
||||
|
||||
def test_normalize_movie_external_ids_never_include_tmdb_itself():
|
||||
# TMDb's own id is handled separately by the ingestion pipeline via
|
||||
# `normalized.external_id`, not folded into `external_ids` (section 3:
|
||||
# Aurora doesn't conflate its own identity with provider identity).
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
assert "tmdb" not in movie.external_ids
|
||||
assert movie.external_ids["imdb"] == "tt0133093"
|
||||
|
||||
|
||||
def test_normalize_movie_maps_collection_and_recommendations():
|
||||
movie = normalize_movie(FAKE_MOVIE)
|
||||
assert movie.collection == ("2344", "The Matrix Collection")
|
||||
assert movie.recommendation_external_ids == ["604"]
|
||||
|
||||
|
||||
def test_normalize_movie_handles_missing_optional_sections():
|
||||
minimal = {"id": 1, "title": "Minimal Movie"}
|
||||
movie = normalize_movie(minimal)
|
||||
assert movie.title == "Minimal Movie"
|
||||
assert movie.genres == []
|
||||
assert movie.cast == []
|
||||
assert movie.artwork == []
|
||||
assert movie.collection is None
|
||||
|
||||
|
||||
def test_normalize_person_maps_core_fields():
|
||||
person = normalize_person(FAKE_PERSON)
|
||||
assert person.external_id == "6384"
|
||||
assert person.name == "Keanu Reeves"
|
||||
assert person.birthday == date(1964, 9, 2)
|
||||
assert person.deathday is None
|
||||
assert person.gender == "male"
|
||||
assert person.external_ids["imdb"] == "nm0000206"
|
||||
assert "Keanu Charles Reeves" in person.aliases
|
||||
|
||||
|
||||
def test_normalize_person_profile_artwork_deduplicated():
|
||||
person = normalize_person(FAKE_PERSON)
|
||||
# profile_path duplicates an entry already in images.profiles; it must
|
||||
# not be inserted twice.
|
||||
paths = [a.provider_asset_id for a in person.artwork]
|
||||
assert paths.count("/keanu.jpg") == 1
|
||||
Reference in New Issue
Block a user