Compare commits

...

4 Commits

4 changed files with 601 additions and 1 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace, payouts,
chat_world, wallet, payments, marketplace, payouts, discovery,
)
from app.gateway import ws
@@ -62,7 +62,9 @@ app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
+246
View File
@@ -0,0 +1,246 @@
"""Discovery router - visit tracking and world browsing.
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
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
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
class VisitResponse(BaseModel):
play_count: int
last_played_at: datetime | None = None
model_config = {"from_attributes": True}
DEDUP_WINDOW_SECONDS = 60
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
async def record_world_visit(
world_id: int,
request: Request,
db: DbDep,
current_user: OptionalUser = None,
visit_data: VisitRequest | None = None,
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
idempotency_key: str | None = Query(None),
):
"""Record a visit to a world.
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
- Updates worlds.last_played_at to func.now().
- Records row in world_visits (with world_id, user_id nullable, visited_at).
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
"""
# 1. Fetch world
res = await db.execute(select(World).where(World.id == world_id))
world = res.scalar_one_or_none()
if not world:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
# 2. Check visibility: public or owned by requesting user
if world.visibility != WorldVisibility.PUBLIC:
if not current_user or world.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
# Determine token / idempotency key
explicit_key = (
(visit_data.idempotency_key if visit_data else None)
or (visit_data.anonymous_token if visit_data else None)
or x_idempotency_key
or idempotency_key
)
fallback_host = request.client.host if request.client else "unknown_client"
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
# 3. Deduplication check
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
is_duplicate = False
if current_user:
dedup_res = await db.execute(
select(WorldVisit).where(
WorldVisit.world_id == world_id,
WorldVisit.user_id == current_user.id,
WorldVisit.visited_at >= cutoff,
).limit(1)
)
if dedup_res.scalar_one_or_none():
is_duplicate = True
elif dedup_key:
dedup_res = await db.execute(
select(WorldVisit).where(
WorldVisit.world_id == world_id,
WorldVisit.anonymous_token == dedup_key,
WorldVisit.visited_at >= cutoff,
).limit(1)
)
if dedup_res.scalar_one_or_none():
is_duplicate = True
if is_duplicate:
return VisitResponse(
play_count=world.play_count,
last_played_at=world.last_played_at,
)
# 4. Atomic increment and visit log in single transaction
await db.execute(
update(World)
.where(World.id == world_id)
.values(
play_count=World.play_count + 1,
last_played_at=func.now(),
)
)
visit = WorldVisit(
world_id=world_id,
user_id=current_user.id if current_user else None,
anonymous_token=dedup_key if not current_user else None,
)
db.add(visit)
await db.commit()
# Re-fetch or refresh to return updated fields
await db.refresh(world)
return VisitResponse(
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,
)
+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
+164
View File
@@ -0,0 +1,164 @@
"""API tests for world visit tracking endpoint.
docs/planning/discovery.md (task 42726e60fe10).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
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 import World, WorldVisibility
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_visit_world_atomic_increment_authenticated():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_1")
visitor_id, visitor_headers = _register(client, "visitor_user_1")
# Create world
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Neon City", "visibility": "public", "category": "Roleplay"},
)
assert resp.status_code == 201, resp.text
world_id = resp.json()["id"]
# Visitor visits the world
v_resp = client.post(
f"/api/discovery/worlds/{world_id}/visit",
headers=visitor_headers,
)
assert v_resp.status_code == 200, v_resp.text
data = v_resp.json()
assert data["play_count"] == 1
assert data["last_played_at"] is not None
# Verify DB state
engine, sf = _session_factory()
try:
async with sf() as session:
w = (await session.execute(select(World).where(World.id == world_id))).scalar_one()
assert w.play_count == 1
assert w.last_played_at is not None
visits = (
await session.execute(
select(WorldVisit).where(WorldVisit.world_id == world_id)
)
).scalars().all()
assert len(visits) == 1
assert visits[0].user_id == visitor_id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_visit_world_anonymous_and_idempotency():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_2")
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Space Station", "visibility": "public"},
)
world_id = resp.json()["id"]
# Anonymous visit with token
anon_token = "guest_token_999"
v_resp1 = client.post(
f"/api/discovery/worlds/{world_id}/visit",
json={"anonymous_token": anon_token},
)
assert v_resp1.status_code == 200, v_resp1.text
assert v_resp1.json()["play_count"] == 1
# Idempotent second call with same anonymous token
v_resp2 = client.post(
f"/api/discovery/worlds/{world_id}/visit",
json={"anonymous_token": anon_token},
)
assert v_resp2.status_code == 200, v_resp2.text
assert v_resp2.json()["play_count"] == 1
engine, sf = _session_factory()
try:
async with sf() as session:
visits = (
await session.execute(
select(WorldVisit).where(WorldVisit.world_id == world_id)
)
).scalars().all()
assert len(visits) == 1
assert visits[0].user_id is None
assert visits[0].anonymous_token == anon_token
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_visit_world_visibility_and_404():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_3")
other_id, other_headers = _register(client, "other_user_3")
# 404 on non-existent world
res_404 = client.post("/api/discovery/worlds/999999/visit", headers=owner_headers)
assert res_404.status_code == 404
# Private world
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Secret World", "visibility": "private"},
)
priv_world_id = resp.json()["id"]
# Other user gets 404
other_res = client.post(
f"/api/discovery/worlds/{priv_world_id}/visit",
headers=other_headers,
)
assert other_res.status_code == 404
# Anonymous user gets 404
anon_res = client.post(f"/api/discovery/worlds/{priv_world_id}/visit")
assert anon_res.status_code == 404
# Owner gets 200
owner_res = client.post(
f"/api/discovery/worlds/{priv_world_id}/visit",
headers=owner_headers,
)
assert owner_res.status_code == 200
assert owner_res.json()["play_count"] == 1