"""Integration tests for the public S05 API endpoints: techniques, topics, and enhanced creators. Tests run against a real PostgreSQL test database via httpx.AsyncClient. """ from __future__ import annotations import uuid import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from models import ( ContentType, Creator, KeyMoment, KeyMomentContentType, ProcessingStatus, RelatedTechniqueLink, RelationshipType, SourceVideo, TechniquePage, ) TECHNIQUES_URL = "/api/v1/techniques" TOPICS_URL = "/api/v1/topics" CREATORS_URL = "/api/v1/creators" # ── Seed helpers ───────────────────────────────────────────────────────────── async def _seed_full_data(db_engine) -> dict: """Seed 2 creators, 2 videos, 3 technique pages, key moments, and a related link. Returns a dict of IDs and metadata for assertions. """ session_factory = async_sessionmaker( db_engine, class_=AsyncSession, expire_on_commit=False ) async with session_factory() as session: # Creators creator1 = Creator( name="Alpha Creator", slug="alpha-creator", genres=["Bass music", "Dubstep"], folder_name="AlphaCreator", ) creator2 = Creator( name="Beta Producer", slug="beta-producer", genres=["House", "Techno"], folder_name="BetaProducer", ) session.add_all([creator1, creator2]) await session.flush() # Videos video1 = SourceVideo( creator_id=creator1.id, filename="bass-tutorial.mp4", file_path="AlphaCreator/bass-tutorial.mp4", duration_seconds=600, content_type=ContentType.tutorial, processing_status=ProcessingStatus.extracted, ) video2 = SourceVideo( creator_id=creator2.id, filename="mixing-masterclass.mp4", file_path="BetaProducer/mixing-masterclass.mp4", duration_seconds=1200, content_type=ContentType.tutorial, processing_status=ProcessingStatus.extracted, ) session.add_all([video1, video2]) await session.flush() # Technique pages tp1 = TechniquePage( creator_id=creator1.id, title="Reese Bass Design", slug="reese-bass-design", topic_category="Sound design", topic_tags=["bass", "textures"], summary="Classic reese bass creation", body_sections={"intro": "Getting started with reese bass"}, ) tp2 = TechniquePage( creator_id=creator2.id, title="Granular Pad Textures", slug="granular-pad-textures", topic_category="Synthesis", topic_tags=["granular", "pads"], summary="Creating evolving pad textures", ) tp3 = TechniquePage( creator_id=creator1.id, title="FM Bass Layering", slug="fm-bass-layering", topic_category="Synthesis", topic_tags=["fm", "bass"], summary="FM synthesis for bass layers", ) session.add_all([tp1, tp2, tp3]) await session.flush() # Key moments km1 = KeyMoment( source_video_id=video1.id, technique_page_id=tp1.id, title="Oscillator setup", summary="Setting up the initial oscillator", start_time=10.0, end_time=60.0, content_type=KeyMomentContentType.technique, ) km2 = KeyMoment( source_video_id=video1.id, technique_page_id=tp1.id, title="Distortion chain", summary="Adding distortion to the reese", start_time=60.0, end_time=120.0, content_type=KeyMomentContentType.technique, ) km3 = KeyMoment( source_video_id=video2.id, technique_page_id=tp2.id, title="Granular engine parameters", summary="Configuring the granular engine", start_time=20.0, end_time=80.0, content_type=KeyMomentContentType.settings, ) session.add_all([km1, km2, km3]) await session.flush() # Related technique link: tp1 → tp3 (same_creator_adjacent) link = RelatedTechniqueLink( source_page_id=tp1.id, target_page_id=tp3.id, relationship=RelationshipType.same_creator_adjacent, ) session.add(link) await session.commit() return { "creator1_id": str(creator1.id), "creator1_name": creator1.name, "creator1_slug": creator1.slug, "creator2_id": str(creator2.id), "creator2_name": creator2.name, "creator2_slug": creator2.slug, "video1_id": str(video1.id), "video2_id": str(video2.id), "tp1_slug": tp1.slug, "tp1_title": tp1.title, "tp2_slug": tp2.slug, "tp3_slug": tp3.slug, "tp3_title": tp3.title, } # ── Technique Tests ────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_list_techniques(client, db_engine): """GET /techniques returns a paginated list of technique pages.""" seed = await _seed_full_data(db_engine) resp = await client.get(TECHNIQUES_URL) assert resp.status_code == 200 data = resp.json() assert data["total"] == 3 assert len(data["items"]) == 3 # Each item has required fields slugs = {item["slug"] for item in data["items"]} assert seed["tp1_slug"] in slugs assert seed["tp2_slug"] in slugs assert seed["tp3_slug"] in slugs @pytest.mark.asyncio async def test_list_techniques_with_category_filter(client, db_engine): """GET /techniques?category=Synthesis returns only Synthesis technique pages.""" await _seed_full_data(db_engine) resp = await client.get(TECHNIQUES_URL, params={"category": "Synthesis"}) assert resp.status_code == 200 data = resp.json() assert data["total"] == 2 for item in data["items"]: assert item["topic_category"] == "Synthesis" @pytest.mark.asyncio async def test_get_technique_detail(client, db_engine): """GET /techniques/{slug} returns full detail with key_moments, creator_info, and related_links.""" seed = await _seed_full_data(db_engine) resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") assert resp.status_code == 200 data = resp.json() assert data["title"] == seed["tp1_title"] assert data["slug"] == seed["tp1_slug"] assert data["topic_category"] == "Sound design" # Key moments: tp1 has 2 key moments assert len(data["key_moments"]) == 2 km_titles = {km["title"] for km in data["key_moments"]} assert "Oscillator setup" in km_titles assert "Distortion chain" in km_titles # Creator info assert data["creator_info"] is not None assert data["creator_info"]["name"] == seed["creator1_name"] assert data["creator_info"]["slug"] == seed["creator1_slug"] # Related links: tp1 → tp3 (same_creator_adjacent) assert len(data["related_links"]) >= 1 related_slugs = {link["target_slug"] for link in data["related_links"]} assert seed["tp3_slug"] in related_slugs @pytest.mark.asyncio async def test_get_technique_invalid_slug_returns_404(client, db_engine): """GET /techniques/{invalid-slug} returns 404.""" await _seed_full_data(db_engine) resp = await client.get(f"{TECHNIQUES_URL}/nonexistent-slug-xyz") assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() # ── Topics Tests ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_list_topics_hierarchy(client, db_engine): """GET /topics returns category hierarchy with counts matching seeded data.""" await _seed_full_data(db_engine) resp = await client.get(TOPICS_URL) assert resp.status_code == 200 data = resp.json() # Should have the 6 categories from canonical_tags.yaml assert len(data) == 6 category_names = {cat["name"] for cat in data} assert "Sound design" in category_names assert "Synthesis" in category_names assert "Mixing" in category_names # Check Sound design category — should have "bass" sub-topic with count sound_design = next(c for c in data if c["name"] == "Sound design") bass_sub = next( (st for st in sound_design["sub_topics"] if st["name"] == "bass"), None ) assert bass_sub is not None # tp1 (tags: ["bass", "textures"]) and tp3 (tags: ["fm", "bass"]) both have "bass" assert bass_sub["technique_count"] == 2 # Both from creator1 assert bass_sub["creator_count"] == 1 # Check Synthesis category — "granular" sub-topic synthesis = next(c for c in data if c["name"] == "Synthesis") granular_sub = next( (st for st in synthesis["sub_topics"] if st["name"] == "granular"), None ) assert granular_sub is not None assert granular_sub["technique_count"] == 1 assert granular_sub["creator_count"] == 1 @pytest.mark.asyncio async def test_topics_with_no_technique_pages(client, db_engine): """GET /topics with no seeded data returns categories with zero counts.""" # No data seeded — just use the clean DB resp = await client.get(TOPICS_URL) assert resp.status_code == 200 data = resp.json() assert len(data) == 6 # All sub-topic counts should be zero for category in data: for st in category["sub_topics"]: assert st["technique_count"] == 0 assert st["creator_count"] == 0 # ── Creator Tests ──────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_list_creators_random_sort(client, db_engine): """GET /creators?sort=random returns all creators (order may vary).""" seed = await _seed_full_data(db_engine) resp = await client.get(CREATORS_URL, params={"sort": "random"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 2 names = {item["name"] for item in data} assert seed["creator1_name"] in names assert seed["creator2_name"] in names # Each item has technique_count and video_count for item in data: assert "technique_count" in item assert "video_count" in item @pytest.mark.asyncio async def test_list_creators_alpha_sort(client, db_engine): """GET /creators?sort=alpha returns creators in alphabetical order.""" seed = await _seed_full_data(db_engine) resp = await client.get(CREATORS_URL, params={"sort": "alpha"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 2 # "Alpha Creator" < "Beta Producer" alphabetically assert data[0]["name"] == "Alpha Creator" assert data[1]["name"] == "Beta Producer" @pytest.mark.asyncio async def test_list_creators_genre_filter(client, db_engine): """GET /creators?genre=Bass+music returns only matching creators.""" seed = await _seed_full_data(db_engine) resp = await client.get(CREATORS_URL, params={"genre": "Bass music"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["name"] == seed["creator1_name"] assert data[0]["slug"] == seed["creator1_slug"] @pytest.mark.asyncio async def test_get_creator_detail(client, db_engine): """GET /creators/{slug} returns detail with video_count.""" seed = await _seed_full_data(db_engine) resp = await client.get(f"{CREATORS_URL}/{seed['creator1_slug']}") assert resp.status_code == 200 data = resp.json() assert data["name"] == seed["creator1_name"] assert data["slug"] == seed["creator1_slug"] assert data["video_count"] == 1 # creator1 has 1 video @pytest.mark.asyncio async def test_get_creator_invalid_slug_returns_404(client, db_engine): """GET /creators/{invalid-slug} returns 404.""" await _seed_full_data(db_engine) resp = await client.get(f"{CREATORS_URL}/nonexistent-creator-xyz") assert resp.status_code == 404 @pytest.mark.asyncio async def test_creators_with_counts(client, db_engine): """GET /creators returns correct technique_count and video_count.""" seed = await _seed_full_data(db_engine) resp = await client.get(CREATORS_URL, params={"sort": "alpha"}) assert resp.status_code == 200 data = resp.json() # Alpha Creator: 2 technique pages, 1 video alpha = data[0] assert alpha["name"] == "Alpha Creator" assert alpha["technique_count"] == 2 assert alpha["video_count"] == 1 # Beta Producer: 1 technique page, 1 video beta = data[1] assert beta["name"] == "Beta Producer" assert beta["technique_count"] == 1 assert beta["video_count"] == 1 @pytest.mark.asyncio async def test_creators_empty_list(client, db_engine): """GET /creators with no creators returns empty list.""" # No data seeded resp = await client.get(CREATORS_URL) assert resp.status_code == 200 data = resp.json() assert data == []