Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66adcff145 | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| 3cb4d01073 | |||
| 3e81d60268 | |||
| b3d2d71789 | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 7d89e860fd | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 | |||
| cbe5e992dd | |||
| d4998d7359 | |||
| ef78caa4de | |||
| 87575eec43 | |||
| 6d5051bbb2 | |||
| 72f39bc547 | |||
| 69b6f40eb9 | |||
| 4d467c0c9e | |||
| e43bc9ff10 | |||
| 5c91bb3f34 | |||
| ca7924d5e1 | |||
| 758307bbee | |||
| 478638d4ff | |||
| 7b074a01ed | |||
| c68877239f | |||
| ed6811a438 | |||
| f64ffd6422 | |||
| 7e28e2b525 | |||
| 99be8f1dab | |||
| b3e66fc072 | |||
| 634b2e39c0 | |||
| 1c75367c87 | |||
| ef21bf9d69 | |||
| e06cd2a23d | |||
| 77f9b74286 | |||
| 6238078565 | |||
| 6108c63c46 | |||
| 109c6ef1a0 | |||
| 33c5236a2d | |||
| 4bb4e9fb8a | |||
| ffbc4849a7 | |||
| a29cfc74af | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| 073813f3e7 | |||
| cc971ee9f5 | |||
| 54abe7ee96 | |||
| a7cf385d0d | |||
| b5525665dd | |||
| ff610c0466 | |||
| acbc518b4a | |||
| 25a8900357 | |||
| 5a1f99eb28 | |||
| cb95895612 | |||
| 62889f39f1 | |||
| ef646a8e54 | |||
| e4d3fe566b | |||
| 1d0222d980 | |||
| 433d5d8f9f | |||
| 30b8dc6de2 |
@@ -8,3 +8,6 @@ export/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Godot UID files (regenerated from path)
|
||||||
|
*.uid
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# JWT
|
# JWT / Auth
|
||||||
SECRET_KEY=change-me-to-a-secure-random-string
|
ALGORITHM=RS256
|
||||||
ALGORITHM=HS256
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
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
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_NAME=AGP Backend
|
APP_NAME=AGP Backend
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ def upgrade() -> None:
|
|||||||
sa.Column(
|
sa.Column(
|
||||||
"visibility",
|
"visibility",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
"public", "private", "friends_only", name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
),
|
),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
@@ -89,7 +90,7 @@ def upgrade() -> None:
|
|||||||
"asset_type",
|
"asset_type",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"image", "audio", "video", "document", "model_3d", "other",
|
"image", "audio", "video", "document", "model_3d", "other",
|
||||||
name="asset_type",
|
name="asset_type", create_type=False,
|
||||||
),
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add date_of_birth column to users table
|
||||||
|
|
||||||
|
Revision ID: 0002_add_date_of_birth_to_users
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2025-01-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0002_add_date_of_birth_to_users"
|
||||||
|
down_revision: Union[str, None] = "0001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add date_of_birth column to users table."""
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("date_of_birth", sa.Date(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove date_of_birth column from users table."""
|
||||||
|
op.drop_column("users", "date_of_birth")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""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")
|
||||||
+80
-6
@@ -1,10 +1,56 @@
|
|||||||
"""Application configuration via pydantic-settings."""
|
"""Application configuration via pydantic-settings."""
|
||||||
|
|
||||||
from functools import lru_cache
|
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
|
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):
|
class Settings(BaseSettings):
|
||||||
# App
|
# App
|
||||||
APP_NAME: str = "AGP Backend"
|
APP_NAME: str = "AGP Backend"
|
||||||
@@ -16,14 +62,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
REDIS_TIMEOUT_SECONDS: float = 2.0
|
|
||||||
|
|
||||||
# JWT / Auth
|
# JWT / Auth
|
||||||
# NOTE: production must override SECRET_KEY with a secure random value
|
ALGORITHM: str = "RS256"
|
||||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
ALGORITHM: str = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 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")
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||||
@@ -31,6 +78,7 @@ class Settings(BaseSettings):
|
|||||||
# File uploads
|
# File uploads
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
MAX_UPLOAD_SIZE_MB: int = 100
|
MAX_UPLOAD_SIZE_MB: int = 100
|
||||||
|
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@@ -42,6 +90,32 @@ class Settings(BaseSettings):
|
|||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
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()
|
@lru_cache()
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||||
)
|
)
|
||||||
user_id: str | None = payload.get("sub")
|
user_id: str | None = payload.get("sub")
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import uuid
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -18,3 +19,23 @@ class Session(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="sessions")
|
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")
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -12,10 +15,24 @@ class User(Base):
|
|||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
email = Column(String(120), unique=True, index=True, nullable=False)
|
email = Column(String(120), unique=True, index=True, nullable=False)
|
||||||
hashed_password = Column(String(255), nullable=False)
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
date_of_birth = Column(Date, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
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")
|
worlds = relationship("World", back_populates="owner")
|
||||||
assets = relationship("Asset", back_populates="owner")
|
assets = relationship("Asset", back_populates="owner")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_adult(self) -> bool:
|
||||||
|
"""Return True if the user is 18 or older based on date_of_birth."""
|
||||||
|
if self.date_of_birth is None:
|
||||||
|
return False
|
||||||
|
today = date.today()
|
||||||
|
age = today.year - self.date_of_birth.year
|
||||||
|
# Adjust if birthday hasn't occurred yet this year
|
||||||
|
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
|
||||||
|
age -= 1
|
||||||
|
return age >= 18
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class World(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
visibility = Column(
|
visibility = Column(
|
||||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
ENUM(
|
||||||
|
WorldVisibility,
|
||||||
|
name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||||
|
),
|
||||||
default=WorldVisibility.PUBLIC,
|
default=WorldVisibility.PUBLIC,
|
||||||
)
|
)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
"""Redis-backed token-bucket rate limiting.
|
|
||||||
|
|
||||||
This module provides a reusable FastAPI dependency that rate-limits requests
|
|
||||||
per endpoint scope (optionally per-user) using an atomic Lua script on Redis.
|
|
||||||
|
|
||||||
If Redis is unavailable the limiter can either allow the request through
|
|
||||||
(fail-open) or reject it with a ``503`` (fail-closed), depending on the
|
|
||||||
policy configured for that limit. Chat send uses fail-closed by default
|
|
||||||
because flooding/abuse is worse than a temporary outage.
|
|
||||||
|
|
||||||
Example
|
|
||||||
-------
|
|
||||||
from app.rate_limit import rate_limit, named_limit
|
|
||||||
|
|
||||||
chat_send_limit = rate_limit(
|
|
||||||
named_limit("chat_send", capacity=5, refill_rate=0.2, fail_open=False),
|
|
||||||
user_id_dependency=Depends(get_current_user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/messages")
|
|
||||||
async def send_message(
|
|
||||||
payload: MessageCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
_: None = Depends(chat_send_limit),
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
from redis.exceptions import RedisError
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Atomic token-bucket implementation.
|
|
||||||
#
|
|
||||||
# KEYS[1] is the bucket key. ARGV[1] is the bucket capacity, ARGV[2] is the
|
|
||||||
# refill rate in tokens per second, ARGV[3] is the current Unix time in
|
|
||||||
# seconds (float), and ARGV[4] is the request cost (tokens to take).
|
|
||||||
#
|
|
||||||
# The bucket state is stored as a hash with "tokens" and "ts". The script
|
|
||||||
# refills the bucket up to capacity, then takes the requested tokens. It
|
|
||||||
# returns an array:
|
|
||||||
# [0] allowed (1 or 0)
|
|
||||||
# [1] tokens remaining after the take
|
|
||||||
# [2] retry-after seconds (0 when allowed)
|
|
||||||
_TOKEN_BUCKET_LUA = """
|
|
||||||
local key = KEYS[1]
|
|
||||||
local capacity = tonumber(ARGV[1])
|
|
||||||
local refill_rate = tonumber(ARGV[2])
|
|
||||||
local now = tonumber(ARGV[3])
|
|
||||||
local cost = tonumber(ARGV[4])
|
|
||||||
|
|
||||||
local state = redis.call("HMGET", key, "tokens", "ts")
|
|
||||||
local tokens = capacity
|
|
||||||
local ts = now
|
|
||||||
|
|
||||||
if state[1] ~= false then
|
|
||||||
tokens = tonumber(state[1])
|
|
||||||
ts = tonumber(state[2]) or now
|
|
||||||
end
|
|
||||||
|
|
||||||
local elapsed = math.max(0, now - ts)
|
|
||||||
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
|
|
||||||
|
|
||||||
local allowed = 0
|
|
||||||
local retry_after = 0
|
|
||||||
|
|
||||||
if tokens >= cost then
|
|
||||||
tokens = tokens - cost
|
|
||||||
allowed = 1
|
|
||||||
else
|
|
||||||
-- Time until enough tokens for a single request have accrued.
|
|
||||||
retry_after = math.ceil((cost - tokens) / refill_rate)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Keep the bucket state small and expire-able. Retaining the timestamp as a
|
|
||||||
-- float lets partial tokens accumulate smoothly between requests.
|
|
||||||
redis.call("HSET", key, "tokens", tostring(tokens), "ts", tostring(now))
|
|
||||||
redis.call("PEXPIRE", key, 600000)
|
|
||||||
|
|
||||||
return {allowed, tostring(tokens), retry_after}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitExceeded(HTTPException):
|
|
||||||
"""Raised when a rate-limit bucket has no tokens left."""
|
|
||||||
|
|
||||||
def __init__(self, retry_after: int) -> None:
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail="Rate limit exceeded",
|
|
||||||
headers={"Retry-After": str(retry_after)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RateLimit:
|
|
||||||
"""Definition of a named token-bucket rate limit.
|
|
||||||
|
|
||||||
``capacity`` is the burst size and ``refill_rate`` is the steady-state
|
|
||||||
token accumulation rate (tokens/second). For example,
|
|
||||||
``RateLimit(name="chat_send", capacity=10, refill_rate=1.0)`` allows a
|
|
||||||
burst of 10 requests and then one request per second on average.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
capacity: int
|
|
||||||
refill_rate: float
|
|
||||||
# When Redis is unreachable: ``True`` allows the request (fail-open),
|
|
||||||
# ``False`` rejects it with 503 (fail-closed).
|
|
||||||
fail_open: bool = True
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not self.name:
|
|
||||||
raise ValueError("name must be non-empty")
|
|
||||||
if self.capacity <= 0:
|
|
||||||
raise ValueError("capacity must be positive")
|
|
||||||
if self.refill_rate <= 0:
|
|
||||||
raise ValueError("refill_rate must be positive")
|
|
||||||
|
|
||||||
|
|
||||||
def default_user_scope(request: Request, user_id: int | None) -> str:
|
|
||||||
"""Scope a bucket per authenticated user, or per client IP if anonymous."""
|
|
||||||
if user_id is not None:
|
|
||||||
return f"user:{user_id}"
|
|
||||||
client = request.client
|
|
||||||
return f"ip:{client.host if client else 'unknown'}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RateLimitConfig:
|
|
||||||
"""A named rate limit plus how it is applied to a request."""
|
|
||||||
|
|
||||||
limit: RateLimit
|
|
||||||
# Builds the Redis key fragment for the request. The resolved user id is
|
|
||||||
# passed as the second argument (or ``None`` for anonymous limits).
|
|
||||||
scope: Callable[[Request, int | None], str] = default_user_scope
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RateLimitResult:
|
|
||||||
"""Outcome of a rate-limit check (used by tests)."""
|
|
||||||
|
|
||||||
allowed: bool
|
|
||||||
remaining: int
|
|
||||||
retry_after: int
|
|
||||||
|
|
||||||
|
|
||||||
_redis_client: Redis | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> Redis:
|
|
||||||
"""Return a shared async Redis client for rate limiting.
|
|
||||||
|
|
||||||
The client is created lazily (``Redis.from_url`` does not connect until a
|
|
||||||
command is issued), so this dependency never blocks or fails just because
|
|
||||||
Redis is down.
|
|
||||||
"""
|
|
||||||
global _redis_client
|
|
||||||
if _redis_client is None:
|
|
||||||
_redis_client = Redis.from_url(
|
|
||||||
settings.REDIS_URL,
|
|
||||||
encoding="utf-8",
|
|
||||||
decode_responses=True,
|
|
||||||
socket_connect_timeout=settings.REDIS_TIMEOUT_SECONDS,
|
|
||||||
socket_timeout=settings.REDIS_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
return _redis_client
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
|
||||||
"""Enforce a token bucket in Redis.
|
|
||||||
|
|
||||||
Buckets are keyed as ``rate:{limit.name}:{scope_key}`` so multiple
|
|
||||||
endpoints can share a named limit while remaining independently bucketed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config: RateLimitConfig, redis: Redis | None = None) -> None:
|
|
||||||
self._config = config
|
|
||||||
self._redis = redis
|
|
||||||
|
|
||||||
def _redis_client(self) -> Redis:
|
|
||||||
return self._redis or get_redis()
|
|
||||||
|
|
||||||
def _bucket_key(self, request: Request, user_id: int | None) -> str:
|
|
||||||
limit = self._config.limit
|
|
||||||
scope_key = self._config.scope(request, user_id)
|
|
||||||
return f"rate:{limit.name}:{scope_key}"
|
|
||||||
|
|
||||||
async def check(self, request: Request, user_id: int | None = None) -> RateLimitResult:
|
|
||||||
"""Atomically refill/take a token and return the outcome.
|
|
||||||
|
|
||||||
Redis failures are mapped to ``allowed=True`` (fail-open) or
|
|
||||||
``allowed=False`` (fail-closed, represented with ``retry_after=-1``)
|
|
||||||
according to the limit's policy, without raising.
|
|
||||||
"""
|
|
||||||
limit = self._config.limit
|
|
||||||
key = self._bucket_key(request, user_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self._redis_client().eval(
|
|
||||||
_TOKEN_BUCKET_LUA,
|
|
||||||
1,
|
|
||||||
key,
|
|
||||||
str(limit.capacity),
|
|
||||||
str(limit.refill_rate),
|
|
||||||
str(time.time()),
|
|
||||||
"1", # each request consumes one token
|
|
||||||
)
|
|
||||||
except RedisError:
|
|
||||||
if limit.fail_open:
|
|
||||||
return RateLimitResult(allowed=True, remaining=0, retry_after=0)
|
|
||||||
# Fail-closed: no reliable retry estimate is available.
|
|
||||||
return RateLimitResult(allowed=False, remaining=0, retry_after=-1)
|
|
||||||
|
|
||||||
# Redis may return ints or strings depending on decode_responses.
|
|
||||||
allowed = bool(int(result[0]))
|
|
||||||
remaining = int(result[1])
|
|
||||||
retry_after = int(result[2])
|
|
||||||
return RateLimitResult(allowed=allowed, remaining=remaining, retry_after=retry_after)
|
|
||||||
|
|
||||||
async def __call__(self, request: Request, user_id: int | None = None) -> None:
|
|
||||||
"""Enforce the rate limit, raising 429 or 503 as appropriate."""
|
|
||||||
result = await self.check(request, user_id)
|
|
||||||
if result.allowed:
|
|
||||||
return
|
|
||||||
|
|
||||||
if result.retry_after < 0:
|
|
||||||
# Redis was unavailable and this limit is fail-closed.
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Rate limiting service unavailable",
|
|
||||||
)
|
|
||||||
raise RateLimitExceeded(retry_after=result.retry_after)
|
|
||||||
|
|
||||||
|
|
||||||
def named_limit(
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
capacity: int,
|
|
||||||
refill_rate: float,
|
|
||||||
fail_open: bool = True,
|
|
||||||
) -> RateLimitConfig:
|
|
||||||
"""Create a named rate-limit config with the default per-user/IP scope.
|
|
||||||
|
|
||||||
``capacity`` is the burst size and ``refill_rate`` is tokens/second.
|
|
||||||
"""
|
|
||||||
return RateLimitConfig(
|
|
||||||
limit=RateLimit(
|
|
||||||
name=name,
|
|
||||||
capacity=capacity,
|
|
||||||
refill_rate=refill_rate,
|
|
||||||
fail_open=fail_open,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limit(
|
|
||||||
config: RateLimitConfig,
|
|
||||||
*,
|
|
||||||
user_id_dependency: Any = None,
|
|
||||||
) -> Callable[[Request, Any], Awaitable[None]]:
|
|
||||||
"""Build a FastAPI dependency enforcing ``config``.
|
|
||||||
|
|
||||||
``user_id_dependency`` optionally injects the authenticated user id, e.g.
|
|
||||||
``Depends(get_current_user_id)``. If omitted the limiter buckets by client
|
|
||||||
IP (the ``default_user_scope`` fallback), which is suitable for anonymous
|
|
||||||
endpoints but should be paired with an auth resolver for per-user limits.
|
|
||||||
|
|
||||||
The returned callable is intended for ``Depends(...)``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def _anonymous_user_id() -> int | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
resolver = user_id_dependency if user_id_dependency is not None else Depends(_anonymous_user_id)
|
|
||||||
|
|
||||||
async def dependency(
|
|
||||||
request: Request,
|
|
||||||
user_id: int | None = resolver,
|
|
||||||
) -> None:
|
|
||||||
limiter = RateLimiter(config)
|
|
||||||
await limiter(request, user_id)
|
|
||||||
|
|
||||||
return dependency
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Asset management router."""
|
"""Asset management router."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -13,6 +14,7 @@ from app.database import get_db
|
|||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
from app.models.asset import Asset, AssetType
|
from app.models.asset import Asset, AssetType
|
||||||
from app.models.world import World
|
from app.models.world import World
|
||||||
|
from app.services import gltf as gltf_service
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
|
|||||||
return safe or "file"
|
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 ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||||
@@ -183,10 +193,16 @@ async def upload_asset_file(
|
|||||||
# Sanitize filename to prevent path traversal.
|
# Sanitize filename to prevent path traversal.
|
||||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||||
|
|
||||||
# Validate file size against configured maximum.
|
# Read the file content.
|
||||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
file_size = len(content)
|
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:
|
if file_size > max_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
@@ -200,10 +216,28 @@ async def upload_asset_file(
|
|||||||
file_path = upload_dir / stored_name
|
file_path = upload_dir / stored_name
|
||||||
file_path.write_bytes(content)
|
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.file_path = str(file_path)
|
||||||
asset.mime_type = file.content_type
|
asset.mime_type = file.content_type
|
||||||
asset.file_size = file_size
|
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.commit()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
return asset
|
return asset
|
||||||
|
|||||||
+192
-12
@@ -1,20 +1,21 @@
|
|||||||
"""Authentication router - login, register, token refresh."""
|
"""Authentication router - login, register, token refresh."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
import uuid
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from jose import jwt
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.session import RefreshToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@@ -25,19 +26,27 @@ router = APIRouter()
|
|||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
access_token_expires_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
over_18: bool # Simple boolean confirmation instead of date_of_birth
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
|
date_of_birth: str | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
@@ -47,12 +56,43 @@ class UserResponse(BaseModel):
|
|||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
def _create_access_token(data: dict) -> str:
|
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 = data.copy()
|
||||||
|
to_encode["jti"] = str(uuid.uuid4())
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
@@ -65,11 +105,64 @@ def hash_password(password: str) -> str:
|
|||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||||
|
"""Calculate age in years from a birth date.
|
||||||
|
|
||||||
|
Not called by /register anymore (that endpoint now just trusts the
|
||||||
|
client's over_18 boolean, see RegisterRequest) -- kept as a real,
|
||||||
|
tested utility since date-of-birth storage/verification is coming
|
||||||
|
back in a future pass (real age math, not just a client-side claim,
|
||||||
|
the user explicitly wants to revisit this design). Removing it broke
|
||||||
|
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
|
||||||
|
on `from app.routers.auth import calculate_age`) for zero benefit --
|
||||||
|
restoring it is the lower-risk path over rewriting or deleting 17
|
||||||
|
tests for logic this codebase will need again.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
birth_date: The person's date of birth.
|
||||||
|
today: The reference date. Defaults to today's date (UTC).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Age in whole years as of the reference date.
|
||||||
|
"""
|
||||||
|
if today is None:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
age = today.year - birth_date.year
|
||||||
|
# Adjust if birthday hasn't occurred yet this year
|
||||||
|
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||||
|
age -= 1
|
||||||
|
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 ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Register a new user and return an access token."""
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
|
Requires over_18 boolean confirmation; rejects users who are not 18+.
|
||||||
|
"""
|
||||||
|
if not req.over_18:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Must be 18 or older to register",
|
||||||
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
result = await db.execute(select(User).where(User.username == req.username))
|
result = await db.execute(select(User).where(User.username == req.username))
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
@@ -90,18 +183,29 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
username=req.username,
|
username=req.username,
|
||||||
email=req.email,
|
email=req.email,
|
||||||
hashed_password=hash_password(req.password),
|
hashed_password=hash_password(req.password),
|
||||||
|
date_of_birth=None, # No longer collecting DOB
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.flush() # flush to get user.id before creating refresh token
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# 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("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||||
"""Authenticate user and return an access token.
|
"""Authenticate user and return access + refresh tokens.
|
||||||
|
|
||||||
Accepts form-encoded credentials (username/password) as expected by
|
Accepts form-encoded credentials (username/password) as expected by
|
||||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||||
@@ -121,8 +225,84 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
|||||||
detail="User account is inactive",
|
detail="User account is inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Worlds router - CRUD for game worlds."""
|
"""Worlds router - CRUD for game worlds."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -40,7 +41,7 @@ class WorldResponse(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
visibility: WorldVisibility
|
visibility: WorldVisibility
|
||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: str | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: agp_password
|
POSTGRES_PASSWORD: agp_password
|
||||||
POSTGRES_DB: agp_db
|
POSTGRES_DB: agp_db
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
@@ -9,6 +9,14 @@ pydantic==2.10.3
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
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
|
python-multipart==0.0.20
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Tests for the 18+ age gate on user registration."""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.routers.auth import calculate_age
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateAge:
|
||||||
|
"""Unit tests for the calculate_age helper."""
|
||||||
|
|
||||||
|
def test_exactly_18(self):
|
||||||
|
"""A person born exactly 18 years ago today is 18."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = today.replace(year=today.year - 18)
|
||||||
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
|
def test_just_turned_18(self):
|
||||||
|
"""A person who turned 18 yesterday is 18."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||||
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
|
def test_one_day_short_of_18(self):
|
||||||
|
"""A person born one day after their 18th birthday is 17."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||||
|
assert calculate_age(dob, today=today) == 17
|
||||||
|
|
||||||
|
def test_17_years_old(self):
|
||||||
|
"""A person who is 17 years old today is 17."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = date(2008, 6, 15)
|
||||||
|
assert calculate_age(dob, today=today) == 17
|
||||||
|
|
||||||
|
def test_65_years_old(self):
|
||||||
|
"""A person who is 65 is correctly calculated."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = today.replace(year=today.year - 65)
|
||||||
|
assert calculate_age(dob, today=today) == 65
|
||||||
|
|
||||||
|
def test_newborn(self):
|
||||||
|
"""A newborn is 0."""
|
||||||
|
today = date(2025, 6, 15)
|
||||||
|
dob = today
|
||||||
|
assert calculate_age(dob, today=today) == 0
|
||||||
|
|
||||||
|
def test_birthday_not_yet_this_year(self):
|
||||||
|
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
|
||||||
|
dob = date(2000, 12, 31)
|
||||||
|
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
|
||||||
|
|
||||||
|
def test_birthday_today(self):
|
||||||
|
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
|
||||||
|
dob = date(2000, 12, 31)
|
||||||
|
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegisterRequestOver18Field:
|
||||||
|
"""Tests for RegisterRequest's over_18 field.
|
||||||
|
|
||||||
|
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
|
||||||
|
field_validator were removed from RegisterRequest 2026-08-20 (interim
|
||||||
|
simplification: a plain client-side over_18 confirmation instead of
|
||||||
|
collecting/validating an actual date of birth; see calculate_age's
|
||||||
|
own docstring in app/routers/auth.py for why that function is still
|
||||||
|
here even though this endpoint no longer calls it). There's no date
|
||||||
|
format left to validate, so the equivalent coverage is just: the
|
||||||
|
field exists, is a real bool, and is required."""
|
||||||
|
|
||||||
|
def test_over_18_true_accepted(self):
|
||||||
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
|
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
||||||
|
assert req.over_18 is True
|
||||||
|
|
||||||
|
def test_over_18_false_accepted(self):
|
||||||
|
"""False is a valid value at the schema level -- the endpoint,
|
||||||
|
not the schema, is what rejects it (see
|
||||||
|
TestRegister::test_register_rejects_not_over_18 in
|
||||||
|
test_auth_endpoints.py)."""
|
||||||
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
|
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
|
||||||
|
assert req.over_18 is False
|
||||||
|
|
||||||
|
def test_over_18_missing_rejected(self):
|
||||||
|
"""over_18 has no default -- omitting it entirely is a validation error."""
|
||||||
|
from app.routers.auth import RegisterRequest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""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",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
def test_register_rejects_not_over_18(self):
|
||||||
|
"""over_18=false gets a 400 with a detail message.
|
||||||
|
|
||||||
|
The interim design (2026-08-20) trusts a client-side boolean
|
||||||
|
confirmation instead of collecting/validating an actual date of
|
||||||
|
birth -- this only checks that the flag is respected, not real
|
||||||
|
age math (there's no date to verify against anymore). Real DOB
|
||||||
|
collection/verification may come back in a future pass."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "kid",
|
||||||
|
"email": "kid@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"over_18": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Second registration with same username, different email
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "duplicate",
|
||||||
|
"email": "dup2@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "user2",
|
||||||
|
"email": "same@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "Email already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_register_missing_over_18(self):
|
||||||
|
"""Missing the required over_18 field returns 422.
|
||||||
|
|
||||||
|
Replaces the old test_register_invalid_date_format -- there's no
|
||||||
|
date format to get wrong anymore (no date field at all), so the
|
||||||
|
closest equivalent "malformed request" case is the required
|
||||||
|
over_18 boolean being absent entirely."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "user",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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,
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""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",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""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,193 +0,0 @@
|
|||||||
"""Tests for the Redis-backed token-bucket rate limiter.
|
|
||||||
|
|
||||||
These tests use fakeredis (Lua enabled via lupa) so no live Redis server is
|
|
||||||
required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
sys.path.insert(0, "/workspace")
|
|
||||||
|
|
||||||
import fakeredis.aioredis
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
from redis.exceptions import ConnectionError
|
|
||||||
|
|
||||||
from app.rate_limit import (
|
|
||||||
RateLimit,
|
|
||||||
RateLimitConfig,
|
|
||||||
RateLimiter,
|
|
||||||
default_user_scope,
|
|
||||||
named_limit,
|
|
||||||
rate_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fake_redis():
|
|
||||||
return fakeredis.aioredis.FakeRedis(decode_responses=True)
|
|
||||||
|
|
||||||
|
|
||||||
def make_request(client_ip: str = "127.0.0.1") -> Request:
|
|
||||||
scope = {
|
|
||||||
"type": "http",
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/",
|
|
||||||
"headers": [],
|
|
||||||
"client": (client_ip, 12345),
|
|
||||||
}
|
|
||||||
return Request(scope)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_token_bucket_allows_burst_then_exhausts(fake_redis):
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="burst", capacity=3, refill_rate=0.1),
|
|
||||||
),
|
|
||||||
redis=fake_redis,
|
|
||||||
)
|
|
||||||
request = make_request()
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for _ in range(4):
|
|
||||||
result = await limiter.check(request, user_id=42)
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
assert [r.allowed for r in results] == [True, True, True, False]
|
|
||||||
assert results[0].remaining == 2
|
|
||||||
assert results[1].remaining == 1
|
|
||||||
assert results[2].remaining == 0
|
|
||||||
assert results[3].retry_after > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retry_after_reflects_refill_rate(fake_redis):
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="slow", capacity=1, refill_rate=0.5),
|
|
||||||
),
|
|
||||||
redis=fake_redis,
|
|
||||||
)
|
|
||||||
request = make_request()
|
|
||||||
|
|
||||||
assert (await limiter.check(request, user_id=7)).allowed is True
|
|
||||||
denied = await limiter.check(request, user_id=7)
|
|
||||||
assert denied.allowed is False
|
|
||||||
# 1 token / 0.5 tokens per second = 2 seconds.
|
|
||||||
assert denied.retry_after == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_separate_scopes_get_separate_buckets(fake_redis):
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="scoped", capacity=1, refill_rate=0.1),
|
|
||||||
),
|
|
||||||
redis=fake_redis,
|
|
||||||
)
|
|
||||||
request = make_request()
|
|
||||||
|
|
||||||
assert (await limiter.check(request, user_id=1)).allowed is True
|
|
||||||
assert (await limiter.check(request, user_id=2)).allowed is True
|
|
||||||
assert (await limiter.check(request, user_id=1)).allowed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fail_open_returns_allowed_when_redis_unavailable():
|
|
||||||
class BrokenRedis(Redis):
|
|
||||||
async def eval(self, *args, **kwargs):
|
|
||||||
raise ConnectionError("down")
|
|
||||||
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="open", capacity=1, refill_rate=0.1, fail_open=True),
|
|
||||||
),
|
|
||||||
redis=BrokenRedis(),
|
|
||||||
)
|
|
||||||
result = await limiter.check(make_request(), user_id=1)
|
|
||||||
assert result.allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fail_closed_returns_denied_with_negative_retry_after():
|
|
||||||
class BrokenRedis(Redis):
|
|
||||||
async def eval(self, *args, **kwargs):
|
|
||||||
raise ConnectionError("down")
|
|
||||||
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
|
|
||||||
),
|
|
||||||
redis=BrokenRedis(),
|
|
||||||
)
|
|
||||||
result = await limiter.check(make_request(), user_id=1)
|
|
||||||
assert result.allowed is False
|
|
||||||
assert result.retry_after == -1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fail_closed_call_raises_503(fake_redis):
|
|
||||||
class BrokenRedis(Redis):
|
|
||||||
async def eval(self, *args, **kwargs):
|
|
||||||
raise ConnectionError("down")
|
|
||||||
|
|
||||||
limiter = RateLimiter(
|
|
||||||
RateLimitConfig(
|
|
||||||
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
|
|
||||||
),
|
|
||||||
redis=BrokenRedis(),
|
|
||||||
)
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
await limiter(make_request(), user_id=1)
|
|
||||||
assert exc_info.value.status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
def test_named_limit_builds_expected_config():
|
|
||||||
config = named_limit(
|
|
||||||
"chat_send",
|
|
||||||
capacity=5,
|
|
||||||
refill_rate=0.2,
|
|
||||||
fail_open=False,
|
|
||||||
)
|
|
||||||
assert config.limit.name == "chat_send"
|
|
||||||
assert config.limit.capacity == 5
|
|
||||||
assert config.limit.refill_rate == 0.2
|
|
||||||
assert config.limit.fail_open is False
|
|
||||||
assert config.scope is default_user_scope
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_limit_definition_rejected():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
named_limit("bad", capacity=0, refill_rate=1.0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
named_limit("bad", capacity=1, refill_rate=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dependency_integration_returns_429_with_retry_after():
|
|
||||||
async def fake_user_id() -> int:
|
|
||||||
return 99
|
|
||||||
|
|
||||||
chat_limit = rate_limit(
|
|
||||||
named_limit("chat_send_test", capacity=2, refill_rate=0.1, fail_open=False),
|
|
||||||
user_id_dependency=Depends(fake_user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/probe")
|
|
||||||
async def probe(_: None = Depends(chat_limit)):
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
assert client.get("/probe").status_code == 200
|
|
||||||
assert client.get("/probe").status_code == 200
|
|
||||||
|
|
||||||
denied = client.get("/probe")
|
|
||||||
assert denied.status_code == 429
|
|
||||||
assert denied.headers["retry-after"]
|
|
||||||
assert denied.json() == {"detail": "Rate limit exceeded"}
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""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,
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"over_18": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||||
|
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||||
|
|
||||||
|
### 1. On cloudbison — the AGP dashboard
|
||||||
|
|
||||||
|
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||||
|
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||||
|
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||||
|
the API server, all in one step. The page shows the LAN address it's
|
||||||
|
reachable at once it's up (under a minute).
|
||||||
|
|
||||||
|
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||||
|
it tears down everything that step started (API server, Postgres
|
||||||
|
container, its volume and network). Nothing is left running or lying
|
||||||
|
around afterward, even if the dashboard itself gets restarted in between.
|
||||||
|
|
||||||
|
### 2. On pinkbison — download and run the .exe
|
||||||
|
|
||||||
|
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||||
|
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||||
|
binary with cloudbison's backend address already baked in. Click
|
||||||
|
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||||
|
environment variable, no config file, no build step on pinkbison at all.
|
||||||
|
|
||||||
|
### 3. Tear down when done
|
||||||
|
|
||||||
|
**Stop & Clean Up** on the dashboard page. That's it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||||
|
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||||
|
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||||
|
explicitly out of scope.
|
||||||
|
|
||||||
|
## Verified backend contract (as of this writing)
|
||||||
|
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||||
|
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||||
|
Rejects under-18 with `400`.
|
||||||
|
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||||
|
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||||
|
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||||
|
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||||
|
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||||
|
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||||
|
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||||
|
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||||
|
|
||||||
|
## Task breakdown
|
||||||
|
| # | 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 |
|
||||||
|
|
||||||
|
## Design decisions captured (resolved in task 1)
|
||||||
|
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||||
|
open but not required yet (no re-login on restart in this slice).
|
||||||
|
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||||
|
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,295 @@
|
|||||||
|
# Economy — Currency, Marketplace & Creator Payouts — Plan
|
||||||
|
|
||||||
|
> **Status:** Planned
|
||||||
|
> **Owner:** Platform team
|
||||||
|
> **Feature ID:** `economy`
|
||||||
|
> **Godot version:** 4.6.2 stable
|
||||||
|
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document breaks the **Economy** feature into user stories and right-sized
|
||||||
|
implementation tasks. The feature delivers:
|
||||||
|
|
||||||
|
- **Virtual currency ("Credits")** — a single, server-authoritative currency
|
||||||
|
with an immutable, append-only transaction ledger.
|
||||||
|
- **Premium purchase** — buying Credits with real money through a pluggable
|
||||||
|
payment-provider hook.
|
||||||
|
- **Marketplace** — user-generated content (UGC) listings where creators sell
|
||||||
|
items (assets, avatar items, experience passes) to players for Credits.
|
||||||
|
- **Creator payouts** — accumulating creator earnings and paying them out to
|
||||||
|
external accounts through a pluggable payout-provider hook.
|
||||||
|
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
|
||||||
|
and creator payout screens.
|
||||||
|
|
||||||
|
The economy is the monetization backbone of the platform. Because it moves
|
||||||
|
virtual value between real people, correctness (no double-spend, no lost funds,
|
||||||
|
no balance drift) and safety (fraud prevention, moderation, audit) are treated
|
||||||
|
as **blocking requirements**, not add-ons.
|
||||||
|
|
||||||
|
## 2. Why correctness is the differentiator
|
||||||
|
|
||||||
|
The platform is a creator economy (players buy UGC from other players). The
|
||||||
|
economy is the highest-risk surface after chat: a single arithmetic or
|
||||||
|
concurrency bug can mint or destroy value and irreversibly erode trust.
|
||||||
|
Correctness is therefore designed in from the start:
|
||||||
|
|
||||||
|
- **One currency, integer units.** Credits are whole-number integers. There are
|
||||||
|
no floats, no rounding, and no client-computed totals.
|
||||||
|
- **Append-only ledger.** Every balance change is a row in a `transactions`
|
||||||
|
table with a signed amount, a `balance_after` snapshot, a reference to the
|
||||||
|
event that caused it, and an **idempotency key** so retries cannot double
|
||||||
|
credit or double debit.
|
||||||
|
- **Atomic, locked mutations.** Balance changes run in a single database
|
||||||
|
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
|
||||||
|
purchases/payouts cannot race.
|
||||||
|
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
|
||||||
|
configurable window before becoming available to the seller, so fraudulent
|
||||||
|
or moderated sales can be reversed without clawing back paid-out funds.
|
||||||
|
- **Server-authoritative fees.** The platform commission is computed
|
||||||
|
server-side from a configured rate; the client never supplies the fee or the
|
||||||
|
seller's net.
|
||||||
|
|
||||||
|
## 3. Existing constraints (do not re-build)
|
||||||
|
|
||||||
|
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
||||||
|
`app/dependencies.py`. All economy endpoints reuse this.
|
||||||
|
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
||||||
|
Existing tables: `users`, `sessions`, `worlds`, `assets`.
|
||||||
|
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
||||||
|
idempotency short-caching and rate limiting.
|
||||||
|
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
|
||||||
|
Marketplace items reference existing `assets` rows and/or avatar items.
|
||||||
|
- **Content moderation:** `docs/architecture/content_moderation.md` defines
|
||||||
|
flag vocabulary, age gate, and moderation actions. Marketplace items and
|
||||||
|
listings must inherit and enforce these rules (see §7).
|
||||||
|
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
|
||||||
|
|
||||||
|
## 4. Key design decisions
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
|
||||||
|
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
|
||||||
|
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
|
||||||
|
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
|
||||||
|
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
|
||||||
|
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
|
||||||
|
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
|
||||||
|
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
|
||||||
|
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
|
||||||
|
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
|
||||||
|
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
|
||||||
|
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
|
||||||
|
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
|
||||||
|
|
||||||
|
## 5. User stories
|
||||||
|
|
||||||
|
### US-1 Currency & wallet
|
||||||
|
As a player, I can see my Credit balance and transaction history, and the system
|
||||||
|
credits/debits my wallet only through server-authoritative events.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- Exactly one wallet per user, created lazily on first use.
|
||||||
|
- Balance is always a non-negative integer; no operation can make it negative.
|
||||||
|
- Every mutation appends an immutable ledger row with amount, resulting balance,
|
||||||
|
reference, and idempotency key.
|
||||||
|
- Retrying a request with the same idempotency key does not double-apply.
|
||||||
|
- `GET` endpoints expose current balance and paged transaction history.
|
||||||
|
|
||||||
|
### US-2 Premium purchase (buy Credits)
|
||||||
|
As a player, I can purchase Credits with real money and have them credited only
|
||||||
|
after the payment is confirmed.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- A purchase session is created through the payment provider (or the no-op
|
||||||
|
default) and is idempotent.
|
||||||
|
- Credits are granted exactly once, only after a verified provider confirmation
|
||||||
|
(webhook/confirm endpoint), never on client claim.
|
||||||
|
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
|
||||||
|
prices.
|
||||||
|
- Confirmation is idempotent and audited (provider reference recorded).
|
||||||
|
|
||||||
|
### US-3 Marketplace listing (sell)
|
||||||
|
As a creator, I can list a catalog item for sale at a Credit price and
|
||||||
|
manage/remove my listings.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- A listing references a valid `catalog_item` owned by the seller (or an
|
||||||
|
authorized seller).
|
||||||
|
- Price is bounded (min/max) and set server-side; status transitions are
|
||||||
|
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
|
||||||
|
- Only the seller (or a moderator) can update/remove a listing.
|
||||||
|
- Listings are searchable/filterable (item type, price range, owner).
|
||||||
|
|
||||||
|
### US-4 Marketplace purchase (buy)
|
||||||
|
As a player, I can buy a listed item. The purchase is atomic, holds funds in
|
||||||
|
escrow, grants the item, and splits the payment between platform and seller.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- A single transaction debits the buyer, holds funds in escrow, and grants the
|
||||||
|
item — no partial states on failure.
|
||||||
|
- Platform fee and seller net are computed server-side from the configured rate.
|
||||||
|
- After the escrow hold window, funds move from escrow to the seller's
|
||||||
|
available balance.
|
||||||
|
- Refunds during the hold window reverse the sale and return funds to the buyer.
|
||||||
|
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
|
||||||
|
|
||||||
|
### US-5 Creator payouts
|
||||||
|
As a creator, I can view my earned balance, request a payout, and track its
|
||||||
|
status; admins can review and approve/reject payouts.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- Available balance is derived only from completed (escrow-released) sales and
|
||||||
|
completed payouts; it never goes negative.
|
||||||
|
- Payout requests are bounded (min threshold, max per period) and rate-limited.
|
||||||
|
- Large payouts require admin review; all payout requests carry an audit trail
|
||||||
|
(requested by, reviewed by, provider reference, status).
|
||||||
|
- The payout provider is called exactly once; idempotent on retry.
|
||||||
|
|
||||||
|
### US-6 Godot client
|
||||||
|
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
|
||||||
|
payouts from the game UI.
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
|
||||||
|
calls.
|
||||||
|
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
|
||||||
|
sell-listing, and creator payout.
|
||||||
|
- The client never computes balances, fees, or seller nets — it only displays
|
||||||
|
server responses.
|
||||||
|
|
||||||
|
## 6. Task breakdown
|
||||||
|
|
||||||
|
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
||||||
|
are sequential for dependency chaining.
|
||||||
|
|
||||||
|
| # | Task | Story | Depends on |
|
||||||
|
|---|------|-------|-----------|
|
||||||
|
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
|
||||||
|
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
|
||||||
|
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
|
||||||
|
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
|
||||||
|
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
|
||||||
|
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
|
||||||
|
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
|
||||||
|
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
|
||||||
|
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
|
||||||
|
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
|
||||||
|
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
|
||||||
|
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
|
||||||
|
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
|
||||||
|
|
||||||
|
## 7. Phasing
|
||||||
|
|
||||||
|
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
|
||||||
|
money movement must be rock-solid before anything spends Credits.
|
||||||
|
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
|
||||||
|
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
|
||||||
|
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
|
||||||
|
content and abuse.
|
||||||
|
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
|
||||||
|
|
||||||
|
## 8. Data model sketch
|
||||||
|
|
||||||
|
```text
|
||||||
|
wallets
|
||||||
|
id bigint PK
|
||||||
|
user_id int UNIQUE FK -> users.id
|
||||||
|
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
|
||||||
|
created_at timestamptz
|
||||||
|
updated_at timestamptz
|
||||||
|
|
||||||
|
transactions (append-only ledger)
|
||||||
|
id bigint PK
|
||||||
|
wallet_id bigint FK -> wallets.id
|
||||||
|
type enum(credit_purchase, marketplace_purchase,
|
||||||
|
marketplace_sale, escrow_release, payout,
|
||||||
|
refund, admin_adjustment)
|
||||||
|
amount bigint NOT NULL (signed)
|
||||||
|
balance_after bigint NOT NULL
|
||||||
|
reference_type text (e.g. "purchase", "sale", "payout_request")
|
||||||
|
reference_id text
|
||||||
|
idempotency_key text UNIQUE
|
||||||
|
metadata_json jsonb
|
||||||
|
created_at timestamptz
|
||||||
|
|
||||||
|
catalog_items
|
||||||
|
id bigint PK
|
||||||
|
owner_id int FK -> users.id
|
||||||
|
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
|
||||||
|
item_type enum(asset, avatar_item, experience_pass)
|
||||||
|
name text
|
||||||
|
description text
|
||||||
|
metadata_json jsonb
|
||||||
|
created_at timestamptz
|
||||||
|
|
||||||
|
marketplace_listings
|
||||||
|
id bigint PK
|
||||||
|
catalog_item_id bigint FK -> catalog_items.id
|
||||||
|
seller_id int FK -> users.id
|
||||||
|
price bigint NOT NULL CHECK (price > 0)
|
||||||
|
status enum(active, sold, delisted, removed_by_moderation)
|
||||||
|
created_at timestamptz
|
||||||
|
updated_at timestamptz
|
||||||
|
|
||||||
|
purchases
|
||||||
|
id bigint PK
|
||||||
|
listing_id bigint FK -> marketplace_listings.id
|
||||||
|
buyer_id int FK -> users.id
|
||||||
|
seller_id int FK -> users.id
|
||||||
|
price bigint NOT NULL
|
||||||
|
platform_fee bigint NOT NULL
|
||||||
|
seller_net bigint NOT NULL
|
||||||
|
status enum(pending_escrow, completed, refunded, reversed)
|
||||||
|
escrow_hold_until timestamptz
|
||||||
|
created_at timestamptz
|
||||||
|
|
||||||
|
creator_balances
|
||||||
|
id bigint PK
|
||||||
|
user_id int UNIQUE FK -> users.id
|
||||||
|
available bigint NOT NULL DEFAULT 0
|
||||||
|
pending bigint NOT NULL DEFAULT 0
|
||||||
|
lifetime_earned bigint NOT NULL DEFAULT 0
|
||||||
|
updated_at timestamptz
|
||||||
|
|
||||||
|
payout_requests
|
||||||
|
id bigint PK
|
||||||
|
creator_id int FK -> users.id
|
||||||
|
amount bigint NOT NULL CHECK (amount > 0)
|
||||||
|
status enum(pending, approved, paid, rejected)
|
||||||
|
external_ref text
|
||||||
|
provider_ref text
|
||||||
|
requested_by int FK -> users.id
|
||||||
|
reviewed_by int FK -> users.id (nullable)
|
||||||
|
reviewed_at timestamptz
|
||||||
|
created_at timestamptz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Security, anti-fraud & audit
|
||||||
|
|
||||||
|
- **Server-authoritative balances/fees:** clients never compute totals; amounts
|
||||||
|
are validated server-side against bounds and the current listing.
|
||||||
|
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
|
||||||
|
UPDATE` within one transaction.
|
||||||
|
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
|
||||||
|
duplicate keys return the stored original outcome.
|
||||||
|
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
|
||||||
|
rate-limited per user.
|
||||||
|
- **Escrow & reversibility:** funds are not payable until the hold window
|
||||||
|
elapses; chargebacks/refunds reverse before release.
|
||||||
|
- **Moderation:** listings carry the item's effective content flags; mature
|
||||||
|
items require age verification (per `content_moderation.md`); moderators can
|
||||||
|
`delist`/`remove` and trigger audited reversals.
|
||||||
|
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
|
||||||
|
audit trail for every movement of value.
|
||||||
|
|
||||||
|
## 10. Out of scope for this feature
|
||||||
|
|
||||||
|
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
|
||||||
|
defaults are deterministic no-ops).
|
||||||
|
- Subscriptions, recurring billing, or premium memberships.
|
||||||
|
- Gift cards, promotional codes, or user-to-user Credit transfers.
|
||||||
|
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
|
||||||
|
- In-world vending machines / scripted economy triggers (future, built on this
|
||||||
|
ledger via the scripting sandbox).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 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-20 — full PC1 checkpoint run,
|
||||||
|
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
||||||
|
clean. Real bugs found and fixed via real playtesting this same day (not
|
||||||
|
caught by the checkpoint suite as it stood before today): the main menu
|
||||||
|
had no way to actually open the login/register form, the world list's
|
||||||
|
cards rendered at ~1px wide once real data existed, and navigating Play →
|
||||||
|
Back logged the user out despite a still-valid session. All three are now
|
||||||
|
covered by the smoke tests below so they can't silently regress.
|
||||||
|
|
||||||
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
|
reachable Postgres — see the runner script).
|
||||||
|
|
||||||
|
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
|
| File | Tests | What it covers |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||||
|
| `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). |
|
||||||
|
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
|
|
||||||
|
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||||
|
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||||
|
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||||
|
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||||
|
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||||
|
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||||
|
truncates all app tables once before the suite runs.
|
||||||
|
|
||||||
|
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||||
|
these yet):
|
||||||
|
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||||
|
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||||
|
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||||
|
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||||
|
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||||
|
auth-header validation is covered via expired-token tests).
|
||||||
|
- No tests for session management (create/validate/revoke sessions).
|
||||||
|
|
||||||
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
|
**7 smoke tests merged into `main`.** Each needs a live backend —
|
||||||
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
|
`application/agp/api_base_url` project setting (default
|
||||||
|
`http://127.0.0.1:8000`) if unset.
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||||
|
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||||
|
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||||
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
|
||||||
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
|
then for each smoke test: `godot --headless --path . --script
|
||||||
|
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||||
|
their awaited HTTP calls finish.
|
||||||
|
|
||||||
|
**Known gaps:**
|
||||||
|
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||||
|
- No smoke tests for dynamic/static object nodes.
|
||||||
|
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||||
|
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||||
|
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||||
|
the `ApiClient` autoload directly.
|
||||||
|
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||||
|
has no smoke coverage yet.
|
||||||
|
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||||
|
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||||
|
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||||
|
inference, not a direct check.
|
||||||
|
|
||||||
|
## Client export
|
||||||
|
|
||||||
|
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||||
|
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||||
|
exports complete clean from a fresh checkout and produce valid binaries
|
||||||
|
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||||
|
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||||
|
window) — actually launching and clicking through the exported binary is a
|
||||||
|
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||||
|
|
||||||
|
## What "full coverage" should eventually mean here
|
||||||
|
|
||||||
|
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||||
|
"integration tests for every endpoint + smoke tests for every major scene."
|
||||||
|
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.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[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=true
|
||||||
|
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=true
|
||||||
|
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,9 +9,12 @@ config_version=5
|
|||||||
config/name="AGP-Game"
|
config/name="AGP-Game"
|
||||||
run/main_scene="res://scenes/main_menu.tscn"
|
run/main_scene="res://scenes/main_menu.tscn"
|
||||||
config/features=PackedStringArray("4.6.2")
|
config/features=PackedStringArray("4.6.2")
|
||||||
|
agp/api_base_url="http://127.0.0.1:8000"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
ApiClient="*res://scripts/api_client.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
|
|||||||
+84
-1
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
||||||
|
|
||||||
@@ -29,6 +29,84 @@ text = "AGP Game"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
theme_override_font_sizes/font_size = 72
|
theme_override_font_sizes/font_size = 72
|
||||||
|
|
||||||
|
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Register"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[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="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
visible = false
|
||||||
|
text = "I am 18 or older"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[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"]
|
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Play"
|
text = "Play"
|
||||||
@@ -39,5 +117,10 @@ layout_mode = 1
|
|||||||
text = "Quit"
|
text = "Quit"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[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="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
|
[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"]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[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 = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
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 = 3
|
||||||
|
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 = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_mode = 2
|
||||||
|
vertical_mode = 2
|
||||||
|
|
||||||
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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 over_18 Must be true; the backend rejects registration otherwise.
|
||||||
|
func register(
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
over_18: bool
|
||||||
|
) -> Dictionary:
|
||||||
|
var payload := {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"over_18": over_18,
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjd0uhll7iylj
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/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
|
||||||
+254
-6
@@ -1,14 +1,262 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
## Main menu with login/register UI wired to the backend auth API.
|
||||||
|
##
|
||||||
|
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||||
|
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||||
|
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||||
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
|
## the panel is open.
|
||||||
|
##
|
||||||
|
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||||
|
## the welcome label shows the username, and the Play button becomes enabled.
|
||||||
|
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||||
|
##
|
||||||
|
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||||
|
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||||
|
## UI state (Play enabled, welcome message visible) instead of forcing the user
|
||||||
|
## back to the login form.
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||||
pass
|
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||||
|
## anything else) know the UI state is trustworthy now, rather than
|
||||||
|
## guessing from a frame count or from UI properties that are ambiguous
|
||||||
|
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||||
|
## resolves AND in a genuine logged-out end state).
|
||||||
|
signal auth_state_ready
|
||||||
|
|
||||||
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _auth_buttons_row: HBoxContainer
|
||||||
|
var _login_button: Button
|
||||||
|
var _register_button: Button
|
||||||
|
var _auth_panel: PanelContainer
|
||||||
|
var _auth_mode_label: Label
|
||||||
|
var _username_edit: LineEdit
|
||||||
|
var _password_edit: LineEdit
|
||||||
|
var _email_edit: LineEdit
|
||||||
|
var _over_18_check: CheckBox
|
||||||
|
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 _on_play_button_pressed():
|
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
# No gameplay scene exists yet — wire this to the real scene once one lands.
|
|
||||||
print("Play pressed — no gameplay scene yet")
|
func _ready() -> void:
|
||||||
|
# Cache node references.
|
||||||
|
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||||
|
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||||
|
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
|
||||||
|
_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
|
||||||
|
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||||
|
_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
|
||||||
|
|
||||||
|
# Default to Play being gated. When a token exists, the async session
|
||||||
|
# check below may take several frames (a real get_me() round-trip);
|
||||||
|
# keeping the button disabled until validation succeeds prevents a fast
|
||||||
|
# click from navigating to world_list with an expired/invalid token.
|
||||||
|
_play_button.disabled = true
|
||||||
|
|
||||||
|
# Check whether a valid session already exists (e.g. user logged in,
|
||||||
|
# navigated to world_list, then clicked Back). If so, restore the
|
||||||
|
# authenticated UI state instead of forcing the login form again.
|
||||||
|
if ApiClient.has_token():
|
||||||
|
var me_result: Dictionary = await ApiClient.get_me()
|
||||||
|
if me_result.ok and me_result.data is Dictionary:
|
||||||
|
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||||
|
if username != "":
|
||||||
|
_show_authenticated(username)
|
||||||
|
auth_state_ready.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Token missing or invalid — fall through to logged-out state.
|
||||||
|
_reset_to_logged_out()
|
||||||
|
auth_state_ready.emit()
|
||||||
|
|
||||||
|
|
||||||
func _on_quit_button_pressed():
|
## Restore the UI to the authenticated state (Play enabled, welcome message
|
||||||
|
## visible, auth buttons hidden). AuthPanel itself stays visible -- the
|
||||||
|
## welcome label lives inside it, so hiding the panel hides the label too
|
||||||
|
## even when the label's own `visible` is true (real bug, 2026-08-20: a
|
||||||
|
## review caught the panel being hidden here while the label claimed to be
|
||||||
|
## visible, which is a no-op UI-wise since Control.visible doesn't override
|
||||||
|
## an ancestor's hidden state).
|
||||||
|
func _show_authenticated(username: String) -> void:
|
||||||
|
_auth_panel.visible = true
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % username
|
||||||
|
_welcome_label.visible = true
|
||||||
|
# Hide the same full set of form fields _on_auth_complete() hides right
|
||||||
|
# after a fresh login/register -- this path (session restore on scene
|
||||||
|
# reload, e.g. after Play -> Back) used to only hide EmailEdit/
|
||||||
|
# Over18Check, leaving UsernameEdit/PasswordEdit/AuthButton/
|
||||||
|
# AuthModeLabel visible on top of the welcome message. Real bug: Play
|
||||||
|
# was enabled and the welcome label was correct, so it wasn't caught by
|
||||||
|
# checks for those alone -- the login fields being back on screen is
|
||||||
|
# what a user actually sees as "it's making me log in again."
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||||
|
## login/register buttons visible).
|
||||||
|
func _reset_to_logged_out() -> void:
|
||||||
|
_auth_panel.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_over_18_check.button_pressed = false
|
||||||
|
_play_button.disabled = true # Gate play behind successful auth.
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 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:
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _on_login_button_pressed() -> void:
|
||||||
|
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
|
||||||
|
_auth_panel.visible = true
|
||||||
|
_is_register_mode = false
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_register_button_pressed() -> void:
|
||||||
|
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
|
||||||
|
_auth_panel.visible = true
|
||||||
|
_is_register_mode = true
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
_over_18_check.visible = true
|
||||||
|
else:
|
||||||
|
_auth_mode_label.text = "Login"
|
||||||
|
_auth_button.text = "Login"
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.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()
|
||||||
|
|
||||||
|
if email == "":
|
||||||
|
_show_status("Email is required.")
|
||||||
|
return
|
||||||
|
if not _over_18_check.button_pressed:
|
||||||
|
_show_status("You must confirm you are 18 or older to register.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_auth_button.disabled = true
|
||||||
|
_auth_button.text = "Registering..."
|
||||||
|
_is_authenticating = true
|
||||||
|
_clear_status()
|
||||||
|
|
||||||
|
var result := await ApiClient.register(username, email, password, true)
|
||||||
|
_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 — hide the auth form fields, show only the welcome label.
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
|
_welcome_label.visible = true
|
||||||
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = 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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgnc3iamfgf6k
|
||||||
Executable
+170
@@ -0,0 +1,170 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 with over_18=false → 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!"
|
||||||
|
|
||||||
|
|
||||||
|
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 ---
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
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, true)
|
||||||
|
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, true)
|
||||||
|
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 with over_18=false ---
|
||||||
|
print("\n=== Test 3: over_18=false 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, false)
|
||||||
|
if _check("over_18=false register not ok", not result.ok) \
|
||||||
|
and _check("over_18=false status is 400", result.status == 400) \
|
||||||
|
and _check("over_18=false 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
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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, true)
|
||||||
|
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)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression test for the Play -> Back navigation bug.
|
||||||
|
##
|
||||||
|
## The reported bug: after logging in and clicking Play, the user lands on
|
||||||
|
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||||
|
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||||
|
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||||
|
## used to unconditionally reset the UI on every scene load.
|
||||||
|
##
|
||||||
|
## This test drives the real navigation flow (change_scene_to_file) against
|
||||||
|
## a live backend:
|
||||||
|
## 1. Register a unique user so ApiClient holds a valid token.
|
||||||
|
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||||
|
## 3. Click Play -> world_list.tscn.
|
||||||
|
## 4. Click Back -> main_menu.tscn.
|
||||||
|
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||||
|
## actually visible in the tree, login/register buttons hidden).
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||||
|
|
||||||
|
const USERNAME := "backnav_%d"
|
||||||
|
const EMAIL := "backnav_%d@test.example.com"
|
||||||
|
const PASSWORD := "BackNav123!"
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
if api == null:
|
||||||
|
printerr("FAIL: ApiClient autoload not found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Establish a real, valid session exactly the way a user would.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := USERNAME % unique_id
|
||||||
|
var email := EMAIL % unique_id
|
||||||
|
print("\n=== Setup: register ", username, " ===")
|
||||||
|
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
|
||||||
|
if not _check("register succeeds", register_result.ok):
|
||||||
|
print(" detail: ", register_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
_check("ApiClient stores token after register", api.has_token())
|
||||||
|
|
||||||
|
# 2. First visit to the main menu: should already show the authenticated
|
||||||
|
# state (no login form, Play enabled).
|
||||||
|
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||||
|
# plenty for the scene object itself to exist (unlike the real
|
||||||
|
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||||
|
# actually waits for below).
|
||||||
|
for i in 5:
|
||||||
|
await process_frame
|
||||||
|
if current_scene is Control:
|
||||||
|
break
|
||||||
|
|
||||||
|
var menu: Control = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
await menu.auth_state_ready
|
||||||
|
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||||
|
|
||||||
|
# 3. Play -> world_list.tscn (real button press).
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
play_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(menu)
|
||||||
|
|
||||||
|
var world_list_scene := current_scene
|
||||||
|
if world_list_scene == null or world_list_scene == menu:
|
||||||
|
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Back -> main_menu.tscn (real button press).
|
||||||
|
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||||
|
back_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(world_list_scene)
|
||||||
|
|
||||||
|
menu = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||||
|
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||||
|
# object exists, not that its authenticated-state fields have been
|
||||||
|
# set yet. Wait for the real signal, not a guess.
|
||||||
|
await menu.auth_state_ready
|
||||||
|
|
||||||
|
# 5. THE regression assertion: still logged in after returning.
|
||||||
|
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_authenticated(menu: Control) -> bool:
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var username_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var auth_button: Button = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
|
||||||
|
_check("Play enabled", not play_button.disabled)
|
||||||
|
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
|
||||||
|
# false-positive: the label can have visible=true while its parent is
|
||||||
|
# hidden and it renders as nothing. `is_visible_in_tree()` is the
|
||||||
|
# check that proves the user can actually see the welcome message.
|
||||||
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
|
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||||
|
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
|
||||||
|
# Real bug, 2026-08-20: the session-restore path (this one, after
|
||||||
|
# Play -> Back) hid EmailEdit/Over18Check but never the rest of the
|
||||||
|
# login form -- Play enabled + welcome label visible were both still
|
||||||
|
# true, so a check that stopped there passed while the user was
|
||||||
|
# looking at the login fields again on top of the welcome message.
|
||||||
|
_check("Username field hidden", not username_edit.visible)
|
||||||
|
_check("Password field hidden", not password_edit.visible)
|
||||||
|
_check("Auth button hidden", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden", not auth_mode_label.visible)
|
||||||
|
|
||||||
|
return (not play_button.disabled) \
|
||||||
|
and welcome_label.is_visible_in_tree() \
|
||||||
|
and not auth_buttons_row.visible \
|
||||||
|
and auth_panel.visible \
|
||||||
|
and not username_edit.visible \
|
||||||
|
and not password_edit.visible \
|
||||||
|
and not auth_button.visible \
|
||||||
|
and not auth_mode_label.visible
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||||
|
for i in 20:
|
||||||
|
await process_frame
|
||||||
|
if current_scene != previous_scene and current_scene != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Full click-through simulation of the real user flow: press Register,
|
||||||
|
## fill the form exactly as a human would, submit, confirm Play actually
|
||||||
|
## becomes clickable. Also verifies that after successful auth the auth
|
||||||
|
## form fields are hidden, leaving only the welcome message visible.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||||
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
|
||||||
|
_check("Play starts disabled", play_button.disabled)
|
||||||
|
|
||||||
|
# Click Register -- a real signal emission is fine here since
|
||||||
|
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||||
|
# real click's GUI event dispatch would drive it.
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||||
|
|
||||||
|
# Fill the form exactly as a user would type into it.
|
||||||
|
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||||
|
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||||
|
password_edit.text = "TestPass123!"
|
||||||
|
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||||
|
over_18_check.button_pressed = true
|
||||||
|
|
||||||
|
# Call the submit handler directly (awaited) rather than via
|
||||||
|
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||||
|
# SceneTree headless mode doesn't drive an await-using handler's
|
||||||
|
# coroutine forward the same way real GUI input event dispatch does
|
||||||
|
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||||
|
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||||
|
# its own await point even after 50 real process_frame waits). This
|
||||||
|
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||||
|
# click-driven flow.
|
||||||
|
await instance._on_auth_button_pressed()
|
||||||
|
|
||||||
|
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||||
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
# Verify the auth form fields are hidden after successful auth,
|
||||||
|
# leaving only the welcome message visible within the panel.
|
||||||
|
_check("Username edit hidden after auth", not username_edit.visible)
|
||||||
|
_check("Password edit hidden after auth", not password_edit.visible)
|
||||||
|
_check("Email edit hidden after auth", not email_edit.visible)
|
||||||
|
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
|
||||||
|
_check("Auth button hidden after auth", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
|
||||||
|
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> void:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Verifies the main menu's Login/Register buttons correctly open the auth
|
||||||
|
## panel and toggle between Login and Register modes via signal emission.
|
||||||
|
##
|
||||||
|
## Key tests:
|
||||||
|
## - AuthPanel starts hidden.
|
||||||
|
## - LoginButton press opens the panel in Login mode.
|
||||||
|
## - RegisterButton press opens the panel in Register mode.
|
||||||
|
## - Clicking Login while in Register mode switches back to Login mode.
|
||||||
|
## - Clicking Register while in Login mode switches to Register mode.
|
||||||
|
## - The ModeToggle CheckButton no longer exists in the scene.
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||||
|
|
||||||
|
# --- Test 1: auth panel starts hidden ---
|
||||||
|
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 2: LoginButton press opens panel in Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel visible after LoginButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden in Login mode", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 3: RegisterButton press switches to Register mode ---
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field visible in Register mode", email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 4: LoginButton press switches back to Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden after switching back to Login", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 5: ModeToggle CheckButton no longer exists ---
|
||||||
|
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
|
||||||
|
var has_mode_toggle = instance.has_node(mode_toggle_path)
|
||||||
|
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
return condition
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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. POST a real world via the API so the populated-card path actually
|
||||||
|
## gets exercised -- run_checkpoint_tests.py always starts from a
|
||||||
|
## genuinely empty DB, so without this the card-rendering code below
|
||||||
|
## would never run in CI at all, only the empty-list path would.
|
||||||
|
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||||
|
## must not error.
|
||||||
|
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||||
|
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||||
|
## that the rendered card has a real width -- not just non-zero
|
||||||
|
## child_count. 2026-08-20: a real bug shipped where every card
|
||||||
|
## technically existed in the tree (child_count > 0, no error, no
|
||||||
|
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||||
|
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||||
|
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||||
|
## child-count check alone would never catch that, only a real size
|
||||||
|
## assertion does.
|
||||||
|
##
|
||||||
|
## Exits 0 on full pass, 1 on any failure.
|
||||||
|
|
||||||
|
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||||
|
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||||
|
|
||||||
|
|
||||||
|
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!", true)
|
||||||
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
|
print(" detail: ", reg_result.detail)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||||
|
print("\n=== Setup: seed a real world ===")
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||||
|
if not _check("setup: world created", create_result.ok):
|
||||||
|
print(" detail: ", create_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 empty_label.visible:
|
||||||
|
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||||
|
# isn't actually seeing what POST /api/worlds/ just created.
|
||||||
|
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||||
|
failed += 1
|
||||||
|
elif world_list_node.visible:
|
||||||
|
var card_count := world_list_node.get_child_count()
|
||||||
|
print(" Note: rendered %d world card(s)" % card_count)
|
||||||
|
|
||||||
|
if _check("at least one card rendered", card_count > 0):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# The actual bug this test exists to catch: a card can be present
|
||||||
|
# in the tree, not crash, and still be visually nothing because
|
||||||
|
# it rendered at ~1px wide. Check every card's real computed size,
|
||||||
|
# not just that it exists.
|
||||||
|
for i in range(card_count):
|
||||||
|
var card: Control = world_list_node.get_child(i)
|
||||||
|
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||||
|
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- 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
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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:
|
||||||
|
# PanelContainer for the background, not a ColorRect moved to child index
|
||||||
|
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||||
|
# vertical sequence, it has no concept of "float this one child behind
|
||||||
|
# the others as an overlay". That version rendered as an empty dark bar
|
||||||
|
# sitting above the text, not a background behind it.
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
panel.custom_minimum_size = Vector2(0, 80)
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||||
|
style.content_margin_left = 12
|
||||||
|
style.content_margin_right = 12
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
|
var card := VBoxContainer.new()
|
||||||
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
card.add_theme_constant_override("separation", 4)
|
||||||
|
panel.add_child(card)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return panel
|
||||||
Reference in New Issue
Block a user