From 38dc0cab211bf0706a1584d6144f019a83a0bfa3 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 20:57:25 +0000 Subject: [PATCH] feat(discovery): world visit tracking endpoint with atomic play_count increment, last_played_at, and deduplication --- backend/app/main.py | 4 +- backend/app/routers/discovery.py | 134 ++++++++++++++++++ backend/tests/test_discovery_visit_api.py | 164 ++++++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/discovery.py create mode 100644 backend/tests/test_discovery_visit_api.py diff --git a/backend/app/main.py b/backend/app/main.py index e817b08..36e3c76 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/routers/discovery.py b/backend/app/routers/discovery.py new file mode 100644 index 0000000..388462d --- /dev/null +++ b/backend/app/routers/discovery.py @@ -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, + ) diff --git a/backend/tests/test_discovery_visit_api.py b/backend/tests/test_discovery_visit_api.py new file mode 100644 index 0000000..a2801af --- /dev/null +++ b/backend/tests/test_discovery_visit_api.py @@ -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