Compare commits

...

62 Commits

Author SHA1 Message Date
coder-bot e51c4d80b8 WIP checkpoint: Address review feedback: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 16:47:47 +00:00
architect-bot 00cdab0452 WIP checkpoint: Address review feedback: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 16:40:56 +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
claude-checkpoint-tooling 8fc9d378e4 Fix: world cards render at ~1px wide once real data exists
Reported directly by the user after seeding a test world through the
browse-worlds screen: "screen is blank now". Two compounding layout
bugs, both real:

1. world_list.tscn's root VBoxContainer had no anchors at all, only
   size_flags -- but its parent (the scene's root node) is a plain
   Control, not a Container, so size_flags on a Control's direct child
   are meaningless there. Without anchors it never actually filled the
   screen, so it (and everything under it) collapsed toward zero size.

2. size_flags_horizontal/vertical used the raw integer 4 everywhere
   expand-fill was clearly intended (ScrollContainer, its WorldList
   child, HeaderHBox, TitleLabel) -- but 4 is Control.SIZE_SHRINK_CENTER
   in Godot 4, not SIZE_EXPAND_FILL (which is 3). ScrollContainer with
   SHRINK_CENTER takes only its own minimum size instead of expanding,
   cascading a ~1px width down through every card and every label.
   Confirmed the actual enum values by asking Godot directly rather
   than assuming.

Both are exactly the kind of bug a headless smoke test checking only
"did cards get added to the tree" can't catch -- child_count was
correctly non-zero, nothing crashed, the content was just rendered
invisibly thin. Verified the fix is real by deliberately reintroducing
bug #2 and confirming the test now correctly fails (card width 25px,
below the 200px sanity threshold) before restoring it.

Also replaced the card background: it was a ColorRect manually moved to
child index 0 of a VBoxContainer, which doesn't work as an overlay --
a VBoxContainer lays out ALL its children in sequence, so the "moved to
front" ColorRect rendered as an empty dark bar ABOVE the text, not
behind it. Now a proper PanelContainer with a StyleBoxFlat background,
the idiomatic way to get a colored panel behind auto-laid-out content
in Godot.

smoke_test_world_list.gd strengthened to actually catch this class of
bug going forward: seeds a real world via POST /api/worlds/ first (the
checkpoint runner's fresh DB meant the populated-card path never ran in
CI at all before this, only empty-list did), and asserts each card's
real computed width, not just that cards exist.

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests, both platform exports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:35:46 +00:00
claude-checkpoint-tooling 3679e4dbc4 Fix: Login/Register unreachable from the main menu -- no button opened it
Reported directly by the user playtesting the exported exe: Play was
permanently disabled and there was no way to log in or register. Root
cause: main_menu.tscn wired Play and Quit, but nothing was ever
connected to _on_login_button_pressed()/_on_register_button_pressed() --
the AuthPanel, its form, and all its backend-calling logic were fully
correct, just completely unreachable. The script's own top comment even
said "(the existing Play/Quit buttons)" as if Play/Quit somehow doubled
as login triggers, which they never did -- a sign nobody ever actually
clicked through the running scene, only checked things at the API level.

Fix: added a real AuthButtonsRow (LoginButton, RegisterButton) wired to
the existing handlers, which now hide themselves once auth succeeds.
Also fixed grab_focus() targeting the AuthPanel (which can't accept
focus, just a background warning) to focus the username field instead.

Verified via two new smoke tests (smoke_test_main_menu_ui.gd,
smoke_test_main_menu_full_flow.gd) using a *real signal emission* on
the buttons, not a direct method call -- that distinction is exactly
what would have caught the original bug. Full click-through confirmed
against a real backend: open register form, fill it, submit, Play
actually becomes enabled, welcome message shows, auth buttons hide.

Also documents a real headless-testing gotcha found while verifying
this: a synthetic Button.pressed.emit() on an await-using handler
doesn't reliably get driven forward in --script/SceneTree mode the way
real GUI input dispatch does -- confirmed by comparing direct-call vs
emit() behavior side by side. Not a bug in the actual click-driven flow,
a test-harness quirk to route around (call the handler directly,
awaited, instead of emitting the signal).

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests (including the 2 new ones, picked
up automatically by run_checkpoint_tests.py's glob).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:13:38 +00:00
claude-checkpoint-tooling c33b10ac46 Embed the .pck into the exported binary instead of a separate file
export_presets.cfg had binary_format/embed_pck=false on both presets,
so every export produced two files (agp-game.exe + agp-game.pck) that
have to stay together and correctly named or Godot refuses to start:
"Couldn't load project data ... Is the .pck missing?" -- hit for real
running a downloaded PC1 playtest exe today (only the .exe got copied
over, not the .pck).

Verified both platforms now produce a single self-contained binary:
Windows PE32+ (104,580,904 bytes, no separate .pck) and Linux ELF64
(71,140,152 bytes, no separate .pck).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:03:19 +00:00
claude-checkpoint-tooling 8c54129bcd Update PC1 playtest instructions for the new dashboard controls
Replaces the manual multi-step shell commands (docker compose, alembic,
uvicorn with env vars, manual teardown) with the one-click dashboard
flow at /checkpoint/PC1 in ~/agp (commit 261c45f): Start Backend / Stop
& Clean Up, and Build / Download a pre-configured Windows .exe. No
terminal commands needed on either machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:51:27 +00:00
reviewer-bot 409e78baa5 Merge pull request 'Update test_inventory.md to reflect PC1 checkpoint reality' (#40) from agent/398bf1a22a2d into main 2026-08-20 01:37:26 +00:00
architect-bot 1cc81979a4 Merge architect's per-file test detail into the checkpoint doc fix
Architect independently reclaimed this task after my fix landed and
rewrote the same doc with more precise per-file test descriptions and a
more specific gap list -- genuinely better in places (caught real detail
I'd summarized loosely) but its own investigation also introduced one
factual error (claimed 18 tests in test_auth_age_gate.py; verified via
pytest --collect-only, it's 17) and dropped two things worth keeping:
the exact commit hash for reproducibility, and the Client export section
(it never looked at the export step).

Merged: kept the architect's richer per-file coverage descriptions and
gap analysis, restored the verified counts/commit hash/export section/
--quit gotcha from my original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:37:01 +00:00
agent-bot f8e5de9293 Merge pull request 'Add PC1 checkpoint record + playtest instructions' (#41) from docs/pc1-checkpoint-record into main 2026-08-20 01:17:30 +00:00
claude-checkpoint-tooling 3a7b2ab306 Add PC1 checkpoint record + playtest instructions
Validation results from the 2026-08-20 checkpoint run (fresh clone,
commit 927f9bd: migrations, 68/68 pytest, all 4 Godot smoke tests, both
platform exports) plus manual playtest instructions for backend on
cloudbison / client on pinkbison. First of docs/checkpoints/PC<N>.md,
one per Package Checkpoint going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:17:18 +00:00
claude-checkpoint-tooling afc2211d70 Update test_inventory.md to reflect PC1 checkpoint reality
Was written 2026-08-19 and badly stale by the time the full PC1
checkpoint validation ran 2026-08-20: said 38 backend tests (actually
68), said zero integration/endpoint tests exist (4 real integration
test files landed since), and said nothing had merged for Godot smoke
tests (4 real ones did).

Verified for real, fresh clone of main, commit 927f9bd: migrations +
68/68 pytest + all 4 Godot smoke tests + both platform exports (Linux
ELF64 + Windows PE32+, confirmed valid via file) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 00:47:18 +00:00
reviewer-bot 927f9bd95e Merge pull request 'End-to-end headless smoke test: register -> login -> fetch worlds' (#39) from agent/775379a9bedc into main 2026-08-20 00:26:58 +00:00
reviewer-bot 2d1f0111c3 Merge pull request 'Write auth endpoint tests (pytest)' (#38) from agent/daf820da4497 into main 2026-08-19 23:33:04 +00:00
tester-bot fcfb767c49 End-to-end headless smoke test: register -> login -> fetch worlds
Tester had already written a complete, correct test (4/4 steps pass
against a real backend) but stalled investigating DB/migration setup
after its own verification run silently exited with no result -- the
task spec's suggested invocation included --quit, which kills a --script
SceneTree test before its awaited HTTP calls finish (same Godot
--script-mode gotcha documented elsewhere this session).

Renamed smoke_test.gd -> smoke_test_e2e.gd: the task spec named it
without the checkpoint runner's expected smoke_test_*.gd suffix, so
scripts/run_checkpoint_tests.py's glob would have silently never picked
it up. Also fixed the --quit flag in the file's own usage comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:30:52 +00:00
tester-bot e2e771d2f8 WIP checkpoint: End-to-end headless smoke test + manual verify 2026-08-19 23:15:22 +00:00
reviewer-bot 8a5d3aabea Merge pull request 'Wire browse-worlds screen to GET /api/worlds' (#36) from agent/1de295a0da42 into main 2026-08-19 23:12:27 +00:00
coder-bot 3dd5416e81 Merge origin/main into agent/1de295a0da42
Resolves conflict in main_menu.gd's play button: kept this branch's real
world_list.tscn transition over main's stale TODO placeholder (main's
version predates this fix; nothing else has touched the play button
since). Brings in PR #37's /auth/refresh endpoint and its tests cleanly,
no other conflicts.
2026-08-19 23:11:05 +00:00
tester-bot c5a5476620 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:50:22 +00:00
tester-bot 06604a24a8 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:49:19 +00:00
reviewer-bot 5c21e5051c Merge pull request 'Implement /auth/refresh endpoint' (#37) from agent/82c3ebd03921 into main 2026-08-19 22:39:42 +00:00
coder-bot 3cb4d01073 Fix access token collision and cross-file username collision
- auth.py: _create_access_token had no jti claim, so two refreshes
  within the same wall-clock second produced byte-identical access
  tokens (same failure mode already fixed for refresh tokens earlier)
- test_refresh_endpoint.py: usernames (jwttoken, refreshuser, etc.)
  collided with test_auth_endpoints.py's own test usernames once run
  in the same pytest session against a shared DB with no per-test
  isolation -- exactly how run_checkpoint_tests.py invokes pytest.
  Namespaced this file's usernames with a refep_ prefix.

Full backend suite (63 tests) passes clean end-to-end against a real
Postgres with real migrations applied.
2026-08-19 22:15:55 +00:00
coder-bot 3e81d60268 WIP checkpoint: Implement /auth/refresh endpoint 2026-08-19 22:03:00 +00:00
coder-bot b3d2d71789 WIP checkpoint: Implement /auth/refresh endpoint 2026-08-19 21:58:48 +00:00
coder-bot b3227f5b78 Wire browse-worlds screen to GET /api/worlds
- main_menu.gd: Play button now navigates to world_list.tscn
- world_list.gd: fix invalid theme_override_constants property-path syntax
  (use add_theme_constant_override), fix @onready node paths missing
  /HeaderHBox/, cast Variant dict values to String to avoid type-inference
  errors when cards actually render
- world_list.tscn: wire up Back/Refresh button signal connections (scene
  had zero [connection] blocks, buttons were dead)
- backend: fix real bug where World.visibility enum sent member names
  ("PUBLIC") to Postgres instead of lowercase values, breaking both GET
  and POST /api/worlds/ for any real query
- backend: fix WorldResponse.created_at typed as str instead of datetime,
  causing a second 500 on GET once the enum bug was fixed
- add smoke_test_world_list.gd: headless scene-level smoke test that
  registers a user, instantiates the real scene, and confirms world cards
  render from live backend data (not just an API-level check)
2026-08-19 21:57:39 +00:00
coder-bot 2ba6bc6b9f WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:27:24 +00:00
reviewer-bot 7d89e860fd Merge pull request 'Implementation: Login/Register UI wired to /api/auth' (#35) from agent/1bc0bc15886d into main 2026-08-19 21:27:13 +00:00
coder-bot 03da22aa9e WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 55a65bb7b1 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 62e15d0c87 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
reviewer-bot cbe5e992dd Merge pull request 'Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions' (#33) from agent/424ce35e1b38 into main 2026-08-19 21:12:42 +00:00
reviewer-bot d4998d7359 Merge pull request 'Pin bcrypt<4.1 to fix passlib 1.7.4 compatibility' (#34) from fix/pin-bcrypt-version into main 2026-08-19 21:12:07 +00:00
coder-bot ef78caa4de Fix broken integration test fixture (dead imports, sync/async mismatch, cross-request connection reuse, username collision)
Verified end-to-end before this fix: the actual deliverable (login/register
UI + ApiClient wiring) already worked correctly -- confirmed via the real
Godot headless smoke test (scripts/smoke_test_auth.gd), 6/6 passing against
a live backend. This commit only fixes the bonus backend integration test
file the task also added, which had 4 separate real bugs:

1. Imported override_get_db/SessionLocal from app.database -- neither
   exists, ImportError on collection (backend/app/database.py only ever
   exposed get_db + the async engine/session factory).
2. Base.metadata.create_all(bind=engine) used the sync API against an
   async engine -- doesn't work, and unnecessary anyway since Alembic
   migrations already create the real tables.
3. Reusing the app's shared (pooled) async engine across FastAPI
   TestClient's per-request anyio portals corrupted asyncpg connections
   handed to a different event loop than the one that opened them
   ("cannot perform operation: another operation is in progress") the
   moment any test made more than one request. Fixed with a NullPool
   test-only engine so every request gets a genuinely fresh connection.
4. test_login_success and test_login_wrong_password both hardcoded the
   same username ("loginuser") with no per-test DB isolation/rollback --
   the second test's register call 409'd against the first test's already-
   created user instead of exercising the wrong-password path it was
   actually testing.

Also fixed 2 pre-existing collision-prone tests by re-running against a
freshly truncated DB and confirming clean. Full suite: 49/49 passing
(38 pre-existing + 11 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:11:00 +00:00
claude-checkpoint-tooling 87575eec43 Pin bcrypt<4.1 to fix passlib 1.7.4 compatibility
passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed. Every
hash_password/verify_password call was raising 'password cannot be longer
than 72 bytes' -- a misleading downstream symptom of the real
AttributeError: module 'bcrypt' has no attribute '__about__'.

Without this pin, pip install -r requirements.txt silently grabs whatever
the latest bcrypt is and breaks register/login outright -- confirmed this
broke 3 separate pieces of work this session (manual register curl tests,
and now a real Godot smoke test) purely from environment drift, not from
any actual code bug. Verified the fix works from a genuinely clean venv +
pip install, not just patching the already-installed shared venv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:06:41 +00:00
coder-bot 6d5051bbb2 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 20:23:02 +00:00
coder-bot 72f39bc547 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:58:20 +00:00
coder-bot 69b6f40eb9 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot 4d467c0c9e WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot e43bc9ff10 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot 5c91bb3f34 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
architect-bot ca7924d5e1 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
architect-bot 758307bbee WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 478638d4ff WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 7b074a01ed WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot c68877239f WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot ed6811a438 WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
reviewer-bot f64ffd6422 Merge pull request 'Write headless Godot export CI script' (#32) from agent/a7d462fe1322 into main 2026-08-19 17:16:46 +00:00
reviewer-bot 7e28e2b525 Merge pull request 'Add test inventory + checkpoint test runner' (#31) from docs/test-inventory-and-runner into main 2026-08-19 17:01:42 +00:00
coder-bot 99be8f1dab Write headless Godot export CI script
Adds export_presets.cfg (Linux/X11 + Windows Desktop, x86_64) -- didn't
exist at all before this, which is why the task got stuck: you can't run
godot --export-release without presets already defined, and Godot only
auto-generates this file interactively through the editor. Hand-wrote a
minimal valid one and verified both platforms actually export and produce
real binaries (not just that the command exits 0):
- export/linux/agp-game.x86_64 -- confirmed real ELF 64-bit executable via `file`
- export/windows/agp-game.exe -- 104MB PE binary

scripts/build.sh wraps both exports, documents the exact Godot version +
export template requirement, and was run end-to-end (not just written) to
confirm it works as a real CI entrypoint would invoke it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:54:50 +00:00
reviewer-bot b3e66fc072 Merge pull request 'Architecture: ApiClient autoload (HTTP + JWT + error contract)' (#29) from agent/5b3da417b50c into main 2026-08-19 15:45:01 +00:00
claude-checkpoint-tooling 634b2e39c0 Add test inventory + checkpoint test runner
docs/testing/test_inventory.md: honest, current list of what automated
coverage actually exists (38 backend pytest tests across 2 files, 0 Godot
tests merged yet) and what doesn't (no integration/endpoint tests at all,
no coverage yet for the in-review RS256/moderation work) -- verified
against a genuinely fresh checkout + fresh Postgres, not just read off the
file tree.

scripts/run_checkpoint_tests.py: runs the whole inventory in one shot --
throwaway Postgres, migrations, pytest, then starts the backend server and
runs every scripts/smoke_test_*.gd against it headlessly. Verified working
both with and without a smoke test present (temporarily tested against
PC1's not-yet-merged scripts/api_client.gd + smoke_test_api.gd, removed
before this commit -- that PR carries its own files).

docs/testing/README.md: what running this at a Package Checkpoint review
does and doesn't prove, and how to grow the inventory as real coverage
gets added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:18:49 +00:00
reviewer-bot 1c75367c87 Merge pull request 'Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring' (#28) from agent/76aa20cd2e45 into main 2026-08-19 15:11:39 +00:00
architect-bot ef21bf9d69 Fix smoke test: resolve ApiClient via get_node, defer one frame before first request
Two Godot --script-mode-only quirks, neither a bug in ApiClient itself
(verified working against a real backend, health_check returns 200):
1. Bare autoload identifiers don't resolve at compile time in --script/
   SceneTree runs (the node exists at runtime, root.has_node() is true,
   but referencing it as a plain global name fails to compile) -- use
   get_node() instead.
2. A freshly add_child()'d HTTPRequest isn't reliably "inside tree" yet
   at _initialize() time, since --script mode hasn't processed a frame
   yet -- one process_frame await fixes it.

Verified: full smoke test now passes end-to-end against a live backend
(docker compose postgres + alembic migrations + uvicorn), confirming
ApiClient.health_check() genuinely round-trips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:40:51 +00:00
architect-bot e06cd2a23d WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:31:35 +00:00
architect-bot 77f9b74286 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:12:56 +00:00
planner-bot 6238078565 WIP checkpoint: Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring 2026-08-19 14:12:45 +00:00
architect-bot 6108c63c46 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 109c6ef1a0 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 33c5236a2d WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 4bb4e9fb8a WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
36 changed files with 3240 additions and 34 deletions
+3
View File
@@ -8,3 +8,6 @@ export/
# OS
.DS_Store
Thumbs.db
# Godot UID files (regenerated from path)
*.uid
+7 -4
View File
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
SECRET_KEY=change-me-to-a-secure-random-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# JWT / Auth
ALGORITHM=RS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# Directory where the RSA keypair is loaded/generated at runtime.
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
RSA_KEY_DIR=~/.agp/keys
# App
APP_NAME=AGP Backend
@@ -0,0 +1,36 @@
"""add refresh_tokens table
Revision ID: 0003_add_refresh_tokens_table
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_add_refresh_tokens_table"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the refresh_tokens table."""
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the refresh_tokens table."""
op.drop_table("refresh_tokens")
+79 -5
View File
@@ -1,10 +1,56 @@
"""Application configuration via pydantic-settings."""
from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git.
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.PKCS8,
NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
@@ -18,11 +64,13 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "RS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# RS256 key storage. Defaults to a directory outside the repo so the
# generated keypair is never accidentally committed.
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
@@ -42,6 +90,32 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> bytes:
"""Return the private key PEM bytes (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return private_pem
@property
def public_pem(self) -> bytes:
"""Return the public key PEM bytes (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return public_pem
@property
def private_key(self) -> RSAPrivateKey:
"""Return the deserialized RSA private key object."""
return load_pem_private_key(self.private_pem, password=None)
@property
def public_key(self) -> RSAPublicKey:
"""Return the deserialized RSA public key object."""
return load_pem_public_key(self.public_pem)
@lru_cache()
def get_settings() -> Settings:
+1 -1
View File
@@ -27,7 +27,7 @@ async def get_current_user(
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
+21
View File
@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -18,3 +19,23 @@ class Session(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
token = Column(String(512), unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
user = relationship("User", back_populates="refresh_tokens")
+1
View File
@@ -21,6 +21,7 @@ class User(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
+6 -1
View File
@@ -21,7 +21,12 @@ class World(Base):
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
visibility = Column(
ENUM(WorldVisibility, name="world_visibility", create_type=False),
ENUM(
WorldVisibility,
name="world_visibility",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
default=WorldVisibility.PUBLIC,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
+149 -11
View File
@@ -1,20 +1,21 @@
"""Authentication router - login, register, token refresh."""
import uuid
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -25,7 +26,13 @@ router = APIRouter()
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
access_token_expires_minutes: int
class RefreshRequest(BaseModel):
refresh_token: str
class RegisterRequest(BaseModel):
@@ -59,12 +66,43 @@ class UserResponse(BaseModel):
# --- Helpers ---
def _create_access_token(data: dict) -> str:
"""Create a signed access token using RS256.
Includes a unique ``jti`` claim so that concurrent/same-second calls
(e.g. back-to-back /refresh requests) still produce distinct tokens —
without it, two tokens minted for the same user within the same
second are byte-identical since ``sub``/``exp`` are the only claims.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _create_refresh_token(data: dict) -> str:
"""Create a signed refresh token using RS256.
Includes a unique ``jti`` claim so that concurrent calls produce
distinct tokens even when issued within the same second.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -97,11 +135,25 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
return age
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
"""Create and persist a refresh token for the given user."""
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user_id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc)
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
return refresh_token_value
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token.
"""Register a new user and return access + refresh tokens.
Requires date_of_birth and rejects users under 18.
"""
@@ -138,16 +190,26 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
date_of_birth=dob,
)
db.add(user)
await db.commit()
await db.flush() # flush to get user.id before creating refresh token
await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token.
"""Authenticate user and return access + refresh tokens.
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
@@ -167,8 +229,84 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
detail="User account is inactive",
)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""Exchange a valid refresh token for a new access token.
Validates the refresh token against the database:
- Must exist in the refresh_tokens table
- Must not be expired (expires_at > now)
- Must be active (is_active == true)
On success, returns a new access token. The refresh token itself is
NOT rotated (the client keeps using the same refresh token).
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
# Step 1: Decode and validate the JWT signature/claims
try:
payload = jwt.decode(
req.refresh_token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
except JWTError:
raise credentials_exception
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
raise credentials_exception
# Step 2: Look up the refresh token in the database
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token == req.refresh_token,
RefreshToken.user_id == user_id,
)
)
stored_token = result.scalar_one_or_none()
if stored_token is None:
raise credentials_exception
# Step 3: Check if the token is expired
if datetime.now(timezone.utc) >= stored_token.expires_at:
raise credentials_exception
# Step 4: Check if the token is active
if not stored_token.is_active:
raise credentials_exception
# Step 5: Issue a new access token
access_token = _create_access_token({"sub": str(user_id)})
return TokenResponse(
access_token=access_token,
refresh_token=req.refresh_token, # Return the same refresh token
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.get("/me", response_model=UserResponse)
+2 -1
View File
@@ -1,5 +1,6 @@
"""Worlds router - CRUD for game worlds."""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -40,7 +41,7 @@ class WorldResponse(BaseModel):
description: str | None
visibility: WorldVisibility
owner_id: int
created_at: str | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
+8
View File
@@ -9,6 +9,14 @@ pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
# hashing/verifying a password then raises "password cannot be longer than
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
# module 'bcrypt' has no attribute '__about__') on every call. Without this
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
bcrypt==4.0.1
python-multipart==0.0.20
email-validator==2.2.0
httpx==0.28.1
+38
View File
@@ -0,0 +1,38 @@
"""Shared pytest fixtures.
The auth integration tests hit the *real* Postgres database (per
``DATABASE_URL``), so they persist data between runs. Without cleanup a
second run of the suite fails with 409 conflicts because the users it
registered on the first run already exist. The session-scoped fixture below
truncates all application tables once before the first test, making the suite
re-runnable.
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
keys, but the tables are listed explicitly for clarity.
"""
import asyncio
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
@pytest.fixture(scope="session", autouse=True)
def _reset_database():
"""Truncate all application tables once per test session."""
async def _truncate() -> None:
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.execute(
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
)
await engine.dispose()
asyncio.run(_truncate())
yield
+263
View File
@@ -0,0 +1,263 @@
"""Integration tests for the auth endpoints (register + login).
These tests hit the real FastAPI app against the real configured database
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
JWT → response. Requires migrations already applied (alembic upgrade head)
before running.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
from app.routers.auth import pwd_context
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test.
Deliberately does NOT reuse app.database's shared engine: FastAPI's
(sync) TestClient runs each request through anyio's blocking-portal
machinery, which can hand different requests different event loops.
asyncpg connections are bound to the loop they were created on, so
reusing one from the shared engine's connection pool across requests
raises "cannot perform operation: another operation is in progress" the
moment a test makes more than one request (register-then-login,
register-twice, etc.) -- confirmed this is the actual cause, not a bug
in the endpoints themselves. NullPool sidesteps it: every checkout is a
brand-new connection, never handed to a different loop than the one
that opened it.
"""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Register endpoint
# ---------------------------------------------------------------------------
class TestRegister:
"""Tests for POST /api/auth/register."""
def test_register_success(self):
"""A valid registration returns 201 with a token."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_register_rejects_underage(self):
"""Users under 18 get a 400 with a detail message."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"date_of_birth": "2010-06-15", # ~15 years old
},
)
assert resp.status_code == 400
assert "18 or older" in resp.json()["detail"]
def test_register_rejects_duplicate_username(self):
"""Registering the same username twice returns 409."""
client = TestClient(app)
# First registration
client.post(
"/api/auth/register",
json={
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
# Second registration with same username, different email
resp = client.post(
"/api/auth/register",
json={
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 409
assert "Username already registered" in resp.json()["detail"]
def test_register_rejects_duplicate_email(self):
"""Registering the same email twice returns 409."""
client = TestClient(app)
client.post(
"/api/auth/register",
json={
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
resp = client.post(
"/api/auth/register",
json={
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_invalid_date_format(self):
"""Invalid date format returns 422 validation error."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "user",
"email": "user@example.com",
"password": "secret123",
"date_of_birth": "01-01-2000", # wrong format
},
)
assert resp.status_code == 422
def test_register_exactly_18(self):
"""A user who just turned 18 can register."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "just18",
"email": "just18@example.com",
"password": "secret123",
"date_of_birth": "2007-06-15",
},
)
assert resp.status_code == 201
# ---------------------------------------------------------------------------
# Login endpoint
# ---------------------------------------------------------------------------
class TestLogin:
"""Tests for POST /api/auth/login."""
def _create_user(self, username: str, password: str, email: str = None):
"""Helper to create a user via register."""
client = TestClient(app)
if email is None:
email = f"{username}@example.com"
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": email,
"password": password,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
return resp.json()["access_token"]
def test_login_success(self):
"""Correct credentials return a token."""
self._create_user("loginuser", "correctpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "loginuser", "password": "correctpass"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_wrong_password(self):
"""Wrong password returns 401."""
# Distinct username from test_login_success -- there's no per-test
# DB rollback/isolation here (real Postgres via the app's own
# dependency-overridden session), so reusing "loginuser" collided
# with the user that test already created (409 on register instead
# of the intended 401-on-login assertion this test is actually for).
self._create_user("loginuser_wrongpass", "correctpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
)
assert resp.status_code == 401
assert "Incorrect username or password" in resp.json()["detail"]
def test_login_nonexistent_user(self):
"""Non-existent user returns 401 (not 404)."""
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "nobody", "password": "any"},
)
assert resp.status_code == 401
assert "Incorrect username or password" in resp.json()["detail"]
def test_login_accepts_form_encoded(self):
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
self._create_user("formuser", "formpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "formuser", "password": "formpass"},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
def test_login_token_is_valid_jwt(self):
"""The returned token is a valid JWT that can be decoded."""
self._create_user("jwttoken", "jwt123")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "jwttoken", "password": "jwt123"},
)
token = resp.json()["access_token"]
# Verify it's a 3-part JWT
parts = token.split(".")
assert len(parts) == 3
+171
View File
@@ -0,0 +1,171 @@
"""Tests for expired-token rejection across auth endpoints.
Covers two distinct expiry paths:
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
signature is still valid.
Requires migrations already applied (alembic upgrade head) before running.
"""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from jose import jwt
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
from app.models.session import RefreshToken
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test."""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mint_access_token(exp: datetime) -> str:
"""Mint an RS256 access token with an explicit ``exp`` claim."""
return jwt.encode(
{"sub": "1", "exp": exp},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _expire_refresh_token_in_db(token: str) -> None:
"""Set the persisted refresh token's ``expires_at`` into the past."""
async def _do():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
from sqlalchemy import update
await session.execute(
update(RefreshToken)
.where(RefreshToken.token == token)
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
)
await session.commit()
await engine.dispose()
asyncio.run(_do())
def _register_user(client: TestClient, username: str) -> str:
"""Register a user and return its refresh token."""
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
return resp.json()["refresh_token"]
# ---------------------------------------------------------------------------
# Expired access token (GET /api/auth/me)
# ---------------------------------------------------------------------------
class TestExpiredAccessToken:
def test_expired_access_token_is_rejected_by_me(self):
"""An access JWT with a past exp claim returns 401 on /me."""
client = TestClient(app)
expired_token = _mint_access_token(
datetime.now(timezone.utc) - timedelta(minutes=5)
)
resp = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {expired_token}"},
)
assert resp.status_code == 401
assert "Could not validate credentials" in resp.json()["detail"]
def test_garbage_access_token_is_rejected_by_me(self):
"""A non-JWT bearer token returns 401 on /me."""
client = TestClient(app)
resp = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert resp.status_code == 401
def test_missing_bearer_token_returns_401(self):
"""No Authorization header at all returns 401 on /me."""
client = TestClient(app)
resp = client.get("/api/auth/me")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Expired refresh token (POST /api/auth/refresh)
# ---------------------------------------------------------------------------
class TestExpiredRefreshToken:
def test_expired_refresh_token_is_rejected(self):
"""A refresh token whose DB expires_at is in the past returns 401."""
client = TestClient(app)
refresh_token = _register_user(client, "expired_rt_user")
# Force the persisted record to be expired while its JWT signature
# is still valid.
_expire_refresh_token_in_db(refresh_token)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
def test_expired_refresh_token_jwt_is_rejected(self):
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
client = TestClient(app)
expired_jwt = jwt.encode(
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": expired_jwt},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the POST /api/auth/refresh endpoint.
These tests exercise the full stack: register → get refresh token →
refresh → new access token. Requires migrations already applied
(alembic upgrade head) before running.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test."""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Refresh endpoint tests
# ---------------------------------------------------------------------------
class TestRefresh:
"""Tests for POST /api/auth/refresh."""
def _register_user(self, username: str, password: str, email: str = None):
"""Helper to register a user and return the refresh token."""
client = TestClient(app)
if email is None:
email = f"{username}@example.com"
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": email,
"password": password,
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
return resp.json()["refresh_token"]
def test_refresh_success(self):
"""A valid refresh token returns a new access token."""
refresh_token = self._register_user("refep_refreshuser", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# The refresh token in the response is the same one we sent
assert data["refresh_token"] == refresh_token
def test_refresh_returns_different_access_token(self):
"""Refreshing produces a new access token (different from any previous)."""
refresh_token = self._register_user("refep_refreshtoken", "secret123")
client = TestClient(app)
# First refresh
resp1 = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp1.status_code == 200
access_token_1 = resp1.json()["access_token"]
# Second refresh
resp2 = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp2.status_code == 200
access_token_2 = resp2.json()["access_token"]
# Each refresh should produce a new access token
assert access_token_1 != access_token_2
def test_refresh_invalid_token(self):
"""An invalid (garbage) refresh token returns 401."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": "not.a.jwt.token"},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
def test_refresh_empty_body(self):
"""Missing refresh_token field returns 422 validation error."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={},
)
assert resp.status_code == 422
def test_refresh_missing_field(self):
"""No body at all returns 422."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
content="{}",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 422
def test_refresh_with_login_token(self):
"""A refresh token obtained from login also works for /refresh."""
client = TestClient(app)
# Register first
client.post(
"/api/auth/register",
json={
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
# Login to get a refresh token
login_resp = client.post(
"/api/auth/login",
data={"username": "refep_loginrefuser", "password": "secret123"},
)
assert login_resp.status_code == 200
refresh_token = login_resp.json()["refresh_token"]
# Use it to refresh
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
def test_refresh_access_token_is_valid_jwt(self):
"""The access token returned by /refresh is a valid JWT."""
from jose import jwt
refresh_token = self._register_user("refep_jwttoken", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
access_token = resp.json()["access_token"]
# Verify it's a 3-part JWT
parts = access_token.split(".")
assert len(parts) == 3
# Verify it decodes with the public key
payload = jwt.decode(
access_token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "sub" in payload
assert "exp" in payload
def test_refresh_expires_minutes_in_response(self):
"""The response includes access_token_expires_minutes."""
refresh_token = self._register_user("refep_expiretest", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token_expires_minutes" in resp.json()
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
+79
View File
@@ -0,0 +1,79 @@
"""Tests for refresh token uniqueness (jti claim) and model column length."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from jose import jwt
from app.config import settings
from app.routers.auth import _create_refresh_token
from app.models.session import RefreshToken
class TestRefreshTokenJtiUniqueness:
"""Verify that _create_refresh_token produces unique tokens even when
called with identical data in rapid succession (issue #1 from review)."""
def test_two_calls_produce_different_tokens(self):
"""Two calls with the same sub produce different JWTs because of jti."""
token_a = _create_refresh_token({"sub": "42"})
token_b = _create_refresh_token({"sub": "42"})
assert token_a != token_b
def test_jti_is_valid_uuid(self):
"""The jti claim inside the refresh token is a valid UUID."""
token = _create_refresh_token({"sub": "1"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "jti" in payload
# Verify it parses as a UUID (raises ValueError if not)
uuid.UUID(payload["jti"])
def test_each_token_has_unique_jti(self):
"""Generate 100 tokens for the same user — all jtis must differ."""
jtis = set()
for _ in range(100):
token = _create_refresh_token({"sub": "999"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
jtis.add(payload["jti"])
assert len(jtis) == 100
def test_jti_survives_decode(self):
"""The jti claim is present and correct after decode."""
token = _create_refresh_token({"sub": "7"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert payload["sub"] == "7"
assert "jti" in payload
assert "exp" in payload
class TestRefreshTokenModelColumnLength:
"""Verify the model's token column length matches the migration (issue #2)."""
def test_token_column_has_length_512(self):
"""RefreshToken.token column should be String(512), not unbounded."""
from sqlalchemy import inspect
# Get the column definition from the model's table
column = RefreshToken.__table__.columns["token"]
# The type should be String with length 512
assert column.type.length == 512
def test_token_column_type_is_string(self):
"""The token column should be a String type."""
from sqlalchemy import String as SAString
column = RefreshToken.__table__.columns["token"]
assert isinstance(column.type, SAString)
+72
View File
@@ -0,0 +1,72 @@
# Package Checkpoint 1 (PC1)
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
clone (not a reused agent workspace).
## Automated validation
Run with `scripts/run_checkpoint_tests.py` — see
`docs/testing/test_inventory.md` for exactly what each suite covers.
| Check | Result |
|---|---|
| Alembic migrations, fresh DB | PASS |
| Backend pytest | 68/68 PASS |
| Godot smoke tests (4 files) | ALL PASS |
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
## Problems found during this checkpoint (fixed, tracked as tasks)
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
done — a historical data gap (a review-feedback follow-up task's
supersession link was never recorded, predating when that field started
being tracked). Backfilled in the PM database; confirmed green.
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
actually 68; said no integration tests existed, 4 real ones had landed).
Fixed via task `398bf1a22a2d` / PR #40.
## What's actually testable right now
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
the real scope of PC1; don't expect more when playtesting.
- Register a new account (18+ only, rejected otherwise)
- Log out, log back in with the same credentials
- Play → Browse Worlds — loads a (currently empty) world list with no errors
## Manual playtest: backend on cloudbison, client on pinkbison
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
env vars, no editing files. Foolproof enough to hand to someone else.
### 1. On cloudbison — the AGP dashboard
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
the API server, all in one step. The page shows the LAN address it's
reachable at once it's up (under a minute).
When you're done playtesting, click **Stop & Clean Up** on the same page —
it tears down everything that step started (API server, Postgres
container, its volume and network). Nothing is left running or lying
around afterward, even if the dashboard itself gets restarted in between.
### 2. On pinkbison — download and run the .exe
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
pick up newer changes) — this pulls the latest `main` and exports a Windows
binary with cloudbison's backend address already baked in. Click
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
environment variable, no config file, no build step on pinkbison at all.
### 3. Tear down when done
**Stop & Clean Up** on the dashboard page. That's it.
## Status
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
restricts agent claiming to the 5 PC1-scope features. Agents will not move
on to post-PC1 work until that's widened.
+8 -4
View File
@@ -23,10 +23,9 @@ explicitly out of scope.
| # | Type | Task | Depends on |
|---|------|------|-----------|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
## Design decisions captured (resolved in task 1)
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
@@ -35,3 +34,8 @@ explicitly out of scope.
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
- Login is form-encoded; register is JSON — the client helper must support both content types.
## Notes
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
- The world-list screen also needs to be **built** (a new scene), not just wired.
+50
View File
@@ -0,0 +1,50 @@
# Testing at Package Checkpoints
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
real milestones, the client and server builds get packaged together and
independently reviewed before anyone calls anything actually done. See
`project_agp` in Claude's memory, or ask, for the full definition; this
doc is just the mechanical "how do I run the tests" part of that review.
## Running the suite
```
python scripts/run_checkpoint_tests.py
```
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
backend pytest suite, starts the backend server, and runs every Godot
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
Postgres container down afterward (`--keep-postgres` to skip that if you
want to poke at the DB after a failure).
If port 5432 is already taken on the machine, pass `--postgres-port` with
something else.
Exit code is 0 only if everything that actually ran passed — suites with
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
reported SKIPPED, not treated as a failure.
## What this does and doesn't prove
Passing this script means: migrations apply cleanly to a genuinely empty
database, the backend's unit-level logic is correct, and whatever Godot
smoke tests exist actually round-trip against a live backend. It does
**not** mean full endpoint/integration coverage exists — see
`test_inventory.md` for the current, honest list of what's tested and what
isn't. A checkpoint review should still involve reading the actual diffs
for anything this script's current coverage doesn't reach, not just running
green and calling it done.
## Growing this over time
As real integration tests get added (endpoint tests hitting the FastAPI app,
more Godot smoke coverage beyond `health_check`), they should land under
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
picks up new files automatically, nothing to register by hand. Update
`test_inventory.md`'s tables when you do, so the inventory stays a source
of truth instead of quietly going stale.
Eventual goal (not implemented yet): gate merges to `main` on this script
passing, the same way a real CI pipeline would. For now it's a manual step
run at checkpoint review time.
+104
View File
@@ -0,0 +1,104 @@
# Test Inventory
What automated coverage actually exists, what it actually verifies, and what
doesn't have coverage yet. Keep this current as tests are added — it's the
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
migrations + pytest + all 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)
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
reachable Postgres — see the runner script).
**Current total: 68 tests, all passing on a clean `main` checkout.**
| 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_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_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
that hit the FastAPI app against a real Postgres database via `TestClient`.
The integration tests exercise the full stack: Pydantic validation → DB →
JWT → response. The suite is also re-runnable without manual cleanup —
`backend/tests/conftest.py` has a session-scoped autouse fixture that
truncates all app tables once before the suite runs.
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
these yet):
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
world/asset CRUD routes — only auth endpoints have integration coverage
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
- No tests for the moderation schema/migration beyond "the migration runs."
- No tests for the `GET /api/auth/me` endpoint's response body (only its
auth-header validation is covered via expired-token tests).
- No tests for session management (create/validate/revoke sessions).
## Godot client — `scripts/smoke_test_*.gd`
**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.
| 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), 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. |
Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
their awaited HTTP calls finish.
**Known gaps:**
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
- No smoke tests for dynamic/static object nodes.
- All smoke tests require a live backend; no offline/unit smoke tests exist.
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
ones that exercise a real `.tscn` scene instantiation — others only test
the `ApiClient` autoload directly.
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
has no smoke coverage yet.
- The Login path (as opposed to Register) through `main_menu.gd` isn't
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
`_on_auth_complete()` code path already exercised for Register, but that's
inference, not a direct check.
## Client export
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
exports complete clean from a fresh checkout and produce valid binaries
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
export is exercised by an automated GUI test (headless Godot can't drive a
window) — actually launching and clicking through the exported binary is a
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
## What "full coverage" should eventually mean here
Not defined precisely yet — the honest target as of 2026-08-20 is
"integration tests for every endpoint + smoke tests for every major scene."
As real integration/endpoint tests get added, update the tables above
rather than letting this doc drift stale; a wrong inventory is worse than
no inventory.
+74
View File
@@ -0,0 +1,74 @@
[preset.0]
name="Linux"
platform="Linux/X11"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
codesign/enable=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
d3d12/agility_sdk_multizip=0
+3
View File
@@ -9,9 +9,12 @@ config_version=5
config/name="AGP-Game"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2")
agp/api_base_url="http://127.0.0.1:8000"
[autoload]
ApiClient="*res://scripts/api_client.gd"
[display]
window/size/viewport_width=1920
+89 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
@@ -29,6 +29,88 @@ text = "AGP Game"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 72
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
theme_override_constants/separation = 16
alignment = 1
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
layout_mode = 1
text = "Login"
size_flags_horizontal = 3
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
layout_mode = 1
text = "Register"
size_flags_horizontal = 3
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
visible = false
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/VBoxContainer/AuthPanel"]
layout_mode = 1
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="AuthVBox" type="VBoxContainer" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer"]
layout_mode = 1
theme_override_constants/separation = 8
size_flags_horizontal = 3
[node name="AuthModeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Login"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 24
[node name="UsernameEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
placeholder_text = "Username"
size_flags_horizontal = 3
[node name="PasswordEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
placeholder_text = "Password"
size_flags_horizontal = 3
echo_password = true
[node name="EmailEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
placeholder_text = "Email"
size_flags_horizontal = 3
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
placeholder_text = "Date of Birth (YYYY-MM-DD)"
size_flags_horizontal = 3
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Register"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Login"
size_flags_horizontal = 3
[node name="AuthStatusLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = ""
horizontal_alignment = 1
autowrap_mode = 2
visible = false
[node name="WelcomeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
text = ""
horizontal_alignment = 1
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Play"
@@ -39,5 +121,11 @@ layout_mode = 1
text = "Quit"
size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
+78
View File
@@ -0,0 +1,78 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
[node name="WorldList" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_wl")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 8
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "< Back"
size_flags_horizontal = 0
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "Browse Worlds"
horizontal_alignment = 1
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "Refresh"
size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 3
size_flags_vertical = 3
horizontal_mode = 2
vertical_mode = 2
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
layout_mode = 3
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = "Loading worlds..."
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = ""
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = ""
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
+292
View File
@@ -0,0 +1,292 @@
extends Node
## Central HTTP client singleton (autoload name: "ApiClient").
##
## Every network call in the game flows through this class. Callers use the
## high-level helpers ([method login], [method register], [method get_worlds],
## [method health_check], [method get_me]) or the generic [method request]
## primitive. All results conform to the result-dictionary contract below.
##
## [b]Result Dictionary Contract[/b]
## Every public method returns (after `await`) a Dictionary with this shape:
##
## {
## "ok": bool, # true for HTTP 2xx, false otherwise
## "status": int, # HTTP status code (0 on network failure)
## "data": Variant, # parsed JSON body on success (null otherwise)
## "detail": String, # human-readable message
## }
##
## Network failures (timeout / DNS / refused connection) return `status == 0`
## with a `detail` describing the transport failure. Non-2xx responses return
## the real HTTP status and the backend's `detail` field from the JSON error
## body when present. No caller can crash on a failed request.
##
## [b]Token storage[/b]
## JWTs are kept in memory. [method set_token] is the single seam for future
## disk persistence (override it to save to `user://`).
##
## [b]Usage[/b]
## var result: Dictionary = await ApiClient.login("alice", "secret")
## if result.ok:
## print("Logged in!")
## else:
## print("Error: ", result.detail)
# ---------------------------------------------------------------------------
# Token storage
# ---------------------------------------------------------------------------
var _access_token: String = ""
var _token_type: String = "Bearer"
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
var bearer_token: String:
get:
return _token_type + " " + _access_token if _access_token != "" else ""
## Whether an access token is currently stored.
func has_token() -> bool:
return _access_token != ""
## Set the access token (called after login / register / token refresh).
## This is the persistence seam: override to also write to disk.
func set_token(access_token: String, token_type: String = "Bearer") -> void:
_access_token = access_token
_token_type = token_type
## Clear stored tokens.
func clear_token() -> void:
_access_token = ""
_token_type = "Bearer"
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
var _base_url: String = "http://127.0.0.1:8000"
## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0
func _ready() -> void:
# Environment variable override (e.g. for headless smoke tests against a
# backend on a non-default port). Falls back to the ProjectSetting, then
# to the documented default.
var env_url := OS.get_environment("AGP_API_BASE_URL")
if env_url != "":
_base_url = env_url.rstrip("/")
else:
_base_url = str(ProjectSettings.get_setting(
"application/agp/api_base_url",
"http://127.0.0.1:8000"
)).rstrip("/")
# ---------------------------------------------------------------------------
# Core request primitive
# ---------------------------------------------------------------------------
## Generic request primitive. Awaitable via `await`.
##
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
## cross-talk, and so the result maps 1:1 to the awaiting caller.
##
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
## @param path URL path, e.g. "/api/health".
## @param body Request body string (JSON or form-encoded).
## @param content_type "application/json" or "application/x-www-form-urlencoded".
## @param require_auth If true, attach the stored bearer token when present.
## @return Result dictionary conforming to the contract.
func request(
method: HTTPClient.Method,
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = timeout_seconds
http.max_redirects = 8
http.accept_gzip = true
add_child(http)
var headers := PackedStringArray()
headers.append("Accept: application/json")
if content_type != "":
headers.append("Content-Type: " + content_type)
if require_auth and has_token():
headers.append("Authorization: " + bearer_token)
var url := _base_url + path
var err := http.request(url, headers, method, body)
if err != OK:
http.queue_free()
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
# request_completed emits (result, response_code, headers, body).
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var response_body: PackedByteArray = args[3]
http.queue_free()
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
if result_code != HTTPRequest.RESULT_SUCCESS:
return _make_result(false, 0, null, _transport_error_detail(result_code))
# Server accepted the connection but returned no HTTP status line.
if response_code == 0:
return _make_result(false, 0, null, "No response received from server.")
var json_string := response_body.get_string_from_utf8()
var ok := response_code >= 200 and response_code < 300
if ok:
var data: Variant = _parse_json(json_string)
return _make_result(true, response_code, data, "OK")
# Non-2xx: surface the backend's "detail" message when available.
var detail := "HTTP error %d" % response_code
var parsed: Variant = _parse_json(json_string)
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
detail = str((parsed as Dictionary)["detail"])
return _make_result(false, response_code, null, detail)
# ---------------------------------------------------------------------------
# High-level helpers
# ---------------------------------------------------------------------------
## Log in with username/password (OAuth2PasswordRequestForm).
## On success stores the returned access token.
func login(username: String, password: String) -> Dictionary:
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/login",
form,
"application/x-www-form-urlencoded",
false
)
_store_token_from_result(result)
return result
## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO date string "YYYY-MM-DD".
func register(
username: String,
email: String,
password: String,
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/register",
JSON.stringify(payload),
"application/json",
false
)
_store_token_from_result(result)
return result
## List worlds visible to the current user (requires auth).
## @param visibility Optional filter: "public", "private", "friends_only".
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
if visibility != "":
path += "&visibility=" + visibility.uri_encode()
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Ping the backend health endpoint (no auth).
func health_check() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
## Fetch the current user's profile from the stored JWT (requires auth).
func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
func _store_token_from_result(result: Dictionary) -> void:
if result.ok and result.data is Dictionary:
var token: String = str((result.data as Dictionary).get("access_token", ""))
if token != "":
set_token(token)
func _parse_json(text: String) -> Variant:
if text == "":
return null
var parsed: Variant = JSON.parse_string(text)
if parsed == null:
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
# the raw string so callers never lose the body entirely.
return text if text != "null" else null
return parsed
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
return {
"ok": ok,
"status": status,
"data": data,
"detail": detail,
}
func _transport_error_detail(result_code: int) -> String:
match result_code:
HTTPRequest.RESULT_CANT_CONNECT:
return "Could not connect to server. Is it running?"
HTTPRequest.RESULT_CANT_RESOLVE:
return "Could not resolve server hostname."
HTTPRequest.RESULT_CONNECTION_ERROR:
return "Connection error."
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS handshake failed."
HTTPRequest.RESULT_NO_RESPONSE:
return "No response from server."
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "Response body too large."
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
return "Failed to decompress response body."
HTTPRequest.RESULT_REQUEST_FAILED:
return "Request failed."
HTTPRequest.RESULT_TIMEOUT:
return "Request timed out."
_:
return "Network error (code %d)." % result_code
func _error_string(err_code: int) -> String:
match err_code:
OK:
return "OK"
ERR_UNCONFIGURED:
return "HTTPRequest not configured (not in tree)."
ERR_BUSY:
return "HTTPRequest is busy with a previous request."
ERR_INVALID_PARAMETER:
return "Invalid URL format."
ERR_CANT_CONNECT:
return "Cannot connect to host."
_:
return "Unknown error (%d)." % err_code
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Headless CI export for Linux and Windows.
#
# Requires:
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
# built against; a different Godot minor version's export templates are
# not guaranteed compatible)
# - The matching export templates installed at
# ~/.local/share/godot/export_templates/4.6.2.stable/
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
# "Export templates" -- NOT the same as the editor download)
# - godot on PATH (or set GODOT_BIN below)
#
# Usage: scripts/build.sh
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
set -euo pipefail
cd "$(dirname "$0")/.."
GODOT_BIN="${GODOT_BIN:-godot}"
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
if [ -x "$HOME/godot/godot" ]; then
GODOT_BIN="$HOME/godot/godot"
else
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
exit 1
fi
fi
echo "Godot version: $("$GODOT_BIN" --version)"
mkdir -p export/linux export/windows
echo "=== Exporting Linux/X11 ==="
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
echo "=== Exporting Windows Desktop ==="
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
echo ""
echo "Build artifacts:"
ls -la export/linux export/windows
+266 -6
View File
@@ -1,14 +1,274 @@
extends Control
## Main menu with login/register UI wired to the backend auth API.
##
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
## The ModeToggle switches between Login and Register modes, showing/hiding
## the email and DOB fields accordingly.
##
## 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.
func _ready():
pass
## 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 ────────────────────────────────────────────────────────────
var _auth_buttons_row: HBoxContainer
var _login_button: Button
var _register_button: Button
var _auth_panel: PanelContainer
var _auth_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _dob_edit: LineEdit
var _mode_toggle: CheckButton
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
var _play_button: Button
var _quit_button: Button
var _is_register_mode: bool = false
var _is_authenticating: bool = false
func _on_play_button_pressed():
# No gameplay scene exists yet — wire this to the real scene once one lands.
print("Play pressed — no gameplay scene yet")
# ─── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void:
# Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
_play_button = $CenterContainer/VBoxContainer/PlayButton
_quit_button = $CenterContainer/VBoxContainer/QuitButton
# Default to Play being gated. When a token exists, the async session
# check below may take several frames (a real get_me() round-trip);
# keeping the button disabled until validation succeeds prevents a fast
# click from navigating to world_list with an expired/invalid token.
_play_button.disabled = true
# Check whether a valid session already exists (e.g. user logged in,
# navigated to world_list, then clicked Back). If so, restore the
# authenticated UI state instead of forcing the login form again.
if ApiClient.has_token():
var me_result: Dictionary = await ApiClient.get_me()
if me_result.ok and me_result.data is Dictionary:
var username: String = str((me_result.data as Dictionary).get("username", ""))
if username != "":
_show_authenticated(username)
auth_state_ready.emit()
return
# Token missing or invalid — fall through to logged-out state.
_reset_to_logged_out()
auth_state_ready.emit()
func _on_quit_button_pressed():
## Restore the UI to the authenticated state (Play enabled, welcome label,
## auth buttons hidden).
func _show_authenticated(username: String) -> void:
_auth_panel.visible = true
_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
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_dob_edit.visible = false
_play_button.disabled = true # Gate play behind successful auth.
_update_auth_mode_ui()
# ─── Play / Quit ─────────────────────────────────────────────────────────────
func _on_play_button_pressed() -> void:
if _play_button.disabled:
return
# Browse worlds is the first real screen past login -- actual gameplay
# (joining a world) doesn't exist yet, browsing does.
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _on_quit_button_pressed() -> void:
get_tree().quit()
# ─── Auth panel toggle ───────────────────────────────────────────────────────
func _on_login_button_pressed() -> void:
"""Show the auth panel, called from AuthButtonsRow/LoginButton."""
_auth_panel.visible = true
_username_edit.grab_focus()
func _on_register_button_pressed() -> void:
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
_auth_panel.visible = true
_is_register_mode = true
_mode_toggle.button_pressed = true
_update_auth_mode_ui()
_clear_status()
_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:
"""Update labels and field visibility based on login vs register mode."""
if _is_register_mode:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_dob_edit.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_dob_edit.visible = false
# ─── Auth submission ─────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void:
"""Handle login or register button press."""
if _is_authenticating:
return
# Validate inputs.
var username := _username_edit.text.strip_edges()
var password := _password_edit.text
if username == "":
_show_status("Username is required.")
return
if password == "":
_show_status("Password is required.")
return
if _is_register_mode:
var email := _email_edit.text.strip_edges()
var dob := _dob_edit.text.strip_edges()
if email == "":
_show_status("Email is required.")
return
if dob == "":
_show_status("Date of birth is required.")
return
# Basic YYYY-MM-DD format check.
if not _is_valid_date(dob):
_show_status("Date of birth must be in YYYY-MM-DD format.")
return
_auth_button.disabled = true
_auth_button.text = "Registering..."
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, dob)
_on_auth_complete(result)
else:
_auth_button.disabled = true
_auth_button.text = "Logging in..."
_is_authenticating = true
_clear_status()
var result := await ApiClient.login(username, password)
_on_auth_complete(result)
func _on_auth_complete(result: Dictionary) -> void:
"""Handle the result of a login or register call."""
_auth_button.disabled = false
_auth_button.text = "Register" if _is_register_mode else "Login"
_is_authenticating = false
if result.ok:
# Success — show welcome, enable Play, 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.")
else:
# Failure — show backend error detail.
_show_status(result.detail)
# ─── Status helpers ──────────────────────────────────────────────────────────
func _show_status(message: String) -> void:
_status_label.text = message
_status_label.visible = true
func _clear_status() -> void:
_status_label.text = ""
_status_label.visible = false
# ─── Validation ──────────────────────────────────────────────────────────────
func _is_valid_date(date_str: String) -> bool:
"""Check if a string looks like a valid YYYY-MM-DD date."""
if date_str.length() != 10:
return false
var parts := date_str.split("-")
if parts.size() != 3:
return false
var year := parts[0].to_int()
var month := parts[1].to_int()
var day := parts[2].to_int()
if month < 1 or month > 12:
return false
if day < 1 or day > 31:
return false
# Rough month-day validation.
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if day > days_in_month[month]:
return false
return true
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Run the full automated test inventory for a Package Checkpoint review.
See docs/testing/test_inventory.md for what each suite actually covers (and
doesn't) -- keep that doc in sync as tests are added, this script just runs
whatever's there.
Usage:
python scripts/run_checkpoint_tests.py [--keep-postgres] [--postgres-port PORT]
Exits 0 if everything that ran passed, 1 otherwise. Suites with nothing to
run (e.g. no scripts/smoke_test_*.gd yet, or no Godot binary found) are
reported as SKIPPED, not counted as a failure.
"""
import argparse
import glob
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BACKEND = ROOT / "backend"
def run(cmd, cwd=None, env=None, capture=False):
print(f"$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else ""))
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=capture, text=True)
def find_godot():
for candidate in ("godot", str(Path.home() / "godot" / "godot")):
if shutil.which(candidate) or Path(candidate).is_file():
return candidate
return None
def wait_for_http(url: str, timeout_s: int = 20) -> bool:
import urllib.request
import urllib.error
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, ConnectionError, OSError):
pass
time.sleep(1)
return False
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--keep-postgres", action="store_true",
help="Don't tear down the checkpoint Postgres container afterward")
parser.add_argument("--postgres-port", default=os.environ.get("CHECKPOINT_POSTGRES_PORT", "5432"),
help="Host port for the checkpoint's own Postgres container (default 5432 -- "
"override if that's already in use on this machine)")
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
help="Port to run the backend server on for the Godot smoke tests")
args = parser.parse_args()
results = {}
# --- Backend: bring up Postgres ---
compose_env = os.environ.copy()
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
print("=== Backend: bringing up Postgres ===")
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
print("Waiting for Postgres to be ready...")
pg_ready = False
for _ in range(20):
r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"],
cwd=BACKEND, env=compose_env, capture=True)
if r.returncode == 0:
pg_ready = True
break
time.sleep(1)
if not pg_ready:
print("Postgres never became ready -- aborting.")
sys.exit(1)
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
backend_env = os.environ.copy()
backend_env["DATABASE_URL"] = db_url
print("\n=== Backend: alembic upgrade head ===")
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
results["migrations (fresh DB)"] = r.returncode == 0
print("\n=== Backend: pytest ===")
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
results["backend pytest"] = r.returncode == 0
# --- Godot: headless smoke tests (need a live backend server) ---
godot_bin = find_godot()
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
if not godot_bin:
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
results["godot smoke tests"] = None
elif not smoke_tests:
print("\n=== Godot: no scripts/smoke_test_*.gd found yet, nothing to run ===")
results["godot smoke tests"] = None
else:
print(f"\n=== Backend: starting server on :{args.backend_port} for Godot smoke tests ===")
server_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", args.backend_port],
cwd=BACKEND, env=backend_env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
base_url = f"http://127.0.0.1:{args.backend_port}"
if not wait_for_http(f"{base_url}/api/health"):
print("Backend server never became healthy -- skipping Godot smoke tests.")
results["godot smoke tests"] = False
else:
godot_env = os.environ.copy()
godot_env["AGP_API_BASE_URL"] = base_url
print("=== Godot: import pass (builds the class cache) ===")
run([godot_bin, "--headless", "--path", str(ROOT), "--import"], env=godot_env)
run([godot_bin, "--headless", "--path", str(ROOT), "--quit"], env=godot_env)
godot_ok = True
for test in smoke_tests:
rel = os.path.relpath(test, ROOT)
print(f"\n--- {rel} ---")
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
if r.returncode != 0:
godot_ok = False
results["godot smoke tests"] = godot_ok
finally:
server_proc.terminate()
try:
server_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
server_proc.kill()
if not args.keep_postgres:
print("\n=== Backend: tearing down checkpoint Postgres ===")
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
# --- Summary ---
print("\n" + "=" * 50)
print("SUMMARY")
print("=" * 50)
overall_ok = True
for name, ok in results.items():
if ok is None:
print(f" {name}: SKIPPED")
elif ok:
print(f" {name}: PASS")
else:
print(f" {name}: FAIL")
overall_ok = False
print("\nSee docs/testing/test_inventory.md for what each suite actually covers (and doesn't).")
sys.exit(0 if overall_ok else 1)
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
extends SceneTree
## Headless smoke test for the ApiClient autoload.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_api.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
##
## Calls the real autoload's `health_check()` (which exercises the generic
## `request()` primitive) and exits 0 on a `status == "ok"` response, 1
## otherwise. This script is intentionally NOT wired into any game scene.
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the SceneTree has processed its first frame,
# so a freshly add_child()'d HTTPRequest node isn't reliably considered
# "inside tree" yet (request_raw fails with !is_inside_tree()). One frame
# of delay is enough; this only matters for --script mode, not a real
# running game.
await process_frame
# Godot's --script/SceneTree runner doesn't resolve autoload singletons
# as bare global identifiers at compile time (the node exists at
# runtime -- root.has_node("ApiClient") is true -- but referencing it as
# a plain identifier fails with "Identifier not found: ApiClient" during
# script compilation). get_node sidesteps compile-time resolution
# entirely. This only affects --script mode; a real game scene resolves
# the bare autoload name normally.
var api_client := root.get_node("ApiClient")
print("SMOKE: calling ApiClient.health_check() ...")
var result: Dictionary = await api_client.health_check()
print("SMOKE: result = ", result)
if result.ok \
and result.data is Dictionary \
and str((result.data as Dictionary).get("status", "")) == "ok":
print("SMOKE RESULT: PASS (status=ok)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)
+133
View File
@@ -0,0 +1,133 @@
extends SceneTree
## Headless smoke test for the AuthPanel login/register flow.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_auth.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
##
## Tests:
## 1. Register a unique user → 201 + token stored.
## 2. Register the same user again → 409 "Username already registered".
## 3. Register an under-18 user → 400 "Must be 18 or older".
## 4. Login with correct credentials → 200 + token stored.
## 5. Login with wrong password → 401 "Incorrect username or password".
## 6. Login with non-existent user → 401 "Incorrect username or password".
##
## Exits 0 on full pass, 1 on any failure.
const API_BASE := "http://127.0.0.1:8000"
const TEST_USERNAME := "smoke_test_user_%d"
const TEST_EMAIL := "smoke_%d@test.example.com"
const TEST_PASSWORD := "SmokeTest123!"
const TEST_DOB_ADULT := "2000-01-01"
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
func _initialize() -> void:
_run()
func _run() -> void:
# Wait for autoloads to be ready.
await process_frame
var api := root.get_node("ApiClient") as ApiClient
assert(api != null, "ApiClient autoload not found")
var passed: int = 0
var failed: int = 0
# --- Test 1: Register a unique user ---
var unique_id := Time.get_ticks_msec()
var username := "smoke_%d" % unique_id
var email := "smoke_%d@test.example.com" % unique_id
print("\n=== Test 1: Register unique user ===")
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
if _check("register returns ok", result.ok) \
and _check("register status is 201", result.status == 201) \
and _check("register stores token", api.has_token()):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Test 2: Register same user again (duplicate) ---
print("\n=== Test 2: Duplicate username ===")
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
if _check("duplicate register not ok", not result.ok) \
and _check("duplicate status is 409", result.status == 409) \
and _check("duplicate detail mentions username",
"username" in result.detail.to_lower()):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Test 3: Register under-18 user ---
print("\n=== Test 3: Under-18 registration ===")
var child_username := "child_%d" % unique_id
var child_email := "child_%d@test.example.com" % unique_id
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
if _check("under-18 register not ok", not result.ok) \
and _check("under-18 status is 400", result.status == 400) \
and _check("under-18 detail mentions age",
"18" in result.detail):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Test 4: Login with correct credentials ---
print("\n=== Test 4: Correct login ===")
result = await api.login(username, TEST_PASSWORD)
if _check("login returns ok", result.ok) \
and _check("login status is 200", result.status == 200) \
and _check("login stores token", api.has_token()):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Test 5: Login with wrong password ---
print("\n=== Test 5: Wrong password ===")
result = await api.login(username, "WrongPassword!")
if _check("wrong pwd login not ok", not result.ok) \
and _check("wrong pwd status is 401", result.status == 401) \
and _check("wrong pwd detail",
"Incorrect" in result.detail or "incorrect" in result.detail):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Test 6: Login with non-existent user ---
print("\n=== Test 6: Non-existent user ===")
result = await api.login("nonexistent_user_xyz", TEST_PASSWORD)
if _check("missing user login not ok", not result.ok) \
and _check("missing user status is 401", result.status == 401) \
and _check("missing user detail",
"Incorrect" in result.detail or "incorrect" in result.detail):
passed += 1
else:
failed += 1
print(" detail: ", result.detail)
# --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
if failed > 0:
quit(1)
else:
print("SMOKE RESULT: ALL PASS")
quit(0)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
return true
else:
printerr(" FAIL: ", description)
return false
+148
View File
@@ -0,0 +1,148 @@
extends SceneTree
## End-to-end headless smoke test: register -> login -> fetch worlds list.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_e2e.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
## the AGP_API_BASE_URL environment variable).
##
## Steps:
## 1. Health check GET /api/health -> 200, {"status": "ok"}
## 2. Register POST /api/auth/register -> 201, access_token present
## 3. Login POST /api/auth/login -> 200, access_token present
## 4. Worlds list GET /api/worlds -> 200, JSON array
##
## Exits 0 if all steps pass, 1 otherwise. If the backend is unreachable the
## health check fails with a clear message and the script exits 1 immediately.
# Fixed credentials from the task spec. The username gets a unique numeric
# suffix so the test stays re-runnable (a second run would otherwise hit
# "Username already registered" on step 2).
const TEST_USERNAME_BASE := "smoke_test_user"
const TEST_EMAIL := "smoke@test.com"
const TEST_PASSWORD := "TestPass123"
const TEST_DOB := "2000-01-01"
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the first frame; wait one frame so freshly
# created HTTPRequest nodes are reliably inside the tree.
await process_frame
# In --script mode the autoload is reachable via get_node(), not as a
# bare identifier (bare identifiers fail to resolve at compile time here).
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found in the scene tree")
var unique_id := int(Time.get_unix_time_from_system() * 1000)
var username := "%s_%d" % [TEST_USERNAME_BASE, unique_id]
var email := "smoke_%d@test.com" % unique_id
var results := {}
# --- Step 1: Health check -------------------------------------------------
print("\n=== Step 1: Health check (GET /api/health) ===")
var health: Dictionary = await api.health_check()
var health_ok: bool = (
health.ok
and health.status == 200
and health.data is Dictionary
and str((health.data as Dictionary).get("status", "")) == "ok"
)
_record(results, "health", health_ok, "status=%d data=%s" % [
health.status, health.data
])
if not health.ok or health.status == 0:
printerr("\nERROR: Backend is not reachable at the configured base URL.")
printerr(" detail: %s" % health.detail)
printerr(" Start the backend first (see backend/README), then re-run:")
printerr(" godot --headless --path . --script res://scripts/smoke_test_e2e.gd")
_summarize(results)
quit(1)
return
# --- Step 2: Register -----------------------------------------------------
print("\n=== Step 2: Register (POST /api/auth/register) ===")
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
and reg.status == 201
and reg_token != ""
)
_record(results, "register", reg_ok, "status=%d token=%s detail=%s" % [
reg.status, "yes" if reg_token != "" else "no", reg.detail
])
# --- Step 3: Login --------------------------------------------------------
print("\n=== Step 3: Login (POST /api/auth/login) ===")
var login: Dictionary = await api.login(username, TEST_PASSWORD)
var login_token := _extract_token(login)
var login_ok: bool = (
login.ok
and login.status == 200
and login_token != ""
)
_record(results, "login", login_ok, "status=%d token=%s detail=%s" % [
login.status, "yes" if login_token != "" else "no", login.detail
])
# --- Step 4: Worlds list ---------------------------------------------------
print("\n=== Step 4: Worlds list (GET /api/worlds) ===")
# login/register already stored the token; make the auth header explicit by
# re-applying the extracted token so step 4 is independent of storage order.
if login_token != "":
api.set_token(login_token)
var worlds: Dictionary = await api.get_worlds()
var worlds_ok: bool = (
worlds.ok
and worlds.status == 200
and worlds.data is Array
)
_record(results, "worlds", worlds_ok, "status=%d is_array=%s count=%d detail=%s" % [
worlds.status,
"yes" if (worlds.data is Array) else "no",
(worlds.data as Array).size() if (worlds.data is Array) else -1,
worlds.detail,
])
# --- Summary ---------------------------------------------------------------
_summarize(results)
func _extract_token(result: Dictionary) -> String:
if result.ok and result.data is Dictionary:
return str((result.data as Dictionary).get("access_token", ""))
return ""
func _record(results: Dictionary, name: String, ok: bool, detail: String) -> void:
results[name] = {"ok": ok, "detail": detail}
if ok:
print(" PASS: %s" % detail)
else:
printerr(" FAIL: %s" % detail)
func _summarize(results: Dictionary) -> void:
var all_pass := true
print("\n=== Smoke test summary ===")
for step in ["health", "register", "login", "worlds"]:
var entry: Dictionary = results.get(step, {"ok": false, "detail": "not run"})
if not entry["ok"]:
all_pass = false
print(" %-8s : %s" % [step, "PASS" if entry["ok"] else "FAIL"])
if all_pass:
print("SMOKE RESULT: ALL PASS (4/4)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)
+154
View File
@@ -0,0 +1,154 @@
extends SceneTree
## Regression test for the Play -> Back navigation bug.
##
## The reported bug: after logging in and clicking Play, the user lands on
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
## reset to a fully logged-out state (Play disabled, login form showing)
## even though ApiClient still held a valid token. main_menu.gd's _ready()
## used to unconditionally reset the UI on every scene load.
##
## This test drives the real navigation flow (change_scene_to_file) against
## a live backend:
## 1. Register a unique user so ApiClient holds a valid token.
## 2. Load main_menu.tscn and assert it restores the authenticated state.
## 3. Click Play -> world_list.tscn.
## 4. Click Back -> main_menu.tscn.
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
## actually visible in the tree, login/register buttons hidden).
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
const USERNAME := "backnav_%d"
const EMAIL := "backnav_%d@test.example.com"
const PASSWORD := "BackNav123!"
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)
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
# false-positive: the label can have visible=true while its parent is
# hidden and it renders as nothing. `is_visible_in_tree()` is the
# check that proves the user can actually see the welcome message.
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
_check("Auth buttons row hidden", not auth_buttons_row.visible)
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
return (not play_button.disabled) \
and welcome_label.is_visible_in_tree() \
and not auth_buttons_row.visible \
and 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)
+76
View File
@@ -0,0 +1,76 @@
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. 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
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var packed: PackedScene = load("res://scenes/main_menu.tscn")
var instance: Control = packed.instantiate()
root.add_child(instance)
await process_frame
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
var 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 welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
_check("Play starts disabled", play_button.disabled)
# Click Register -- a real signal emission is fine here since
# _on_register_button_pressed() is synchronous (no await), same as a
# real click's GUI event dispatch would drive it.
register_button.pressed.emit()
await process_frame
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
# Fill the form exactly as a user would type into it.
var unique_id := int(Time.get_unix_time_from_system() * 1000)
username_edit.text = "mainmenu_e2e_%d" % unique_id
password_edit.text = "TestPass123!"
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
dob_edit.text = "2000-01-01"
# Call the submit handler directly (awaited) rather than via
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
# SceneTree headless mode doesn't drive an await-using handler's
# coroutine forward the same way real GUI input event dispatch does
# (confirmed via a direct-call vs emit() comparison: the direct call
# always resolves, the synthetic emit sometimes doesn't advance past
# its own await point even after 50 real process_frame waits). This
# is a headless-mode test-harness limitation, not a bug in the actual
# click-driven flow.
await instance._on_auth_button_pressed()
_check("Play became enabled after real register submission", not play_button.disabled)
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _check(description: String, condition: bool) -> void:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
+60
View File
@@ -0,0 +1,60 @@
extends SceneTree
## Verifies the main menu's Login/Register buttons actually open the auth
## panel via a real signal emission (not calling the handler method
## directly) -- that's the exact gap that shipped without ever being
## caught: the AuthPanel and its logic were all correct, but nothing in
## the scene was ever wired to open it.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var passed := 0
var failed := 0
var packed: PackedScene = load("res://scenes/main_menu.tscn")
var instance: Control = packed.instantiate()
root.add_child(instance)
await process_frame
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
if _check("auth panel starts hidden", not auth_panel.visible):
passed += 1
else:
failed += 1
login_button.pressed.emit()
await process_frame
if _check("auth panel visible after a real LoginButton press signal", auth_panel.visible):
passed += 1
else:
failed += 1
# Reset and check RegisterButton independently too.
auth_panel.visible = false
register_button.pressed.emit()
await process_frame
if _check("auth panel visible after a real RegisterButton press signal", auth_panel.visible):
passed += 1
else:
failed += 1
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
else:
printerr(" FAIL: ", description)
return condition
+168
View File
@@ -0,0 +1,168 @@
extends SceneTree
## Headless smoke test for the world list screen.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_world_list.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with
## AGP_API_BASE_URL).
##
## Unlike a pure API-level test, this actually instantiates scenes/
## world_list.tscn as a real scene and adds it to the tree -- catches
## @onready node-path bugs (a node reference pointing at the wrong path
## errors right here, not just "looks right" from reading the script) that
## an API-only test would miss entirely.
##
## Tests:
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
## 2. POST a real world via the API so the populated-card path actually
## gets exercised -- run_checkpoint_tests.py always starts from a
## genuinely empty DB, so without this the card-rendering code below
## would never run in CI at all, only the empty-list path would.
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
## must not error.
## 4. Wait for _refresh_worlds()'s awaited request to complete.
## 5. Confirm it actually reached the populated (not empty) state, and
## that the rendered card has a real width -- not just non-zero
## child_count. 2026-08-20: a real bug shipped where every card
## technically existed in the tree (child_count > 0, no error, no
## crash) but rendered at 1.0px wide because size_flags_horizontal/
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
## child-count check alone would never catch that, only a real size
## assertion does.
##
## Exits 0 on full pass, 1 on any failure.
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found")
var passed: int = 0
var failed: int = 0
# --- Setup: register + log in so the world list has a valid token ---
print("\n=== Setup: register + login ===")
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "worldlist_smoke_%d" % unique_id
var email := "worldlist_smoke_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_result.detail)
quit(1)
return
# --- Seed a real world so the populated-card path is actually exercised ---
print("\n=== Setup: seed a real world ===")
var world_body := JSON.stringify({
"name": "Smoke Test World %d" % unique_id,
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
"visibility": "public",
})
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
if not _check("setup: world created", create_result.ok):
print(" detail: ", create_result.detail)
quit(1)
return
# --- Test 1: Instantiate the real scene ---
print("\n=== Test 1: Instantiate world_list.tscn ===")
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
if not _check("scene loads", packed_scene != null):
quit(1)
return
var instance: Control = packed_scene.instantiate()
if not _check("scene instantiates", instance != null):
quit(1)
return
root.add_child(instance)
# One frame for _ready() (and its @onready resolution) to run. A wrong
# node path in an @onready var errors during this, not silently.
await process_frame
if _check("scene added to tree without error", is_instance_valid(instance)):
passed += 1
else:
failed += 1
# --- Test 2: Wait for the world fetch to resolve, check a real end state ---
print("\n=== Test 2: World fetch reaches a real end state ===")
# _refresh_worlds() runs from _ready(); give its awaited HTTP call time
# to complete rather than a fixed sleep -- poll for either end state.
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel")
var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel")
var settled := false
for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits
await process_frame
if world_list_node.visible or empty_label.visible or error_label.visible:
settled = true
break
if _check("reached a real end state (cards, empty, or error) not stuck loading", settled):
passed += 1
else:
failed += 1
print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [
world_list_node.visible, empty_label.visible, error_label.visible
])
if error_label.visible:
printerr(" Note: ended in error state: ", error_label.text)
elif empty_label.visible:
# We just seeded a world -- ending up empty means GET /api/worlds
# isn't actually seeing what POST /api/worlds/ just created.
_check("ended in populated state, not empty (a world was just seeded)", false)
failed += 1
elif world_list_node.visible:
var card_count := world_list_node.get_child_count()
print(" Note: rendered %d world card(s)" % card_count)
if _check("at least one card rendered", card_count > 0):
passed += 1
else:
failed += 1
# The actual bug this test exists to catch: a card can be present
# in the tree, not crash, and still be visually nothing because
# it rendered at ~1px wide. Check every card's real computed size,
# not just that it exists.
for i in range(card_count):
var card: Control = world_list_node.get_child(i)
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
passed += 1
else:
failed += 1
# --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
if failed > 0:
quit(1)
else:
print("SMOKE RESULT: ALL PASS")
quit(0)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
return true
else:
printerr(" FAIL: ", description)
return false
+142
View File
@@ -0,0 +1,142 @@
extends Control
## World list screen — browses public worlds via GET /api/worlds.
##
## Displays a scrollable list of world cards (name + description).
## Handles loading, empty list, errors, and refresh.
# UI references
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
@onready var error_label: Label = $VBoxContainer/ErrorLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
func _ready() -> void:
refresh_button.disabled = false
back_button.disabled = false
loading_label.visible = false
error_label.visible = false
empty_label.visible = false
world_list.visible = false
_refresh_worlds()
func _on_refresh_button_pressed() -> void:
_refresh_worlds()
func _on_back_button_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
## Fetch the world list from the backend and update the UI.
func _refresh_worlds() -> void:
# Reset UI state
loading_label.visible = true
error_label.visible = false
empty_label.visible = false
world_list.visible = false
refresh_button.disabled = true
# Clear previous world cards
for child in world_list.get_children():
child.queue_free()
var result := await ApiClient.get_worlds()
loading_label.visible = false
refresh_button.disabled = false
if not result.ok:
# Show error message (401, network failure, etc.)
error_label.text = "Error: %s" % result.detail
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
var data = result.data
if not data is Array:
error_label.text = "Unexpected response format."
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
if data.is_empty():
empty_label.text = "No worlds yet. Check back later!"
empty_label.visible = true
error_label.visible = false
world_list.visible = false
return
# Render world cards
world_list.visible = true
empty_label.visible = false
error_label.visible = false
for world_dict in data:
if not world_dict is Dictionary:
continue
var card := _create_world_card(world_dict)
world_list.add_child(card)
## Create a visual card for a single world entry.
func _create_world_card(world_dict: Dictionary) -> Control:
# PanelContainer for the background, not a ColorRect moved to child index
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
# vertical sequence, it has no concept of "float this one child behind
# the others as an overlay". That version rendered as an empty dark bar
# sitting above the text, not a background behind it.
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 80)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
panel.add_theme_stylebox_override("panel", style)
var card := VBoxContainer.new()
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.add_theme_constant_override("separation", 4)
panel.add_child(card)
var name_label := Label.new()
name_label.text = world_dict.get("name", "Unnamed World")
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
name_label.size_flags_horizontal = Control.SIZE_FILL
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
name_label.add_theme_font_size_override("font_size", 18)
var desc_label := Label.new()
var description: String = str(world_dict.get("description", ""))
desc_label.text = description if description != "" else "No description."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.size_flags_horizontal = Control.SIZE_FILL
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
desc_label.add_theme_font_size_override("font_size", 14)
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
var meta_label := Label.new()
var visibility: String = str(world_dict.get("visibility", "public"))
var created: String = str(world_dict.get("created_at", ""))
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
meta_label.size_flags_horizontal = Control.SIZE_FILL
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
meta_label.add_theme_font_size_override("font_size", 12)
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
card.add_child(name_label)
card.add_child(desc_label)
card.add_child(meta_label)
return panel