feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import app.models # noqa: F401 — ensures all models register with Base.metadata
|
||||
from app.core.auth import AuthUser, get_current_user
|
||||
from app.core.database import Base, get_session
|
||||
from app.main import app
|
||||
|
||||
TEST_JWT_SECRET = "test-jwt-secret-for-testing-only"
|
||||
|
||||
TEST_DATABASE_URL = os.getenv(
|
||||
"TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
|
||||
@@ -59,3 +64,43 @@ async def client(db_session):
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth_user():
|
||||
"""Return a mock authenticated user for tests."""
|
||||
return AuthUser(id="test-user-123", email="test@example.com", role="authenticated")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_override(mock_auth_user):
|
||||
"""Override get_current_user to return a mock user."""
|
||||
|
||||
def _override():
|
||||
return mock_auth_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(db_session, auth_override):
|
||||
"""Async HTTP client with mocked authentication."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_token():
|
||||
"""Generate a valid JWT token for testing."""
|
||||
payload = {
|
||||
"sub": "test-user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
179
backend/tests/test_auth.py
Normal file
179
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||
from app.core.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_secret():
|
||||
"""Provide a test JWT secret."""
|
||||
return "test-jwt-secret-for-testing-only"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_token(jwt_secret):
|
||||
"""Generate a valid JWT token."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_token(jwt_secret):
|
||||
"""Generate an expired JWT token."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) - 3600, # Expired 1 hour ago
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_token():
|
||||
"""Generate a token signed with wrong secret."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, "wrong-secret", algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(db_session, jwt_secret, valid_token, monkeypatch):
|
||||
"""Client with valid auth token and configured JWT secret."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
async def _get_client():
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {valid_token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
return _get_client
|
||||
|
||||
|
||||
async def test_get_current_user_valid_token(jwt_secret, valid_token, monkeypatch):
|
||||
"""Test get_current_user returns user for valid token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {valid_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is not None
|
||||
assert user.id == "user-123"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.role == "authenticated"
|
||||
|
||||
|
||||
async def test_get_current_user_no_token(jwt_secret, monkeypatch):
|
||||
"""Test get_current_user returns None when no token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch):
|
||||
"""Test get_current_user returns None for expired token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {expired_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch):
|
||||
"""Test get_current_user returns None for invalid token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {invalid_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_malformed_header(jwt_secret, monkeypatch):
|
||||
"""Test get_current_user returns None for malformed auth header."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": "NotBearer token"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_require_auth_valid_user():
|
||||
"""Test require_auth passes through valid user."""
|
||||
user = AuthUser(id="user-123", email="test@example.com")
|
||||
result = require_auth(user)
|
||||
assert result is user
|
||||
|
||||
|
||||
async def test_require_auth_no_user():
|
||||
"""Test require_auth raises 401 for no user."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_auth(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Authentication required"
|
||||
|
||||
|
||||
async def test_protected_endpoint_without_token(db_session):
|
||||
"""Test that write endpoint returns 401 without token."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Authentication required"
|
||||
|
||||
|
||||
async def test_protected_endpoint_with_expired_token(
|
||||
db_session, jwt_secret, expired_token, monkeypatch
|
||||
):
|
||||
"""Test that write endpoint returns 401 with expired token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
) as ac:
|
||||
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_read_endpoint_without_token(db_session):
|
||||
"""Test that read endpoints work without authentication."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.get("/runs")
|
||||
assert response.status_code == 200
|
||||
@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def game(client: AsyncClient) -> dict:
|
||||
async def game(auth_client: AsyncClient) -> dict:
|
||||
"""A game created via the API (no version_group_id)."""
|
||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
@@ -68,22 +68,24 @@ class TestListGames:
|
||||
|
||||
|
||||
class TestCreateGame:
|
||||
async def test_creates_and_returns_game(self, client: AsyncClient):
|
||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
||||
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Pokemon Red"
|
||||
assert data["slug"] == "red"
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
|
||||
response = await client.post(
|
||||
async def test_duplicate_slug_returns_409(
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_missing_required_field_returns_422(self, client: AsyncClient):
|
||||
response = await client.post(BASE, json={"name": "Pokemon Red"})
|
||||
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -113,29 +115,35 @@ class TestGetGame:
|
||||
|
||||
|
||||
class TestUpdateGame:
|
||||
async def test_updates_name(self, client: AsyncClient, game: dict):
|
||||
response = await client.put(
|
||||
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Pokemon Blue"
|
||||
|
||||
async def test_slug_unchanged_on_partial_update(
|
||||
self, client: AsyncClient, game: dict
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "New Name"}
|
||||
)
|
||||
assert response.json()["slug"] == "red"
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
|
||||
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
|
||||
r1 = await client.post(
|
||||
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
|
||||
await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
|
||||
)
|
||||
r1 = await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
||||
)
|
||||
game_id = r1.json()["id"]
|
||||
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@@ -145,13 +153,13 @@ class TestUpdateGame:
|
||||
|
||||
|
||||
class TestDeleteGame:
|
||||
async def test_deletes_game(self, client: AsyncClient, game: dict):
|
||||
response = await client.delete(f"{BASE}/{game['id']}")
|
||||
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.delete(f"{BASE}/{game['id']}")
|
||||
assert response.status_code == 204
|
||||
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,9 +195,9 @@ class TestListByRegion:
|
||||
|
||||
|
||||
class TestCreateRoute:
|
||||
async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes",
|
||||
json={"name": "Pallet Town", "order": 1},
|
||||
)
|
||||
@@ -200,35 +208,35 @@ class TestCreateRoute:
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_game_detail_includes_route(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await client.get(f"{BASE}/{game_id}")
|
||||
response = await auth_client.get(f"{BASE}/{game_id}")
|
||||
routes = response.json()["routes"]
|
||||
assert len(routes) == 1
|
||||
assert routes[0]["name"] == "Route 1"
|
||||
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, client: AsyncClient, game: dict
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{BASE}/{game['id']}/routes",
|
||||
json={"name": "Route 1", "order": 1},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_list_routes_excludes_routes_without_encounters(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
"""list_game_routes only returns routes that have Pokemon encounters."""
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -239,14 +247,16 @@ class TestCreateRoute:
|
||||
|
||||
|
||||
class TestUpdateRoute:
|
||||
async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_updates_route_name(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
||||
)
|
||||
).json()
|
||||
response = await client.put(
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game_id}/routes/{r['id']}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
@@ -254,11 +264,11 @@ class TestUpdateRoute:
|
||||
assert response.json()["name"] == "New Name"
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (
|
||||
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -268,25 +278,27 @@ class TestUpdateRoute:
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
).json()
|
||||
assert (
|
||||
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
).status_code == 204
|
||||
# No longer in game detail
|
||||
detail = (await client.get(f"{BASE}/{game_id}")).json()
|
||||
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
|
||||
assert all(route["id"] != r["id"] for route in detail["routes"])
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
|
||||
assert (
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -295,20 +307,20 @@ class TestDeleteRoute:
|
||||
|
||||
|
||||
class TestReorderRoutes:
|
||||
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r1 = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
||||
)
|
||||
).json()
|
||||
r2 = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
||||
)
|
||||
).json()
|
||||
|
||||
response = await client.put(
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game_id}/routes/reorder",
|
||||
json={
|
||||
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
||||
|
||||
@@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def run(client: AsyncClient, game_id: int) -> dict:
|
||||
async def run(auth_client: AsyncClient, game_id: int) -> dict:
|
||||
"""An active run created via the API."""
|
||||
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "My Run"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
@@ -127,8 +129,8 @@ class TestListRuns:
|
||||
|
||||
|
||||
class TestCreateRun:
|
||||
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
|
||||
response = await client.post(
|
||||
async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int):
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
@@ -138,20 +140,22 @@ class TestCreateRun:
|
||||
assert data["gameId"] == game_id
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_rules_stored(self, client: AsyncClient, game_id: int):
|
||||
async def test_rules_stored(self, auth_client: AsyncClient, game_id: int):
|
||||
rules = {"duplicatesClause": True, "shinyClause": False}
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["rules"]["duplicatesClause"] is True
|
||||
|
||||
async def test_invalid_game_returns_404(self, client: AsyncClient):
|
||||
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
|
||||
async def test_invalid_game_returns_404(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": 9999, "name": "Run"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_missing_required_returns_422(self, client: AsyncClient):
|
||||
response = await client.post(RUNS_BASE, json={"name": "Run"})
|
||||
async def test_missing_required_returns_422(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(RUNS_BASE, json={"name": "Run"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -181,15 +185,17 @@ class TestGetRun:
|
||||
|
||||
|
||||
class TestUpdateRun:
|
||||
async def test_updates_name(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_updates_name(self, auth_client: AsyncClient, run: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Renamed"
|
||||
|
||||
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_complete_run_sets_completed_at(
|
||||
self, auth_client: AsyncClient, run: dict
|
||||
):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -197,25 +203,27 @@ class TestUpdateRun:
|
||||
assert data["status"] == "completed"
|
||||
assert data["completedAt"] is not None
|
||||
|
||||
async def test_fail_run(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_fail_run(self, auth_client: AsyncClient, run: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "failed"
|
||||
|
||||
async def test_ending_already_ended_run_returns_400(
|
||||
self, client: AsyncClient, run: dict
|
||||
self, auth_client: AsyncClient, run: dict
|
||||
):
|
||||
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
|
||||
response = await client.patch(
|
||||
await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||
)
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||||
await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -225,12 +233,12 @@ class TestUpdateRun:
|
||||
|
||||
|
||||
class TestDeleteRun:
|
||||
async def test_deletes_run(self, client: AsyncClient, run: dict):
|
||||
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||||
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||||
async def test_deletes_run(self, auth_client: AsyncClient, run: dict):
|
||||
assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||||
assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,8 +247,8 @@ class TestDeleteRun:
|
||||
|
||||
|
||||
class TestCreateEncounter:
|
||||
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -255,8 +263,10 @@ class TestCreateEncounter:
|
||||
assert data["status"] == "caught"
|
||||
assert data["isShiny"] is False
|
||||
|
||||
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_invalid_run_returns_404(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/9999/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -266,8 +276,10 @@ class TestCreateEncounter:
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_invalid_route_returns_404(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": 9999,
|
||||
@@ -278,9 +290,9 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_invalid_pokemon_returns_404(
|
||||
self, client: AsyncClient, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -290,9 +302,11 @@ class TestCreateEncounter:
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
|
||||
async def test_parent_route_rejected_400(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["parent_id"],
|
||||
@@ -303,10 +317,10 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_route_lock_prevents_second_sibling_encounter(
|
||||
self, client: AsyncClient, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -314,7 +328,7 @@ class TestCreateEncounter:
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -325,11 +339,11 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_shiny_bypasses_route_lock(
|
||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
):
|
||||
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
|
||||
# First encounter occupies the group
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -338,7 +352,7 @@ class TestCreateEncounter:
|
||||
},
|
||||
)
|
||||
# Shiny encounter on sibling should succeed
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -351,7 +365,7 @@ class TestCreateEncounter:
|
||||
assert response.json()["isShiny"] is True
|
||||
|
||||
async def test_gift_bypasses_route_lock_when_clause_on(
|
||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
):
|
||||
"""A gift encounter bypasses route-lock when giftClause is enabled."""
|
||||
# Enable giftClause on the run
|
||||
@@ -359,7 +373,7 @@ class TestCreateEncounter:
|
||||
run.rules = {"shinyClause": True, "giftClause": True}
|
||||
await db_session.commit()
|
||||
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -367,7 +381,7 @@ class TestCreateEncounter:
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -387,8 +401,8 @@ class TestCreateEncounter:
|
||||
|
||||
class TestUpdateEncounter:
|
||||
@pytest.fixture
|
||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await client.post(
|
||||
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -398,17 +412,17 @@ class TestUpdateEncounter:
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
|
||||
response = await client.patch(
|
||||
async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nickname"] == "Sparky"
|
||||
|
||||
async def test_updates_status_to_fainted(
|
||||
self, client: AsyncClient, encounter: dict
|
||||
self, auth_client: AsyncClient, encounter: dict
|
||||
):
|
||||
response = await client.patch(
|
||||
response = await auth_client.patch(
|
||||
f"{ENC_BASE}/{encounter['id']}",
|
||||
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
|
||||
)
|
||||
@@ -418,9 +432,9 @@ class TestUpdateEncounter:
|
||||
assert data["faintLevel"] == 12
|
||||
assert data["deathCause"] == "wild battle"
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||||
await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -431,8 +445,8 @@ class TestUpdateEncounter:
|
||||
|
||||
class TestDeleteEncounter:
|
||||
@pytest.fixture
|
||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await client.post(
|
||||
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -443,12 +457,14 @@ class TestDeleteEncounter:
|
||||
return response.json()
|
||||
|
||||
async def test_deletes_encounter(
|
||||
self, client: AsyncClient, encounter: dict, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, encounter: dict, enc_ctx: dict
|
||||
):
|
||||
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
|
||||
assert (
|
||||
await auth_client.delete(f"{ENC_BASE}/{encounter['id']}")
|
||||
).status_code == 204
|
||||
# Run detail should no longer include it
|
||||
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||||
detail = (await auth_client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||||
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user