feat(discovery): browse and search endpoint with name/tag/category filters, sorting, and categories listing
This commit is contained in:
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import OptionalUser
|
from app.dependencies import OptionalUser
|
||||||
@@ -21,7 +22,35 @@ router = APIRouter()
|
|||||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
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):
|
class VisitRequest(BaseModel):
|
||||||
|
|
||||||
anonymous_token: str | None = None
|
anonymous_token: str | None = None
|
||||||
idempotency_key: str | None = None
|
idempotency_key: str | None = None
|
||||||
|
|
||||||
@@ -132,3 +161,86 @@ async def record_world_visit(
|
|||||||
play_count=world.play_count,
|
play_count=world.play_count,
|
||||||
last_played_at=world.last_played_at,
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user