diff --git a/backend/app/routers/discovery.py b/backend/app/routers/discovery.py index 388462d..5ab16cc 100644 --- a/backend/app/routers/discovery.py +++ b/backend/app/routers/discovery.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database import get_db from app.dependencies import OptionalUser @@ -21,7 +22,35 @@ router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] +class WorldOwnerSummary(BaseModel): + id: int + username: str + + model_config = {"from_attributes": True} + + +class DiscoveredWorld(BaseModel): + id: int + name: str + description: str | None = None + category: str | None = None + tags: list[str] = [] + play_count: int = 0 + created_at: datetime | None = None + owner: WorldOwnerSummary + + model_config = {"from_attributes": True} + + +class DiscoveryListResponse(BaseModel): + items: list[DiscoveredWorld] + total: int + skip: int + limit: int + + class VisitRequest(BaseModel): + anonymous_token: str | None = None idempotency_key: str | None = None @@ -132,3 +161,86 @@ async def record_world_visit( play_count=world.play_count, last_played_at=world.last_played_at, ) + + +@router.get("/categories", response_model=list[str]) +async def list_categories( + db: DbDep, +): + """List distinct categories of public worlds.""" + res = await db.execute( + select(World.category) + .where(World.category.is_not(None), World.visibility == WorldVisibility.PUBLIC) + .distinct() + .order_by(World.category) + ) + categories = [c for c in res.scalars().all() if c] + return categories + + +@router.get("/worlds", response_model=DiscoveryListResponse) +async def list_discovery_worlds( + db: DbDep, + current_user: OptionalUser = None, + q: str | None = Query(None, description="Substring search on world name"), + tags: list[str] | None = Query(None, description="Filter by tags"), + category: str | None = Query(None, description="Exact category filter"), + sort: str = Query("newest", description="Sort order (newest, most_played)"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), +): + """List public worlds for discovery with search, category/tag filters, and pagination.""" + base_query = select(World).where(World.visibility == WorldVisibility.PUBLIC) + + if q: + base_query = base_query.where(World.name.ilike(f"%{q}%")) + + if category: + base_query = base_query.where(func.lower(World.category) == category.lower()) + + if tags: + parsed_tags = [t.strip() for item in tags for t in item.split(",") if t.strip()] + if parsed_tags: + base_query = base_query.where(World.tags.overlap(parsed_tags)) + + # Count total matching worlds + count_query = select(func.count()).select_from(base_query.subquery()) + total = (await db.execute(count_query)).scalar_one() + + # Sort + if sort == "most_played": + base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc()) + else: # default: newest + base_query = base_query.order_by(World.created_at.desc()) + + # Pagination & load owner + query = ( + base_query + .offset(skip) + .limit(limit) + .options(selectinload(World.owner)) + ) + result = await db.execute(query) + worlds = result.scalars().all() + + items = [ + DiscoveredWorld( + id=w.id, + name=w.name, + description=w.description, + category=w.category, + tags=w.tags or [], + play_count=w.play_count, + created_at=w.created_at, + owner=WorldOwnerSummary(id=w.owner.id, username=w.owner.username), + ) + for w in worlds + ] + + return DiscoveryListResponse( + items=items, + total=total, + skip=skip, + limit=limit, + ) + diff --git a/backend/tests/test_discovery_browse_api.py b/backend/tests/test_discovery_browse_api.py new file mode 100644 index 0000000..98c98a1 --- /dev/null +++ b/backend/tests/test_discovery_browse_api.py @@ -0,0 +1,188 @@ +"""API tests for discovery browse & search endpoints. + +docs/planning/discovery.md (task 300da1dcddf3). +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"], headers + + +@pytest.mark.asyncio +async def test_discovery_public_worlds_only_and_fields(): + client = TestClient(app) + uid, headers = _register(client, "creator_browse_1") + + # Create public world + pub_resp = client.post( + "/api/worlds/", + headers=headers, + json={ + "name": "Fantasy Kingdom", + "description": "A magical realm", + "visibility": "public", + "category": "RPG", + "tags": ["magic", "dragons"], + }, + ) + assert pub_resp.status_code == 201, pub_resp.text + pub_id = pub_resp.json()["id"] + + # Create private world + priv_resp = client.post( + "/api/worlds/", + headers=headers, + json={ + "name": "Secret Base", + "description": "Classified", + "visibility": "private", + "category": "RPG", + "tags": ["secret"], + }, + ) + assert priv_resp.status_code == 201, priv_resp.text + + # 1. Anonymous listing: only public world is returned + anon_resp = client.get("/api/discovery/worlds") + assert anon_resp.status_code == 200, anon_resp.text + data = anon_resp.json() + assert data["total"] >= 1 + items = data["items"] + ids = [w["id"] for w in items] + assert pub_id in ids + assert priv_resp.json()["id"] not in ids + + # Check fields of returned item + pub_item = next(w for w in items if w["id"] == pub_id) + assert pub_item["name"] == "Fantasy Kingdom" + assert pub_item["description"] == "A magical realm" + assert pub_item["category"] == "RPG" + assert "magic" in pub_item["tags"] + assert pub_item["play_count"] == 0 + assert pub_item["owner"]["id"] == uid + assert pub_item["owner"]["username"] == "creator_browse_1" + + # 2. Authenticated caller also does NOT see private worlds in discovery + auth_resp = client.get("/api/discovery/worlds", headers=headers) + assert auth_resp.status_code == 200 + auth_ids = [w["id"] for w in auth_resp.json()["items"]] + assert priv_resp.json()["id"] not in auth_ids + + +@pytest.mark.asyncio +async def test_discovery_filtering_and_categories(): + client = TestClient(app) + uid, headers = _register(client, "creator_filter_2") + + # Create distinct worlds + client.post( + "/api/worlds/", + headers=headers, + json={"name": "Cyber Arena", "visibility": "public", "category": "Action", "tags": ["pvp", "fps"]}, + ) + client.post( + "/api/worlds/", + headers=headers, + json={"name": "Peaceful Meadow", "visibility": "public", "category": "Social", "tags": ["relax", "hangout"]}, + ) + client.post( + "/api/worlds/", + headers=headers, + json={"name": "Cyber Hangout", "visibility": "public", "category": "Social", "tags": ["cyberpunk", "chat"]}, + ) + + # Search by q + res_q = client.get("/api/discovery/worlds?q=cyber") + assert res_q.status_code == 200 + names = [w["name"] for w in res_q.json()["items"]] + assert "Cyber Arena" in names + assert "Cyber Hangout" in names + assert "Peaceful Meadow" not in names + + # Filter by category (case-insensitive) + res_cat = client.get("/api/discovery/worlds?category=action") + assert res_cat.status_code == 200 + names = [w["name"] for w in res_cat.json()["items"]] + assert "Cyber Arena" in names + assert "Peaceful Meadow" not in names + + # Filter by tags (overlap) + res_tags = client.get("/api/discovery/worlds?tags=relax,pvp") + assert res_tags.status_code == 200 + names = [w["name"] for w in res_tags.json()["items"]] + assert "Peaceful Meadow" in names + assert "Cyber Arena" in names + assert "Cyber Hangout" not in names + + # Get distinct categories + res_cats = client.get("/api/discovery/categories") + assert res_cats.status_code == 200 + cats = res_cats.json() + assert "Action" in cats + assert "Social" in cats + + +@pytest.mark.asyncio +async def test_discovery_sorting_and_pagination(): + client = TestClient(app) + uid, headers = _register(client, "creator_sort_3") + + w1 = client.post( + "/api/worlds/", + headers=headers, + json={"name": "Sort World 1", "visibility": "public"}, + ).json() + w2 = client.post( + "/api/worlds/", + headers=headers, + json={"name": "Sort World 2", "visibility": "public"}, + ).json() + + # Record visits on w1 so play_count = 1 + v_res = client.post(f"/api/discovery/worlds/{w1['id']}/visit") + assert v_res.status_code == 200 + + # Sort by most_played + res_mp = client.get("/api/discovery/worlds?sort=most_played") + assert res_mp.status_code == 200 + items = res_mp.json()["items"] + # w1 should come before w2 + idx_w1 = next(i for i, w in enumerate(items) if w["id"] == w1["id"]) + idx_w2 = next(i for i, w in enumerate(items) if w["id"] == w2["id"]) + assert idx_w1 < idx_w2 + + # Sort by newest (default) + res_new = client.get("/api/discovery/worlds?sort=newest") + assert res_new.status_code == 200 + items_new = res_new.json()["items"] + idx_w1_n = next(i for i, w in enumerate(items_new) if w["id"] == w1["id"]) + idx_w2_n = next(i for i, w in enumerate(items_new) if w["id"] == w2["id"]) + # w2 was created after w1, so w2 is newer + assert idx_w2_n < idx_w1_n + + # Pagination: limit 1, skip 0 + p1 = client.get("/api/discovery/worlds?limit=1&skip=0") + assert p1.status_code == 200 + assert len(p1.json()["items"]) == 1 + assert p1.json()["total"] >= 2 + assert p1.json()["skip"] == 0 + assert p1.json()["limit"] == 1