Merge branch 'agent/42726e60fe10-world-visit-tracking' into main
checkpoint / checkpoint (push) Successful in 5m43s
checkpoint / checkpoint (push) Successful in 5m43s
This commit is contained in:
+3
-1
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user