From a7a038beea632fdb0c851ecc912c7ffa361a4685 Mon Sep 17 00:00:00 2001 From: jlightner Date: Tue, 31 Mar 2026 05:59:36 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Added=20GET=20/topics/{category=5Fslug}?= =?UTF-8?q?/{subtopic=5Fslug}=20endpoint=20filter=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/routers/topics.py" - "backend/tests/test_public_api.py" GSD-Task: S01/T01 --- backend/routers/topics.py | 55 ++++++++++++++++++++++++ backend/tests/test_public_api.py | 74 +++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/backend/routers/topics.py b/backend/routers/topics.py index 0a43107..98dafc4 100644 --- a/backend/routers/topics.py +++ b/backend/routers/topics.py @@ -100,6 +100,61 @@ async def list_topics( return result +@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse) +async def get_subtopic_techniques( + category_slug: str, + subtopic_slug: str, + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 50, + db: AsyncSession = Depends(get_session), +) -> PaginatedResponse: + """Return technique pages filtered by sub-topic tag within a category. + + ``subtopic_slug`` is matched case-insensitively against elements of the + ``topic_tags`` ARRAY column. ``category_slug`` is normalised and matched + against ``topic_category`` to scope results to the correct category. + Results are eager-loaded with the creator relation. + """ + category_name = category_slug.replace("-", " ").title() + subtopic_name = subtopic_slug.replace("-", " ") + + # Filter: category matches AND subtopic_name appears in topic_tags. + # Tags are stored lowercase; slugs arrive lowercase. Use PG ARRAY contains (@>). + stmt = ( + select(TechniquePage) + .where(TechniquePage.topic_category.ilike(category_name)) + .where(TechniquePage.topic_tags.contains([subtopic_name])) + ) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = ( + stmt.options(selectinload(TechniquePage.creator)) + .order_by(TechniquePage.title) + .offset(offset) + .limit(limit) + ) + result = await db.execute(stmt) + pages = result.scalars().all() + + items = [] + for p in pages: + item = TechniquePageRead.model_validate(p) + if p.creator: + item.creator_name = p.creator.name + item.creator_slug = p.creator.slug + items.append(item) + + return PaginatedResponse( + items=items, + total=total, + offset=offset, + limit=limit, + ) + + @router.get("/{category_slug}", response_model=PaginatedResponse) async def get_topic_techniques( category_slug: str, diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py index 46eac31..fdddc92 100644 --- a/backend/tests/test_public_api.py +++ b/backend/tests/test_public_api.py @@ -65,7 +65,7 @@ async def _seed_full_data(db_engine) -> dict: file_path="AlphaCreator/bass-tutorial.mp4", duration_seconds=600, content_type=ContentType.tutorial, - processing_status=ProcessingStatus.extracted, + processing_status=ProcessingStatus.complete, ) video2 = SourceVideo( creator_id=creator2.id, @@ -73,7 +73,7 @@ async def _seed_full_data(db_engine) -> dict: file_path="BetaProducer/mixing-masterclass.mp4", duration_seconds=1200, content_type=ContentType.tutorial, - processing_status=ProcessingStatus.extracted, + processing_status=ProcessingStatus.complete, ) session.add_all([video1, video2]) await session.flush() @@ -295,6 +295,76 @@ async def test_topics_with_no_technique_pages(client, db_engine): assert st["creator_count"] == 0 +# ── Sub-Topic Tests ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_subtopic_techniques(client, db_engine): + """GET /topics/{category}/{subtopic} returns matching techniques with creator info.""" + seed = await _seed_full_data(db_engine) + + # "bass" tag appears on tp1 (Sound design) and tp3 (Synthesis). + # Filter to Sound design — only tp1 should match. + resp = await client.get(f"{TOPICS_URL}/sound-design/bass") + assert resp.status_code == 200 + + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + + item = data["items"][0] + assert item["slug"] == seed["tp1_slug"] + assert item["topic_category"] == "Sound design" + assert "bass" in item["topic_tags"] + # Creator relation should be populated + assert item["creator_name"] == seed["creator1_name"] + assert item["creator_slug"] == seed["creator1_slug"] + + +@pytest.mark.asyncio +async def test_get_subtopic_techniques_empty(client, db_engine): + """GET /topics/{category}/{subtopic} returns empty list for nonexistent sub-topic.""" + await _seed_full_data(db_engine) + + resp = await client.get(f"{TOPICS_URL}/mixing/nonexistent-tag") + assert resp.status_code == 200 + + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + +@pytest.mark.asyncio +async def test_get_subtopic_techniques_pagination(client, db_engine): + """GET /topics/{category}/{subtopic} respects offset and limit params.""" + await _seed_full_data(db_engine) + + # "bass" tag exists on both tp1 (Sound design) and tp3 (Synthesis). + # Synthesis has tp3 with tag "bass". + # Use Synthesis category so we only get tp3, then test pagination bounds. + # First: get all to verify baseline + resp = await client.get(f"{TOPICS_URL}/synthesis/bass") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + + # Offset beyond total returns empty items but total is still correct + resp = await client.get(f"{TOPICS_URL}/synthesis/bass?offset=10&limit=10") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["items"] == [] + + # Limit=1 with multiple results in Sound design (bass matches tp1 only there) + # Let's test with a tag that matches multiple in one category. + # "bass" in Sound design matches tp1; only 1 result. Use limit=1 to confirm. + resp = await client.get(f"{TOPICS_URL}/sound-design/bass?limit=1") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) <= 1 + + # ── Creator Tests ────────────────────────────────────────────────────────────