Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3496d8c67 |
@@ -3,6 +3,8 @@ from sqlalchemy.dialects.postgresql import ENUM
|
|||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
import enum
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -38,3 +40,32 @@ class Asset(Base):
|
|||||||
|
|
||||||
owner = relationship("User", back_populates="assets")
|
owner = relationship("User", back_populates="assets")
|
||||||
world = relationship("World", back_populates="assets")
|
world = relationship("World", back_populates="assets")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Computed properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_gltf_metadata(self) -> bool:
|
||||||
|
"""Return True when metadata_json contains glTF-specific keys.
|
||||||
|
|
||||||
|
glTF metadata is considered present when the stored JSON contains at
|
||||||
|
least one of the keys that *extract_gltf_metadata* produces
|
||||||
|
(mesh_count, vertex_count, has_animation, texture_refs,
|
||||||
|
bounding_box_min, bounding_box_max).
|
||||||
|
"""
|
||||||
|
if not self.metadata_json:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data: dict[str, Any] = json.loads(self.metadata_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return False
|
||||||
|
gltf_keys = {
|
||||||
|
"mesh_count",
|
||||||
|
"vertex_count",
|
||||||
|
"has_animation",
|
||||||
|
"texture_refs",
|
||||||
|
"bounding_box_min",
|
||||||
|
"bounding_box_max",
|
||||||
|
}
|
||||||
|
return bool(gltf_keys & data.keys())
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ explicitly out of scope.
|
|||||||
| # | Type | Task | Depends on |
|
| # | Type | Task | Depends on |
|
||||||
|---|------|------|-----------|
|
|---|------|------|-----------|
|
||||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||||
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
|
||||||
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
|
||||||
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
|
||||||
|
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
|
||||||
|
|
||||||
## Design decisions captured (resolved in task 1)
|
## Design decisions captured (resolved in task 1)
|
||||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||||
@@ -34,8 +35,3 @@ explicitly out of scope.
|
|||||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
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`.
|
- 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.
|
- 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.
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 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-19 (commit `1c75367`).
|
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
|
||||||
|
|
||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
|
||||||
reachable Postgres — see the runner script; most of these tests don't
|
|
||||||
actually touch the DB, but the app's import chain requires a valid URL to
|
|
||||||
construct).
|
|
||||||
|
|
||||||
**Current total: 38 tests, all passing on a clean `main` checkout.**
|
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
|
||||||
|---|---|---|
|
|
||||||
| `test_auth_age_gate.py` | 10 | `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). |
|
|
||||||
|
|
||||||
**What these are, honestly:** pure unit tests of standalone functions
|
|
||||||
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
|
|
||||||
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
|
|
||||||
actually need Postgres reachable at all (the import chain just needs a
|
|
||||||
syntactically valid `DATABASE_URL`, nothing connects during collection).
|
|
||||||
|
|
||||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
|
||||||
these yet):
|
|
||||||
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
|
|
||||||
`/worlds`, `/assets/*` have zero tests that actually call them through
|
|
||||||
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
|
|
||||||
real (or test) database. Everything currently shipped works because it
|
|
||||||
was manually verified at merge time (checkpoint review), not because a
|
|
||||||
test asserts it.
|
|
||||||
- No tests for the RS256 auth upgrade (key generation, refresh-token
|
|
||||||
issuance/persistence) — PR #30, pending review as of this writing,
|
|
||||||
manually verified (correct key usage, all 38 existing tests still pass)
|
|
||||||
but not covered by an automated test of its own yet.
|
|
||||||
- No tests for the moderation schema/migration (in progress as of this
|
|
||||||
writing) beyond "the migration runs."
|
|
||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
|
||||||
|
|
||||||
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
|
|
||||||
is the only Godot script on `main` right now, and it's **not a real
|
|
||||||
test** (a leftover manual probe, returns a hardcoded string, asserts
|
|
||||||
nothing). Don't count it toward coverage; delete or repurpose it if anyone
|
|
||||||
actually needs a sandbox probe again.
|
|
||||||
|
|
||||||
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
|
|
||||||
will be the first real one once merged — `ApiClient` autoload's
|
|
||||||
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
|
|
||||||
at one), verified by hand end-to-end before opening the PR: exercises the
|
|
||||||
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
|
|
||||||
result-dictionary contract), not just that the script compiles. Exits 1 on
|
|
||||||
failure, so it's CI/script-friendly.
|
|
||||||
|
|
||||||
Run (once merged): `godot --headless --path . --import` once (builds the
|
|
||||||
class cache — skip this and every `class_name`-declared class falsely
|
|
||||||
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
|
|
||||||
`godot --headless --path . --script res://scripts/<name>.gd`
|
|
||||||
|
|
||||||
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
|
|
||||||
`login`, `register`, `get_worlds`, `get_me` are implemented (see
|
|
||||||
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
|
|
||||||
real user/world fixture in the target backend to test against meaningfully.
|
|
||||||
|
|
||||||
## What "full coverage" should eventually mean here
|
|
||||||
|
|
||||||
Not defined precisely yet — the honest target as of 2026-08-19 is just
|
|
||||||
"more than pure-function unit tests." 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.
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
Reference in New Issue
Block a user