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,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"] == []
|
||||
Reference in New Issue
Block a user