Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747eebaa83 | |||
| 1aa74c9bfe | |||
| 1aae4c00f0 |
@@ -8,6 +8,3 @@ export/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Godot UID files (regenerated from path)
|
||||
*.uid
|
||||
|
||||
@@ -4,13 +4,10 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT / Auth
|
||||
ALGORITHM=RS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
# Directory where the RSA keypair is loaded/generated at runtime.
|
||||
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
|
||||
RSA_KEY_DIR=~/.agp/keys
|
||||
# JWT
|
||||
SECRET_KEY=change-me-to-a-secure-random-string
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# App
|
||||
APP_NAME=AGP Backend
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""add refresh_tokens table
|
||||
|
||||
Revision ID: 0003_add_refresh_tokens_table
|
||||
Revises: 0002_add_date_of_birth_to_users
|
||||
Create Date: 2025-07-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0003_add_refresh_tokens_table"
|
||||
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the refresh_tokens table."""
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the refresh_tokens table."""
|
||||
op.drop_table("refresh_tokens")
|
||||
+5
-80
@@ -1,56 +1,10 @@
|
||||
"""Application configuration via pydantic-settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||
RSAPrivateKey,
|
||||
RSAPublicKey,
|
||||
generate_private_key,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
|
||||
"""Load existing RSA keys or generate a new pair.
|
||||
|
||||
Keys are stored as PEM files in *key_dir*. The directory is created
|
||||
if it does not exist. Files are named ``private.pem`` and
|
||||
``public.pem`` and are **not** committed to git.
|
||||
"""
|
||||
private_path = key_dir / "private.pem"
|
||||
public_path = key_dir / "public.pem"
|
||||
|
||||
if private_path.exists() and public_path.exists():
|
||||
private_pem = private_path.read_bytes()
|
||||
public_pem = public_path.read_bytes()
|
||||
else:
|
||||
# Generate a new 2048-bit RSA key pair
|
||||
private_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.PKCS8,
|
||||
NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
Encoding.PEM,
|
||||
PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
key_dir.mkdir(parents=True, exist_ok=True)
|
||||
private_path.write_bytes(private_pem)
|
||||
public_path.write_bytes(public_pem)
|
||||
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "AGP Backend"
|
||||
@@ -64,13 +18,11 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT / Auth
|
||||
ALGORITHM: str = "RS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
# RS256 key storage. Defaults to a directory outside the repo so the
|
||||
# generated keypair is never accidentally committed.
|
||||
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
|
||||
# NOTE: production must override SECRET_KEY with a secure random value
|
||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||
@@ -78,7 +30,6 @@ class Settings(BaseSettings):
|
||||
# File uploads
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -90,32 +41,6 @@ class Settings(BaseSettings):
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
@property
|
||||
def rsa_key_dir(self) -> Path:
|
||||
return Path(self.RSA_KEY_DIR)
|
||||
|
||||
@property
|
||||
def private_pem(self) -> bytes:
|
||||
"""Return the private key PEM bytes (loads/generates on first call)."""
|
||||
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return private_pem
|
||||
|
||||
@property
|
||||
def public_pem(self) -> bytes:
|
||||
"""Return the public key PEM bytes (loads/generates on first call)."""
|
||||
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return public_pem
|
||||
|
||||
@property
|
||||
def private_key(self) -> RSAPrivateKey:
|
||||
"""Return the deserialized RSA private key object."""
|
||||
return load_pem_private_key(self.private_pem, password=None)
|
||||
|
||||
@property
|
||||
def public_key(self) -> RSAPublicKey:
|
||||
"""Return the deserialized RSA public key object."""
|
||||
return load_pem_public_key(self.public_pem)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
|
||||
@@ -3,7 +3,6 @@ import uuid
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@@ -19,23 +18,3 @@ class Session(Base):
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
|
||||
token = Column(String(512), unique=True, index=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="refresh_tokens")
|
||||
|
||||
@@ -21,7 +21,6 @@ class User(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
|
||||
|
||||
@@ -21,12 +21,7 @@ class World(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
visibility = Column(
|
||||
ENUM(
|
||||
WorldVisibility,
|
||||
name="world_visibility",
|
||||
create_type=False,
|
||||
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||
),
|
||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
||||
default=WorldVisibility.PUBLIC,
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Asset management router."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -14,7 +13,6 @@ from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.asset import Asset, AssetType
|
||||
from app.models.world import World
|
||||
from app.services import gltf as gltf_service
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,14 +64,6 @@ def _sanitize_filename(filename: str) -> str:
|
||||
return safe or "file"
|
||||
|
||||
|
||||
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
|
||||
"""Detect the asset type from filename and content."""
|
||||
if filename and gltf_service.looks_like_gltf(content, filename):
|
||||
return AssetType.MODEL_3D
|
||||
# Fallback: let the caller decide based on mime_type or default.
|
||||
return AssetType.OTHER
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||
@@ -193,16 +183,10 @@ async def upload_asset_file(
|
||||
# Sanitize filename to prevent path traversal.
|
||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||
|
||||
# Read the file content.
|
||||
# Validate file size against configured maximum.
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Determine which size limit to apply.
|
||||
if gltf_service.looks_like_gltf(content, file.filename):
|
||||
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
else:
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -216,28 +200,10 @@ async def upload_asset_file(
|
||||
file_path = upload_dir / stored_name
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Detect asset type from content/filename.
|
||||
detected_type = _detect_asset_type(file.filename, content)
|
||||
|
||||
# Update asset record.
|
||||
asset.file_path = str(file_path)
|
||||
asset.mime_type = file.content_type
|
||||
asset.file_size = file_size
|
||||
|
||||
# If this is a glTF file, extract and store metadata.
|
||||
if detected_type == AssetType.MODEL_3D:
|
||||
try:
|
||||
metadata = gltf_service.extract_gltf_metadata(content)
|
||||
asset.metadata_json = json.dumps(metadata)
|
||||
# Also set the asset type to model_3d if it was auto-detected.
|
||||
if asset.asset_type == AssetType.OTHER:
|
||||
asset.asset_type = AssetType.MODEL_3D
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid glTF file: {exc}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
+11
-149
@@ -1,21 +1,20 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.session import RefreshToken
|
||||
from app.models.user import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -26,13 +25,7 @@ router = APIRouter()
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
access_token_expires_minutes: int
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
@@ -66,43 +59,12 @@ class UserResponse(BaseModel):
|
||||
# --- Helpers ---
|
||||
|
||||
def _create_access_token(data: dict) -> str:
|
||||
"""Create a signed access token using RS256.
|
||||
|
||||
Includes a unique ``jti`` claim so that concurrent/same-second calls
|
||||
(e.g. back-to-back /refresh requests) still produce distinct tokens —
|
||||
without it, two tokens minted for the same user within the same
|
||||
second are byte-identical since ``sub``/``exp`` are the only claims.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
to_encode["jti"] = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _create_refresh_token(data: dict) -> str:
|
||||
"""Create a signed refresh token using RS256.
|
||||
|
||||
Includes a unique ``jti`` claim so that concurrent calls produce
|
||||
distinct tokens even when issued within the same second.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
to_encode["jti"] = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
@@ -135,25 +97,11 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
return age
|
||||
|
||||
|
||||
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
"""Create and persist a refresh token for the given user."""
|
||||
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||
refresh_token_record = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
db.add(refresh_token_record)
|
||||
return refresh_token_value
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
"""Register a new user and return an access token.
|
||||
|
||||
Requires date_of_birth and rejects users under 18.
|
||||
"""
|
||||
@@ -190,26 +138,16 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
date_of_birth=dob,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # flush to get user.id before creating refresh token
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user and return access + refresh tokens.
|
||||
"""Authenticate user and return an access token.
|
||||
|
||||
Accepts form-encoded credentials (username/password) as expected by
|
||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||
@@ -229,84 +167,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
||||
detail="User account is inactive",
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access token.
|
||||
|
||||
Validates the refresh token against the database:
|
||||
- Must exist in the refresh_tokens table
|
||||
- Must not be expired (expires_at > now)
|
||||
- Must be active (is_active == true)
|
||||
|
||||
On success, returns a new access token. The refresh token itself is
|
||||
NOT rotated (the client keeps using the same refresh token).
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
# Step 1: Decode and validate the JWT signature/claims
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
req.refresh_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user_id_str: str | None = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
# Step 2: Look up the refresh token in the database
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token == req.refresh_token,
|
||||
RefreshToken.user_id == user_id,
|
||||
)
|
||||
)
|
||||
stored_token = result.scalar_one_or_none()
|
||||
|
||||
if stored_token is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 3: Check if the token is expired
|
||||
if datetime.now(timezone.utc) >= stored_token.expires_at:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 4: Check if the token is active
|
||||
if not stored_token.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 5: Issue a new access token
|
||||
access_token = _create_access_token({"sub": str(user_id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=req.refresh_token, # Return the same refresh token
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Worlds router - CRUD for game worlds."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -41,7 +40,7 @@ class WorldResponse(BaseModel):
|
||||
description: str | None
|
||||
visibility: WorldVisibility
|
||||
owner_id: int
|
||||
created_at: datetime | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Lightweight glTF / GLB metadata extraction.
|
||||
|
||||
This module intentionally avoids heavyweight dependencies such as trimesh or
|
||||
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
|
||||
vertex counts, animation presence, texture references, and the bounding box)
|
||||
a small amount of glTF-specific parsing is sufficient and keeps the backend
|
||||
dependency-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Optional
|
||||
|
||||
# GLB binary header magic bytes ("glTF").
|
||||
_GLB_MAGIC = b"glTF"
|
||||
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
|
||||
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
|
||||
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
|
||||
|
||||
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
|
||||
# but be lenient with null bytes and other whitespace that real exporters emit.
|
||||
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
|
||||
|
||||
|
||||
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
|
||||
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
return True
|
||||
|
||||
if filename:
|
||||
lowered = filename.lower()
|
||||
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
|
||||
return True
|
||||
|
||||
# A bare JSON buffer (e.g. uploaded without an extension) that has the
|
||||
# glTF asset marker is also a glTF file.
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith(b"{"):
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _parse_glb(content: bytes) -> dict[str, Any]:
|
||||
"""Parse the JSON chunk of a GLB binary container."""
|
||||
if len(content) < 12:
|
||||
raise ValueError("GLB file is too short")
|
||||
|
||||
magic, _version, _length = struct.unpack_from("<III", content, 0)
|
||||
if magic != _GLB_MAGIC_INT:
|
||||
raise ValueError("invalid GLB magic")
|
||||
|
||||
offset = 12
|
||||
json_chunk: Optional[dict[str, Any]] = None
|
||||
|
||||
while offset + 8 <= len(content):
|
||||
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
|
||||
offset += 8
|
||||
chunk_end = offset + chunk_length
|
||||
if chunk_end > len(content):
|
||||
raise ValueError("GLB chunk overruns file boundary")
|
||||
|
||||
chunk_data = content[offset:chunk_end]
|
||||
if chunk_type == _GLB_JSON_CHUNK_TYPE:
|
||||
try:
|
||||
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
|
||||
|
||||
offset = chunk_end
|
||||
|
||||
if json_chunk is None:
|
||||
raise ValueError("GLB file does not contain a JSON chunk")
|
||||
|
||||
return json_chunk
|
||||
|
||||
|
||||
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
|
||||
"""Parse a plain .gltf JSON file."""
|
||||
stripped = content.lstrip()
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid glTF JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("glTF JSON root must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
|
||||
"""Return POSITION accessor counts for every mesh primitive."""
|
||||
counts: list[int] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
count = accessor.get("count")
|
||||
if isinstance(count, int):
|
||||
counts.append(count)
|
||||
return counts
|
||||
|
||||
|
||||
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
|
||||
"""Return min/max bounds for every POSITION accessor that declares them."""
|
||||
bounds: list[list[float]] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
minimum = accessor.get("min")
|
||||
maximum = accessor.get("max")
|
||||
if isinstance(minimum, list) and isinstance(maximum, list):
|
||||
bounds.append([float(x) for x in minimum])
|
||||
bounds.append([float(x) for x in maximum])
|
||||
return bounds
|
||||
|
||||
|
||||
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
|
||||
"""Collect external texture source references from the image array."""
|
||||
refs: list[str] = []
|
||||
for index, image in enumerate(gltf.get("images", [])):
|
||||
uri = image.get("uri")
|
||||
if isinstance(uri, str) and not uri.startswith("data:"):
|
||||
refs.append(uri)
|
||||
continue
|
||||
|
||||
name = image.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
refs.append(name)
|
||||
continue
|
||||
|
||||
if isinstance(uri, str):
|
||||
# Embedded data URI; keep a stable, non-huge reference.
|
||||
refs.append(f"data:{index}")
|
||||
continue
|
||||
|
||||
buffer_view = image.get("bufferView")
|
||||
if buffer_view is not None:
|
||||
refs.append(f"bufferView:{buffer_view}")
|
||||
continue
|
||||
|
||||
refs.append(f"image:{index}")
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
|
||||
"""Extract upload-relevant metadata from glTF/GLB bytes.
|
||||
|
||||
Raises ValueError for malformed glTF content. The returned dict contains:
|
||||
mesh_count, vertex_count, has_animation, texture_refs,
|
||||
bounding_box_min, bounding_box_max.
|
||||
"""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
gltf = _parse_glb(content)
|
||||
else:
|
||||
gltf = _parse_gltf_json(content)
|
||||
|
||||
position_counts = _position_accessor_counts(gltf)
|
||||
bounds = _position_bounds(gltf)
|
||||
|
||||
if bounds:
|
||||
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
|
||||
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
|
||||
else:
|
||||
bounding_box_min = [0.0, 0.0, 0.0]
|
||||
bounding_box_max = [0.0, 0.0, 0.0]
|
||||
|
||||
return {
|
||||
"mesh_count": len(gltf.get("meshes", [])),
|
||||
"vertex_count": sum(position_counts),
|
||||
"has_animation": bool(gltf.get("animations")),
|
||||
"texture_refs": _texture_refs(gltf),
|
||||
"bounding_box_min": bounding_box_min,
|
||||
"bounding_box_max": bounding_box_max,
|
||||
}
|
||||
@@ -9,14 +9,6 @@ pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
|
||||
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
|
||||
# hashing/verifying a password then raises "password cannot be longer than
|
||||
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
|
||||
# module 'bcrypt' has no attribute '__about__') on every call. Without this
|
||||
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
|
||||
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.20
|
||||
email-validator==2.2.0
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
The auth integration tests hit the *real* Postgres database (per
|
||||
``DATABASE_URL``), so they persist data between runs. Without cleanup a
|
||||
second run of the suite fails with 409 conflicts because the users it
|
||||
registered on the first run already exist. The session-scoped fixture below
|
||||
truncates all application tables once before the first test, making the suite
|
||||
re-runnable.
|
||||
|
||||
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
|
||||
keys, but the tables are listed explicitly for clarity.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _reset_database():
|
||||
"""Truncate all application tables once per test session."""
|
||||
|
||||
async def _truncate() -> None:
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_truncate())
|
||||
yield
|
||||
@@ -1,263 +0,0 @@
|
||||
"""Integration tests for the auth endpoints (register + login).
|
||||
|
||||
These tests hit the real FastAPI app against the real configured database
|
||||
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
|
||||
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
|
||||
JWT → response. Requires migrations already applied (alembic upgrade head)
|
||||
before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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.database import get_db
|
||||
from app.routers.auth import pwd_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test.
|
||||
|
||||
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||
(sync) TestClient runs each request through anyio's blocking-portal
|
||||
machinery, which can hand different requests different event loops.
|
||||
asyncpg connections are bound to the loop they were created on, so
|
||||
reusing one from the shared engine's connection pool across requests
|
||||
raises "cannot perform operation: another operation is in progress" the
|
||||
moment a test makes more than one request (register-then-login,
|
||||
register-twice, etc.) -- confirmed this is the actual cause, not a bug
|
||||
in the endpoints themselves. NullPool sidesteps it: every checkout is a
|
||||
brand-new connection, never handed to a different loop than the one
|
||||
that opened it.
|
||||
"""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegister:
|
||||
"""Tests for POST /api/auth/register."""
|
||||
|
||||
def test_register_success(self):
|
||||
"""A valid registration returns 201 with a token."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_register_rejects_underage(self):
|
||||
"""Users under 18 get a 400 with a detail message."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2010-06-15", # ~15 years old
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "18 or older" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_username(self):
|
||||
"""Registering the same username twice returns 409."""
|
||||
client = TestClient(app)
|
||||
# First registration
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Username already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_email(self):
|
||||
"""Registering the same email twice returns 409."""
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_invalid_date_format(self):
|
||||
"""Invalid date format returns 422 validation error."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "01-01-2000", # wrong format
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_register_exactly_18(self):
|
||||
"""A user who just turned 18 can register."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "just18",
|
||||
"email": "just18@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2007-06-15",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogin:
|
||||
"""Tests for POST /api/auth/login."""
|
||||
|
||||
def _create_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to create a user via register."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_login_success(self):
|
||||
"""Correct credentials return a token."""
|
||||
self._create_user("loginuser", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser", "password": "correctpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
"""Wrong password returns 401."""
|
||||
# Distinct username from test_login_success -- there's no per-test
|
||||
# DB rollback/isolation here (real Postgres via the app's own
|
||||
# dependency-overridden session), so reusing "loginuser" collided
|
||||
# with the user that test already created (409 on register instead
|
||||
# of the intended 401-on-login assertion this test is actually for).
|
||||
self._create_user("loginuser_wrongpass", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_nonexistent_user(self):
|
||||
"""Non-existent user returns 401 (not 404)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "nobody", "password": "any"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_accepts_form_encoded(self):
|
||||
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
|
||||
self._create_user("formuser", "formpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "formuser", "password": "formpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_login_token_is_valid_jwt(self):
|
||||
"""The returned token is a valid JWT that can be decoded."""
|
||||
self._create_user("jwttoken", "jwt123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "jwttoken", "password": "jwt123"},
|
||||
)
|
||||
token = resp.json()["access_token"]
|
||||
# Verify it's a 3-part JWT
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Tests for expired-token rejection across auth endpoints.
|
||||
|
||||
Covers two distinct expiry paths:
|
||||
|
||||
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
|
||||
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
|
||||
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
|
||||
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
|
||||
signature is still valid.
|
||||
|
||||
Requires migrations already applied (alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
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.database import get_db
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mint_access_token(exp: datetime) -> str:
|
||||
"""Mint an RS256 access token with an explicit ``exp`` claim."""
|
||||
return jwt.encode(
|
||||
{"sub": "1", "exp": exp},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _expire_refresh_token_in_db(token: str) -> None:
|
||||
"""Set the persisted refresh token's ``expires_at`` into the past."""
|
||||
|
||||
async def _do():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import update
|
||||
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token == token)
|
||||
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_do())
|
||||
|
||||
|
||||
def _register_user(client: TestClient, username: str) -> str:
|
||||
"""Register a user and return its refresh token."""
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired access token (GET /api/auth/me)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredAccessToken:
|
||||
def test_expired_access_token_is_rejected_by_me(self):
|
||||
"""An access JWT with a past exp claim returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
expired_token = _mint_access_token(
|
||||
datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Could not validate credentials" in resp.json()["detail"]
|
||||
|
||||
def test_garbage_access_token_is_rejected_by_me(self):
|
||||
"""A non-JWT bearer token returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_bearer_token_returns_401(self):
|
||||
"""No Authorization header at all returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired refresh token (POST /api/auth/refresh)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredRefreshToken:
|
||||
def test_expired_refresh_token_is_rejected(self):
|
||||
"""A refresh token whose DB expires_at is in the past returns 401."""
|
||||
client = TestClient(app)
|
||||
refresh_token = _register_user(client, "expired_rt_user")
|
||||
|
||||
# Force the persisted record to be expired while its JWT signature
|
||||
# is still valid.
|
||||
_expire_refresh_token_in_db(refresh_token)
|
||||
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_expired_refresh_token_jwt_is_rejected(self):
|
||||
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
|
||||
client = TestClient(app)
|
||||
expired_jwt = jwt.encode(
|
||||
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": expired_jwt},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
@@ -1,220 +0,0 @@
|
||||
"""Tests for the lightweight glTF metadata extraction service."""
|
||||
|
||||
import json
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from app.services.gltf import (
|
||||
extract_gltf_metadata,
|
||||
looks_like_gltf,
|
||||
_parse_glb,
|
||||
_parse_gltf_json,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_gltf_json(
|
||||
meshes: int = 1,
|
||||
vertices_per_mesh: int = 24,
|
||||
has_animations: bool = False,
|
||||
images: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal glTF JSON structure."""
|
||||
accessors = []
|
||||
meshes_data = []
|
||||
for i in range(meshes):
|
||||
acc_index = len(accessors)
|
||||
accessors.append({
|
||||
"bufferView": 0,
|
||||
"componentType": 5126, # FLOAT
|
||||
"count": vertices_per_mesh,
|
||||
"type": "VEC3",
|
||||
"max": [1.0, 1.0, 1.0],
|
||||
"min": [-1.0, -1.0, -1.0],
|
||||
})
|
||||
meshes_data.append({
|
||||
"primitives": [{
|
||||
"attributes": {"POSITION": acc_index},
|
||||
}],
|
||||
})
|
||||
gltf = {
|
||||
"asset": {"version": "2.0"},
|
||||
"accessors": accessors,
|
||||
"meshes": meshes_data,
|
||||
}
|
||||
if has_animations:
|
||||
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
|
||||
if images:
|
||||
gltf["images"] = images
|
||||
return gltf
|
||||
|
||||
|
||||
def _make_glb(gltf_json: dict) -> bytes:
|
||||
"""Wrap *gltf_json* in a minimal GLB binary container."""
|
||||
json_bytes = json.dumps(gltf_json).encode("utf-8")
|
||||
json_length = len(json_bytes)
|
||||
# Pad JSON chunk to 4-byte boundary.
|
||||
json_padding = (4 - json_length % 4) % 4
|
||||
json_chunk = json_bytes + b"\x00" * json_padding
|
||||
json_chunk_type = 0x4E4F534A # "JSON"
|
||||
# No BIN chunk.
|
||||
bin_length = 0
|
||||
bin_chunk_type = 0x004E4942
|
||||
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
|
||||
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
|
||||
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
|
||||
if bin_length > 0:
|
||||
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
|
||||
else:
|
||||
bin_header = b""
|
||||
return header + json_header + json_chunk + bin_header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# looks_like_gltf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLooksLikeGltf:
|
||||
def test_glb_magic(self):
|
||||
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
|
||||
|
||||
def test_gltf_extension(self):
|
||||
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
|
||||
|
||||
def test_glb_extension(self):
|
||||
assert looks_like_gltf(b"some binary", "model.glb") is True
|
||||
|
||||
def test_json_with_asset_version(self):
|
||||
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
|
||||
assert looks_like_gltf(payload) is True
|
||||
|
||||
def test_plain_json_no_asset(self):
|
||||
payload = json.dumps({"foo": "bar"}).encode()
|
||||
assert looks_like_gltf(payload) is False
|
||||
|
||||
def test_non_json_bytes(self):
|
||||
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_glb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGlb:
|
||||
def test_parses_valid_glb(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
glb = _make_glb(gltf_json)
|
||||
result = _parse_glb(glb)
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
assert len(result["meshes"]) == 1
|
||||
|
||||
def test_rejects_too_short(self):
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
_parse_glb(b"glTF\x00\x00")
|
||||
|
||||
def test_rejects_invalid_magic(self):
|
||||
with pytest.raises(ValueError, match="invalid GLB magic"):
|
||||
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_gltf_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGltfJson:
|
||||
def test_parses_valid_gltf(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
result = _parse_gltf_json(json.dumps(gltf_json).encode())
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
|
||||
def test_rejects_non_object(self):
|
||||
with pytest.raises(ValueError, match="root must be an object"):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (GLB path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGlb:
|
||||
def test_basic_glb(self):
|
||||
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 1
|
||||
assert meta["vertex_count"] == 24
|
||||
assert meta["has_animation"] is False
|
||||
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
|
||||
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
|
||||
assert meta["texture_refs"] == []
|
||||
|
||||
def test_multiple_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 3
|
||||
assert meta["vertex_count"] == 36
|
||||
|
||||
def test_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
def test_with_external_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["textures/rock.png"]
|
||||
|
||||
def test_with_named_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["my_texture"]
|
||||
|
||||
def test_empty_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=0)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 0
|
||||
assert meta["vertex_count"] == 0
|
||||
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
|
||||
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (plain .gltf JSON path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGltfJson:
|
||||
def test_plain_gltf(self):
|
||||
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["mesh_count"] == 2
|
||||
assert meta["vertex_count"] == 16
|
||||
assert meta["has_animation"] is False
|
||||
|
||||
def test_plain_gltf_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataErrors:
|
||||
def test_invalid_glb(self):
|
||||
with pytest.raises(ValueError):
|
||||
extract_gltf_metadata(b"not a glb")
|
||||
|
||||
def test_invalid_gltf_json(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Tests for the POST /api/auth/refresh endpoint.
|
||||
|
||||
These tests exercise the full stack: register → get refresh token →
|
||||
refresh → new access token. Requires migrations already applied
|
||||
(alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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.database import get_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refresh endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRefresh:
|
||||
"""Tests for POST /api/auth/refresh."""
|
||||
|
||||
def _register_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to register a user and return the refresh token."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
def test_refresh_success(self):
|
||||
"""A valid refresh token returns a new access token."""
|
||||
refresh_token = self._register_user("refep_refreshuser", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
# The refresh token in the response is the same one we sent
|
||||
assert data["refresh_token"] == refresh_token
|
||||
|
||||
def test_refresh_returns_different_access_token(self):
|
||||
"""Refreshing produces a new access token (different from any previous)."""
|
||||
refresh_token = self._register_user("refep_refreshtoken", "secret123")
|
||||
client = TestClient(app)
|
||||
|
||||
# First refresh
|
||||
resp1 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
access_token_1 = resp1.json()["access_token"]
|
||||
|
||||
# Second refresh
|
||||
resp2 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
access_token_2 = resp2.json()["access_token"]
|
||||
|
||||
# Each refresh should produce a new access token
|
||||
assert access_token_1 != access_token_2
|
||||
|
||||
def test_refresh_invalid_token(self):
|
||||
"""An invalid (garbage) refresh token returns 401."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "not.a.jwt.token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_refresh_empty_body(self):
|
||||
"""Missing refresh_token field returns 422 validation error."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_missing_field(self):
|
||||
"""No body at all returns 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
content="{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_with_login_token(self):
|
||||
"""A refresh token obtained from login also works for /refresh."""
|
||||
client = TestClient(app)
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
# Login to get a refresh token
|
||||
login_resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "refep_loginrefuser", "password": "secret123"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
refresh_token = login_resp.json()["refresh_token"]
|
||||
|
||||
# Use it to refresh
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_refresh_access_token_is_valid_jwt(self):
|
||||
"""The access token returned by /refresh is a valid JWT."""
|
||||
from jose import jwt
|
||||
|
||||
refresh_token = self._register_user("refep_jwttoken", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
access_token = resp.json()["access_token"]
|
||||
|
||||
# Verify it's a 3-part JWT
|
||||
parts = access_token.split(".")
|
||||
assert len(parts) == 3
|
||||
|
||||
# Verify it decodes with the public key
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "sub" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
def test_refresh_expires_minutes_in_response(self):
|
||||
"""The response includes access_token_expires_minutes."""
|
||||
refresh_token = self._register_user("refep_expiretest", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token_expires_minutes" in resp.json()
|
||||
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Tests for refresh token uniqueness (jti claim) and model column length."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.routers.auth import _create_refresh_token
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
class TestRefreshTokenJtiUniqueness:
|
||||
"""Verify that _create_refresh_token produces unique tokens even when
|
||||
called with identical data in rapid succession (issue #1 from review)."""
|
||||
|
||||
def test_two_calls_produce_different_tokens(self):
|
||||
"""Two calls with the same sub produce different JWTs because of jti."""
|
||||
token_a = _create_refresh_token({"sub": "42"})
|
||||
token_b = _create_refresh_token({"sub": "42"})
|
||||
assert token_a != token_b
|
||||
|
||||
def test_jti_is_valid_uuid(self):
|
||||
"""The jti claim inside the refresh token is a valid UUID."""
|
||||
token = _create_refresh_token({"sub": "1"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "jti" in payload
|
||||
# Verify it parses as a UUID (raises ValueError if not)
|
||||
uuid.UUID(payload["jti"])
|
||||
|
||||
def test_each_token_has_unique_jti(self):
|
||||
"""Generate 100 tokens for the same user — all jtis must differ."""
|
||||
jtis = set()
|
||||
for _ in range(100):
|
||||
token = _create_refresh_token({"sub": "999"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
jtis.add(payload["jti"])
|
||||
assert len(jtis) == 100
|
||||
|
||||
def test_jti_survives_decode(self):
|
||||
"""The jti claim is present and correct after decode."""
|
||||
token = _create_refresh_token({"sub": "7"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert payload["sub"] == "7"
|
||||
assert "jti" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
class TestRefreshTokenModelColumnLength:
|
||||
"""Verify the model's token column length matches the migration (issue #2)."""
|
||||
|
||||
def test_token_column_has_length_512(self):
|
||||
"""RefreshToken.token column should be String(512), not unbounded."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# Get the column definition from the model's table
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
# The type should be String with length 512
|
||||
assert column.type.length == 512
|
||||
|
||||
def test_token_column_type_is_string(self):
|
||||
"""The token column should be a String type."""
|
||||
from sqlalchemy import String as SAString
|
||||
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
assert isinstance(column.type, SAString)
|
||||
@@ -1,84 +0,0 @@
|
||||
# Package Checkpoint 1 (PC1)
|
||||
|
||||
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||
clone (not a reused agent workspace).
|
||||
|
||||
## Automated validation
|
||||
|
||||
Run with `scripts/run_checkpoint_tests.py` — see
|
||||
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Alembic migrations, fresh DB | PASS |
|
||||
| Backend pytest | 68/68 PASS |
|
||||
| Godot smoke tests (4 files) | ALL PASS |
|
||||
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||
|
||||
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||
|
||||
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||
done — a historical data gap (a review-feedback follow-up task's
|
||||
supersession link was never recorded, predating when that field started
|
||||
being tracked). Backfilled in the PM database; confirmed green.
|
||||
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||
Fixed via task `398bf1a22a2d` / PR #40.
|
||||
|
||||
## What's actually testable right now
|
||||
|
||||
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||
the real scope of PC1; don't expect more when playtesting.
|
||||
|
||||
- Register a new account (18+ only, rejected otherwise)
|
||||
- Log out, log back in with the same credentials
|
||||
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||
|
||||
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||
|
||||
Cloudbison's LAN IP: `192.168.58.138`. Pinkbison's: `192.168.58.124`.
|
||||
|
||||
### 1. On cloudbison — start a real (persistent, not throwaway) backend
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone http://localhost:3000/daddy/agp-game.git agp-game-playtest
|
||||
cd agp-game-playtest/backend
|
||||
docker compose up -d postgres
|
||||
docker compose exec -T postgres pg_isready -U agp_user -d agp_db # wait for this to succeed
|
||||
|
||||
export DATABASE_URL="postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
||||
export JWT_ALGORITHM="RS256"
|
||||
/home/daddy/agp/venv/bin/python3 -m alembic upgrade head
|
||||
nohup /home/daddy/agp/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/agp-backend.log 2>&1 &
|
||||
disown
|
||||
```
|
||||
|
||||
`--host 0.0.0.0` matters — the default `127.0.0.1` only listens on loopback
|
||||
and pinkbison won't be able to reach it. Verify: `curl
|
||||
http://localhost:8000/api/health` should return `{"status":"ok",...}`.
|
||||
|
||||
### 2. On pinkbison — get the game and point it at cloudbison
|
||||
|
||||
Copy `export/windows/agp-game.exe` (or `export/linux/agp-game.x86_64`,
|
||||
whichever matches pinkbison's OS) from cloudbison, or clone the repo there
|
||||
and run `scripts/build.sh` locally. Launch with the backend URL as an env
|
||||
var so you don't have to rebuild to change it:
|
||||
|
||||
- Windows (PowerShell): `$env:AGP_API_BASE_URL="http://192.168.58.138:8000"; .\agp-game.exe`
|
||||
- Linux: `AGP_API_BASE_URL=http://192.168.58.138:8000 ./agp-game.x86_64`
|
||||
|
||||
### 3. Tear down when done
|
||||
|
||||
```bash
|
||||
# on cloudbison
|
||||
pkill -f "uvicorn app.main:app"
|
||||
cd ~/agp-game-playtest/backend && docker compose down -v
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||
on to post-PC1 work until that's widened.
|
||||
@@ -23,9 +23,10 @@ explicitly out of scope.
|
||||
| # | Type | Task | Depends on |
|
||||
|---|------|------|-----------|
|
||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
||||
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
||||
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
||||
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
|
||||
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
|
||||
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
|
||||
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
|
||||
|
||||
## Design decisions captured (resolved in task 1)
|
||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||
@@ -34,8 +35,3 @@ explicitly out of scope.
|
||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||
|
||||
## Notes
|
||||
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
|
||||
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
|
||||
- The world-list screen also needs to be **built** (a new scene), not just wired.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Creator Studio — Extended Authoring Tools
|
||||
|
||||
> **Feature:** Creator Studio — Extended Authoring Tools
|
||||
> **Status:** Planning
|
||||
> **Owner:** Platform team
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Extends the in-game world editor (M6) toward a real authoring tool with:
|
||||
1. **Asset import pipeline** (glTF models/textures)
|
||||
2. **Script editor** with sandboxed API surface exposed clearly
|
||||
3. **Experience publishing flow** (draft → review → live)
|
||||
4. **Basic analytics** for creators (plays, session length)
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
_As a creator, I can import glTF models and textures into my experience so that I can use custom 3D assets._
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
_As a creator, I can write and test scripts in the editor with autocomplete/docs for the available API surface._
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
_As a creator, I can publish my experience through a draft → review → live pipeline so that players can discover and play it._
|
||||
|
||||
### US-4: Creator Analytics
|
||||
_As a creator, I can see how many plays and average session length my experiences receive so that I can understand player engagement._
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
|
||||
#### T1.1: Backend — glTF Asset Upload with Metadata Extraction
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (extend upload endpoint), `backend/app/models/asset.py` (add glTF-specific metadata fields)
|
||||
- **Scope:** Extend the existing `upload_asset_file` endpoint in `assets.py` to detect `.glb`/`.gltf` files and extract glTF metadata (mesh_count, vertex_count, has_animation, texture_refs, bounding_box) from the embedded JSON. Store extracted metadata in `metadata_json`. Add a `has_gltf_metadata` computed property. The Asset model already has `asset_type = MODEL_3D` and `metadata_json` — this task adds the extraction logic.
|
||||
- **Acceptance:** POST `/api/assets/{id}/upload` with a `.glb` file returns asset with `metadata_json` containing `mesh_count`, `vertex_count`, `has_animation`, `texture_refs`. GET lists include these fields. File stored on disk. Non-glTF files are unaffected.
|
||||
- **Dependencies:** None (builds on existing asset infrastructure)
|
||||
|
||||
#### T1.2: Backend — Public Asset Catalog Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (new endpoint)
|
||||
- **Scope:** Add `GET /api/assets/catalog` endpoint that returns all public glTF assets with their metadata (name, thumbnail_url, mesh_count, vertex_count, tags). Add filtering by tags and pagination. This is what the editor's asset palette queries. Uses the existing `Asset.is_public` field.
|
||||
- **Acceptance:** Catalog endpoint returns paginated list of public assets; filtering by tag works; pagination params respected; returns 401 without auth.
|
||||
- **Dependencies:** T1.1
|
||||
|
||||
#### T1.3: Godot — glTF Import Utility (Editor-Side)
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/gltf_importer.gd` (new), `scripts/asset_catalog.gd` (new)
|
||||
- **Scope:** Godot-side utility that connects to the catalog API, downloads glTF files, and converts them to Godot-compatible resources (`.res` files with embedded mesh data). Provides `import_gltf(url: String) -> Resource` function. Handles texture extraction and mesh merging. Uses the ApiClient autoload (from client-server-wiring plan) for HTTP.
|
||||
- **Acceptance:** `import_gltf()` downloads a glTF from the catalog, converts mesh to `Mesh` resource, returns a ready-to-use Godot resource. Texture files stored in `user://assets/`.
|
||||
- **Dependencies:** T1.2, client-server-wiring ApiClient (task 1 from that plan)
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
|
||||
> **Design note from `scripting_sandbox.md`:** Godot 4 has no built-in GDScript sandbox. The recommended approach is a **data-driven DSL** (JSON/Resource) interpreted by trusted GDScript, not arbitrary code execution. This task follows that recommendation.
|
||||
|
||||
#### T2.1: Backend — Script Model & CRUD Endpoints
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/script.py` (new), `backend/app/routers/scripts.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Script` model (id, name, world_id, owner_id, source_code TEXT, version INT, script_type ENUM[gdscript, dsl], dsl_payload JSON, created_at, updated_at). Add full CRUD endpoints: `POST /api/scripts/` (create), `GET /api/scripts/` (list), `GET /api/scripts/{id}` (get), `PUT /api/scripts/{id}` (update), `DELETE /api/scripts/{id}` (delete). Scripts are owned by users and associated with a world. `dsl_payload` stores the JSON/Resource data for the DSL approach; `source_code` stores raw GDScript if used.
|
||||
- **Acceptance:** All CRUD endpoints work; scripts are owned by creator; list endpoint filters by owner/world; update increments version; DSL and gdscript types supported.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T2.2: Godot — Script API Surface Definition
|
||||
- **Type:** architecture
|
||||
- **Files:** `scripts/editor_script_api.gd` (new)
|
||||
- **Scope:** Define the public API surface available to creator scripts. This is a `RefCounted` class that wraps the Godot engine's capabilities into a safe, documented interface. Includes: `WorldAPI` (get_tile, get_object, set_property), `PlayerAPI` (get_position, get_health), `AudioAPI` (play_sound, play_music), `NetworkAPI` (send_event, broadcast). Each method has documented parameters and return types. Self-documenting via `##` doc comments for autocomplete.
|
||||
- **Acceptance:** `EditorScriptAPI` class exposes all documented methods; methods are callable from user scripts; API surface is self-documenting.
|
||||
- **Dependencies:** None (standalone definition)
|
||||
|
||||
#### T2.3: Godot — In-Game Script Editor UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_script_editor.tscn` (new), `scripts/editor_script_editor.gd` (new)
|
||||
- **Scope:** A `Control`-based UI panel that provides a script editor within the game. Features: code editor with syntax highlighting (basic), autocomplete dropdown, run button (calls backend `/run` endpoint), output console (shows stdout/stderr), API reference panel (shows available methods). Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Editor panel opens from toolbar; code editor accepts input; autocomplete shows API methods; run button executes script and shows output; API reference panel is browsable.
|
||||
- **Dependencies:** T2.1, T2.2
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
|
||||
#### T3.1: Backend — Experience Model & Status Transitions
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/experience.py` (new), `backend/app/routers/experiences.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Experience` model (id, world_id FK→worlds, title, description, status ENUM[DRAFT,SUBMITTED,IN_REVIEW,APPROVED,REJECTED,LIVE], published_at, review_notes, reviewer_id FK→users). Status transitions enforced: DRAFT→SUBMITTED→IN_REVIEW→APPROVED→LIVE; REJECTED→DRAFT (resubmit). Add endpoints: `POST /api/experiences/` (create draft), `POST /api/experiences/{id}/submit` (submit for review), `POST /api/experiences/{id}/approve` (approve), `POST /api/experiences/{id}/reject` (reject), `GET /api/experiences/` (list with status filter). An experience is a **publishing wrapper** around a World — the world's visibility is set to PUBLIC when experience goes LIVE.
|
||||
- **Acceptance:** All CRUD + transition endpoints work; status transitions enforced; rejected can be resubmitted; list filters by status; approve sets world visibility to PUBLIC; only world owner can submit; only moderators can approve/reject.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T3.2: Backend — Review Queue & Moderation Integration
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/experiences.py` (add endpoints)
|
||||
- **Scope:** Add review queue endpoint `GET /api/experiences/review-queue` (returns experiences in IN_REVIEW status). Add `POST /api/experiences/{id}/review` endpoint for moderators to add review notes and change status. Integrates with content moderation: when an experience is submitted, its world's content flags are computed and attached to the review.
|
||||
- **Acceptance:** Review queue returns IN_REVIEW experiences; review endpoint accepts notes and status change; content flags attached on submit; only moderators can review.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
#### T3.3: Godot — Publishing UI Panel
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_publish_panel.tscn` (new), `scripts/editor_publish_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that lets creators manage their experience's publishing state. Features: title/description editor, status badge (DRAFT/SUBMITTED/LIVE), submit button, review notes display, publish history timeline. Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Panel shows current status; submit button calls backend; review notes displayed; publish history shown; status badge updates on refresh.
|
||||
- **Dependencies:** T3.1, T3.2
|
||||
|
||||
#### T3.4: Godot — Experience List & Discovery
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/experience_list.tscn` (new), `scripts/experience_list.gd` (new)
|
||||
- **Scope:** A UI panel that lists all experiences available to the player (LIVE status). Features: search/filter, experience cards (title, description, creator, play count), join button. Queries `GET /api/experiences/?status=LIVE` and displays results.
|
||||
- **Acceptance:** Lists LIVE experiences; search filters by title/description; cards show metadata; join button loads the experience's world.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
### US-4: Creator Analytics
|
||||
|
||||
#### T4.1: Backend — Session & Play Tracking Models
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/analytics.py` (new), `backend/app/routers/analytics.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `PlaySession` model (id, experience_id FK→experiences, player_id FK→users, started_at, ended_at, duration_seconds, chunk_loads INT, interactions_count INT). Create `PlayEvent` model (id, experience_id FK→experiences, player_id FK→users, event_type, payload JSON, created_at). Add endpoints: `POST /api/analytics/session/start` (start session), `POST /api/analytics/session/end` (end session), `POST /api/analytics/event` (log event). Note: `sessions` table is taken by auth tokens; this uses `play_sessions` table name.
|
||||
- **Acceptance:** Session start/end endpoints work; events logged; duration calculated correctly; endpoints accept valid experience/player IDs.
|
||||
- **Dependencies:** None (new models + routers)
|
||||
|
||||
#### T4.2: Backend — Creator Dashboard Analytics Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/analytics.py` (add endpoint)
|
||||
- **Scope:** Add `GET /api/analytics/experience/{id}` endpoint that returns aggregated analytics for an experience: total_plays, unique_players, avg_session_length, total_play_time, plays_over_time (daily breakdown), top_chunks_loaded, top_interactions. Data is aggregated from PlaySession and PlayEvent tables.
|
||||
- **Acceptance:** Returns all aggregated metrics; daily breakdown covers last 30 days; only owner or moderator can view; empty experience returns zeros.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.3: Godot — Analytics Tracking in Game
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/analytics_tracker.gd` (new)
|
||||
- **Scope:** A singleton-like service that tracks player sessions and events during gameplay. Automatically starts session on world load, tracks chunk loads, interactions, and time spent. Sends periodic heartbeat events to backend. Handles session end on world unload or disconnect.
|
||||
- **Acceptance:** Session starts on world load; events logged during play; heartbeat sends every 60s; session ends on world unload; handles disconnect gracefully.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.4: Godot — Creator Analytics Dashboard UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/creator_analytics_panel.tscn` (new), `scripts/creator_analytics_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that displays analytics for the current experience. Features: total plays counter, avg session length, plays over time chart (simple bar chart), top chunks, top interactions. Queries `GET /api/analytics/experience/{id}` and renders data.
|
||||
- **Acceptance:** Panel shows all metrics; chart renders bar chart of daily plays; data refreshes on button press; handles empty data gracefully.
|
||||
- **Dependencies:** T4.2, T4.3
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Plan
|
||||
|
||||
New tables needed (in order):
|
||||
1. `scripts` — script storage
|
||||
2. `experiences` — publishing state (wraps worlds)
|
||||
3. `play_sessions` — session tracking (note: `sessions` table already exists for auth tokens)
|
||||
4. `play_events` — granular events
|
||||
|
||||
Migration file: `backend/alembic/versions/0003_creator_studio.py`
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies Summary
|
||||
|
||||
```
|
||||
T1.1 (glTF upload) ──→ T1.2 (catalog API) ──→ T1.3 (Godot importer)
|
||||
T2.1 (script model) ──→ T2.3 (editor UI)
|
||||
T2.2 (API surface) ──→ T2.3 (editor UI)
|
||||
T3.1 (experience model) ──→ T3.2 (review queue) ──→ T3.3 (publish UI)
|
||||
T3.1 ──→ T3.4 (discovery list)
|
||||
T4.1 (tracking model) ──→ T4.2 (dashboard endpoint) ──→ T4.4 (analytics UI)
|
||||
T4.1 ──→ T4.3 (game tracker) ──→ T4.4 (analytics UI)
|
||||
```
|
||||
|
||||
## Implementation Order Recommendation
|
||||
|
||||
1. **T1.1** — Foundation: asset upload with glTF metadata extraction
|
||||
2. **T2.1** — Foundation: script model + CRUD
|
||||
3. **T3.1** — Foundation: experience model + status transitions
|
||||
4. **T4.1** — Foundation: analytics tracking model
|
||||
5. **T1.2** — Asset catalog for editor
|
||||
6. **T2.2** — Script API surface definition
|
||||
7. **T3.2** — Review queue integration
|
||||
8. **T4.2** — Analytics dashboard endpoint
|
||||
9. **T1.3** — Godot glTF importer (depends on ApiClient from client-server-wiring)
|
||||
10. **T2.3** — Script editor UI
|
||||
11. **T3.3** — Publishing UI panel
|
||||
12. **T3.4** — Experience discovery list
|
||||
13. **T4.3** — Analytics tracker in game
|
||||
14. **T4.4** — Analytics dashboard UI
|
||||
@@ -1,50 +0,0 @@
|
||||
# Testing at Package Checkpoints
|
||||
|
||||
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
|
||||
real milestones, the client and server builds get packaged together and
|
||||
independently reviewed before anyone calls anything actually done. See
|
||||
`project_agp` in Claude's memory, or ask, for the full definition; this
|
||||
doc is just the mechanical "how do I run the tests" part of that review.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```
|
||||
python scripts/run_checkpoint_tests.py
|
||||
```
|
||||
|
||||
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
|
||||
backend pytest suite, starts the backend server, and runs every Godot
|
||||
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
|
||||
Postgres container down afterward (`--keep-postgres` to skip that if you
|
||||
want to poke at the DB after a failure).
|
||||
|
||||
If port 5432 is already taken on the machine, pass `--postgres-port` with
|
||||
something else.
|
||||
|
||||
Exit code is 0 only if everything that actually ran passed — suites with
|
||||
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
|
||||
reported SKIPPED, not treated as a failure.
|
||||
|
||||
## What this does and doesn't prove
|
||||
|
||||
Passing this script means: migrations apply cleanly to a genuinely empty
|
||||
database, the backend's unit-level logic is correct, and whatever Godot
|
||||
smoke tests exist actually round-trip against a live backend. It does
|
||||
**not** mean full endpoint/integration coverage exists — see
|
||||
`test_inventory.md` for the current, honest list of what's tested and what
|
||||
isn't. A checkpoint review should still involve reading the actual diffs
|
||||
for anything this script's current coverage doesn't reach, not just running
|
||||
green and calling it done.
|
||||
|
||||
## Growing this over time
|
||||
|
||||
As real integration tests get added (endpoint tests hitting the FastAPI app,
|
||||
more Godot smoke coverage beyond `health_check`), they should land under
|
||||
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
|
||||
picks up new files automatically, nothing to register by hand. Update
|
||||
`test_inventory.md`'s tables when you do, so the inventory stays a source
|
||||
of truth instead of quietly going stale.
|
||||
|
||||
Eventual goal (not implemented yet): gate merges to `main` on this script
|
||||
passing, the same way a real CI pipeline would. For now it's a manual step
|
||||
run at checkpoint review time.
|
||||
@@ -1,78 +0,0 @@
|
||||
# Test Inventory
|
||||
|
||||
What automated coverage actually exists, what it actually verifies, and what
|
||||
doesn't have coverage yet. Keep this current as tests are added — it's the
|
||||
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
|
||||
to know what "run the tests" actually checked.
|
||||
|
||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||
|
||||
Last verified against `main`: 2026-08-19 (commit `1c75367`).
|
||||
|
||||
## Backend — `backend/tests/` (pytest)
|
||||
|
||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||
reachable Postgres — see the runner script; most of these tests don't
|
||||
actually touch the DB, but the app's import chain requires a valid URL to
|
||||
construct).
|
||||
|
||||
**Current total: 38 tests, all passing on a clean `main` checkout.**
|
||||
|
||||
| File | Tests | What it covers |
|
||||
|---|---|---|
|
||||
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||
|
||||
**What these are, honestly:** pure unit tests of standalone functions
|
||||
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
|
||||
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
|
||||
actually need Postgres reachable at all (the import chain just needs a
|
||||
syntactically valid `DATABASE_URL`, nothing connects during collection).
|
||||
|
||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||
these yet):
|
||||
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
|
||||
`/worlds`, `/assets/*` have zero tests that actually call them through
|
||||
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
|
||||
real (or test) database. Everything currently shipped works because it
|
||||
was manually verified at merge time (checkpoint review), not because a
|
||||
test asserts it.
|
||||
- No tests for the RS256 auth upgrade (key generation, refresh-token
|
||||
issuance/persistence) — PR #30, pending review as of this writing,
|
||||
manually verified (correct key usage, all 38 existing tests still pass)
|
||||
but not covered by an automated test of its own yet.
|
||||
- No tests for the moderation schema/migration (in progress as of this
|
||||
writing) beyond "the migration runs."
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
|
||||
is the only Godot script on `main` right now, and it's **not a real
|
||||
test** (a leftover manual probe, returns a hardcoded string, asserts
|
||||
nothing). Don't count it toward coverage; delete or repurpose it if anyone
|
||||
actually needs a sandbox probe again.
|
||||
|
||||
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
|
||||
will be the first real one once merged — `ApiClient` autoload's
|
||||
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
|
||||
at one), verified by hand end-to-end before opening the PR: exercises the
|
||||
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
|
||||
result-dictionary contract), not just that the script compiles. Exits 1 on
|
||||
failure, so it's CI/script-friendly.
|
||||
|
||||
Run (once merged): `godot --headless --path . --import` once (builds the
|
||||
class cache — skip this and every `class_name`-declared class falsely
|
||||
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
|
||||
`godot --headless --path . --script res://scripts/<name>.gd`
|
||||
|
||||
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
|
||||
`login`, `register`, `get_worlds`, `get_me` are implemented (see
|
||||
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
|
||||
real user/world fixture in the target backend to test against meaningfully.
|
||||
|
||||
## What "full coverage" should eventually mean here
|
||||
|
||||
Not defined precisely yet — the honest target as of 2026-08-19 is just
|
||||
"more than pure-function unit tests." As real integration/endpoint tests get
|
||||
added, update the tables above rather than letting this doc drift stale;
|
||||
a wrong inventory is worse than no inventory.
|
||||
@@ -1,74 +0,0 @@
|
||||
[preset.0]
|
||||
|
||||
name="Linux"
|
||||
platform="Linux/X11"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="export/linux/agp-game.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=false
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Windows Desktop"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="export/windows/agp-game.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=false
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
codesign/enable=false
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version=""
|
||||
application/product_version=""
|
||||
application/company_name=""
|
||||
application/product_name=""
|
||||
application/file_description=""
|
||||
application/copyright=""
|
||||
application/trademarks=""
|
||||
application/export_angle=0
|
||||
application/export_d3d12=0
|
||||
d3d12/agility_sdk_multizip=0
|
||||
@@ -9,12 +9,9 @@ config_version=5
|
||||
config/name="AGP-Game"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6.2")
|
||||
agp/api_base_url="http://127.0.0.1:8000"
|
||||
|
||||
[autoload]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
|
||||
+1
-72
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
||||
|
||||
@@ -29,73 +29,6 @@ text = "AGP Game"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 72
|
||||
|
||||
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/VBoxContainer/AuthPanel"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="AuthVBox" type="VBoxContainer" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/separation = 8
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthModeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="UsernameEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
placeholder_text = "Username"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="PasswordEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
placeholder_text = "Password"
|
||||
size_flags_horizontal = 3
|
||||
echo_password = true
|
||||
|
||||
[node name="EmailEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
placeholder_text = "Email"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = "Register"
|
||||
|
||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthStatusLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="WelcomeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
text = "Play"
|
||||
@@ -108,7 +41,3 @@ size_flags_horizontal = 4
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
|
||||
|
||||
[node name="WorldList" type="Control"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_wl")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 3
|
||||
theme_override_constants/separation = 8
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "< Back"
|
||||
size_flags_horizontal = 0
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "Browse Worlds"
|
||||
horizontal_alignment = 1
|
||||
size_flags_horizontal = 4
|
||||
theme_override_font_sizes/font_size = 28
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "Refresh"
|
||||
size_flags_horizontal = 0
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
horizontal_mode = 2
|
||||
vertical_mode = 2
|
||||
|
||||
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = "Loading worlds..."
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = ""
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = ""
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
|
||||
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
|
||||
@@ -1,292 +0,0 @@
|
||||
extends Node
|
||||
## Central HTTP client singleton (autoload name: "ApiClient").
|
||||
##
|
||||
## Every network call in the game flows through this class. Callers use the
|
||||
## high-level helpers ([method login], [method register], [method get_worlds],
|
||||
## [method health_check], [method get_me]) or the generic [method request]
|
||||
## primitive. All results conform to the result-dictionary contract below.
|
||||
##
|
||||
## [b]Result Dictionary Contract[/b]
|
||||
## Every public method returns (after `await`) a Dictionary with this shape:
|
||||
##
|
||||
## {
|
||||
## "ok": bool, # true for HTTP 2xx, false otherwise
|
||||
## "status": int, # HTTP status code (0 on network failure)
|
||||
## "data": Variant, # parsed JSON body on success (null otherwise)
|
||||
## "detail": String, # human-readable message
|
||||
## }
|
||||
##
|
||||
## Network failures (timeout / DNS / refused connection) return `status == 0`
|
||||
## with a `detail` describing the transport failure. Non-2xx responses return
|
||||
## the real HTTP status and the backend's `detail` field from the JSON error
|
||||
## body when present. No caller can crash on a failed request.
|
||||
##
|
||||
## [b]Token storage[/b]
|
||||
## JWTs are kept in memory. [method set_token] is the single seam for future
|
||||
## disk persistence (override it to save to `user://`).
|
||||
##
|
||||
## [b]Usage[/b]
|
||||
## var result: Dictionary = await ApiClient.login("alice", "secret")
|
||||
## if result.ok:
|
||||
## print("Logged in!")
|
||||
## else:
|
||||
## print("Error: ", result.detail)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _access_token: String = ""
|
||||
var _token_type: String = "Bearer"
|
||||
|
||||
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
||||
var bearer_token: String:
|
||||
get:
|
||||
return _token_type + " " + _access_token if _access_token != "" else ""
|
||||
|
||||
|
||||
## Whether an access token is currently stored.
|
||||
func has_token() -> bool:
|
||||
return _access_token != ""
|
||||
|
||||
|
||||
## Set the access token (called after login / register / token refresh).
|
||||
## This is the persistence seam: override to also write to disk.
|
||||
func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||
_access_token = access_token
|
||||
_token_type = token_type
|
||||
|
||||
|
||||
## Clear stored tokens.
|
||||
func clear_token() -> void:
|
||||
_access_token = ""
|
||||
_token_type = "Bearer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _base_url: String = "http://127.0.0.1:8000"
|
||||
|
||||
## Request timeout in seconds. 0 disables the timeout.
|
||||
var timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Environment variable override (e.g. for headless smoke tests against a
|
||||
# backend on a non-default port). Falls back to the ProjectSetting, then
|
||||
# to the documented default.
|
||||
var env_url := OS.get_environment("AGP_API_BASE_URL")
|
||||
if env_url != "":
|
||||
_base_url = env_url.rstrip("/")
|
||||
else:
|
||||
_base_url = str(ProjectSettings.get_setting(
|
||||
"application/agp/api_base_url",
|
||||
"http://127.0.0.1:8000"
|
||||
)).rstrip("/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core request primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Generic request primitive. Awaitable via `await`.
|
||||
##
|
||||
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
|
||||
## cross-talk, and so the result maps 1:1 to the awaiting caller.
|
||||
##
|
||||
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
|
||||
## @param path URL path, e.g. "/api/health".
|
||||
## @param body Request body string (JSON or form-encoded).
|
||||
## @param content_type "application/json" or "application/x-www-form-urlencoded".
|
||||
## @param require_auth If true, attach the stored bearer token when present.
|
||||
## @return Result dictionary conforming to the contract.
|
||||
func request(
|
||||
method: HTTPClient.Method,
|
||||
path: String,
|
||||
body: String = "",
|
||||
content_type: String = "application/json",
|
||||
require_auth: bool = false
|
||||
) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = timeout_seconds
|
||||
http.max_redirects = 8
|
||||
http.accept_gzip = true
|
||||
add_child(http)
|
||||
|
||||
var headers := PackedStringArray()
|
||||
headers.append("Accept: application/json")
|
||||
if content_type != "":
|
||||
headers.append("Content-Type: " + content_type)
|
||||
if require_auth and has_token():
|
||||
headers.append("Authorization: " + bearer_token)
|
||||
|
||||
var url := _base_url + path
|
||||
var err := http.request(url, headers, method, body)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
|
||||
|
||||
# request_completed emits (result, response_code, headers, body).
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var response_body: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
return _make_result(false, 0, null, _transport_error_detail(result_code))
|
||||
|
||||
# Server accepted the connection but returned no HTTP status line.
|
||||
if response_code == 0:
|
||||
return _make_result(false, 0, null, "No response received from server.")
|
||||
|
||||
var json_string := response_body.get_string_from_utf8()
|
||||
var ok := response_code >= 200 and response_code < 300
|
||||
|
||||
if ok:
|
||||
var data: Variant = _parse_json(json_string)
|
||||
return _make_result(true, response_code, data, "OK")
|
||||
|
||||
# Non-2xx: surface the backend's "detail" message when available.
|
||||
var detail := "HTTP error %d" % response_code
|
||||
var parsed: Variant = _parse_json(json_string)
|
||||
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||
detail = str((parsed as Dictionary)["detail"])
|
||||
return _make_result(false, response_code, null, detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Log in with username/password (OAuth2PasswordRequestForm).
|
||||
## On success stores the returned access token.
|
||||
func login(username: String, password: String) -> Dictionary:
|
||||
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/login",
|
||||
form,
|
||||
"application/x-www-form-urlencoded",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param date_of_birth ISO date string "YYYY-MM-DD".
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
date_of_birth: String
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": date_of_birth,
|
||||
}
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/register",
|
||||
JSON.stringify(payload),
|
||||
"application/json",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## List worlds visible to the current user (requires auth).
|
||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
|
||||
if visibility != "":
|
||||
path += "&visibility=" + visibility.uri_encode()
|
||||
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
|
||||
## Ping the backend health endpoint (no auth).
|
||||
func health_check() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||
|
||||
|
||||
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||
func get_me() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _store_token_from_result(result: Dictionary) -> void:
|
||||
if result.ok and result.data is Dictionary:
|
||||
var token: String = str((result.data as Dictionary).get("access_token", ""))
|
||||
if token != "":
|
||||
set_token(token)
|
||||
|
||||
|
||||
func _parse_json(text: String) -> Variant:
|
||||
if text == "":
|
||||
return null
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null:
|
||||
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
|
||||
# the raw string so callers never lose the body entirely.
|
||||
return text if text != "null" else null
|
||||
return parsed
|
||||
|
||||
|
||||
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
|
||||
return {
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
func _transport_error_detail(result_code: int) -> String:
|
||||
match result_code:
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "Could not connect to server. Is it running?"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "Could not resolve server hostname."
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "Connection error."
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS handshake failed."
|
||||
HTTPRequest.RESULT_NO_RESPONSE:
|
||||
return "No response from server."
|
||||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||
return "Response body too large."
|
||||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||
return "Failed to decompress response body."
|
||||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||
return "Request failed."
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "Request timed out."
|
||||
_:
|
||||
return "Network error (code %d)." % result_code
|
||||
|
||||
|
||||
func _error_string(err_code: int) -> String:
|
||||
match err_code:
|
||||
OK:
|
||||
return "OK"
|
||||
ERR_UNCONFIGURED:
|
||||
return "HTTPRequest not configured (not in tree)."
|
||||
ERR_BUSY:
|
||||
return "HTTPRequest is busy with a previous request."
|
||||
ERR_INVALID_PARAMETER:
|
||||
return "Invalid URL format."
|
||||
ERR_CANT_CONNECT:
|
||||
return "Cannot connect to host."
|
||||
_:
|
||||
return "Unknown error (%d)." % err_code
|
||||
@@ -1 +0,0 @@
|
||||
uid://cjd0uhll7iylj
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Headless CI export for Linux and Windows.
|
||||
#
|
||||
# Requires:
|
||||
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
|
||||
# built against; a different Godot minor version's export templates are
|
||||
# not guaranteed compatible)
|
||||
# - The matching export templates installed at
|
||||
# ~/.local/share/godot/export_templates/4.6.2.stable/
|
||||
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
|
||||
# "Export templates" -- NOT the same as the editor download)
|
||||
# - godot on PATH (or set GODOT_BIN below)
|
||||
#
|
||||
# Usage: scripts/build.sh
|
||||
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
|
||||
if [ -x "$HOME/godot/godot" ]; then
|
||||
GODOT_BIN="$HOME/godot/godot"
|
||||
else
|
||||
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Godot version: $("$GODOT_BIN" --version)"
|
||||
|
||||
mkdir -p export/linux export/windows
|
||||
|
||||
echo "=== Exporting Linux/X11 ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
|
||||
|
||||
echo "=== Exporting Windows Desktop ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
|
||||
|
||||
echo ""
|
||||
echo "Build artifacts:"
|
||||
ls -la export/linux export/windows
|
||||
+6
-204
@@ -1,212 +1,14 @@
|
||||
extends Control
|
||||
## Main menu with login/register UI wired to the backend auth API.
|
||||
##
|
||||
## The AuthPanel is hidden by default. When the user clicks "Login" or "Register"
|
||||
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle
|
||||
## switches between Login and Register modes, showing/hiding the email and DOB
|
||||
## fields accordingly.
|
||||
##
|
||||
## On successful auth the token is stored via ApiClient, the welcome label
|
||||
## shows the username, and the Play button becomes enabled.
|
||||
|
||||
|
||||
# ── UI references ────────────────────────────────────────────────────────────
|
||||
|
||||
var _auth_panel: PanelContainer
|
||||
var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
var _password_edit: LineEdit
|
||||
var _email_edit: LineEdit
|
||||
var _dob_edit: LineEdit
|
||||
var _mode_toggle: CheckButton
|
||||
var _auth_button: Button
|
||||
var _status_label: Label
|
||||
var _welcome_label: Label
|
||||
var _play_button: Button
|
||||
var _quit_button: Button
|
||||
|
||||
var _is_register_mode: bool = false
|
||||
var _is_authenticating: bool = false
|
||||
func _ready():
|
||||
pass
|
||||
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _ready() -> void:
|
||||
# Cache node references.
|
||||
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
||||
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||
|
||||
# Initial state.
|
||||
_auth_panel.visible = false
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_play_button.disabled = true # Gate play behind successful auth.
|
||||
_update_auth_mode_ui()
|
||||
func _on_play_button_pressed():
|
||||
# No gameplay scene exists yet — wire this to the real scene once one lands.
|
||||
print("Play pressed — no gameplay scene yet")
|
||||
|
||||
|
||||
# ── Play / Quit ──────────────────────────────────────────────────────────────
|
||||
|
||||
func _on_play_button_pressed() -> void:
|
||||
if _play_button.disabled:
|
||||
return
|
||||
# Browse worlds is the first real screen past login -- actual gameplay
|
||||
# (joining a world) doesn't exist yet, browsing does.
|
||||
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||
|
||||
|
||||
func _on_quit_button_pressed() -> void:
|
||||
func _on_quit_button_pressed():
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
# ── Auth panel toggle ────────────────────────────────────────────────────────
|
||||
|
||||
func _on_login_button_pressed() -> void:
|
||||
"""Show the auth panel (called from a future Login button on the main menu)."""
|
||||
_auth_panel.visible = true
|
||||
_auth_panel.grab_focus()
|
||||
|
||||
|
||||
func _on_register_button_pressed() -> void:
|
||||
"""Show the auth panel in register mode."""
|
||||
_auth_panel.visible = true
|
||||
_is_register_mode = true
|
||||
_mode_toggle.button_pressed = true
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
|
||||
|
||||
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
|
||||
|
||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
||||
_is_register_mode = button_pressed
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
|
||||
|
||||
func _update_auth_mode_ui() -> void:
|
||||
"""Update labels and field visibility based on login vs register mode."""
|
||||
if _is_register_mode:
|
||||
_auth_mode_label.text = "Register"
|
||||
_auth_button.text = "Register"
|
||||
_email_edit.visible = true
|
||||
_dob_edit.visible = true
|
||||
else:
|
||||
_auth_mode_label.text = "Login"
|
||||
_auth_button.text = "Login"
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
|
||||
|
||||
# ── Auth submission ──────────────────────────────────────────────────────────
|
||||
|
||||
func _on_auth_button_pressed() -> void:
|
||||
"""Handle login or register button press."""
|
||||
if _is_authenticating:
|
||||
return
|
||||
|
||||
# Validate inputs.
|
||||
var username := _username_edit.text.strip_edges()
|
||||
var password := _password_edit.text
|
||||
|
||||
if username == "":
|
||||
_show_status("Username is required.")
|
||||
return
|
||||
if password == "":
|
||||
_show_status("Password is required.")
|
||||
return
|
||||
|
||||
if _is_register_mode:
|
||||
var email := _email_edit.text.strip_edges()
|
||||
var dob := _dob_edit.text.strip_edges()
|
||||
|
||||
if email == "":
|
||||
_show_status("Email is required.")
|
||||
return
|
||||
if dob == "":
|
||||
_show_status("Date of birth is required.")
|
||||
return
|
||||
# Basic YYYY-MM-DD format check.
|
||||
if not _is_valid_date(dob):
|
||||
_show_status("Date of birth must be in YYYY-MM-DD format.")
|
||||
return
|
||||
|
||||
_auth_button.disabled = true
|
||||
_auth_button.text = "Registering..."
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.register(username, email, password, dob)
|
||||
_on_auth_complete(result)
|
||||
else:
|
||||
_auth_button.disabled = true
|
||||
_auth_button.text = "Logging in..."
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.login(username, password)
|
||||
_on_auth_complete(result)
|
||||
|
||||
|
||||
func _on_auth_complete(result: Dictionary) -> void:
|
||||
"""Handle the result of a login or register call."""
|
||||
_auth_button.disabled = false
|
||||
_auth_button.text = "Register" if _is_register_mode else "Login"
|
||||
_is_authenticating = false
|
||||
|
||||
if result.ok:
|
||||
# Success — show welcome, enable Play.
|
||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||
_welcome_label.visible = true
|
||||
_status_label.visible = false
|
||||
_play_button.disabled = false
|
||||
print("Auth successful. Token stored.")
|
||||
else:
|
||||
# Failure — show backend error detail.
|
||||
_show_status(result.detail)
|
||||
|
||||
|
||||
# ── Status helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
_status_label.text = message
|
||||
_status_label.visible = true
|
||||
|
||||
|
||||
func _clear_status() -> void:
|
||||
_status_label.text = ""
|
||||
_status_label.visible = false
|
||||
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _is_valid_date(date_str: String) -> bool:
|
||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
||||
if date_str.length() != 10:
|
||||
return false
|
||||
var parts := date_str.split("-")
|
||||
if parts.size() != 3:
|
||||
return false
|
||||
var year := parts[0].to_int()
|
||||
var month := parts[1].to_int()
|
||||
var day := parts[2].to_int()
|
||||
if month < 1 or month > 12:
|
||||
return false
|
||||
if day < 1 or day > 31:
|
||||
return false
|
||||
# Rough month-day validation.
|
||||
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
if day > days_in_month[month]:
|
||||
return false
|
||||
return true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://bgnc3iamfgf6k
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the full automated test inventory for a Package Checkpoint review.
|
||||
|
||||
See docs/testing/test_inventory.md for what each suite actually covers (and
|
||||
doesn't) -- keep that doc in sync as tests are added, this script just runs
|
||||
whatever's there.
|
||||
|
||||
Usage:
|
||||
python scripts/run_checkpoint_tests.py [--keep-postgres] [--postgres-port PORT]
|
||||
|
||||
Exits 0 if everything that ran passed, 1 otherwise. Suites with nothing to
|
||||
run (e.g. no scripts/smoke_test_*.gd yet, or no Godot binary found) are
|
||||
reported as SKIPPED, not counted as a failure.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def run(cmd, cwd=None, env=None, capture=False):
|
||||
print(f"$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else ""))
|
||||
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=capture, text=True)
|
||||
|
||||
|
||||
def find_godot():
|
||||
for candidate in ("godot", str(Path.home() / "godot" / "godot")):
|
||||
if shutil.which(candidate) or Path(candidate).is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_http(url: str, timeout_s: int = 20) -> bool:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--keep-postgres", action="store_true",
|
||||
help="Don't tear down the checkpoint Postgres container afterward")
|
||||
parser.add_argument("--postgres-port", default=os.environ.get("CHECKPOINT_POSTGRES_PORT", "5432"),
|
||||
help="Host port for the checkpoint's own Postgres container (default 5432 -- "
|
||||
"override if that's already in use on this machine)")
|
||||
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
|
||||
help="Port to run the backend server on for the Godot smoke tests")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {}
|
||||
|
||||
# --- Backend: bring up Postgres ---
|
||||
compose_env = os.environ.copy()
|
||||
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
|
||||
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
|
||||
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
|
||||
print("=== Backend: bringing up Postgres ===")
|
||||
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
print("Waiting for Postgres to be ready...")
|
||||
pg_ready = False
|
||||
for _ in range(20):
|
||||
r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"],
|
||||
cwd=BACKEND, env=compose_env, capture=True)
|
||||
if r.returncode == 0:
|
||||
pg_ready = True
|
||||
break
|
||||
time.sleep(1)
|
||||
if not pg_ready:
|
||||
print("Postgres never became ready -- aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
||||
backend_env = os.environ.copy()
|
||||
backend_env["DATABASE_URL"] = db_url
|
||||
|
||||
print("\n=== Backend: alembic upgrade head ===")
|
||||
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
||||
results["migrations (fresh DB)"] = r.returncode == 0
|
||||
|
||||
print("\n=== Backend: pytest ===")
|
||||
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
|
||||
results["backend pytest"] = r.returncode == 0
|
||||
|
||||
# --- Godot: headless smoke tests (need a live backend server) ---
|
||||
godot_bin = find_godot()
|
||||
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
|
||||
|
||||
if not godot_bin:
|
||||
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
|
||||
results["godot smoke tests"] = None
|
||||
elif not smoke_tests:
|
||||
print("\n=== Godot: no scripts/smoke_test_*.gd found yet, nothing to run ===")
|
||||
results["godot smoke tests"] = None
|
||||
else:
|
||||
print(f"\n=== Backend: starting server on :{args.backend_port} for Godot smoke tests ===")
|
||||
server_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", args.backend_port],
|
||||
cwd=BACKEND, env=backend_env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
base_url = f"http://127.0.0.1:{args.backend_port}"
|
||||
if not wait_for_http(f"{base_url}/api/health"):
|
||||
print("Backend server never became healthy -- skipping Godot smoke tests.")
|
||||
results["godot smoke tests"] = False
|
||||
else:
|
||||
godot_env = os.environ.copy()
|
||||
godot_env["AGP_API_BASE_URL"] = base_url
|
||||
|
||||
print("=== Godot: import pass (builds the class cache) ===")
|
||||
run([godot_bin, "--headless", "--path", str(ROOT), "--import"], env=godot_env)
|
||||
run([godot_bin, "--headless", "--path", str(ROOT), "--quit"], env=godot_env)
|
||||
|
||||
godot_ok = True
|
||||
for test in smoke_tests:
|
||||
rel = os.path.relpath(test, ROOT)
|
||||
print(f"\n--- {rel} ---")
|
||||
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
|
||||
if r.returncode != 0:
|
||||
godot_ok = False
|
||||
results["godot smoke tests"] = godot_ok
|
||||
finally:
|
||||
server_proc.terminate()
|
||||
try:
|
||||
server_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server_proc.kill()
|
||||
|
||||
if not args.keep_postgres:
|
||||
print("\n=== Backend: tearing down checkpoint Postgres ===")
|
||||
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
# --- Summary ---
|
||||
print("\n" + "=" * 50)
|
||||
print("SUMMARY")
|
||||
print("=" * 50)
|
||||
overall_ok = True
|
||||
for name, ok in results.items():
|
||||
if ok is None:
|
||||
print(f" {name}: SKIPPED")
|
||||
elif ok:
|
||||
print(f" {name}: PASS")
|
||||
else:
|
||||
print(f" {name}: FAIL")
|
||||
overall_ok = False
|
||||
|
||||
print("\nSee docs/testing/test_inventory.md for what each suite actually covers (and doesn't).")
|
||||
sys.exit(0 if overall_ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,46 +0,0 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the ApiClient autoload.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_api.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
##
|
||||
## Calls the real autoload's `health_check()` (which exercises the generic
|
||||
## `request()` primitive) and exits 0 on a `status == "ok"` response, 1
|
||||
## otherwise. This script is intentionally NOT wired into any game scene.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# _initialize() fires before the SceneTree has processed its first frame,
|
||||
# so a freshly add_child()'d HTTPRequest node isn't reliably considered
|
||||
# "inside tree" yet (request_raw fails with !is_inside_tree()). One frame
|
||||
# of delay is enough; this only matters for --script mode, not a real
|
||||
# running game.
|
||||
await process_frame
|
||||
|
||||
# Godot's --script/SceneTree runner doesn't resolve autoload singletons
|
||||
# as bare global identifiers at compile time (the node exists at
|
||||
# runtime -- root.has_node("ApiClient") is true -- but referencing it as
|
||||
# a plain identifier fails with "Identifier not found: ApiClient" during
|
||||
# script compilation). get_node sidesteps compile-time resolution
|
||||
# entirely. This only affects --script mode; a real game scene resolves
|
||||
# the bare autoload name normally.
|
||||
var api_client := root.get_node("ApiClient")
|
||||
print("SMOKE: calling ApiClient.health_check() ...")
|
||||
var result: Dictionary = await api_client.health_check()
|
||||
print("SMOKE: result = ", result)
|
||||
|
||||
if result.ok \
|
||||
and result.data is Dictionary \
|
||||
and str((result.data as Dictionary).get("status", "")) == "ok":
|
||||
print("SMOKE RESULT: PASS (status=ok)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL")
|
||||
quit(1)
|
||||
@@ -1,133 +0,0 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the AuthPanel login/register flow.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_auth.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
##
|
||||
## Tests:
|
||||
## 1. Register a unique user → 201 + token stored.
|
||||
## 2. Register the same user again → 409 "Username already registered".
|
||||
## 3. Register an under-18 user → 400 "Must be 18 or older".
|
||||
## 4. Login with correct credentials → 200 + token stored.
|
||||
## 5. Login with wrong password → 401 "Incorrect username or password".
|
||||
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
||||
##
|
||||
## Exits 0 on full pass, 1 on any failure.
|
||||
|
||||
const API_BASE := "http://127.0.0.1:8000"
|
||||
const TEST_USERNAME := "smoke_test_user_%d"
|
||||
const TEST_EMAIL := "smoke_%d@test.example.com"
|
||||
const TEST_PASSWORD := "SmokeTest123!"
|
||||
const TEST_DOB_ADULT := "2000-01-01"
|
||||
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# Wait for autoloads to be ready.
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient") as ApiClient
|
||||
assert(api != null, "ApiClient autoload not found")
|
||||
|
||||
var passed: int = 0
|
||||
var failed: int = 0
|
||||
|
||||
# --- Test 1: Register a unique user ---
|
||||
var unique_id := Time.get_ticks_msec()
|
||||
var username := "smoke_%d" % unique_id
|
||||
var email := "smoke_%d@test.example.com" % unique_id
|
||||
|
||||
print("\n=== Test 1: Register unique user ===")
|
||||
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||
if _check("register returns ok", result.ok) \
|
||||
and _check("register status is 201", result.status == 201) \
|
||||
and _check("register stores token", api.has_token()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 2: Register same user again (duplicate) ---
|
||||
print("\n=== Test 2: Duplicate username ===")
|
||||
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||
if _check("duplicate register not ok", not result.ok) \
|
||||
and _check("duplicate status is 409", result.status == 409) \
|
||||
and _check("duplicate detail mentions username",
|
||||
"username" in result.detail.to_lower()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 3: Register under-18 user ---
|
||||
print("\n=== Test 3: Under-18 registration ===")
|
||||
var child_username := "child_%d" % unique_id
|
||||
var child_email := "child_%d@test.example.com" % unique_id
|
||||
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
|
||||
if _check("under-18 register not ok", not result.ok) \
|
||||
and _check("under-18 status is 400", result.status == 400) \
|
||||
and _check("under-18 detail mentions age",
|
||||
"18" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 4: Login with correct credentials ---
|
||||
print("\n=== Test 4: Correct login ===")
|
||||
result = await api.login(username, TEST_PASSWORD)
|
||||
if _check("login returns ok", result.ok) \
|
||||
and _check("login status is 200", result.status == 200) \
|
||||
and _check("login stores token", api.has_token()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 5: Login with wrong password ---
|
||||
print("\n=== Test 5: Wrong password ===")
|
||||
result = await api.login(username, "WrongPassword!")
|
||||
if _check("wrong pwd login not ok", not result.ok) \
|
||||
and _check("wrong pwd status is 401", result.status == 401) \
|
||||
and _check("wrong pwd detail",
|
||||
"Incorrect" in result.detail or "incorrect" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 6: Login with non-existent user ---
|
||||
print("\n=== Test 6: Non-existent user ===")
|
||||
result = await api.login("nonexistent_user_xyz", TEST_PASSWORD)
|
||||
if _check("missing user login not ok", not result.ok) \
|
||||
and _check("missing user status is 401", result.status == 401) \
|
||||
and _check("missing user detail",
|
||||
"Incorrect" in result.detail or "incorrect" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Summary ---
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
print("SMOKE RESULT: ALL PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
return true
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return false
|
||||
@@ -1,148 +0,0 @@
|
||||
extends SceneTree
|
||||
## End-to-end headless smoke test: register -> login -> fetch worlds list.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_e2e.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||
## the AGP_API_BASE_URL environment variable).
|
||||
##
|
||||
## Steps:
|
||||
## 1. Health check GET /api/health -> 200, {"status": "ok"}
|
||||
## 2. Register POST /api/auth/register -> 201, access_token present
|
||||
## 3. Login POST /api/auth/login -> 200, access_token present
|
||||
## 4. Worlds list GET /api/worlds -> 200, JSON array
|
||||
##
|
||||
## Exits 0 if all steps pass, 1 otherwise. If the backend is unreachable the
|
||||
## health check fails with a clear message and the script exits 1 immediately.
|
||||
|
||||
# Fixed credentials from the task spec. The username gets a unique numeric
|
||||
# suffix so the test stays re-runnable (a second run would otherwise hit
|
||||
# "Username already registered" on step 2).
|
||||
const TEST_USERNAME_BASE := "smoke_test_user"
|
||||
const TEST_EMAIL := "smoke@test.com"
|
||||
const TEST_PASSWORD := "TestPass123"
|
||||
const TEST_DOB := "2000-01-01"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# _initialize() fires before the first frame; wait one frame so freshly
|
||||
# created HTTPRequest nodes are reliably inside the tree.
|
||||
await process_frame
|
||||
|
||||
# In --script mode the autoload is reachable via get_node(), not as a
|
||||
# bare identifier (bare identifiers fail to resolve at compile time here).
|
||||
var api := root.get_node("ApiClient")
|
||||
assert(api != null, "ApiClient autoload not found in the scene tree")
|
||||
|
||||
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||
var username := "%s_%d" % [TEST_USERNAME_BASE, unique_id]
|
||||
var email := "smoke_%d@test.com" % unique_id
|
||||
|
||||
var results := {}
|
||||
|
||||
# --- Step 1: Health check -------------------------------------------------
|
||||
print("\n=== Step 1: Health check (GET /api/health) ===")
|
||||
var health: Dictionary = await api.health_check()
|
||||
var health_ok: bool = (
|
||||
health.ok
|
||||
and health.status == 200
|
||||
and health.data is Dictionary
|
||||
and str((health.data as Dictionary).get("status", "")) == "ok"
|
||||
)
|
||||
_record(results, "health", health_ok, "status=%d data=%s" % [
|
||||
health.status, health.data
|
||||
])
|
||||
|
||||
if not health.ok or health.status == 0:
|
||||
printerr("\nERROR: Backend is not reachable at the configured base URL.")
|
||||
printerr(" detail: %s" % health.detail)
|
||||
printerr(" Start the backend first (see backend/README), then re-run:")
|
||||
printerr(" godot --headless --path . --script res://scripts/smoke_test_e2e.gd")
|
||||
_summarize(results)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Step 2: Register -----------------------------------------------------
|
||||
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
||||
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
|
||||
var reg_token := _extract_token(reg)
|
||||
var reg_ok: bool = (
|
||||
reg.ok
|
||||
and reg.status == 201
|
||||
and reg_token != ""
|
||||
)
|
||||
_record(results, "register", reg_ok, "status=%d token=%s detail=%s" % [
|
||||
reg.status, "yes" if reg_token != "" else "no", reg.detail
|
||||
])
|
||||
|
||||
# --- Step 3: Login --------------------------------------------------------
|
||||
print("\n=== Step 3: Login (POST /api/auth/login) ===")
|
||||
var login: Dictionary = await api.login(username, TEST_PASSWORD)
|
||||
var login_token := _extract_token(login)
|
||||
var login_ok: bool = (
|
||||
login.ok
|
||||
and login.status == 200
|
||||
and login_token != ""
|
||||
)
|
||||
_record(results, "login", login_ok, "status=%d token=%s detail=%s" % [
|
||||
login.status, "yes" if login_token != "" else "no", login.detail
|
||||
])
|
||||
|
||||
# --- Step 4: Worlds list ---------------------------------------------------
|
||||
print("\n=== Step 4: Worlds list (GET /api/worlds) ===")
|
||||
# login/register already stored the token; make the auth header explicit by
|
||||
# re-applying the extracted token so step 4 is independent of storage order.
|
||||
if login_token != "":
|
||||
api.set_token(login_token)
|
||||
var worlds: Dictionary = await api.get_worlds()
|
||||
var worlds_ok: bool = (
|
||||
worlds.ok
|
||||
and worlds.status == 200
|
||||
and worlds.data is Array
|
||||
)
|
||||
_record(results, "worlds", worlds_ok, "status=%d is_array=%s count=%d detail=%s" % [
|
||||
worlds.status,
|
||||
"yes" if (worlds.data is Array) else "no",
|
||||
(worlds.data as Array).size() if (worlds.data is Array) else -1,
|
||||
worlds.detail,
|
||||
])
|
||||
|
||||
# --- Summary ---------------------------------------------------------------
|
||||
_summarize(results)
|
||||
|
||||
|
||||
func _extract_token(result: Dictionary) -> String:
|
||||
if result.ok and result.data is Dictionary:
|
||||
return str((result.data as Dictionary).get("access_token", ""))
|
||||
return ""
|
||||
|
||||
|
||||
func _record(results: Dictionary, name: String, ok: bool, detail: String) -> void:
|
||||
results[name] = {"ok": ok, "detail": detail}
|
||||
if ok:
|
||||
print(" PASS: %s" % detail)
|
||||
else:
|
||||
printerr(" FAIL: %s" % detail)
|
||||
|
||||
|
||||
func _summarize(results: Dictionary) -> void:
|
||||
var all_pass := true
|
||||
print("\n=== Smoke test summary ===")
|
||||
for step in ["health", "register", "login", "worlds"]:
|
||||
var entry: Dictionary = results.get(step, {"ok": false, "detail": "not run"})
|
||||
if not entry["ok"]:
|
||||
all_pass = false
|
||||
print(" %-8s : %s" % [step, "PASS" if entry["ok"] else "FAIL"])
|
||||
|
||||
if all_pass:
|
||||
print("SMOKE RESULT: ALL PASS (4/4)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL")
|
||||
quit(1)
|
||||
@@ -1,122 +0,0 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the world list screen.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_world_list.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with
|
||||
## AGP_API_BASE_URL).
|
||||
##
|
||||
## Unlike a pure API-level test, this actually instantiates scenes/
|
||||
## world_list.tscn as a real scene and adds it to the tree -- catches
|
||||
## @onready node-path bugs (a node reference pointing at the wrong path
|
||||
## errors right here, not just "looks right" from reading the script) that
|
||||
## an API-only test would miss entirely.
|
||||
##
|
||||
## Tests:
|
||||
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
||||
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||
## must not error.
|
||||
## 3. Wait for _refresh_worlds()'s awaited request to complete.
|
||||
## 4. Confirm the screen reached a real end state (world cards, or the
|
||||
## empty-list message -- either is fine, a crash/stuck-loading isn't).
|
||||
##
|
||||
## Exits 0 on full pass, 1 on any failure.
|
||||
|
||||
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient")
|
||||
assert(api != null, "ApiClient autoload not found")
|
||||
|
||||
var passed: int = 0
|
||||
var failed: int = 0
|
||||
|
||||
# --- Setup: register + log in so the world list has a valid token ---
|
||||
print("\n=== Setup: register + login ===")
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := "worldlist_smoke_%d" % unique_id
|
||||
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
|
||||
if not _check("setup: register succeeded", reg_result.ok):
|
||||
print(" detail: ", reg_result.detail)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Test 1: Instantiate the real scene ---
|
||||
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||
if not _check("scene loads", packed_scene != null):
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var instance: Control = packed_scene.instantiate()
|
||||
if not _check("scene instantiates", instance != null):
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(instance)
|
||||
# One frame for _ready() (and its @onready resolution) to run. A wrong
|
||||
# node path in an @onready var errors during this, not silently.
|
||||
await process_frame
|
||||
|
||||
if _check("scene added to tree without error", is_instance_valid(instance)):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 2: Wait for the world fetch to resolve, check a real end state ---
|
||||
print("\n=== Test 2: World fetch reaches a real end state ===")
|
||||
# _refresh_worlds() runs from _ready(); give its awaited HTTP call time
|
||||
# to complete rather than a fixed sleep -- poll for either end state.
|
||||
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||
var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel")
|
||||
var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel")
|
||||
|
||||
var settled := false
|
||||
for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits
|
||||
await process_frame
|
||||
if world_list_node.visible or empty_label.visible or error_label.visible:
|
||||
settled = true
|
||||
break
|
||||
|
||||
if _check("reached a real end state (cards, empty, or error) not stuck loading", settled):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [
|
||||
world_list_node.visible, empty_label.visible, error_label.visible
|
||||
])
|
||||
|
||||
if error_label.visible:
|
||||
printerr(" Note: ended in error state: ", error_label.text)
|
||||
elif world_list_node.visible:
|
||||
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
|
||||
elif empty_label.visible:
|
||||
print(" Note: ended in empty-list state")
|
||||
|
||||
# --- Summary ---
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
print("SMOKE RESULT: ALL PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
return true
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return false
|
||||
@@ -1,135 +0,0 @@
|
||||
extends Control
|
||||
## World list screen — browses public worlds via GET /api/worlds.
|
||||
##
|
||||
## Displays a scrollable list of world cards (name + description).
|
||||
## Handles loading, empty list, errors, and refresh.
|
||||
|
||||
|
||||
# UI references
|
||||
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
|
||||
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
|
||||
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
|
||||
@onready var error_label: Label = $VBoxContainer/ErrorLabel
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
refresh_button.disabled = false
|
||||
back_button.disabled = false
|
||||
loading_label.visible = false
|
||||
error_label.visible = false
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
_refresh_worlds()
|
||||
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
_refresh_worlds()
|
||||
|
||||
|
||||
func _on_back_button_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
## Fetch the world list from the backend and update the UI.
|
||||
func _refresh_worlds() -> void:
|
||||
# Reset UI state
|
||||
loading_label.visible = true
|
||||
error_label.visible = false
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
refresh_button.disabled = true
|
||||
|
||||
# Clear previous world cards
|
||||
for child in world_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var result := await ApiClient.get_worlds()
|
||||
|
||||
loading_label.visible = false
|
||||
refresh_button.disabled = false
|
||||
|
||||
if not result.ok:
|
||||
# Show error message (401, network failure, etc.)
|
||||
error_label.text = "Error: %s" % result.detail
|
||||
error_label.visible = true
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
var data = result.data
|
||||
if not data is Array:
|
||||
error_label.text = "Unexpected response format."
|
||||
error_label.visible = true
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
if data.is_empty():
|
||||
empty_label.text = "No worlds yet. Check back later!"
|
||||
empty_label.visible = true
|
||||
error_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
# Render world cards
|
||||
world_list.visible = true
|
||||
empty_label.visible = false
|
||||
error_label.visible = false
|
||||
|
||||
for world_dict in data:
|
||||
if not world_dict is Dictionary:
|
||||
continue
|
||||
var card := _create_world_card(world_dict)
|
||||
world_list.add_child(card)
|
||||
|
||||
|
||||
## Create a visual card for a single world entry.
|
||||
func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
var card := VBoxContainer.new()
|
||||
card.size_flags_horizontal = Control.SIZE_FILL
|
||||
card.custom_minimum_size = Vector2(0, 80)
|
||||
card.add_theme_constant_override("separation", 4)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = world_dict.get("name", "Unnamed World")
|
||||
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
name_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
name_label.add_theme_font_size_override("font_size", 18)
|
||||
|
||||
var desc_label := Label.new()
|
||||
var description: String = str(world_dict.get("description", ""))
|
||||
desc_label.text = description if description != "" else "No description."
|
||||
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
desc_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
desc_label.add_theme_font_size_override("font_size", 14)
|
||||
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
||||
|
||||
var meta_label := Label.new()
|
||||
var visibility: String = str(world_dict.get("visibility", "public"))
|
||||
var created: String = str(world_dict.get("created_at", ""))
|
||||
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
|
||||
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
meta_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
meta_label.add_theme_font_size_override("font_size", 12)
|
||||
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||
|
||||
card.add_child(name_label)
|
||||
card.add_child(desc_label)
|
||||
card.add_child(meta_label)
|
||||
|
||||
# Subtle background for the card
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.15, 0.15, 0.15, 1.0)
|
||||
bg.size_flags_horizontal = Control.SIZE_FILL
|
||||
bg.size_flags_vertical = Control.SIZE_FILL
|
||||
bg.custom_minimum_size = Vector2(0, 80)
|
||||
card.add_child(bg)
|
||||
card.move_child(bg, 0)
|
||||
|
||||
return card
|
||||
Reference in New Issue
Block a user