Files
modbison/backend/tests/test_auth_endpoints.py
T
claude-checkpoint-tooling 0ecc16d963 Registration: replace date-of-birth field with a simple 18+ confirmation
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 16:25:27 +00:00

260 lines
9.4 KiB
Python

"""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",
"over_18": True,
},
)
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_register_rejects_not_over_18(self):
"""over_18=false gets a 400 with a detail message.
The interim design (2026-08-20) trusts a client-side boolean
confirmation instead of collecting/validating an actual date of
birth -- this only checks that the flag is respected, not real
age math (there's no date to verify against anymore). Real DOB
collection/verification may come back in a future pass."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"over_18": False,
},
)
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",
"over_18": True,
},
)
# Second registration with same username, different email
resp = client.post(
"/api/auth/register",
json={
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"over_18": True,
},
)
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",
"over_18": True,
},
)
resp = client.post(
"/api/auth/register",
json={
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_missing_over_18(self):
"""Missing the required over_18 field returns 422.
Replaces the old test_register_invalid_date_format -- there's no
date format to get wrong anymore (no date field at all), so the
closest equivalent "malformed request" case is the required
over_18 boolean being absent entirely."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "user",
"email": "user@example.com",
"password": "secret123",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 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,
"over_18": True,
},
)
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