Compare commits

..

4 Commits

Author SHA1 Message Date
claude-checkpoint-tooling 4cb4998b3f Merge main into agent/28357bca192d to resolve PR #43 conflicts
# Conflicts:
#	scripts/main_menu.gd
2026-08-20 16:43:48 +00:00
coder-bot c287202b9d Fix flaky regression test for Play->Back session persistence
The actual fix (main_menu.gd's _ready() checking ApiClient.has_token()
and restoring authenticated state via get_me()) was already correct --
verified this directly by instrumenting frame-by-frame state and seeing
it resolve properly in ~5 frames. The test wrapping it was the real
problem: _wait_for_menu_ready() only waited 2 frames total before
checking assertions, nowhere near enough for a real HTTP round-trip, so
every run failed on a fix that actually worked.

Root fix: added a real auth_state_ready signal to main_menu.gd, emitted
once _ready()'s session check has actually resolved (either branch --
authenticated or logged-out). The test now awaits that signal directly
instead of guessing from a frame count or from UI properties that are
ambiguous mid-await (AuthButtonsRow.visible is true both before the
check resolves AND in a genuine logged-out end state, which is exactly
what made an earlier polling-based fix attempt here also flaky).

Also fixed a second real bug in the test itself: unique_id used
Time.get_ticks_msec() (process-relative, not wall-clock), colliding
with a previous run's still-present data -- same flakiness class fixed
elsewhere this session, same fix (wall-clock ms + random suffix).

Verified: 12/12 passing across 3 consecutive runs against a real
backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:12:16 +00:00
coder-bot c01f9de586 WIP checkpoint: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 15:04:19 +00:00
coder-bot 0505cd7ff1 WIP checkpoint: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 14:03:53 +00:00
16 changed files with 170 additions and 193 deletions
+20 -16
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,7 +39,17 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
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):
@@ -108,16 +118,6 @@ def hash_password(password: str) -> str:
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).
@@ -155,12 +155,16 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""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(
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
@@ -183,7 +187,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=None, # No longer collecting DOB
date_of_birth=dob,
)
db.add(user)
await db.flush() # flush to get user.id before creating refresh token
+29 -29
View File
@@ -57,38 +57,38 @@ class TestCalculateAge:
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestOver18Field:
"""Tests for RegisterRequest's over_18 field.
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
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):
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
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
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
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)."""
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"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
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")
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
+26 -22
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
@@ -81,14 +81,8 @@ class TestRegister:
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."""
def test_register_rejects_underage(self):
"""Users under 18 get a 400 with a detail message."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -96,7 +90,7 @@ class TestRegister:
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"over_18": False,
"date_of_birth": "2010-06-15", # ~15 years old
},
)
assert resp.status_code == 400
@@ -112,7 +106,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
# Second registration with same username, different email
@@ -122,7 +116,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 409
@@ -137,7 +131,7 @@ class TestRegister:
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
resp = client.post(
@@ -146,19 +140,14 @@ class TestRegister:
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
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."""
def test_register_invalid_date_format(self):
"""Invalid date format returns 422 validation error."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -166,10 +155,25 @@ class TestRegister:
"username": "user",
"email": "user@example.com",
"password": "secret123",
"date_of_birth": "01-01-2000", # wrong format
},
)
assert resp.status_code == 422
def test_register_exactly_18(self):
"""A user who just turned 18 can register."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "just18",
"email": "just18@example.com",
"password": "secret123",
"date_of_birth": "2007-06-15",
},
)
assert resp.status_code == 201
# ---------------------------------------------------------------------------
# Login endpoint
@@ -189,7 +193,7 @@ class TestLogin:
"username": username,
"email": email,
"password": password,
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
+1 -1
View File
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
+2 -2
View File
@@ -59,7 +59,7 @@ class TestRefresh:
"username": username,
"email": email,
"password": password,
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
@@ -143,7 +143,7 @@ class TestRefresh:
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "2000-01-01",
},
)
# Login to get a refresh token
+10 -12
View File
@@ -8,12 +8,11 @@ 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
migrations + pytest + all 6 Godot smoke tests + both platform exports, all
clean. Two 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, and the world
list's cards rendered at ~1px wide once real data existed. Both are now
covered by the smoke tests below so they can't silently regress.
## Backend — `backend/tests/` (pytest)
@@ -21,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
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 |
|---|---|---|
| `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_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_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). |
@@ -53,7 +52,7 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd`
**7 smoke tests merged into `main`.** Each needs a live backend —
**6 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.
@@ -61,12 +60,11 @@ these yet):
| 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_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_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
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -83,10 +83,10 @@ visible = false
placeholder_text = "Email"
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
visible = false
text = "I am 18 or older"
placeholder_text = "Date of Birth (YYYY-MM-DD)"
size_flags_horizontal = 3
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
+3 -3
View File
@@ -178,18 +178,18 @@ func login(username: String, password: String) -> Dictionary:
## 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(
username: String,
email: String,
password: String,
over_18: bool
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"over_18": over_18,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
+45 -46
View File
@@ -9,11 +9,10 @@ extends Control
##
## 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
## UI state (Play enabled, welcome label visible) instead of forcing the user
## back to the login form.
@@ -35,7 +34,7 @@ var _auth_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _over_18_check: CheckBox
var _dob_edit: LineEdit
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
@@ -58,19 +57,13 @@ func _ready() -> void:
_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
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
_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.
@@ -88,32 +81,15 @@ func _ready() -> void:
auth_state_ready.emit()
## 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).
## Restore the UI to the authenticated state (Play enabled, welcome label,
## auth buttons hidden).
func _show_authenticated(username: String) -> void:
_auth_panel.visible = true
_auth_panel.visible = false
_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
_dob_edit.visible = false
_play_button.disabled = false
_auth_buttons_row.visible = false
print("Restored authenticated session for: ", username)
@@ -126,8 +102,7 @@ func _reset_to_logged_out() -> void:
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_over_18_check.button_pressed = false
_dob_edit.visible = false
_play_button.disabled = true # Gate play behind successful auth.
_update_auth_mode_ui()
@@ -172,12 +147,12 @@ func _update_auth_mode_ui() -> void:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_over_18_check.visible = true
_dob_edit.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
# ─── Auth submission ──────────────────────────────────────────────────────────
@@ -200,12 +175,17 @@ func _on_auth_button_pressed() -> void:
if _is_register_mode:
var email := _email_edit.text.strip_edges()
var dob := _dob_edit.text.strip_edges()
if email == "":
_show_status("Email is required.")
return
if not _over_18_check.button_pressed:
_show_status("You must confirm you are 18 or older to register.")
if dob == "":
_show_status("Date of birth is required.")
return
# Basic YYYY-MM-DD format check.
if not _is_valid_date(dob):
_show_status("Date of birth must be in YYYY-MM-DD format.")
return
_auth_button.disabled = true
@@ -213,7 +193,7 @@ func _on_auth_button_pressed() -> void:
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, true)
var result := await ApiClient.register(username, email, password, dob)
_on_auth_complete(result)
else:
_auth_button.disabled = true
@@ -232,16 +212,12 @@ func _on_auth_complete(result: Dictionary) -> void:
_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
# Success — show welcome, enable Play, hide the now-redundant
# Login/Register entry points (the auth panel itself stays open,
# showing the welcome message in place of the form).
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
_welcome_label.visible = true
_status_label.visible = false
_play_button.disabled = false
_auth_buttons_row.visible = false
print("Auth successful. Token stored.")
@@ -260,3 +236,26 @@ func _show_status(message: String) -> void:
func _clear_status() -> void:
_status_label.text = ""
_status_label.visible = false
# ─── Validation ───────────────────────────────────────────────────────────────
func _is_valid_date(date_str: String) -> bool:
"""Check if a string looks like a valid YYYY-MM-DD date."""
if date_str.length() != 10:
return false
var parts := date_str.split("-")
if parts.size() != 3:
return false
var year := parts[0].to_int()
var month := parts[1].to_int()
var day := parts[2].to_int()
if month < 1 or month > 12:
return false
if day < 1 or day > 31:
return false
# Rough month-day validation.
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if day > days_in_month[month]:
return false
return true
+12 -11
View File
@@ -10,7 +10,7 @@ extends SceneTree
## 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".
## 3. Register an under-18 user → 400 "Must be 18 or older".
## 4. Login with correct credentials → 200 + token stored.
## 5. Login with wrong password → 401 "Incorrect username or password".
## 6. Login with non-existent user → 401 "Incorrect username or password".
@@ -21,6 +21,8 @@ const API_BASE := "http://127.0.0.1:8000"
const TEST_USERNAME := "smoke_test_user_%d"
const TEST_EMAIL := "smoke_%d@test.example.com"
const TEST_PASSWORD := "SmokeTest123!"
const TEST_DOB_ADULT := "2000-01-01"
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
func _initialize() -> void:
@@ -38,13 +40,12 @@ func _run() -> void:
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 unique_id := Time.get_ticks_msec()
var username := "smoke_%d" % unique_id
var email := "smoke_%d@test.example.com" % unique_id
print("\n=== Test 1: Register unique user ===")
var result := await api.register(username, email, TEST_PASSWORD, true)
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
if _check("register returns ok", result.ok) \
and _check("register status is 201", result.status == 201) \
and _check("register stores token", api.has_token()):
@@ -55,7 +56,7 @@ func _run() -> void:
# --- Test 2: Register same user again (duplicate) ---
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) \
and _check("duplicate status is 409", result.status == 409) \
and _check("duplicate detail mentions username",
@@ -65,14 +66,14 @@ func _run() -> void:
failed += 1
print(" detail: ", result.detail)
# --- Test 3: Register with over_18=false ---
print("\n=== Test 3: over_18=false registration ===")
# --- Test 3: Register under-18 user ---
print("\n=== Test 3: Under-18 registration ===")
var child_username := "child_%d" % unique_id
var child_email := "child_%d@test.example.com" % unique_id
result = await api.register(child_username, child_email, TEST_PASSWORD, 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",
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
if _check("under-18 register not ok", not result.ok) \
and _check("under-18 status is 400", result.status == 400) \
and _check("under-18 detail mentions age",
"18" in result.detail):
passed += 1
else:
+2 -1
View File
@@ -23,6 +23,7 @@ extends SceneTree
const TEST_USERNAME_BASE := "smoke_test_user"
const TEST_EMAIL := "smoke@test.com"
const TEST_PASSWORD := "TestPass123"
const TEST_DOB := "2000-01-01"
func _initialize() -> void:
@@ -69,7 +70,7 @@ func _run() -> void:
# --- 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: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
+7 -27
View File
@@ -14,7 +14,7 @@ extends SceneTree
## 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).
## visible, login/register buttons hidden).
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
@@ -25,6 +25,7 @@ extends SceneTree
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
@@ -49,7 +50,7 @@ func _run() -> void:
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)
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()
@@ -115,37 +116,16 @@ func _is_authenticated(menu: Control) -> bool:
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("Welcome label visible", welcome_label.visible)
_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)
_check("Auth panel hidden", not auth_panel.visible)
return (not play_button.disabled) \
and welcome_label.is_visible_in_tree() \
and welcome_label.visible \
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
and not auth_panel.visible
func _wait_for_scene_change(previous_scene: Node) -> void:
+6 -16
View File
@@ -1,8 +1,9 @@
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.
## becomes clickable. This is the check that would have caught the
## original bug (no way to even open the form) and would catch a repeat
## of it.
var passed := 0
var failed := 0
@@ -25,9 +26,8 @@ func _run() -> void:
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 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_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")
@@ -46,7 +46,7 @@ func _run() -> void:
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
dob_edit.text = "2000-01-01"
# Call the submit handler directly (awaited) rather than via
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
@@ -60,19 +60,9 @@ func _run() -> void:
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("Welcome label visible", welcome_label.visible)
_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)
+4 -4
View File
@@ -31,7 +31,7 @@ func _run() -> void:
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")
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
# --- Test 1: auth panel starts hidden ---
if _check("auth panel starts hidden", not auth_panel.visible):
@@ -58,7 +58,7 @@ func _run() -> void:
else:
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
else:
failed += 1
@@ -82,7 +82,7 @@ func _run() -> void:
else:
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
else:
failed += 1
@@ -101,7 +101,7 @@ func _run() -> void:
else:
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
else:
failed += 1
+1 -1
View File
@@ -58,7 +58,7 @@ func _run() -> void:
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)
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_result.detail)
quit(1)