test: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filter…
- "backend/routers/topics.py" - "backend/tests/test_public_api.py" GSD-Task: S01/T01
This commit is contained in:
parent
348089d635
commit
a7a038beea
2 changed files with 127 additions and 2 deletions
|
|
@ -100,6 +100,61 @@ async def list_topics(
|
||||||
return result
|
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)
|
@router.get("/{category_slug}", response_model=PaginatedResponse)
|
||||||
async def get_topic_techniques(
|
async def get_topic_techniques(
|
||||||
category_slug: str,
|
category_slug: str,
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ async def _seed_full_data(db_engine) -> dict:
|
||||||
file_path="AlphaCreator/bass-tutorial.mp4",
|
file_path="AlphaCreator/bass-tutorial.mp4",
|
||||||
duration_seconds=600,
|
duration_seconds=600,
|
||||||
content_type=ContentType.tutorial,
|
content_type=ContentType.tutorial,
|
||||||
processing_status=ProcessingStatus.extracted,
|
processing_status=ProcessingStatus.complete,
|
||||||
)
|
)
|
||||||
video2 = SourceVideo(
|
video2 = SourceVideo(
|
||||||
creator_id=creator2.id,
|
creator_id=creator2.id,
|
||||||
|
|
@ -73,7 +73,7 @@ async def _seed_full_data(db_engine) -> dict:
|
||||||
file_path="BetaProducer/mixing-masterclass.mp4",
|
file_path="BetaProducer/mixing-masterclass.mp4",
|
||||||
duration_seconds=1200,
|
duration_seconds=1200,
|
||||||
content_type=ContentType.tutorial,
|
content_type=ContentType.tutorial,
|
||||||
processing_status=ProcessingStatus.extracted,
|
processing_status=ProcessingStatus.complete,
|
||||||
)
|
)
|
||||||
session.add_all([video1, video2])
|
session.add_all([video1, video2])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
@ -295,6 +295,76 @@ async def test_topics_with_no_technique_pages(client, db_engine):
|
||||||
assert st["creator_count"] == 0
|
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 ────────────────────────────────────────────────────────────
|
# ── Creator Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue