Compare commits

...

8 Commits

Author SHA1 Message Date
coder-bot 2b88fc476e Merge remote WIP checkpoint; keep the verified over_18 fix (ModeToggle stays removed per PR #42)
# Conflicts:
#	backend/app/routers/auth.py
2026-08-20 16:27:41 +00:00
claude-checkpoint-tooling 0ecc16d963 Registration: replace date-of-birth field with a simple 18+ confirmation
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.

Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's
register() now takes a bool instead of a date string. All Godot callers
(smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend
test files updated to match. Full suite verified against a live backend:
61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 16:25:27 +00:00
coder-bot 4ec71b87b9 WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 15:10:43 +00:00
reviewer-bot dc0c133af8 Merge pull request 'Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle)' (#42) from agent/0b9e2c5ae6ec into main 2026-08-20 15:06:42 +00:00
coder-bot 0f7531008f WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 14:58:15 +00:00
coder-bot 12fef51020 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:03:57 +00:00
coder-bot af50819a16 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:01:20 +00:00
coder-bot 16bb87c6b0 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 13:56:44 +00:00
14 changed files with 191 additions and 178 deletions
+16 -20
View File
@@ -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, field_validator from pydantic import BaseModel, EmailStr
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,17 +39,7 @@ class RegisterRequest(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
password: str password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD) over_18: bool # Simple boolean confirmation instead of date_of_birth
@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):
@@ -118,6 +108,16 @@ 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,16 +155,12 @@ 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 date_of_birth and rejects users under 18. Requires over_18 boolean confirmation; rejects users who are not 18+.
""" """
# Parse and validate date_of_birth if not req.over_18:
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=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})", detail="Must be 18 or older to register",
) )
# Check if username already exists # Check if username already exists
@@ -187,7 +183,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=dob, date_of_birth=None, # No longer collecting 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
+29 -29
View File
@@ -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 TestRegisterRequestDateValidation: class TestRegisterRequestOver18Field:
"""Tests for the RegisterRequest date_of_birth field validator.""" """Tests for RegisterRequest's over_18 field.
@pytest.mark.parametrize( Replaces TestRegisterRequestDateValidation -- date_of_birth and its
"dob", field_validator were removed from RegisterRequest 2026-08-20 (interim
[ simplification: a plain client-side over_18 confirmation instead of
"2000-01-01", collecting/validating an actual date of birth; see calculate_age's
"1990-12-31", own docstring in app/routers/auth.py for why that function is still
"2004-02-29", # leap day 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_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation.""" def test_over_18_true_accepted(self):
from app.routers.auth import RegisterRequest from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob) req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
assert req.date_of_birth == dob assert req.over_18 is True
@pytest.mark.parametrize( def test_over_18_false_accepted(self):
"dob", """False is a valid value at the schema level -- the endpoint,
[ not the schema, is what rejects it (see
"01-01-2000", TestRegister::test_register_rejects_not_over_18 in
"2000/01/01", test_auth_endpoints.py)."""
"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
with pytest.raises(ValueError): req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob) 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")
+22 -26
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser", "username": "newuser",
"email": "new@example.com", "email": "new@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
assert resp.status_code == 201 assert resp.status_code == 201
@@ -81,8 +81,14 @@ 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_underage(self): def test_register_rejects_not_over_18(self):
"""Users under 18 get a 400 with a detail message.""" """over_18=false gets a 400 with a detail message.
The interim design (2026-08-20) trusts a client-side boolean
confirmation instead of collecting/validating an actual date of
birth -- this only checks that the flag is respected, not real
age math (there's no date to verify against anymore). Real DOB
collection/verification may come back in a future pass."""
client = TestClient(app) client = TestClient(app)
resp = client.post( resp = client.post(
"/api/auth/register", "/api/auth/register",
@@ -90,7 +96,7 @@ class TestRegister:
"username": "kid", "username": "kid",
"email": "kid@example.com", "email": "kid@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2010-06-15", # ~15 years old "over_18": False,
}, },
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -106,7 +112,7 @@ class TestRegister:
"username": "duplicate", "username": "duplicate",
"email": "dup1@example.com", "email": "dup1@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
# Second registration with same username, different email # Second registration with same username, different email
@@ -116,7 +122,7 @@ class TestRegister:
"username": "duplicate", "username": "duplicate",
"email": "dup2@example.com", "email": "dup2@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
assert resp.status_code == 409 assert resp.status_code == 409
@@ -131,7 +137,7 @@ class TestRegister:
"username": "user1", "username": "user1",
"email": "same@example.com", "email": "same@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
resp = client.post( resp = client.post(
@@ -140,14 +146,19 @@ class TestRegister:
"username": "user2", "username": "user2",
"email": "same@example.com", "email": "same@example.com",
"password": "secret123", "password": "secret123",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
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_invalid_date_format(self): def test_register_missing_over_18(self):
"""Invalid date format returns 422 validation error.""" """Missing the required over_18 field returns 422.
Replaces the old test_register_invalid_date_format -- there's no
date format to get wrong anymore (no date field at all), so the
closest equivalent "malformed request" case is the required
over_18 boolean being absent entirely."""
client = TestClient(app) client = TestClient(app)
resp = client.post( resp = client.post(
"/api/auth/register", "/api/auth/register",
@@ -155,25 +166,10 @@ 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
@@ -193,7 +189,7 @@ class TestLogin:
"username": username, "username": username,
"email": email, "email": email,
"password": password, "password": password,
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
assert resp.status_code == 201 assert resp.status_code == 201
+1 -1
View File
@@ -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",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
assert resp.status_code == 201 assert resp.status_code == 201
+2 -2
View File
@@ -59,7 +59,7 @@ class TestRefresh:
"username": username, "username": username,
"email": email, "email": email,
"password": password, "password": password,
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
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",
"date_of_birth": "2000-01-01", "over_18": True,
}, },
) )
# Login to get a refresh token # Login to get a refresh token
+4 -4
View File
@@ -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: 68 tests, all passing on a clean `main` checkout.** **Current total: 61 tests, all passing on a clean `main` checkout.**
| File | Tests | What it covers | | File | Tests | What it covers |
|---|---|---| |---|---|---|
| `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_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). | | `test_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` | 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_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. | | `test_refresh_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), under-18 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), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. | | `smoke_test_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. |
+2 -7
View File
@@ -83,16 +83,12 @@ visible = false
placeholder_text = "Email" placeholder_text = "Email"
size_flags_horizontal = 3 size_flags_horizontal = 3
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] [node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1 layout_mode = 1
visible = false visible = false
placeholder_text = "Date of Birth (YYYY-MM-DD)" text = "I am 18 or older"
size_flags_horizontal = 3 size_flags_horizontal = 3
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Register"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] [node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1 layout_mode = 1
text = "Login" text = "Login"
@@ -125,7 +121,6 @@ size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"] [connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"] [connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
+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. ## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO date string "YYYY-MM-DD". ## @param over_18 Must be true; the backend rejects registration otherwise.
func register( func register(
username: String, username: String,
email: String, email: String,
password: String, password: String,
date_of_birth: String over_18: bool
) -> Dictionary: ) -> Dictionary:
var payload := { var payload := {
"username": username, "username": username,
"email": email, "email": email,
"password": password, "password": password,
"date_of_birth": date_of_birth, "over_18": over_18,
} }
var result := await request( var result := await request(
HTTPClient.METHOD_POST, HTTPClient.METHOD_POST,
+22 -60
View File
@@ -3,14 +3,11 @@ extends Control
## ##
## The AuthPanel is hidden by default. When the user clicks the LoginButton or ## The AuthPanel is hidden by default. When the user clicks the LoginButton or
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in. ## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
## The ModeToggle switches between Login and Register modes, showing/hiding ## The Login and Register buttons on the AuthButtonsRow directly set the panel's
## the email and DOB fields accordingly. ## mode, so the user can switch between Login and Register at any time while
## ## the panel is open.
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
## the welcome label shows the username, and the Play button becomes enabled.
# ─── UI references ────────────────────────────────────────────────────────────
# ── UI references ────────────────────────────────────────────────────────────
var _auth_buttons_row: HBoxContainer var _auth_buttons_row: HBoxContainer
var _login_button: Button var _login_button: Button
@@ -20,8 +17,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 _dob_edit: LineEdit var _over_18_check: CheckBox
var _mode_toggle: CheckButton
var _auth_button: Button var _auth_button: Button
var _status_label: Label var _status_label: Label
var _welcome_label: Label var _welcome_label: Label
@@ -32,7 +28,7 @@ var _is_register_mode: bool = false
var _is_authenticating: bool = false var _is_authenticating: bool = false
# ── Lifecycle ──────────────────────────────────────────────────────────────── # ─── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void: func _ready() -> void:
# Cache node references. # Cache node references.
@@ -44,8 +40,7 @@ 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
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit _over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
_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
@@ -57,12 +52,13 @@ func _ready() -> void:
_status_label.visible = false _status_label.visible = false
_welcome_label.visible = false _welcome_label.visible = false
_email_edit.visible = false _email_edit.visible = false
_dob_edit.visible = false _over_18_check.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()
# ── Play / Quit ────────────────────────────────────────────────────────────── # ─── Play / Quit ──────────────────────────────────────────────────────────────
func _on_play_button_pressed() -> void: func _on_play_button_pressed() -> void:
if _play_button.disabled: if _play_button.disabled:
@@ -76,11 +72,14 @@ func _on_quit_button_pressed() -> void:
get_tree().quit() get_tree().quit()
# ── Auth panel toggle ──────────────────────────────────────────────────────── # ─── Auth panel toggle ────────────────────────────────────────────────────────
func _on_login_button_pressed() -> void: func _on_login_button_pressed() -> void:
"""Show the auth panel, called from AuthButtonsRow/LoginButton.""" """Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
_auth_panel.visible = true _auth_panel.visible = true
_is_register_mode = false
_update_auth_mode_ui()
_clear_status()
_username_edit.grab_focus() _username_edit.grab_focus()
@@ -88,35 +87,26 @@ func _on_register_button_pressed() -> void:
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton.""" """Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
_auth_panel.visible = true _auth_panel.visible = true
_is_register_mode = true _is_register_mode = true
_mode_toggle.button_pressed = true
_update_auth_mode_ui() _update_auth_mode_ui()
_clear_status() _clear_status()
_username_edit.grab_focus() _username_edit.grab_focus()
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
_is_register_mode = button_pressed
_update_auth_mode_ui()
_clear_status()
func _update_auth_mode_ui() -> void: func _update_auth_mode_ui() -> void:
"""Update labels and field visibility based on login vs register mode.""" """Update labels and field visibility based on login vs register mode."""
if _is_register_mode: if _is_register_mode:
_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
_dob_edit.visible = true _over_18_check.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
_dob_edit.visible = false _over_18_check.visible = false
# ── Auth submission ────────────────────────────────────────────────────────── # ─── Auth submission ──────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void: func _on_auth_button_pressed() -> void:
"""Handle login or register button press.""" """Handle login or register button press."""
@@ -136,17 +126,12 @@ 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 dob == "": if not _over_18_check.button_pressed:
_show_status("Date of birth is required.") _show_status("You must confirm you are 18 or older to register.")
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
@@ -154,7 +139,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, dob) var result := await ApiClient.register(username, email, password, true)
_on_auth_complete(result) _on_auth_complete(result)
else: else:
_auth_button.disabled = true _auth_button.disabled = true
@@ -187,7 +172,7 @@ func _on_auth_complete(result: Dictionary) -> void:
_show_status(result.detail) _show_status(result.detail)
# ── Status helpers ─────────────────────────────────────────────────────────── # ─── Status helpers ───────────────────────────────────────────────────────────
func _show_status(message: String) -> void: func _show_status(message: String) -> void:
_status_label.text = message _status_label.text = message
@@ -197,26 +182,3 @@ 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
+11 -12
View File
@@ -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 an under-18 user → 400 "Must be 18 or older". ## 3. Register with over_18=false → 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,8 +21,6 @@ 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:
@@ -40,12 +38,13 @@ func _run() -> void:
var failed: int = 0 var failed: int = 0
# --- Test 1: Register a unique user --- # --- Test 1: Register a unique user ---
var unique_id := Time.get_ticks_msec() randomize()
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, TEST_DOB_ADULT) var result := await api.register(username, email, TEST_PASSWORD, true)
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()):
@@ -56,7 +55,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, TEST_DOB_ADULT) result = await api.register(username, email, TEST_PASSWORD, true)
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",
@@ -66,14 +65,14 @@ func _run() -> void:
failed += 1 failed += 1
print(" detail: ", result.detail) print(" detail: ", result.detail)
# --- Test 3: Register under-18 user --- # --- Test 3: Register with over_18=false ---
print("\n=== Test 3: Under-18 registration ===") print("\n=== Test 3: over_18=false 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, TEST_DOB_CHILD) result = await api.register(child_username, child_email, TEST_PASSWORD, false)
if _check("under-18 register not ok", not result.ok) \ if _check("over_18=false register not ok", not result.ok) \
and _check("under-18 status is 400", result.status == 400) \ and _check("over_18=false status is 400", result.status == 400) \
and _check("under-18 detail mentions age", and _check("over_18=false detail mentions age",
"18" in result.detail): "18" in result.detail):
passed += 1 passed += 1
else: else:
+1 -2
View File
@@ -23,7 +23,6 @@ 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:
@@ -70,7 +69,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, TEST_DOB) var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
var reg_token := _extract_token(reg) var reg_token := _extract_token(reg)
var reg_ok: bool = ( var reg_ok: bool = (
reg.ok reg.ok
+2 -2
View File
@@ -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 dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit") var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton") var auth_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
dob_edit.text = "2000-01-01" over_18_check.button_pressed = true
# 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/
+75 -9
View File
@@ -1,9 +1,14 @@
extends SceneTree extends SceneTree
## Verifies the main menu's Login/Register buttons actually open the auth ## Verifies the main menu's Login/Register buttons correctly open the auth
## panel via a real signal emission (not calling the handler method ## panel and toggle between Login and Register modes via signal emission.
## directly) -- that's the exact gap that shipped without ever being ##
## caught: the AuthPanel and its logic were all correct, but nothing in ## Key tests:
## the scene was ever wired to open it. ## - AuthPanel starts hidden.
## - LoginButton press opens the panel in Login mode.
## - RegisterButton press opens the panel in Register mode.
## - Clicking Login while in Register mode switches back to Login mode.
## - Clicking Register while in Login mode switches to Register mode.
## - The ModeToggle CheckButton no longer exists in the scene.
func _initialize() -> void: func _initialize() -> void:
@@ -24,26 +29,87 @@ func _run() -> void:
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton") var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton") var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
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 email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
# --- Test 1: auth panel starts hidden ---
if _check("auth panel starts hidden", not auth_panel.visible): if _check("auth panel starts hidden", not auth_panel.visible):
passed += 1 passed += 1
else: else:
failed += 1 failed += 1
# --- Test 2: LoginButton press opens panel in Login mode ---
login_button.pressed.emit() login_button.pressed.emit()
await process_frame await process_frame
if _check("auth panel visible after a real LoginButton press signal", auth_panel.visible): if _check("auth panel visible after LoginButton press", auth_panel.visible):
passed += 1 passed += 1
else: else:
failed += 1 failed += 1
# Reset and check RegisterButton independently too. if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
auth_panel.visible = false passed += 1
else:
failed += 1
if _check("Email field hidden in Login mode", not email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 3: RegisterButton press switches to Register mode ---
register_button.pressed.emit() register_button.pressed.emit()
await process_frame await process_frame
if _check("auth panel visible after a real RegisterButton press signal", auth_panel.visible): if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
passed += 1
else:
failed += 1
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
passed += 1
else:
failed += 1
if _check("Email field visible in Register mode", email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 4: LoginButton press switches back to Login mode ---
login_button.pressed.emit()
await process_frame
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
passed += 1
else:
failed += 1
if _check("Email field hidden after switching back to Login", not email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 5: ModeToggle CheckButton no longer exists ---
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
var has_mode_toggle = instance.has_node(mode_toggle_path)
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
passed += 1 passed += 1
else: else:
failed += 1 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 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!", "2000-01-01") var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
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)