7bd8124199
POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD). The server computes age (auth.calculate_age) and rejects under-18s with 403; a future/nonsensical date is 400. The DOB is stored and surfaced only to that user (/api/auth/me). Still self-attested (a user can enter a false DOB) -- but a concrete, stored, legally-meaningful data point and the seam for a future ID-check pass. Encryption-at-rest for the column and the Tier-2 hook are not done; the limitation is stated plainly in docs/architecture/content_moderation.md 5.2. - auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register() computes + enforces; UserResponse.date_of_birth typed `date | None` (the old `str | None` would have 500'd /me once a DOB was actually stored). - api_client.gd / main_menu.gd: the Over18 checkbox is now a "Date of birth (YYYY-MM-DD)" field with a client-side sanity check. - tests: test_auth_age_gate.py rewritten around the endpoint + age math (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across 6 backend test files + 6 game smoke tests updated. Backend 74 passed; game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.); fresh-clone boot PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
147 lines
5.0 KiB
GDScript
147 lines
5.0 KiB
GDScript
extends SceneTree
|
|
## Regression/smoke test for the PC2 "Enter World" flow end to end.
|
|
##
|
|
## Real user path: register -> land on world_list.tscn -> click "Enter World"
|
|
## on a real card -> the playable level (scenes/game.tscn) actually loads
|
|
## with the player spawned in it -> pressing Escape returns to world_list.
|
|
##
|
|
## Run with:
|
|
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
|
|
##
|
|
## Requires the backend to be running (default base URL
|
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
|
## AGP_API_BASE_URL) and at least one real world to already exist.
|
|
|
|
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. Register a real user and seed a world so the browse list has a
|
|
# real card to click, same pattern as smoke_test_world_list.gd.
|
|
randomize()
|
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
|
var username := "enterworld_%d" % unique_id
|
|
var email := "enterworld_%d@test.example.com" % unique_id
|
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
|
|
if not _check("setup: register succeeded", reg_result.ok):
|
|
print(" detail: ", reg_result.detail)
|
|
_finish()
|
|
return
|
|
|
|
var world_body := JSON.stringify({
|
|
"name": "EnterWorld Smoke Test World %d" % unique_id,
|
|
"description": "Seeded by smoke_test_enter_world.gd",
|
|
"visibility": "public",
|
|
})
|
|
var world_result: Dictionary = await api.request(
|
|
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
|
)
|
|
if not _check("setup: world created", world_result.ok):
|
|
print(" detail: ", world_result.detail)
|
|
_finish()
|
|
return
|
|
|
|
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
|
|
var packed: PackedScene = load("res://scenes/world_list.tscn")
|
|
var instance: Control = packed.instantiate()
|
|
root.add_child(instance)
|
|
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
|
|
# the HTTP round trip rather than guessing a fixed count.
|
|
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
|
var settled := false
|
|
for i in 120:
|
|
await process_frame
|
|
if world_list_node.get_child_count() > 0:
|
|
settled = true
|
|
break
|
|
if not _check("world list populated with at least one card", settled):
|
|
_finish()
|
|
return
|
|
|
|
# 3. Click "Enter World" on the first real card -- find the button by
|
|
# walking the card's children rather than assuming an index, so this
|
|
# doesn't silently pass if _create_world_card's structure changes.
|
|
var card: Control = world_list_node.get_child(0)
|
|
var enter_button: Button = _find_button(card, "Enter World")
|
|
if not _check("Enter World button exists on the card", enter_button != null):
|
|
_finish()
|
|
return
|
|
|
|
enter_button.pressed.emit()
|
|
await _wait_for_scene_change(instance, 60)
|
|
|
|
var game_scene := current_scene
|
|
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
|
|
_finish()
|
|
return
|
|
|
|
# 4. Verify the player actually exists and spawned at the level's
|
|
# PlayerSpawn marker -- not just that *some* scene loaded.
|
|
var player: Node = game_scene.get_node_or_null("Player")
|
|
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
|
|
|
|
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
|
|
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
|
|
|
|
if player is CharacterBody3D and spawn_marker is Marker3D:
|
|
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
|
|
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
|
|
|
|
# 5. Escape should return to world_list.tscn (real key event, not a
|
|
# direct method call -- proves the actual input binding works).
|
|
var esc_event := InputEventKey.new()
|
|
esc_event.keycode = KEY_ESCAPE
|
|
esc_event.pressed = true
|
|
Input.parse_input_event(esc_event)
|
|
await _wait_for_scene_change(game_scene, 60)
|
|
|
|
var back_to_menu := current_scene
|
|
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
|
|
|
|
_finish()
|
|
|
|
|
|
func _find_button(node: Node, text: String) -> Button:
|
|
if node is Button and (node as Button).text == text:
|
|
return node
|
|
for child in node.get_children():
|
|
var found := _find_button(child, text)
|
|
if found:
|
|
return found
|
|
return null
|
|
|
|
|
|
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
|
|
for i in max_frames:
|
|
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)
|