Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb4998b3f | |||
| c287202b9d | |||
| c01f9de586 | |||
| 0505cd7ff1 |
+20
-16
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -39,7 +39,17 @@ 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
|
date_of_birth: str # ISO format date string (YYYY-MM-DD)
|
||||||
|
|
||||||
|
@field_validator("date_of_birth")
|
||||||
|
@classmethod
|
||||||
|
def validate_date_of_birth(cls, v: str) -> str:
|
||||||
|
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
|
||||||
|
try:
|
||||||
|
datetime.strptime(v, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
@@ -108,16 +118,6 @@ def hash_password(password: str) -> str:
|
|||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||||
"""Calculate age in years from a birth date.
|
"""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:
|
Args:
|
||||||
birth_date: The person's date of birth.
|
birth_date: The person's date of birth.
|
||||||
today: The reference date. Defaults to today's date (UTC).
|
today: The reference date. Defaults to today's date (UTC).
|
||||||
@@ -155,12 +155,16 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
|||||||
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 access + refresh tokens.
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
Requires over_18 boolean confirmation; rejects users who are not 18+.
|
Requires date_of_birth and rejects users under 18.
|
||||||
"""
|
"""
|
||||||
if not req.over_18:
|
# Parse and validate date_of_birth
|
||||||
|
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
|
||||||
|
age = calculate_age(dob)
|
||||||
|
|
||||||
|
if age < 18:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Must be 18 or older to register",
|
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
@@ -183,7 +187,7 @@ 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
|
date_of_birth=dob,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush() # flush to get user.id before creating refresh token
|
await db.flush() # flush to get user.id before creating refresh token
|
||||||
|
|||||||
@@ -57,38 +57,38 @@ class TestCalculateAge:
|
|||||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterRequestOver18Field:
|
class TestRegisterRequestDateValidation:
|
||||||
"""Tests for RegisterRequest's over_18 field.
|
"""Tests for the RegisterRequest date_of_birth field validator."""
|
||||||
|
|
||||||
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
|
@pytest.mark.parametrize(
|
||||||
field_validator were removed from RegisterRequest 2026-08-20 (interim
|
"dob",
|
||||||
simplification: a plain client-side over_18 confirmation instead of
|
[
|
||||||
collecting/validating an actual date of birth; see calculate_age's
|
"2000-01-01",
|
||||||
own docstring in app/routers/auth.py for why that function is still
|
"1990-12-31",
|
||||||
here even though this endpoint no longer calls it). There's no date
|
"2004-02-29", # leap day
|
||||||
format left to validate, so the equivalent coverage is just: the
|
],
|
||||||
field exists, is a real bool, and is required."""
|
)
|
||||||
|
def test_valid_dates(self, dob):
|
||||||
def test_over_18_true_accepted(self):
|
"""Valid YYYY-MM-DD dates pass validation."""
|
||||||
from app.routers.auth import RegisterRequest
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||||
assert req.over_18 is True
|
assert req.date_of_birth == dob
|
||||||
|
|
||||||
def test_over_18_false_accepted(self):
|
@pytest.mark.parametrize(
|
||||||
"""False is a valid value at the schema level -- the endpoint,
|
"dob",
|
||||||
not the schema, is what rejects it (see
|
[
|
||||||
TestRegister::test_register_rejects_not_over_18 in
|
"01-01-2000",
|
||||||
test_auth_endpoints.py)."""
|
"2000/01/01",
|
||||||
|
"2000-13-01",
|
||||||
|
"2000-00-10",
|
||||||
|
"not-a-date",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_dates_rejected(self, dob):
|
||||||
|
"""Invalid dates are rejected by the validator."""
|
||||||
from app.routers.auth import RegisterRequest
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
|
with pytest.raises(ValueError):
|
||||||
assert req.over_18 is False
|
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||||
|
|
||||||
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")
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TestRegister:
|
|||||||
"username": "newuser",
|
"username": "newuser",
|
||||||
"email": "new@example.com",
|
"email": "new@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -81,14 +81,8 @@ class TestRegister:
|
|||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
def test_register_rejects_not_over_18(self):
|
def test_register_rejects_underage(self):
|
||||||
"""over_18=false gets a 400 with a detail message.
|
"""Users under 18 get 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)
|
client = TestClient(app)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -96,7 +90,7 @@ class TestRegister:
|
|||||||
"username": "kid",
|
"username": "kid",
|
||||||
"email": "kid@example.com",
|
"email": "kid@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": False,
|
"date_of_birth": "2010-06-15", # ~15 years old
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
@@ -112,7 +106,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup1@example.com",
|
"email": "dup1@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Second registration with same username, different email
|
# Second registration with same username, different email
|
||||||
@@ -122,7 +116,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup2@example.com",
|
"email": "dup2@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
@@ -137,7 +131,7 @@ class TestRegister:
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -146,19 +140,14 @@ class TestRegister:
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert "Email already registered" in resp.json()["detail"]
|
assert "Email already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
def test_register_missing_over_18(self):
|
def test_register_invalid_date_format(self):
|
||||||
"""Missing the required over_18 field returns 422.
|
"""Invalid date format returns 422 validation error."""
|
||||||
|
|
||||||
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)
|
client = TestClient(app)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -166,10 +155,25 @@ class TestRegister:
|
|||||||
"username": "user",
|
"username": "user",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
|
"date_of_birth": "01-01-2000", # wrong format
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_register_exactly_18(self):
|
||||||
|
"""A user who just turned 18 can register."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "just18",
|
||||||
|
"email": "just18@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "2007-06-15",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Login endpoint
|
# Login endpoint
|
||||||
@@ -189,7 +193,7 @@ class TestLogin:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": f"{username}@example.com",
|
"email": f"{username}@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class TestRefresh:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
|||||||
"username": "refep_loginrefuser",
|
"username": "refep_loginrefuser",
|
||||||
"email": "loginref@example.com",
|
"email": "loginref@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"over_18": True,
|
"date_of_birth": "2000-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Login to get a refresh token
|
# Login to get a refresh token
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ covered by the smoke tests below so they can't silently regress.
|
|||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
reachable Postgres — see the runner script).
|
reachable Postgres — see the runner script).
|
||||||
|
|
||||||
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
**Current total: 68 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
| 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_auth_age_gate.py` | 17 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
||||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
| `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_auth_endpoints.py` | 11 | **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), underage rejection (400), invalid date format (422), exact-18 registration, 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_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_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). |
|
| `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). |
|
||||||
@@ -60,7 +60,7 @@ these yet):
|
|||||||
| File | What it covers |
|
| 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_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_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 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_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_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_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. |
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ visible = false
|
|||||||
placeholder_text = "Email"
|
placeholder_text = "Email"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
text = "I am 18 or older"
|
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
|||||||
@@ -178,18 +178,18 @@ func login(username: String, password: String) -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
## Register a new user. On success stores the returned access token.
|
## Register a new user. On success stores the returned access token.
|
||||||
## @param over_18 Must be true; the backend rejects registration otherwise.
|
## @param date_of_birth ISO date string "YYYY-MM-DD".
|
||||||
func register(
|
func register(
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
over_18: bool
|
date_of_birth: String
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var payload := {
|
var payload := {
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"over_18": over_18,
|
"date_of_birth": date_of_birth,
|
||||||
}
|
}
|
||||||
var result := await request(
|
var result := await request(
|
||||||
HTTPClient.METHOD_POST,
|
HTTPClient.METHOD_POST,
|
||||||
|
|||||||
+87
-10
@@ -6,6 +6,23 @@ extends Control
|
|||||||
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
## 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
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
## the panel is open.
|
## 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.
|
||||||
|
##
|
||||||
|
## 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 label visible) instead of forcing the user
|
||||||
|
## back to the login form.
|
||||||
|
|
||||||
|
|
||||||
|
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||||
|
## 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 ────────────────────────────────────────────────────────────
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -17,7 +34,7 @@ var _auth_mode_label: Label
|
|||||||
var _username_edit: LineEdit
|
var _username_edit: LineEdit
|
||||||
var _password_edit: LineEdit
|
var _password_edit: LineEdit
|
||||||
var _email_edit: LineEdit
|
var _email_edit: LineEdit
|
||||||
var _over_18_check: CheckBox
|
var _dob_edit: LineEdit
|
||||||
var _auth_button: Button
|
var _auth_button: Button
|
||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
@@ -40,20 +57,52 @@ func _ready() -> void:
|
|||||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||||
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
||||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Initial state.
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
## Restore the UI to the authenticated state (Play enabled, welcome label,
|
||||||
|
## auth buttons hidden).
|
||||||
|
func _show_authenticated(username: String) -> void:
|
||||||
|
_auth_panel.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % username
|
||||||
|
_welcome_label.visible = true
|
||||||
|
_email_edit.visible = false
|
||||||
|
_dob_edit.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
|
_auth_panel.visible = false
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_welcome_label.visible = false
|
_welcome_label.visible = false
|
||||||
_email_edit.visible = false
|
_email_edit.visible = false
|
||||||
_over_18_check.visible = false
|
_dob_edit.visible = false
|
||||||
_over_18_check.button_pressed = false
|
|
||||||
_play_button.disabled = true # Gate play behind successful auth.
|
_play_button.disabled = true # Gate play behind successful auth.
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
@@ -98,12 +147,12 @@ func _update_auth_mode_ui() -> void:
|
|||||||
_auth_mode_label.text = "Register"
|
_auth_mode_label.text = "Register"
|
||||||
_auth_button.text = "Register"
|
_auth_button.text = "Register"
|
||||||
_email_edit.visible = true
|
_email_edit.visible = true
|
||||||
_over_18_check.visible = true
|
_dob_edit.visible = true
|
||||||
else:
|
else:
|
||||||
_auth_mode_label.text = "Login"
|
_auth_mode_label.text = "Login"
|
||||||
_auth_button.text = "Login"
|
_auth_button.text = "Login"
|
||||||
_email_edit.visible = false
|
_email_edit.visible = false
|
||||||
_over_18_check.visible = false
|
_dob_edit.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||||
@@ -126,12 +175,17 @@ func _on_auth_button_pressed() -> void:
|
|||||||
|
|
||||||
if _is_register_mode:
|
if _is_register_mode:
|
||||||
var email := _email_edit.text.strip_edges()
|
var email := _email_edit.text.strip_edges()
|
||||||
|
var dob := _dob_edit.text.strip_edges()
|
||||||
|
|
||||||
if email == "":
|
if email == "":
|
||||||
_show_status("Email is required.")
|
_show_status("Email is required.")
|
||||||
return
|
return
|
||||||
if not _over_18_check.button_pressed:
|
if dob == "":
|
||||||
_show_status("You must confirm you are 18 or older to register.")
|
_show_status("Date of birth is required.")
|
||||||
|
return
|
||||||
|
# Basic YYYY-MM-DD format check.
|
||||||
|
if not _is_valid_date(dob):
|
||||||
|
_show_status("Date of birth must be in YYYY-MM-DD format.")
|
||||||
return
|
return
|
||||||
|
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
@@ -139,7 +193,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_is_authenticating = true
|
_is_authenticating = true
|
||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.register(username, email, password, true)
|
var result := await ApiClient.register(username, email, password, dob)
|
||||||
_on_auth_complete(result)
|
_on_auth_complete(result)
|
||||||
else:
|
else:
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
@@ -182,3 +236,26 @@ func _show_status(message: String) -> void:
|
|||||||
func _clear_status() -> void:
|
func _clear_status() -> void:
|
||||||
_status_label.text = ""
|
_status_label.text = ""
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _is_valid_date(date_str: String) -> bool:
|
||||||
|
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
||||||
|
if date_str.length() != 10:
|
||||||
|
return false
|
||||||
|
var parts := date_str.split("-")
|
||||||
|
if parts.size() != 3:
|
||||||
|
return false
|
||||||
|
var year := parts[0].to_int()
|
||||||
|
var month := parts[1].to_int()
|
||||||
|
var day := parts[2].to_int()
|
||||||
|
if month < 1 or month > 12:
|
||||||
|
return false
|
||||||
|
if day < 1 or day > 31:
|
||||||
|
return false
|
||||||
|
# Rough month-day validation.
|
||||||
|
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
if day > days_in_month[month]:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|||||||
+12
-11
@@ -10,7 +10,7 @@ extends SceneTree
|
|||||||
## Tests:
|
## Tests:
|
||||||
## 1. Register a unique user → 201 + token stored.
|
## 1. Register a unique user → 201 + token stored.
|
||||||
## 2. Register the same user again → 409 "Username already registered".
|
## 2. Register the same user again → 409 "Username already registered".
|
||||||
## 3. Register with over_18=false → 400 "Must be 18 or older".
|
## 3. Register an under-18 user → 400 "Must be 18 or older".
|
||||||
## 4. Login with correct credentials → 200 + token stored.
|
## 4. Login with correct credentials → 200 + token stored.
|
||||||
## 5. Login with wrong password → 401 "Incorrect username or password".
|
## 5. Login with wrong password → 401 "Incorrect username or password".
|
||||||
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
||||||
@@ -21,6 +21,8 @@ const API_BASE := "http://127.0.0.1:8000"
|
|||||||
const TEST_USERNAME := "smoke_test_user_%d"
|
const TEST_USERNAME := "smoke_test_user_%d"
|
||||||
const TEST_EMAIL := "smoke_%d@test.example.com"
|
const TEST_EMAIL := "smoke_%d@test.example.com"
|
||||||
const TEST_PASSWORD := "SmokeTest123!"
|
const TEST_PASSWORD := "SmokeTest123!"
|
||||||
|
const TEST_DOB_ADULT := "2000-01-01"
|
||||||
|
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -38,13 +40,12 @@ func _run() -> void:
|
|||||||
var failed: int = 0
|
var failed: int = 0
|
||||||
|
|
||||||
# --- Test 1: Register a unique user ---
|
# --- Test 1: Register a unique user ---
|
||||||
randomize()
|
var unique_id := Time.get_ticks_msec()
|
||||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
|
||||||
var username := "smoke_%d" % unique_id
|
var username := "smoke_%d" % unique_id
|
||||||
var email := "smoke_%d@test.example.com" % unique_id
|
var email := "smoke_%d@test.example.com" % unique_id
|
||||||
|
|
||||||
print("\n=== Test 1: Register unique user ===")
|
print("\n=== Test 1: Register unique user ===")
|
||||||
var result := await api.register(username, email, TEST_PASSWORD, true)
|
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||||
if _check("register returns ok", result.ok) \
|
if _check("register returns ok", result.ok) \
|
||||||
and _check("register status is 201", result.status == 201) \
|
and _check("register status is 201", result.status == 201) \
|
||||||
and _check("register stores token", api.has_token()):
|
and _check("register stores token", api.has_token()):
|
||||||
@@ -55,7 +56,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
# --- Test 2: Register same user again (duplicate) ---
|
# --- Test 2: Register same user again (duplicate) ---
|
||||||
print("\n=== Test 2: Duplicate username ===")
|
print("\n=== Test 2: Duplicate username ===")
|
||||||
result = await api.register(username, email, TEST_PASSWORD, true)
|
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||||
if _check("duplicate register not ok", not result.ok) \
|
if _check("duplicate register not ok", not result.ok) \
|
||||||
and _check("duplicate status is 409", result.status == 409) \
|
and _check("duplicate status is 409", result.status == 409) \
|
||||||
and _check("duplicate detail mentions username",
|
and _check("duplicate detail mentions username",
|
||||||
@@ -65,14 +66,14 @@ func _run() -> void:
|
|||||||
failed += 1
|
failed += 1
|
||||||
print(" detail: ", result.detail)
|
print(" detail: ", result.detail)
|
||||||
|
|
||||||
# --- Test 3: Register with over_18=false ---
|
# --- Test 3: Register under-18 user ---
|
||||||
print("\n=== Test 3: over_18=false registration ===")
|
print("\n=== Test 3: Under-18 registration ===")
|
||||||
var child_username := "child_%d" % unique_id
|
var child_username := "child_%d" % unique_id
|
||||||
var child_email := "child_%d@test.example.com" % unique_id
|
var child_email := "child_%d@test.example.com" % unique_id
|
||||||
result = await api.register(child_username, child_email, TEST_PASSWORD, false)
|
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
|
||||||
if _check("over_18=false register not ok", not result.ok) \
|
if _check("under-18 register not ok", not result.ok) \
|
||||||
and _check("over_18=false status is 400", result.status == 400) \
|
and _check("under-18 status is 400", result.status == 400) \
|
||||||
and _check("over_18=false detail mentions age",
|
and _check("under-18 detail mentions age",
|
||||||
"18" in result.detail):
|
"18" in result.detail):
|
||||||
passed += 1
|
passed += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ extends SceneTree
|
|||||||
const TEST_USERNAME_BASE := "smoke_test_user"
|
const TEST_USERNAME_BASE := "smoke_test_user"
|
||||||
const TEST_EMAIL := "smoke@test.com"
|
const TEST_EMAIL := "smoke@test.com"
|
||||||
const TEST_PASSWORD := "TestPass123"
|
const TEST_PASSWORD := "TestPass123"
|
||||||
|
const TEST_DOB := "2000-01-01"
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -69,7 +70,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
# --- Step 2: Register -----------------------------------------------------
|
# --- Step 2: Register -----------------------------------------------------
|
||||||
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
||||||
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
|
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
|
||||||
var reg_token := _extract_token(reg)
|
var reg_token := _extract_token(reg)
|
||||||
var reg_ok: bool = (
|
var reg_ok: bool = (
|
||||||
reg.ok
|
reg.ok
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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
|
||||||
|
## visible, 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!"
|
||||||
|
const DOB := "2000-01-01"
|
||||||
|
|
||||||
|
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, DOB)
|
||||||
|
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")
|
||||||
|
|
||||||
|
_check("Play enabled", not play_button.disabled)
|
||||||
|
_check("Welcome label visible", welcome_label.visible)
|
||||||
|
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||||
|
_check("Auth panel hidden", not auth_panel.visible)
|
||||||
|
|
||||||
|
return (not play_button.disabled) \
|
||||||
|
and welcome_label.visible \
|
||||||
|
and not auth_buttons_row.visible \
|
||||||
|
and not auth_panel.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)
|
||||||
@@ -26,7 +26,7 @@ func _run() -> void:
|
|||||||
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
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 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 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 dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||||
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
@@ -46,7 +46,7 @@ func _run() -> void:
|
|||||||
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||||
password_edit.text = "TestPass123!"
|
password_edit.text = "TestPass123!"
|
||||||
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||||
over_18_check.button_pressed = true
|
dob_edit.text = "2000-01-01"
|
||||||
|
|
||||||
# Call the submit handler directly (awaited) rather than via
|
# Call the submit handler directly (awaited) rather than via
|
||||||
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func _run() -> void:
|
|||||||
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
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 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 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 dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||||
|
|
||||||
# --- Test 1: auth panel starts hidden ---
|
# --- Test 1: auth panel starts hidden ---
|
||||||
if _check("auth panel starts hidden", not auth_panel.visible):
|
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||||
@@ -58,7 +58,7 @@ func _run() -> void:
|
|||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
|
if _check("DOB field hidden in Login mode", not dob_edit.visible):
|
||||||
passed += 1
|
passed += 1
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -82,7 +82,7 @@ func _run() -> void:
|
|||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
|
if _check("DOB field visible in Register mode", dob_edit.visible):
|
||||||
passed += 1
|
passed += 1
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -101,7 +101,7 @@ func _run() -> void:
|
|||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
|
if _check("DOB field hidden after switching back to Login", not dob_edit.visible):
|
||||||
passed += 1
|
passed += 1
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func _run() -> void:
|
|||||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
var username := "worldlist_smoke_%d" % unique_id
|
var username := "worldlist_smoke_%d" % unique_id
|
||||||
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
||||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
|
||||||
if not _check("setup: register succeeded", reg_result.ok):
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
print(" detail: ", reg_result.detail)
|
print(" detail: ", reg_result.detail)
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user