Compare commits

..

4 Commits

4 changed files with 451 additions and 0 deletions
+126
View File
@@ -10,18 +10,49 @@ 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
from app.models.world import World, WorldVisibility
from app.models.world_visit import WorldVisit
from app.services.trending import get_trending_subquery
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 +163,98 @@ 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 == "trending":
trending_subq = get_trending_subquery()
base_query = (
base_query
.outerjoin(trending_subq, World.id == trending_subq.c.trending_world_id)
.order_by(
func.coalesce(trending_subq.c.trending_score, 0.0).desc(),
World.play_count.desc(),
World.created_at.desc(),
)
)
elif 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,
)
+34
View File
@@ -0,0 +1,34 @@
"""Trending recommendation heuristic service.
docs/planning/discovery.md (task 33706f401ad9).
"""
from sqlalchemy import case, func, select, text
from app.models.world_visit import WorldVisit
def get_trending_subquery(days: int = 7):
"""Build a subquery that computes a decay-weighted trending score per world_id.
Decay weights:
- Visited within 24 hours: weight 3.0
- Visited within 3 days: weight 2.0
- Visited within 7 days: weight 1.0
"""
trending_weight = case(
(WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0),
(WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0),
else_=1.0,
)
interval_str = f"interval '{days} days'"
subq = (
select(
WorldVisit.world_id.label("trending_world_id"),
func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"),
)
.where(WorldVisit.visited_at >= func.now() - text(interval_str))
.group_by(WorldVisit.world_id)
.subquery("trending_scores")
)
return subq
+188
View File
@@ -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
+103
View File
@@ -0,0 +1,103 @@
"""API and heuristic tests for trending discovery sort.
docs/planning/discovery.md (task 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.world_visit import WorldVisit
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
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_trending_sort_heuristic_and_zero_visits():
client = TestClient(app)
uid, headers = _register(client, "creator_trending_1")
# Create 3 worlds
wA = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Trending Hot World", "visibility": "public", "category": "Action"},
).json()
wB = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Older Mild World", "visibility": "public", "category": "Action"},
).json()
wC = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Zero Visit World", "visibility": "public", "category": "Action"},
).json()
engine, sf = _session_factory()
now = datetime.now(timezone.utc)
try:
async with sf() as session:
# World A: 3 visits within last 2 hours (weight 3 each = score 9)
for i in range(3):
session.add(
WorldVisit(
world_id=wA["id"],
anonymous_token=f"hot_visitor_{i}",
visited_at=now - timedelta(hours=1, minutes=i),
)
)
# World B: 1 visit 5 days ago (weight 1 = score 1)
session.add(
WorldVisit(
world_id=wB["id"],
anonymous_token="old_visitor",
visited_at=now - timedelta(days=5),
)
)
# World C: zero visits
await session.commit()
finally:
await engine.dispose()
# Query with sort=trending
resp = client.get("/api/discovery/worlds?sort=trending&category=Action")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
# Verify order: wA should be first, wB second, wC third
ids = [item["id"] for item in items]
assert wA["id"] in ids
assert wB["id"] in ids
assert wC["id"] in ids
idxA = ids.index(wA["id"])
idxB = ids.index(wB["id"])
idxC = ids.index(wC["id"])
assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"