Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38dc0cab21 | |||
| 892e330456 |
+3
-1
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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