feat(discovery): world visit tracking endpoint with atomic play_count increment, last_played_at, and deduplication

This commit is contained in:
2026-09-15 20:57:25 +00:00
parent 892e330456
commit 38dc0cab21
3 changed files with 301 additions and 1 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.routers import ( from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal, auth, users, worlds, assets, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports, 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 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(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"]) app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"]) 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="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"]) app.include_router(ws.router, prefix="/v1", tags=["websocket"])
+134
View File
@@ -0,0 +1,134 @@
"""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 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 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,
)
+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