feat: auth-aware UI and role-based access control #67

Merged
TheFurya merged 10 commits from feature/auth-aware-ui-and-role-based-access-control into develop 2026-03-21 11:44:07 +01:00
27 changed files with 826 additions and 347 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-2zwg # nuzlocke-tracker-2zwg
title: Protect frontend routes with ProtectedRoute and AdminRoute title: Protect frontend routes with ProtectedRoute and AdminRoute
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-21T10:06:20Z created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z updated_at: 2026-03-21T10:19:41Z
parent: nuzlocke-tracker-ce4o parent: nuzlocke-tracker-ce4o
blocked_by: blocked_by:
- nuzlocke-tracker-5svj - nuzlocke-tracker-5svj
@@ -15,14 +15,24 @@ Use the existing \`ProtectedRoute\` component (currently unused) and create an \
## Checklist ## Checklist
- [ ] Wrap \`/runs/new\` and \`/genlockes/new\` with \`ProtectedRoute\` (requires login) - [x] Wrap \`/runs/new\` and \`/genlockes/new\` with \`ProtectedRoute\` (requires login)
- [ ] Create \`AdminRoute\` component that checks \`isAdmin\` from \`useAuth()\`, redirects to \`/\` with a toast/message if not admin - [x] Create \`AdminRoute\` component that checks \`isAdmin\` from \`useAuth()\`, redirects to \`/\` with a toast/message if not admin
- [ ] Wrap all \`/admin/*\` routes with \`AdminRoute\` - [x] Wrap all \`/admin/*\` routes with \`AdminRoute\`
- [ ] Ensure \`/runs\` and \`/runs/:runId\` remain accessible to everyone (public run viewing) - [x] Ensure \`/runs\` and \`/runs/:runId\` remain accessible to everyone (public run viewing)
- [ ] Verify deep-linking works (e.g., visiting \`/admin/games\` while logged out redirects to login, then back to \`/admin/games\` after auth) - [x] Verify deep-linking works (e.g., visiting \`/admin/games\` while logged out redirects to login, then back to \`/admin/games\` after auth)
## Files to change ## Files to change
- \`frontend/src/App.tsx\` — wrap routes - \`frontend/src/App.tsx\` — wrap routes
- \`frontend/src/components/ProtectedRoute.tsx\` — already exists, verify it works - \`frontend/src/components/ProtectedRoute.tsx\` — already exists, verify it works
- \`frontend/src/components/AdminRoute.tsx\` — new file - \`frontend/src/components/AdminRoute.tsx\` — new file
## Summary of Changes
Implemented frontend route protection:
- **ProtectedRoute**: Wraps `/runs/new` and `/genlockes/new` - redirects unauthenticated users to `/login` with return location preserved
- **AdminRoute**: New component that checks `isAdmin` from `useAuth()`, redirects non-admins to `/` with a toast notification
- **Admin routes**: Wrapped `AdminLayout` with `AdminRoute` to protect all `/admin/*` routes
- **Public routes**: `/runs` and `/runs/:runId` remain accessible to everyone
- **Deep-linking**: Location state preserved so users return to original route after login

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-5svj # nuzlocke-tracker-5svj
title: Expose admin status to frontend via user API title: Expose admin status to frontend via user API
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-21T10:06:20Z created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z updated_at: 2026-03-21T10:23:04Z
parent: nuzlocke-tracker-ce4o parent: nuzlocke-tracker-ce4o
blocked_by: blocked_by:
- nuzlocke-tracker-dwah - nuzlocke-tracker-dwah
@@ -15,13 +15,21 @@ The frontend needs to know if the current user is an admin so it can show/hide t
## Checklist ## Checklist
- [ ] Add `is_admin` field to the user response schema (`/api/users/me` endpoint) - [x] Add `is_admin` field to the user response schema (`/api/users/me` endpoint)
- [ ] Update `AuthContext` to fetch `/api/users/me` after login and store `isAdmin` in context - [x] Update `AuthContext` to fetch `/api/users/me` after login and store `isAdmin` in context
- [ ] Expose `isAdmin` boolean from `useAuth()` hook - [x] Expose `isAdmin` boolean from `useAuth()` hook
- [ ] Handle edge case: user exists in Supabase but not yet in local DB (first login creates user row with `is_admin=false`) - [x] Handle edge case: user exists in Supabase but not yet in local DB (first login creates user row with `is_admin=false`)
## Files to change ## Files to change
- `backend/src/app/schemas/user.py` or equivalent — add `is_admin` to response - `backend/src/app/schemas/user.py` or equivalent — add `is_admin` to response
- `backend/src/app/api/users.py` — ensure `/me` returns `is_admin` - `backend/src/app/api/users.py` — ensure `/me` returns `is_admin`
- `frontend/src/contexts/AuthContext.tsx` — fetch and store admin status - `frontend/src/contexts/AuthContext.tsx` — fetch and store admin status
## Summary of Changes
Added `isAdmin` field to frontend auth system:
- **Backend**: Added `is_admin: bool = False` to `UserResponse` schema in `backend/src/app/api/users.py`
- **Frontend**: Updated `AuthContext` to fetch `/api/users/me` after login and expose `isAdmin` boolean
- Edge case handled: `syncUserProfile` returns `false` if API call fails (new user auto-created with `is_admin=false` by backend)

View File

@@ -0,0 +1,50 @@
---
# nuzlocke-tracker-9xac
title: Fix stale PostgreSQL enum causing test failures
status: completed
type: bug
priority: normal
created_at: 2026-03-21T10:27:53Z
updated_at: 2026-03-21T10:29:33Z
---
## Problem
The backend smoke tests fail with:
```
sqlalchemy.exc.DBAPIError: invalid input value for enum run_visibility: "public"
```
This happens during `Base.metadata.create_all` in the `engine` fixture (`backend/tests/conftest.py:27`).
## Root Cause
The `engine` fixture only calls `create_all` during setup and `drop_all` during teardown. If a previous test run was interrupted before teardown, the `run_visibility` PostgreSQL enum type persists in the test database with stale/incorrect values. On the next run, `create_all` (with `checkfirst=True` default) sees the enum exists and skips recreating it, but the existing enum lacks valid values, causing the `DEFAULT 'public'` to fail.
PostgreSQL native enum types are not automatically dropped with `DROP TABLE` — they require explicit `DROP TYPE`.
## Fix
In the `engine` fixture at `backend/tests/conftest.py:23-31`, add `Base.metadata.drop_all` before `create_all` to ensure a clean slate:
```python
@pytest.fixture(scope="session")
async def engine():
eng = create_async_engine(TEST_DATABASE_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) # <-- add this
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
```
## Checklist
- [x] Add `drop_all` before `create_all` in the `engine` fixture (`backend/tests/conftest.py`)
- [x] Verify tests pass with `pytest backend/tests/test_smoke.py`
## Summary of Changes
Added `drop_all` before `create_all` in the test engine fixture to ensure stale PostgreSQL enum types are cleared before recreating the schema. This prevents test failures when a previous test run was interrupted before cleanup.

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-ce4o # nuzlocke-tracker-ce4o
title: Auth-aware UI and role-based access control title: Auth-aware UI and role-based access control
status: todo status: completed
type: epic type: epic
priority: normal
created_at: 2026-03-21T10:05:52Z created_at: 2026-03-21T10:05:52Z
updated_at: 2026-03-21T10:05:52Z updated_at: 2026-03-21T10:18:47Z
--- ---
The app currently shows the same navigation menu to all users regardless of auth state. Logged-out users can navigate to protected pages (e.g., /runs/new, /admin) even though the backend rejects their requests. The admin interface has no role restriction — any authenticated user can access it. The app currently shows the same navigation menu to all users regardless of auth state. Logged-out users can navigate to protected pages (e.g., /runs/new, /admin) even though the backend rejects their requests. The admin interface has no role restriction — any authenticated user can access it.
@@ -19,9 +20,9 @@ The app currently shows the same navigation menu to all users regardless of auth
## Success Criteria ## Success Criteria
- [ ] Logged-out users see only: Home, Runs (public list), Genlockes, Stats, Sign In - [ ] Logged-out users see only: Home, Runs (public list), Genlockes, Stats, Sign In
- [ ] Logged-out users cannot navigate to /runs/new, /genlockes/new, or /admin/* - [x] Logged-out users cannot navigate to /runs/new, /genlockes/new, or /admin/*
- [ ] Logged-in non-admin users see: New Run, My Runs, Genlockes, Stats (no Admin link) - [ ] Logged-in non-admin users see: New Run, My Runs, Genlockes, Stats (no Admin link)
- [ ] Admin users see the full menu including Admin - [ ] Admin users see the full menu including Admin
- [ ] Backend admin endpoints return 403 for non-admin authenticated users - [x] Backend admin endpoints return 403 for non-admin authenticated users
- [ ] Admin role is stored in the `users` table (`is_admin` boolean column) - [ ] Admin role is stored in the `users` table (`is_admin` boolean column)
- [ ] Admin status is exposed to the frontend via the user API or auth context - [x] Admin status is exposed to the frontend via the user API or auth context

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-dwah # nuzlocke-tracker-dwah
title: Add is_admin column to users table title: Add is_admin column to users table
status: todo status: completed
type: task type: task
priority: normal
created_at: 2026-03-21T10:06:19Z created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:19Z updated_at: 2026-03-21T10:10:38Z
parent: nuzlocke-tracker-ce4o parent: nuzlocke-tracker-ce4o
--- ---
@@ -12,12 +13,31 @@ Add an `is_admin` boolean column (default `false`) to the `users` table via an A
## Checklist ## Checklist
- [ ] Create Alembic migration adding `is_admin: Mapped[bool]` column with `server_default="false"` - [x] Create Alembic migration adding `is_admin: Mapped[bool]` column with `server_default="false"`
- [ ] Update `User` model in `backend/src/app/models/user.py` - [x] Update `User` model in `backend/src/app/models/user.py`
- [ ] Run migration and verify column exists - [x] Run migration and verify column exists
- [ ] Seed a test admin user (or document how to set `is_admin=true` via SQL) - [x] Seed a test admin user (or document how to set `is_admin=true` via SQL)
## Files to change ## Files to change
- `backend/src/app/models/user.py` — add `is_admin` field - `backend/src/app/models/user.py` — add `is_admin` field
- `backend/src/app/alembic/versions/` — new migration - `backend/src/app/alembic/versions/` — new migration
## Summary of Changes
Added `is_admin` boolean column to the `users` table:
- **Migration**: `p7e8f9a0b1c2_add_is_admin_to_users.py` adds the column with `server_default='false'`
- **Model**: Updated `User` model with `is_admin: Mapped[bool]` field
### Setting admin via SQL
To promote a user to admin:
```sql
UPDATE users SET is_admin = true WHERE email = 'admin@example.com';
```
Or by user ID:
```sql
UPDATE users SET is_admin = true WHERE id = '<uuid>';
```

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-f4d0 # nuzlocke-tracker-f4d0
title: Add require_admin dependency and protect admin endpoints title: Add require_admin dependency and protect admin endpoints
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-21T10:06:19Z created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:24Z updated_at: 2026-03-21T10:15:14Z
parent: nuzlocke-tracker-ce4o parent: nuzlocke-tracker-ce4o
blocked_by: blocked_by:
- nuzlocke-tracker-dwah - nuzlocke-tracker-dwah
@@ -15,13 +15,13 @@ Add a `require_admin` FastAPI dependency that checks the `is_admin` column on th
## Checklist ## Checklist
- [ ] Add `require_admin` dependency in `backend/src/app/core/auth.py` that: - [x] Add `require_admin` dependency in `backend/src/app/core/auth.py` that:
- Requires authentication (reuses `require_auth`) - Requires authentication (reuses `require_auth`)
- Looks up the user in the `users` table by `AuthUser.id` - Looks up the user in the `users` table by `AuthUser.id`
- Returns 403 if `is_admin` is not `True` - Returns 403 if `is_admin` is not `True`
- [ ] Apply `require_admin` to write endpoints in: `games.py`, `pokemon.py`, `evolutions.py`, `bosses.py` (all POST/PUT/PATCH/DELETE) - [x] Apply `require_admin` to write endpoints in: `games.py`, `pokemon.py`, `evolutions.py`, `bosses.py` (all POST/PUT/PATCH/DELETE)
- [ ] Keep read endpoints (GET) accessible to all authenticated users - [x] Keep read endpoints (GET) accessible to all authenticated users
- [ ] Add tests for 403 response when non-admin user hits admin endpoints - [x] Add tests for 403 response when non-admin user hits admin endpoints
## Files to change ## Files to change
@@ -30,3 +30,20 @@ Add a `require_admin` FastAPI dependency that checks the `is_admin` column on th
- `backend/src/app/api/pokemon.py` — same - `backend/src/app/api/pokemon.py` — same
- `backend/src/app/api/evolutions.py` — same - `backend/src/app/api/evolutions.py` — same
- `backend/src/app/api/bosses.py` — same - `backend/src/app/api/bosses.py` — same
## Summary of Changes
Added `require_admin` FastAPI dependency to `backend/src/app/core/auth.py`:
- Depends on `require_auth` (returns 401 if not authenticated)
- Looks up user in `users` table by UUID
- Returns 403 if user not found or `is_admin` is not True
Applied `require_admin` to all admin-facing write endpoints:
- `games.py`: POST/PUT/DELETE for games and routes
- `pokemon.py`: POST/PUT/DELETE for pokemon and route encounters
- `evolutions.py`: POST/PUT/DELETE for evolutions
- `bosses.py`: POST/PUT/DELETE for game-scoped boss operations (run-scoped endpoints kept with `require_auth`)
Added tests in `test_auth.py`:
- Unit tests for `require_admin` (admin user, non-admin user, user not in DB)
- Integration tests for admin endpoint access (403 for non-admin, 201 for admin)

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-h205 # nuzlocke-tracker-h205
title: Auth-aware navigation menu title: Auth-aware navigation menu
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-21T10:06:20Z created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z updated_at: 2026-03-21T10:22:34Z
parent: nuzlocke-tracker-ce4o parent: nuzlocke-tracker-ce4o
blocked_by: blocked_by:
- nuzlocke-tracker-5svj - nuzlocke-tracker-5svj
@@ -15,13 +15,24 @@ Update the Layout component to show different nav links based on auth state and
## Checklist ## Checklist
- [ ] Replace static \`navLinks\` array with dynamic links based on \`useAuth()\` state - [x] Replace static \`navLinks\` array with dynamic links based on \`useAuth()\` state
- [ ] **Logged out**: Home, Runs, Genlockes, Stats (no New Run, no Admin) - [x] **Logged out**: Home, Runs, Genlockes, Stats (no New Run, no Admin)
- [ ] **Logged in (non-admin)**: New Run, My Runs, Genlockes, Stats - [x] **Logged in (non-admin)**: New Run, My Runs, Genlockes, Stats
- [ ] **Logged in (admin)**: New Run, My Runs, Genlockes, Stats, Admin - [x] **Logged in (admin)**: New Run, My Runs, Genlockes, Stats, Admin
- [ ] Update both desktop and mobile nav (they share the same \`navLinks\` array, so this should be automatic) - [x] Update both desktop and mobile nav (they share the same \`navLinks\` array, so this should be automatic)
- [ ] Verify menu updates reactively on login/logout - [x] Verify menu updates reactively on login/logout
## Files to change ## Files to change
- \`frontend/src/components/Layout.tsx\` — make \`navLinks\` dynamic based on auth state - \`frontend/src/components/Layout.tsx\` — make \`navLinks\` dynamic based on auth state
## Summary of Changes
- Removed static `navLinks` array from module scope
- Added dynamic `navLinks` computation inside `Layout` component using `useMemo`
- Navigation now depends on `user` and `isAdmin` from `useAuth()`:
- Logged out: Home, Runs, Genlockes, Stats
- Logged in (non-admin): New Run, My Runs, Genlockes, Stats
- Logged in (admin): New Run, My Runs, Genlockes, Stats, Admin
- Updated `isActive` function to handle Home route (`/`) correctly
- Both desktop and mobile nav automatically use the same dynamic `navLinks` array

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-he1n # nuzlocke-tracker-he1n
title: Add local GoTrue container for dev auth testing title: Add local GoTrue container for dev auth testing
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-20T20:57:04Z created_at: 2026-03-20T20:57:04Z
updated_at: 2026-03-20T21:13:18Z updated_at: 2026-03-21T10:07:40Z
--- ---
## Problem ## Problem

View File

@@ -0,0 +1,29 @@
"""add is_admin to users
Revision ID: p7e8f9a0b1c2
Revises: o6d7e8f9a0b1
Create Date: 2026-03-21 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "p7e8f9a0b1c2"
down_revision: str | Sequence[str] | None = "o6d7e8f9a0b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("users", "is_admin")

View File

@@ -5,7 +5,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth from app.core.auth import AuthUser, require_admin, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
@@ -86,7 +86,7 @@ async def reorder_bosses(
game_id: int, game_id: int,
data: BossReorderRequest, data: BossReorderRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -130,7 +130,7 @@ async def create_boss(
game_id: int, game_id: int,
data: BossBattleCreate, data: BossBattleCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -161,7 +161,7 @@ async def update_boss(
boss_id: int, boss_id: int,
data: BossBattleUpdate, data: BossBattleUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -202,7 +202,7 @@ async def delete_boss(
game_id: int, game_id: int,
boss_id: int, boss_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -225,7 +225,7 @@ async def bulk_import_bosses(
game_id: int, game_id: int,
items: list[BulkBossItem], items: list[BulkBossItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -268,7 +268,7 @@ async def set_boss_team(
boss_id: int, boss_id: int,
team: list[BossPokemonInput], team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session from app.core.database import get_session
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -89,7 +90,9 @@ async def list_evolutions(
@router.post("/evolutions", response_model=EvolutionAdminResponse, status_code=201) @router.post("/evolutions", response_model=EvolutionAdminResponse, status_code=201)
async def create_evolution( async def create_evolution(
data: EvolutionCreate, session: AsyncSession = Depends(get_session) data: EvolutionCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
from_pokemon = await session.get(Pokemon, data.from_pokemon_id) from_pokemon = await session.get(Pokemon, data.from_pokemon_id)
if from_pokemon is None: if from_pokemon is None:
@@ -117,6 +120,7 @@ async def update_evolution(
evolution_id: int, evolution_id: int,
data: EvolutionUpdate, data: EvolutionUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
evolution = await session.get(Evolution, evolution_id) evolution = await session.get(Evolution, evolution_id)
if evolution is None: if evolution is None:
@@ -150,7 +154,9 @@ async def update_evolution(
@router.delete("/evolutions/{evolution_id}", status_code=204) @router.delete("/evolutions/{evolution_id}", status_code=204)
async def delete_evolution( async def delete_evolution(
evolution_id: int, session: AsyncSession = Depends(get_session) evolution_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
evolution = await session.get(Evolution, evolution_id) evolution = await session.get(Evolution, evolution_id)
if evolution is None: if evolution is None:
@@ -164,6 +170,7 @@ async def delete_evolution(
async def bulk_import_evolutions( async def bulk_import_evolutions(
items: list[BulkEvolutionItem], items: list[BulkEvolutionItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
# Build pokeapi_id -> id mapping # Build pokeapi_id -> id mapping
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))

View File

@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth from app.core.auth import AuthUser, require_admin
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.game import Game from app.models.game import Game
@@ -232,7 +232,7 @@ async def list_game_routes(
async def create_game( async def create_game(
data: GameCreate, data: GameCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
existing = await session.execute(select(Game).where(Game.slug == data.slug)) existing = await session.execute(select(Game).where(Game.slug == data.slug))
if existing.scalar_one_or_none() is not None: if existing.scalar_one_or_none() is not None:
@@ -252,7 +252,7 @@ async def update_game(
game_id: int, game_id: int,
data: GameUpdate, data: GameUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
game = await session.get(Game, game_id) game = await session.get(Game, game_id)
if game is None: if game is None:
@@ -280,7 +280,7 @@ async def update_game(
async def delete_game( async def delete_game(
game_id: int, game_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
result = await session.execute( result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs)) select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
@@ -338,7 +338,7 @@ async def create_route(
game_id: int, game_id: int,
data: RouteCreate, data: RouteCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -354,7 +354,7 @@ async def reorder_routes(
game_id: int, game_id: int,
data: RouteReorderRequest, data: RouteReorderRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -381,7 +381,7 @@ async def update_route(
route_id: int, route_id: int,
data: RouteUpdate, data: RouteUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -402,7 +402,7 @@ async def delete_route(
game_id: int, game_id: int,
route_id: int, route_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -437,7 +437,7 @@ async def bulk_import_routes(
game_id: int, game_id: int,
items: list[BulkRouteItem], items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), _user: AuthUser = Depends(require_admin),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session from app.core.database import get_session
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -68,6 +69,7 @@ async def list_pokemon(
async def bulk_import_pokemon( async def bulk_import_pokemon(
items: list[BulkImportItem], items: list[BulkImportItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
created = 0 created = 0
updated = 0 updated = 0
@@ -100,7 +102,9 @@ async def bulk_import_pokemon(
@router.post("/pokemon", response_model=PokemonResponse, status_code=201) @router.post("/pokemon", response_model=PokemonResponse, status_code=201)
async def create_pokemon( async def create_pokemon(
data: PokemonCreate, session: AsyncSession = Depends(get_session) data: PokemonCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
existing = await session.execute( existing = await session.execute(
select(Pokemon).where(Pokemon.pokeapi_id == data.pokeapi_id) select(Pokemon).where(Pokemon.pokeapi_id == data.pokeapi_id)
@@ -321,6 +325,7 @@ async def update_pokemon(
pokemon_id: int, pokemon_id: int,
data: PokemonUpdate, data: PokemonUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
pokemon = await session.get(Pokemon, pokemon_id) pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None: if pokemon is None:
@@ -349,7 +354,11 @@ async def update_pokemon(
@router.delete("/pokemon/{pokemon_id}", status_code=204) @router.delete("/pokemon/{pokemon_id}", status_code=204)
async def delete_pokemon(pokemon_id: int, session: AsyncSession = Depends(get_session)): async def delete_pokemon(
pokemon_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
result = await session.execute( result = await session.execute(
select(Pokemon) select(Pokemon)
.where(Pokemon.id == pokemon_id) .where(Pokemon.id == pokemon_id)
@@ -405,6 +414,7 @@ async def add_route_encounter(
route_id: int, route_id: int,
data: RouteEncounterCreate, data: RouteEncounterCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
route = await session.get(Route, route_id) route = await session.get(Route, route_id)
if route is None: if route is None:
@@ -436,6 +446,7 @@ async def update_route_encounter(
encounter_id: int, encounter_id: int,
data: RouteEncounterUpdate, data: RouteEncounterUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
result = await session.execute( result = await session.execute(
select(RouteEncounter) select(RouteEncounter)
@@ -466,6 +477,7 @@ async def remove_route_encounter(
route_id: int, route_id: int,
encounter_id: int, encounter_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
): ):
encounter = await session.execute( encounter = await session.execute(
select(RouteEncounter).where( select(RouteEncounter).where(

View File

@@ -16,6 +16,7 @@ class UserResponse(CamelModel):
id: UUID id: UUID
email: str email: str
display_name: str | None = None display_name: str | None = None
is_admin: bool = False
@router.post("/me", response_model=UserResponse) @router.post("/me", response_model=UserResponse)

View File

@@ -1,9 +1,14 @@
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID
import jwt import jwt
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_session
from app.models.user import User
@dataclass @dataclass
@@ -81,3 +86,22 @@ def require_auth(user: AuthUser | None = Depends(get_current_user)) -> AuthUser:
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return user return user
async def require_admin(
user: AuthUser = Depends(require_auth),
session: AsyncSession = Depends(get_session),
) -> AuthUser:
"""
Dependency that requires admin privileges.
Raises 401 if not authenticated, 403 if not an admin.
"""
result = await session.execute(select(User).where(User.id == UUID(user.id)))
db_user = result.scalar_one_or_none()
if db_user is None or not db_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from sqlalchemy import DateTime, String, func from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -19,6 +19,7 @@ class User(Base):
id: Mapped[UUID] = mapped_column(primary_key=True) id: Mapped[UUID] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
is_admin: Mapped[bool] = mapped_column(Boolean, server_default="false")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

View File

@@ -7,7 +7,7 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401 — ensures all models register with Base.metadata import app.models # noqa: F401 — ensures all models register with Base.metadata
from app.core.auth import AuthUser, get_current_user from app.core.auth import AuthUser, get_current_user, require_admin
from app.core.database import Base, get_session from app.core.database import Base, get_session
from app.main import app from app.main import app
@@ -24,6 +24,7 @@ async def engine():
"""Create the test engine and schema once for the entire session.""" """Create the test engine and schema once for the entire session."""
eng = create_async_engine(TEST_DATABASE_URL, echo=False) eng = create_async_engine(TEST_DATABASE_URL, echo=False)
async with eng.begin() as conn: async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
yield eng yield eng
async with eng.begin() as conn: async with eng.begin() as conn:
@@ -69,7 +70,11 @@ async def client(db_session):
@pytest.fixture @pytest.fixture
def mock_auth_user(): def mock_auth_user():
"""Return a mock authenticated user for tests.""" """Return a mock authenticated user for tests."""
return AuthUser(id="test-user-123", email="test@example.com", role="authenticated") return AuthUser(
id="00000000-0000-4000-a000-000000000001",
email="test@example.com",
role="authenticated",
)
@pytest.fixture @pytest.fixture
@@ -93,11 +98,34 @@ async def auth_client(db_session, auth_override):
yield ac yield ac
@pytest.fixture
def admin_override(mock_auth_user):
"""Override require_admin and get_current_user to return a mock user."""
def _override():
return mock_auth_user
app.dependency_overrides[require_admin] = _override
app.dependency_overrides[get_current_user] = _override
yield
app.dependency_overrides.pop(require_admin, None)
app.dependency_overrides.pop(get_current_user, None)
@pytest.fixture
async def admin_client(db_session, admin_override):
"""Async HTTP client with mocked admin authentication."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
@pytest.fixture @pytest.fixture
def valid_token(): def valid_token():
"""Generate a valid JWT token for testing.""" """Generate a valid JWT token for testing."""
payload = { payload = {
"sub": "test-user-123", "sub": "00000000-0000-4000-a000-000000000001",
"email": "test@example.com", "email": "test@example.com",
"role": "authenticated", "role": "authenticated",
"aud": "authenticated", "aud": "authenticated",

View File

@@ -1,12 +1,14 @@
import time import time
from uuid import UUID
import jwt import jwt
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.core.auth import AuthUser, get_current_user, require_auth from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
from app.core.config import settings from app.core.config import settings
from app.main import app from app.main import app
from app.models.user import User
@pytest.fixture @pytest.fixture
@@ -177,3 +179,140 @@ async def test_read_endpoint_without_token(db_session):
) as ac: ) as ac:
response = await ac.get("/runs") response = await ac.get("/runs")
assert response.status_code == 200 assert response.status_code == 200
async def test_require_admin_valid_admin_user(db_session):
"""Test require_admin passes through for admin user."""
user_id = "11111111-1111-1111-1111-111111111111"
admin_user = User(
id=UUID(user_id),
email="admin@example.com",
is_admin=True,
)
db_session.add(admin_user)
await db_session.commit()
auth_user = AuthUser(id=user_id, email="admin@example.com")
result = await require_admin(user=auth_user, session=db_session)
assert result is auth_user
async def test_require_admin_non_admin_user(db_session):
"""Test require_admin raises 403 for non-admin user."""
from fastapi import HTTPException
user_id = "22222222-2222-2222-2222-222222222222"
regular_user = User(
id=UUID(user_id),
email="user@example.com",
is_admin=False,
)
db_session.add(regular_user)
await db_session.commit()
auth_user = AuthUser(id=user_id, email="user@example.com")
with pytest.raises(HTTPException) as exc_info:
await require_admin(user=auth_user, session=db_session)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "Admin access required"
async def test_require_admin_user_not_in_db(db_session):
"""Test require_admin raises 403 for user not in database."""
from fastapi import HTTPException
auth_user = AuthUser(
id="33333333-3333-3333-3333-333333333333", email="ghost@example.com"
)
with pytest.raises(HTTPException) as exc_info:
await require_admin(user=auth_user, session=db_session)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "Admin access required"
async def test_admin_endpoint_returns_403_for_non_admin(
db_session, jwt_secret, monkeypatch
):
"""Test that admin endpoint returns 403 for authenticated non-admin user."""
user_id = "44444444-4444-4444-4444-444444444444"
regular_user = User(
id=UUID(user_id),
email="nonadmin@example.com",
is_admin=False,
)
db_session.add(regular_user)
await db_session.commit()
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
token = jwt.encode(
{
"sub": user_id,
"email": "nonadmin@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
},
jwt_secret,
algorithm="HS256",
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
response = await ac.post(
"/games",
json={
"name": "Test Game",
"slug": "test-game",
"generation": 1,
"region": "Kanto",
"category": "core",
},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin access required"
async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeypatch):
"""Test that admin endpoint succeeds for authenticated admin user."""
user_id = "55555555-5555-5555-5555-555555555555"
admin_user = User(
id=UUID(user_id),
email="admin@example.com",
is_admin=True,
)
db_session.add(admin_user)
await db_session.commit()
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
token = jwt.encode(
{
"sub": user_id,
"email": "admin@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
},
jwt_secret,
algorithm="HS256",
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
response = await ac.post(
"/games",
json={
"name": "Test Game",
"slug": "test-game",
"generation": 1,
"region": "Kanto",
"category": "core",
},
)
assert response.status_code == 201
assert response.json()["name"] == "Test Game"

View File

@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
@pytest.fixture @pytest.fixture
async def game(auth_client: AsyncClient) -> dict: async def game(admin_client: AsyncClient) -> dict:
"""A game created via the API (no version_group_id).""" """A game created via the API (no version_group_id)."""
response = await auth_client.post(BASE, json=GAME_PAYLOAD) response = await admin_client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201 assert response.status_code == 201
return response.json() return response.json()
@@ -68,8 +68,8 @@ class TestListGames:
class TestCreateGame: class TestCreateGame:
async def test_creates_and_returns_game(self, auth_client: AsyncClient): async def test_creates_and_returns_game(self, admin_client: AsyncClient):
response = await auth_client.post(BASE, json=GAME_PAYLOAD) response = await admin_client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["name"] == "Pokemon Red" assert data["name"] == "Pokemon Red"
@@ -77,15 +77,15 @@ class TestCreateGame:
assert isinstance(data["id"], int) assert isinstance(data["id"], int)
async def test_duplicate_slug_returns_409( async def test_duplicate_slug_returns_409(
self, auth_client: AsyncClient, game: dict self, admin_client: AsyncClient, game: dict
): ):
response = await auth_client.post( response = await admin_client.post(
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"} BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
) )
assert response.status_code == 409 assert response.status_code == 409
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient): async def test_missing_required_field_returns_422(self, admin_client: AsyncClient):
response = await auth_client.post(BASE, json={"name": "Pokemon Red"}) response = await admin_client.post(BASE, json={"name": "Pokemon Red"})
assert response.status_code == 422 assert response.status_code == 422
@@ -115,35 +115,35 @@ class TestGetGame:
class TestUpdateGame: class TestUpdateGame:
async def test_updates_name(self, auth_client: AsyncClient, game: dict): async def test_updates_name(self, admin_client: AsyncClient, game: dict):
response = await auth_client.put( response = await admin_client.put(
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"} f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "Pokemon Blue" assert response.json()["name"] == "Pokemon Blue"
async def test_slug_unchanged_on_partial_update( async def test_slug_unchanged_on_partial_update(
self, auth_client: AsyncClient, game: dict self, admin_client: AsyncClient, game: dict
): ):
response = await auth_client.put( response = await admin_client.put(
f"{BASE}/{game['id']}", json={"name": "New Name"} f"{BASE}/{game['id']}", json={"name": "New Name"}
) )
assert response.json()["slug"] == "red" assert response.json()["slug"] == "red"
async def test_not_found_returns_404(self, auth_client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert ( assert (
await auth_client.put(f"{BASE}/9999", json={"name": "x"}) await admin_client.put(f"{BASE}/9999", json={"name": "x"})
).status_code == 404 ).status_code == 404
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient): async def test_duplicate_slug_returns_409(self, admin_client: AsyncClient):
await auth_client.post( await admin_client.post(
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"} BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
) )
r1 = await auth_client.post( r1 = await admin_client.post(
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"} BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
) )
game_id = r1.json()["id"] game_id = r1.json()["id"]
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"}) response = await admin_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
assert response.status_code == 409 assert response.status_code == 409
@@ -153,13 +153,13 @@ class TestUpdateGame:
class TestDeleteGame: class TestDeleteGame:
async def test_deletes_game(self, auth_client: AsyncClient, game: dict): async def test_deletes_game(self, admin_client: AsyncClient, game: dict):
response = await auth_client.delete(f"{BASE}/{game['id']}") response = await admin_client.delete(f"{BASE}/{game['id']}")
assert response.status_code == 204 assert response.status_code == 204
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404 assert (await admin_client.get(f"{BASE}/{game['id']}")).status_code == 404
async def test_not_found_returns_404(self, auth_client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404 assert (await admin_client.delete(f"{BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -195,9 +195,9 @@ class TestListByRegion:
class TestCreateRoute: class TestCreateRoute:
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple): async def test_creates_route(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg game_id, _ = game_with_vg
response = await auth_client.post( response = await admin_client.post(
f"{BASE}/{game_id}/routes", f"{BASE}/{game_id}/routes",
json={"name": "Pallet Town", "order": 1}, json={"name": "Pallet Town", "order": 1},
) )
@@ -208,35 +208,35 @@ class TestCreateRoute:
assert isinstance(data["id"], int) assert isinstance(data["id"], int)
async def test_game_detail_includes_route( async def test_game_detail_includes_route(
self, auth_client: AsyncClient, game_with_vg: tuple self, admin_client: AsyncClient, game_with_vg: tuple
): ):
game_id, _ = game_with_vg game_id, _ = game_with_vg
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
) )
response = await auth_client.get(f"{BASE}/{game_id}") response = await admin_client.get(f"{BASE}/{game_id}")
routes = response.json()["routes"] routes = response.json()["routes"]
assert len(routes) == 1 assert len(routes) == 1
assert routes[0]["name"] == "Route 1" assert routes[0]["name"] == "Route 1"
async def test_game_without_version_group_returns_400( async def test_game_without_version_group_returns_400(
self, auth_client: AsyncClient, game: dict self, admin_client: AsyncClient, game: dict
): ):
response = await auth_client.post( response = await admin_client.post(
f"{BASE}/{game['id']}/routes", f"{BASE}/{game['id']}/routes",
json={"name": "Route 1", "order": 1}, json={"name": "Route 1", "order": 1},
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_list_routes_excludes_routes_without_encounters( async def test_list_routes_excludes_routes_without_encounters(
self, auth_client: AsyncClient, game_with_vg: tuple self, admin_client: AsyncClient, game_with_vg: tuple
): ):
"""list_game_routes only returns routes that have Pokemon encounters.""" """list_game_routes only returns routes that have Pokemon encounters."""
game_id, _ = game_with_vg game_id, _ = game_with_vg
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
) )
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true") response = await admin_client.get(f"{BASE}/{game_id}/routes?flat=true")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == []
@@ -248,15 +248,15 @@ class TestCreateRoute:
class TestUpdateRoute: class TestUpdateRoute:
async def test_updates_route_name( async def test_updates_route_name(
self, auth_client: AsyncClient, game_with_vg: tuple self, admin_client: AsyncClient, game_with_vg: tuple
): ):
game_id, _ = game_with_vg game_id, _ = game_with_vg
r = ( r = (
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
) )
).json() ).json()
response = await auth_client.put( response = await admin_client.put(
f"{BASE}/{game_id}/routes/{r['id']}", f"{BASE}/{game_id}/routes/{r['id']}",
json={"name": "New Name"}, json={"name": "New Name"},
) )
@@ -264,11 +264,11 @@ class TestUpdateRoute:
assert response.json()["name"] == "New Name" assert response.json()["name"] == "New Name"
async def test_route_not_found_returns_404( async def test_route_not_found_returns_404(
self, auth_client: AsyncClient, game_with_vg: tuple self, admin_client: AsyncClient, game_with_vg: tuple
): ):
game_id, _ = game_with_vg game_id, _ = game_with_vg
assert ( assert (
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"}) await admin_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
).status_code == 404 ).status_code == 404
@@ -278,26 +278,26 @@ class TestUpdateRoute:
class TestDeleteRoute: class TestDeleteRoute:
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple): async def test_deletes_route(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg game_id, _ = game_with_vg
r = ( r = (
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
) )
).json() ).json()
assert ( assert (
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}") await admin_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
).status_code == 204 ).status_code == 204
# No longer in game detail # No longer in game detail
detail = (await auth_client.get(f"{BASE}/{game_id}")).json() detail = (await admin_client.get(f"{BASE}/{game_id}")).json()
assert all(route["id"] != r["id"] for route in detail["routes"]) assert all(route["id"] != r["id"] for route in detail["routes"])
async def test_route_not_found_returns_404( async def test_route_not_found_returns_404(
self, auth_client: AsyncClient, game_with_vg: tuple self, admin_client: AsyncClient, game_with_vg: tuple
): ):
game_id, _ = game_with_vg game_id, _ = game_with_vg
assert ( assert (
await auth_client.delete(f"{BASE}/{game_id}/routes/9999") await admin_client.delete(f"{BASE}/{game_id}/routes/9999")
).status_code == 404 ).status_code == 404
@@ -307,20 +307,20 @@ class TestDeleteRoute:
class TestReorderRoutes: class TestReorderRoutes:
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple): async def test_reorders_routes(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg game_id, _ = game_with_vg
r1 = ( r1 = (
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
) )
).json() ).json()
r2 = ( r2 = (
await auth_client.post( await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2} f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
) )
).json() ).json()
response = await auth_client.put( response = await admin_client.put(
f"{BASE}/{game_id}/routes/reorder", f"{BASE}/{game_id}/routes/reorder",
json={ json={
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}] "routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]

View File

@@ -55,7 +55,7 @@ async def games_ctx(db_session: AsyncSession) -> dict:
@pytest.fixture @pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) -> dict: async def ctx(db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict) -> dict:
"""Full context: routes + pokemon + genlocke + encounter for advance/transfer tests.""" """Full context: routes + pokemon + genlocke + encounter for advance/transfer tests."""
route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1) route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1)
route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1) route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1)
@@ -67,7 +67,7 @@ async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) ->
db_session.add(pikachu) db_session.add(pikachu)
await db_session.commit() await db_session.commit()
r = await client.post( r = await admin_client.post(
GENLOCKES_BASE, GENLOCKES_BASE,
json={ json={
"name": "Test Genlocke", "name": "Test Genlocke",
@@ -80,7 +80,7 @@ async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) ->
leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1) leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1)
run_id = leg1["runId"] run_id = leg1["runId"]
enc_r = await client.post( enc_r = await admin_client.post(
f"{RUNS_BASE}/{run_id}/encounters", f"{RUNS_BASE}/{run_id}/encounters",
json={"routeId": route1.id, "pokemonId": pikachu.id, "status": "caught"}, json={"routeId": route1.id, "pokemonId": pikachu.id, "status": "caught"},
) )
@@ -104,13 +104,13 @@ async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) ->
class TestListGenlockes: class TestListGenlockes:
async def test_empty_returns_empty_list(self, client: AsyncClient): async def test_empty_returns_empty_list(self, admin_client: AsyncClient):
response = await client.get(GENLOCKES_BASE) response = await admin_client.get(GENLOCKES_BASE)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == []
async def test_returns_created_genlocke(self, client: AsyncClient, ctx: dict): async def test_returns_created_genlocke(self, admin_client: AsyncClient, ctx: dict):
response = await client.get(GENLOCKES_BASE) response = await admin_client.get(GENLOCKES_BASE)
assert response.status_code == 200 assert response.status_code == 200
names = [g["name"] for g in response.json()] names = [g["name"] for g in response.json()]
assert "Test Genlocke" in names assert "Test Genlocke" in names
@@ -123,9 +123,9 @@ class TestListGenlockes:
class TestCreateGenlocke: class TestCreateGenlocke:
async def test_creates_with_legs_and_first_run( async def test_creates_with_legs_and_first_run(
self, client: AsyncClient, games_ctx: dict self, admin_client: AsyncClient, games_ctx: dict
): ):
response = await client.post( response = await admin_client.post(
GENLOCKES_BASE, GENLOCKES_BASE,
json={ json={
"name": "My Genlocke", "name": "My Genlocke",
@@ -144,14 +144,14 @@ class TestCreateGenlocke:
leg2 = next(leg for leg in data["legs"] if leg["legOrder"] == 2) leg2 = next(leg for leg in data["legs"] if leg["legOrder"] == 2)
assert leg2["runId"] is None assert leg2["runId"] is None
async def test_empty_game_ids_returns_400(self, client: AsyncClient): async def test_empty_game_ids_returns_400(self, admin_client: AsyncClient):
response = await client.post( response = await admin_client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": []} GENLOCKES_BASE, json={"name": "Bad", "gameIds": []}
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_invalid_game_id_returns_404(self, client: AsyncClient): async def test_invalid_game_id_returns_404(self, admin_client: AsyncClient):
response = await client.post( response = await admin_client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": [9999]} GENLOCKES_BASE, json={"name": "Bad", "gameIds": [9999]}
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -164,9 +164,9 @@ class TestCreateGenlocke:
class TestGetGenlocke: class TestGetGenlocke:
async def test_returns_genlocke_with_legs_and_stats( async def test_returns_genlocke_with_legs_and_stats(
self, client: AsyncClient, ctx: dict self, admin_client: AsyncClient, ctx: dict
): ):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["id"] == ctx["genlocke_id"] assert data["id"] == ctx["genlocke_id"]
@@ -174,8 +174,8 @@ class TestGetGenlocke:
assert "stats" in data assert "stats" in data
assert data["stats"]["totalLegs"] == 2 assert data["stats"]["totalLegs"] == 2
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999")).status_code == 404 assert (await admin_client.get(f"{GENLOCKES_BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -184,30 +184,30 @@ class TestGetGenlocke:
class TestUpdateGenlocke: class TestUpdateGenlocke:
async def test_updates_name(self, client: AsyncClient, ctx: dict): async def test_updates_name(self, admin_client: AsyncClient, ctx: dict):
response = await client.patch( response = await admin_client.patch(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}", json={"name": "Renamed"} f"{GENLOCKES_BASE}/{ctx['genlocke_id']}", json={"name": "Renamed"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "Renamed" assert response.json()["name"] == "Renamed"
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert ( assert (
await client.patch(f"{GENLOCKES_BASE}/9999", json={"name": "x"}) await admin_client.patch(f"{GENLOCKES_BASE}/9999", json={"name": "x"})
).status_code == 404 ).status_code == 404
class TestDeleteGenlocke: class TestDeleteGenlocke:
async def test_deletes_genlocke(self, client: AsyncClient, ctx: dict): async def test_deletes_genlocke(self, admin_client: AsyncClient, ctx: dict):
assert ( assert (
await client.delete(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") await admin_client.delete(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 204 ).status_code == 204
assert ( assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 404 ).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.delete(f"{GENLOCKES_BASE}/9999")).status_code == 404 assert (await admin_client.delete(f"{GENLOCKES_BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -216,8 +216,8 @@ class TestDeleteGenlocke:
class TestGenlockeLegs: class TestGenlockeLegs:
async def test_adds_leg(self, client: AsyncClient, ctx: dict): async def test_adds_leg(self, admin_client: AsyncClient, ctx: dict):
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs", f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": ctx["game1_id"]}, json={"gameId": ctx["game1_id"]},
) )
@@ -225,28 +225,28 @@ class TestGenlockeLegs:
legs = response.json()["legs"] legs = response.json()["legs"]
assert len(legs) == 3 # was 2, now 3 assert len(legs) == 3 # was 2, now 3
async def test_remove_leg_without_run(self, client: AsyncClient, ctx: dict): async def test_remove_leg_without_run(self, admin_client: AsyncClient, ctx: dict):
# Leg 2 has no run yet — can be removed # Leg 2 has no run yet — can be removed
leg2 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 2) leg2 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 2)
response = await client.delete( response = await admin_client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg2['id']}" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg2['id']}"
) )
assert response.status_code == 204 assert response.status_code == 204
async def test_remove_leg_with_run_returns_400( async def test_remove_leg_with_run_returns_400(
self, client: AsyncClient, ctx: dict self, admin_client: AsyncClient, ctx: dict
): ):
# Leg 1 has a run — cannot remove # Leg 1 has a run — cannot remove
leg1 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 1) leg1 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 1)
response = await client.delete( response = await admin_client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg1['id']}" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg1['id']}"
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_add_leg_invalid_game_returns_404( async def test_add_leg_invalid_game_returns_404(
self, client: AsyncClient, ctx: dict self, admin_client: AsyncClient, ctx: dict
): ):
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs", f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": 9999}, json={"gameId": 9999},
) )
@@ -259,33 +259,33 @@ class TestGenlockeLegs:
class TestAdvanceLeg: class TestAdvanceLeg:
async def test_uncompleted_run_returns_400(self, client: AsyncClient, ctx: dict): async def test_uncompleted_run_returns_400(self, admin_client: AsyncClient, ctx: dict):
"""Cannot advance when leg 1's run is still active.""" """Cannot advance when leg 1's run is still active."""
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_no_next_leg_returns_400(self, client: AsyncClient, games_ctx: dict): async def test_no_next_leg_returns_400(self, admin_client: AsyncClient, games_ctx: dict):
"""A single-leg genlocke cannot be advanced.""" """A single-leg genlocke cannot be advanced."""
r = await client.post( r = await admin_client.post(
GENLOCKES_BASE, GENLOCKES_BASE,
json={"name": "Single Leg", "gameIds": [games_ctx["game1_id"]]}, json={"name": "Single Leg", "gameIds": [games_ctx["game1_id"]]},
) )
genlocke = r.json() genlocke = r.json()
run_id = genlocke["legs"][0]["runId"] run_id = genlocke["legs"][0]["runId"]
await client.patch(f"{RUNS_BASE}/{run_id}", json={"status": "completed"}) await admin_client.patch(f"{RUNS_BASE}/{run_id}", json={"status": "completed"})
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{genlocke['id']}/legs/1/advance" f"{GENLOCKES_BASE}/{genlocke['id']}/legs/1/advance"
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_advances_to_next_leg(self, client: AsyncClient, ctx: dict): async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict):
"""Completing the current run allows advancing to the next leg.""" """Completing the current run allows advancing to the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -293,11 +293,11 @@ class TestAdvanceLeg:
leg2 = next(leg for leg in legs if leg["legOrder"] == 2) leg2 = next(leg for leg in legs if leg["legOrder"] == 2)
assert leg2["runId"] is not None assert leg2["runId"] is not None
async def test_advances_with_transfers(self, client: AsyncClient, ctx: dict): async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict):
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg.""" """Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance", f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
json={"transferEncounterIds": [ctx["encounter_id"]]}, json={"transferEncounterIds": [ctx["encounter_id"]]},
) )
@@ -308,7 +308,7 @@ class TestAdvanceLeg:
assert new_run_id is not None assert new_run_id is not None
# The new run should contain the transferred (egg) encounter # The new run should contain the transferred (egg) encounter
run_detail = (await client.get(f"{RUNS_BASE}/{new_run_id}")).json() run_detail = (await admin_client.get(f"{RUNS_BASE}/{new_run_id}")).json()
assert len(run_detail["encounters"]) == 1 assert len(run_detail["encounters"]) == 1
@@ -318,56 +318,56 @@ class TestAdvanceLeg:
class TestGenlockeGraveyard: class TestGenlockeGraveyard:
async def test_returns_empty_graveyard(self, client: AsyncClient, ctx: dict): async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard") response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["entries"] == [] assert data["entries"] == []
assert data["totalDeaths"] == 0 assert data["totalDeaths"] == 0
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404 assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
class TestGenlockeLineages: class TestGenlockeLineages:
async def test_returns_empty_lineages(self, client: AsyncClient, ctx: dict): async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages") response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["lineages"] == [] assert data["lineages"] == []
assert data["totalLineages"] == 0 assert data["totalLineages"] == 0
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404 assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
class TestGenlockeRetiredFamilies: class TestGenlockeRetiredFamilies:
async def test_returns_empty_retired_families(self, client: AsyncClient, ctx: dict): async def test_returns_empty_retired_families(self, admin_client: AsyncClient, ctx: dict):
response = await client.get( response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["retired_pokemon_ids"] == [] assert data["retired_pokemon_ids"] == []
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert ( assert (
await client.get(f"{GENLOCKES_BASE}/9999/retired-families") await admin_client.get(f"{GENLOCKES_BASE}/9999/retired-families")
).status_code == 404 ).status_code == 404
class TestLegSurvivors: class TestLegSurvivors:
async def test_returns_survivors(self, client: AsyncClient, ctx: dict): async def test_returns_survivors(self, admin_client: AsyncClient, ctx: dict):
"""The one caught encounter in leg 1 shows up as a survivor.""" """The one caught encounter in leg 1 shows up as a survivor."""
response = await client.get( response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/survivors" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/survivors"
) )
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 1 assert len(response.json()) == 1
async def test_leg_not_found_returns_404(self, client: AsyncClient, ctx: dict): async def test_leg_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
assert ( assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors") await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
).status_code == 404 ).status_code == 404
@@ -385,13 +385,13 @@ BOSS_PAYLOAD = {
class TestBossCRUD: class TestBossCRUD:
async def test_empty_list(self, client: AsyncClient, games_ctx: dict): async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict):
response = await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses") response = await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == []
async def test_creates_boss(self, client: AsyncClient, games_ctx: dict): async def test_creates_boss(self, admin_client: AsyncClient, games_ctx: dict):
response = await client.post( response = await admin_client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -400,50 +400,50 @@ class TestBossCRUD:
assert data["levelCap"] == 14 assert data["levelCap"] == 14
assert data["pokemon"] == [] assert data["pokemon"] == []
async def test_updates_boss(self, client: AsyncClient, games_ctx: dict): async def test_updates_boss(self, admin_client: AsyncClient, games_ctx: dict):
boss = ( boss = (
await client.post( await admin_client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
) )
).json() ).json()
response = await client.put( response = await admin_client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}", f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}",
json={"levelCap": 20}, json={"levelCap": 20},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["levelCap"] == 20 assert response.json()["levelCap"] == 20
async def test_deletes_boss(self, client: AsyncClient, games_ctx: dict): async def test_deletes_boss(self, admin_client: AsyncClient, games_ctx: dict):
boss = ( boss = (
await client.post( await admin_client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
) )
).json() ).json()
assert ( assert (
await client.delete( await admin_client.delete(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}" f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}"
) )
).status_code == 204 ).status_code == 204
assert ( assert (
await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses") await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
).json() == [] ).json() == []
async def test_boss_not_found_returns_404( async def test_boss_not_found_returns_404(
self, client: AsyncClient, games_ctx: dict self, admin_client: AsyncClient, games_ctx: dict
): ):
assert ( assert (
await client.put( await admin_client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/9999", f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/9999",
json={"levelCap": 10}, json={"levelCap": 10},
) )
).status_code == 404 ).status_code == 404
async def test_invalid_game_returns_404(self, client: AsyncClient): async def test_invalid_game_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404 assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
async def test_game_without_version_group_returns_400(self, client: AsyncClient): async def test_game_without_version_group_returns_400(self, admin_client: AsyncClient):
game = ( game = (
await client.post( await admin_client.post(
GAMES_BASE, GAMES_BASE,
json={ json={
"name": "No VG", "name": "No VG",
@@ -454,7 +454,7 @@ class TestBossCRUD:
) )
).json() ).json()
assert ( assert (
await client.get(f"{GAMES_BASE}/{game['id']}/bosses") await admin_client.get(f"{GAMES_BASE}/{game['id']}/bosses")
).status_code == 400 ).status_code == 400
@@ -465,27 +465,27 @@ class TestBossCRUD:
class TestBossResults: class TestBossResults:
@pytest.fixture @pytest.fixture
async def boss_ctx(self, client: AsyncClient, games_ctx: dict) -> dict: async def boss_ctx(self, admin_client: AsyncClient, games_ctx: dict) -> dict:
"""A boss battle and a run for boss-result tests.""" """A boss battle and a run for boss-result tests."""
boss = ( boss = (
await client.post( await admin_client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
) )
).json() ).json()
run = ( run = (
await client.post( await admin_client.post(
RUNS_BASE, json={"gameId": games_ctx["game1_id"], "name": "Boss Run"} RUNS_BASE, json={"gameId": games_ctx["game1_id"], "name": "Boss Run"}
) )
).json() ).json()
return {"boss_id": boss["id"], "run_id": run["id"]} return {"boss_id": boss["id"], "run_id": run["id"]}
async def test_empty_list(self, client: AsyncClient, boss_ctx: dict): async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict):
response = await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") response = await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == []
async def test_creates_boss_result(self, client: AsyncClient, boss_ctx: dict): async def test_creates_boss_result(self, admin_client: AsyncClient, boss_ctx: dict):
response = await client.post( response = await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1}, json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
) )
@@ -495,13 +495,13 @@ class TestBossResults:
assert data["attempts"] == 1 assert data["attempts"] == 1
assert data["completedAt"] is not None assert data["completedAt"] is not None
async def test_upserts_existing_result(self, client: AsyncClient, boss_ctx: dict): async def test_upserts_existing_result(self, admin_client: AsyncClient, boss_ctx: dict):
"""POSTing the same boss twice updates the result (upsert).""" """POSTing the same boss twice updates the result (upsert)."""
await client.post( await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1}, json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
) )
response = await client.post( response = await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "lost", "attempts": 3}, json={"bossBattleId": boss_ctx["boss_id"], "result": "lost", "attempts": 3},
) )
@@ -510,31 +510,31 @@ class TestBossResults:
assert response.json()["attempts"] == 3 assert response.json()["attempts"] == 3
# Still only one record # Still only one record
all_results = ( all_results = (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() ).json()
assert len(all_results) == 1 assert len(all_results) == 1
async def test_deletes_boss_result(self, client: AsyncClient, boss_ctx: dict): async def test_deletes_boss_result(self, admin_client: AsyncClient, boss_ctx: dict):
result = ( result = (
await client.post( await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won"}, json={"bossBattleId": boss_ctx["boss_id"], "result": "won"},
) )
).json() ).json()
assert ( assert (
await client.delete( await admin_client.delete(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results/{result['id']}" f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results/{result['id']}"
) )
).status_code == 204 ).status_code == 204
assert ( assert (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() == [] ).json() == []
async def test_invalid_run_returns_404(self, client: AsyncClient, boss_ctx: dict): async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
assert (await client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404 assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
async def test_invalid_boss_returns_404(self, client: AsyncClient, boss_ctx: dict): async def test_invalid_boss_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
response = await client.post( response = await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": 9999, "result": "won"}, json={"bossBattleId": 9999, "result": "won"},
) )
@@ -547,8 +547,8 @@ class TestBossResults:
class TestStats: class TestStats:
async def test_returns_stats_structure(self, client: AsyncClient): async def test_returns_stats_structure(self, admin_client: AsyncClient):
response = await client.get(STATS_BASE) response = await admin_client.get(STATS_BASE)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["totalRuns"] == 0 assert data["totalRuns"] == 0
@@ -556,9 +556,9 @@ class TestStats:
assert data["topCaughtPokemon"] == [] assert data["topCaughtPokemon"] == []
assert data["typeDistribution"] == [] assert data["typeDistribution"] == []
async def test_reflects_created_data(self, client: AsyncClient, ctx: dict): async def test_reflects_created_data(self, admin_client: AsyncClient, ctx: dict):
"""Stats should reflect the run and encounter created in ctx.""" """Stats should reflect the run and encounter created in ctx."""
response = await client.get(STATS_BASE) response = await admin_client.get(STATS_BASE)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["totalRuns"] >= 1 assert data["totalRuns"] >= 1
@@ -572,23 +572,23 @@ class TestStats:
class TestExport: class TestExport:
async def test_export_games_returns_list(self, client: AsyncClient): async def test_export_games_returns_list(self, admin_client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/games") response = await admin_client.get(f"{EXPORT_BASE}/games")
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json(), list) assert isinstance(response.json(), list)
async def test_export_pokemon_returns_list(self, client: AsyncClient): async def test_export_pokemon_returns_list(self, admin_client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/pokemon") response = await admin_client.get(f"{EXPORT_BASE}/pokemon")
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json(), list) assert isinstance(response.json(), list)
async def test_export_evolutions_returns_list(self, client: AsyncClient): async def test_export_evolutions_returns_list(self, admin_client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/evolutions") response = await admin_client.get(f"{EXPORT_BASE}/evolutions")
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json(), list) assert isinstance(response.json(), list)
async def test_export_game_routes_not_found_returns_404(self, client: AsyncClient): async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404 assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
async def test_export_game_bosses_not_found_returns_404(self, client: AsyncClient): async def test_export_game_bosses_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404 assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404

View File

@@ -29,21 +29,21 @@ CHARMANDER_DATA = {
@pytest.fixture @pytest.fixture
async def pikachu(client: AsyncClient) -> dict: async def pikachu(admin_client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA) response = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201 assert response.status_code == 201
return response.json() return response.json()
@pytest.fixture @pytest.fixture
async def charmander(client: AsyncClient) -> dict: async def charmander(admin_client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=CHARMANDER_DATA) response = await admin_client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert response.status_code == 201 assert response.status_code == 201
return response.json() return response.json()
@pytest.fixture @pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict: async def ctx(db_session: AsyncSession, admin_client: AsyncClient) -> dict:
"""Full context: game + route + two pokemon + nuzlocke encounter on pikachu.""" """Full context: game + route + two pokemon + nuzlocke encounter on pikachu."""
vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg") vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg")
db_session.add(vg) db_session.add(vg)
@@ -63,11 +63,11 @@ async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict:
db_session.add(route) db_session.add(route)
await db_session.flush() await db_session.flush()
r1 = await client.post(POKEMON_BASE, json=PIKACHU_DATA) r1 = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert r1.status_code == 201 assert r1.status_code == 201
pikachu = r1.json() pikachu = r1.json()
r2 = await client.post(POKEMON_BASE, json=CHARMANDER_DATA) r2 = await admin_client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert r2.status_code == 201 assert r2.status_code == 201
charmander = r2.json() charmander = r2.json()
@@ -146,8 +146,8 @@ class TestListPokemon:
class TestCreatePokemon: class TestCreatePokemon:
async def test_creates_pokemon(self, client: AsyncClient): async def test_creates_pokemon(self, admin_client: AsyncClient):
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA) response = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["name"] == "pikachu" assert data["name"] == "pikachu"
@@ -156,16 +156,16 @@ class TestCreatePokemon:
assert isinstance(data["id"], int) assert isinstance(data["id"], int)
async def test_duplicate_pokeapi_id_returns_409( async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict self, admin_client: AsyncClient, pikachu: dict
): ):
response = await client.post( response = await admin_client.post(
POKEMON_BASE, POKEMON_BASE,
json={**PIKACHU_DATA, "name": "pikachu-copy"}, json={**PIKACHU_DATA, "name": "pikachu-copy"},
) )
assert response.status_code == 409 assert response.status_code == 409
async def test_missing_required_returns_422(self, client: AsyncClient): async def test_missing_required_returns_422(self, admin_client: AsyncClient):
response = await client.post(POKEMON_BASE, json={"name": "pikachu"}) response = await admin_client.post(POKEMON_BASE, json={"name": "pikachu"})
assert response.status_code == 422 assert response.status_code == 422
@@ -190,25 +190,25 @@ class TestGetPokemon:
class TestUpdatePokemon: class TestUpdatePokemon:
async def test_updates_name(self, client: AsyncClient, pikachu: dict): async def test_updates_name(self, admin_client: AsyncClient, pikachu: dict):
response = await client.put( response = await admin_client.put(
f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"} f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "Pikachu" assert response.json()["name"] == "Pikachu"
async def test_duplicate_pokeapi_id_returns_409( async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
response = await client.put( response = await admin_client.put(
f"{POKEMON_BASE}/{pikachu['id']}", f"{POKEMON_BASE}/{pikachu['id']}",
json={"pokeapiId": charmander["pokeapiId"]}, json={"pokeapiId": charmander["pokeapiId"]},
) )
assert response.status_code == 409 assert response.status_code == 409
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert ( assert (
await client.put(f"{POKEMON_BASE}/9999", json={"name": "x"}) await admin_client.put(f"{POKEMON_BASE}/9999", json={"name": "x"})
).status_code == 404 ).status_code == 404
@@ -218,22 +218,22 @@ class TestUpdatePokemon:
class TestDeletePokemon: class TestDeletePokemon:
async def test_deletes_pokemon(self, client: AsyncClient, charmander: dict): async def test_deletes_pokemon(self, admin_client: AsyncClient, charmander: dict):
assert ( assert (
await client.delete(f"{POKEMON_BASE}/{charmander['id']}") await admin_client.delete(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 204 ).status_code == 204
assert ( assert (
await client.get(f"{POKEMON_BASE}/{charmander['id']}") await admin_client.get(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 404 ).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.delete(f"{POKEMON_BASE}/9999")).status_code == 404 assert (await admin_client.delete(f"{POKEMON_BASE}/9999")).status_code == 404
async def test_pokemon_with_encounters_returns_409( async def test_pokemon_with_encounters_returns_409(
self, client: AsyncClient, ctx: dict self, admin_client: AsyncClient, ctx: dict
): ):
"""Pokemon referenced by a nuzlocke encounter cannot be deleted.""" """Pokemon referenced by a nuzlocke encounter cannot be deleted."""
response = await client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}") response = await admin_client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}")
assert response.status_code == 409 assert response.status_code == 409
@@ -249,9 +249,9 @@ class TestPokemonFamilies:
assert response.json()["families"] == [] assert response.json()["families"] == []
async def test_returns_family_grouping( async def test_returns_family_grouping(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
await client.post( await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -259,7 +259,7 @@ class TestPokemonFamilies:
"trigger": "level-up", "trigger": "level-up",
}, },
) )
response = await client.get(f"{POKEMON_BASE}/families") response = await admin_client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200 assert response.status_code == 200
families = response.json()["families"] families = response.json()["families"]
assert len(families) == 1 assert len(families) == 1
@@ -280,9 +280,9 @@ class TestPokemonEvolutionChain:
assert response.json() == [] assert response.json() == []
async def test_returns_chain_for_multi_stage( async def test_returns_chain_for_multi_stage(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
await client.post( await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -290,7 +290,7 @@ class TestPokemonEvolutionChain:
"trigger": "level-up", "trigger": "level-up",
}, },
) )
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain") response = await admin_client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200 assert response.status_code == 200
chain = response.json() chain = response.json()
assert len(chain) == 1 assert len(chain) == 1
@@ -317,9 +317,9 @@ class TestListEvolutions:
assert data["total"] == 0 assert data["total"] == 0
async def test_returns_created_evolution( async def test_returns_created_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
await client.post( await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -327,14 +327,14 @@ class TestListEvolutions:
"trigger": "level-up", "trigger": "level-up",
}, },
) )
response = await client.get(EVO_BASE) response = await admin_client.get(EVO_BASE)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["total"] == 1 assert response.json()["total"] == 1
async def test_filter_by_trigger( async def test_filter_by_trigger(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
await client.post( await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -342,9 +342,9 @@ class TestListEvolutions:
"trigger": "use-item", "trigger": "use-item",
}, },
) )
hit = await client.get(EVO_BASE, params={"trigger": "use-item"}) hit = await admin_client.get(EVO_BASE, params={"trigger": "use-item"})
assert hit.json()["total"] == 1 assert hit.json()["total"] == 1
miss = await client.get(EVO_BASE, params={"trigger": "level-up"}) miss = await admin_client.get(EVO_BASE, params={"trigger": "level-up"})
assert miss.json()["total"] == 0 assert miss.json()["total"] == 0
@@ -355,9 +355,9 @@ class TestListEvolutions:
class TestCreateEvolution: class TestCreateEvolution:
async def test_creates_evolution( async def test_creates_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
): ):
response = await client.post( response = await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -374,9 +374,9 @@ class TestCreateEvolution:
assert data["toPokemon"]["name"] == "charmander" assert data["toPokemon"]["name"] == "charmander"
async def test_invalid_from_pokemon_returns_404( async def test_invalid_from_pokemon_returns_404(
self, client: AsyncClient, charmander: dict self, admin_client: AsyncClient, charmander: dict
): ):
response = await client.post( response = await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": 9999, "fromPokemonId": 9999,
@@ -387,9 +387,9 @@ class TestCreateEvolution:
assert response.status_code == 404 assert response.status_code == 404
async def test_invalid_to_pokemon_returns_404( async def test_invalid_to_pokemon_returns_404(
self, client: AsyncClient, pikachu: dict self, admin_client: AsyncClient, pikachu: dict
): ):
response = await client.post( response = await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -408,9 +408,9 @@ class TestCreateEvolution:
class TestUpdateEvolution: class TestUpdateEvolution:
@pytest.fixture @pytest.fixture
async def evolution( async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
) -> dict: ) -> dict:
response = await client.post( response = await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -420,16 +420,16 @@ class TestUpdateEvolution:
) )
return response.json() return response.json()
async def test_updates_trigger(self, client: AsyncClient, evolution: dict): async def test_updates_trigger(self, admin_client: AsyncClient, evolution: dict):
response = await client.put( response = await admin_client.put(
f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"} f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["trigger"] == "use-item" assert response.json()["trigger"] == "use-item"
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert ( assert (
await client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"}) await admin_client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"})
).status_code == 404 ).status_code == 404
@@ -441,9 +441,9 @@ class TestUpdateEvolution:
class TestDeleteEvolution: class TestDeleteEvolution:
@pytest.fixture @pytest.fixture
async def evolution( async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict self, admin_client: AsyncClient, pikachu: dict, charmander: dict
) -> dict: ) -> dict:
response = await client.post( response = await admin_client.post(
EVO_BASE, EVO_BASE,
json={ json={
"fromPokemonId": pikachu["id"], "fromPokemonId": pikachu["id"],
@@ -453,12 +453,12 @@ class TestDeleteEvolution:
) )
return response.json() return response.json()
async def test_deletes_evolution(self, client: AsyncClient, evolution: dict): async def test_deletes_evolution(self, admin_client: AsyncClient, evolution: dict):
assert (await client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204 assert (await admin_client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204
assert (await client.get(EVO_BASE)).json()["total"] == 0 assert (await admin_client.get(EVO_BASE)).json()["total"] == 0
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await client.delete(f"{EVO_BASE}/9999")).status_code == 404 assert (await admin_client.delete(f"{EVO_BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -467,13 +467,13 @@ class TestDeleteEvolution:
class TestRouteEncounters: class TestRouteEncounters:
async def test_empty_list_for_route(self, client: AsyncClient, ctx: dict): async def test_empty_list_for_route(self, admin_client: AsyncClient, ctx: dict):
response = await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon") response = await admin_client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == []
async def test_creates_route_encounter(self, client: AsyncClient, ctx: dict): async def test_creates_route_encounter(self, admin_client: AsyncClient, ctx: dict):
response = await client.post( response = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={ json={
"pokemonId": ctx["charmander_id"], "pokemonId": ctx["charmander_id"],
@@ -490,8 +490,8 @@ class TestRouteEncounters:
assert data["encounterRate"] == 10 assert data["encounterRate"] == 10
assert data["pokemon"]["name"] == "charmander" assert data["pokemon"]["name"] == "charmander"
async def test_invalid_route_returns_404(self, client: AsyncClient, ctx: dict): async def test_invalid_route_returns_404(self, admin_client: AsyncClient, ctx: dict):
response = await client.post( response = await admin_client.post(
f"{ROUTE_BASE}/9999/pokemon", f"{ROUTE_BASE}/9999/pokemon",
json={ json={
"pokemonId": ctx["charmander_id"], "pokemonId": ctx["charmander_id"],
@@ -504,8 +504,8 @@ class TestRouteEncounters:
) )
assert response.status_code == 404 assert response.status_code == 404
async def test_invalid_pokemon_returns_404(self, client: AsyncClient, ctx: dict): async def test_invalid_pokemon_returns_404(self, admin_client: AsyncClient, ctx: dict):
response = await client.post( response = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={ json={
"pokemonId": 9999, "pokemonId": 9999,
@@ -518,8 +518,8 @@ class TestRouteEncounters:
) )
assert response.status_code == 404 assert response.status_code == 404
async def test_updates_route_encounter(self, client: AsyncClient, ctx: dict): async def test_updates_route_encounter(self, admin_client: AsyncClient, ctx: dict):
r = await client.post( r = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={ json={
"pokemonId": ctx["charmander_id"], "pokemonId": ctx["charmander_id"],
@@ -531,23 +531,23 @@ class TestRouteEncounters:
}, },
) )
enc = r.json() enc = r.json()
response = await client.put( response = await admin_client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}",
json={"encounterRate": 25}, json={"encounterRate": 25},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["encounterRate"] == 25 assert response.json()["encounterRate"] == 25
async def test_update_not_found_returns_404(self, client: AsyncClient, ctx: dict): async def test_update_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
assert ( assert (
await client.put( await admin_client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999",
json={"encounterRate": 5}, json={"encounterRate": 5},
) )
).status_code == 404 ).status_code == 404
async def test_deletes_route_encounter(self, client: AsyncClient, ctx: dict): async def test_deletes_route_encounter(self, admin_client: AsyncClient, ctx: dict):
r = await client.post( r = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={ json={
"pokemonId": ctx["charmander_id"], "pokemonId": ctx["charmander_id"],
@@ -560,13 +560,13 @@ class TestRouteEncounters:
) )
enc = r.json() enc = r.json()
assert ( assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}") await admin_client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}")
).status_code == 204 ).status_code == 204
assert ( assert (
await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon") await admin_client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
).json() == [] ).json() == []
async def test_delete_not_found_returns_404(self, client: AsyncClient, ctx: dict): async def test_delete_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
assert ( assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999") await admin_client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999")
).status_code == 404 ).status_code == 404

View File

@@ -1,5 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components' import { Layout, ProtectedRoute, AdminRoute } from './components'
import { AdminLayout } from './components/admin' import { AdminLayout } from './components/admin'
import { import {
AuthCallback, AuthCallback,
@@ -35,18 +35,18 @@ function App() {
<Route path="signup" element={<Signup />} /> <Route path="signup" element={<Signup />} />
<Route path="auth/callback" element={<AuthCallback />} /> <Route path="auth/callback" element={<AuthCallback />} />
<Route path="runs" element={<RunList />} /> <Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} /> <Route path="runs/new" element={<ProtectedRoute><NewRun /></ProtectedRoute>} />
<Route path="runs/:runId" element={<RunEncounters />} /> <Route path="runs/:runId" element={<RunEncounters />} />
<Route path="runs/:runId/journal/:entryId" element={<JournalEntryPage />} /> <Route path="runs/:runId/journal/:entryId" element={<JournalEntryPage />} />
<Route path="genlockes" element={<GenlockeList />} /> <Route path="genlockes" element={<GenlockeList />} />
<Route path="genlockes/new" element={<NewGenlocke />} /> <Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} /> <Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
<Route path="stats" element={<Stats />} /> <Route path="stats" element={<Stats />} />
<Route <Route
path="runs/:runId/encounters" path="runs/:runId/encounters"
element={<Navigate to=".." relative="path" replace />} element={<Navigate to=".." relative="path" replace />}
/> />
<Route path="admin" element={<AdminLayout />}> <Route path="admin" element={<AdminRoute><AdminLayout /></AdminRoute>}>
<Route index element={<Navigate to="/admin/games" replace />} /> <Route index element={<Navigate to="/admin/games" replace />} />
<Route path="games" element={<AdminGames />} /> <Route path="games" element={<AdminGames />} />
<Route path="games/:gameId" element={<AdminGameDetail />} /> <Route path="games/:gameId" element={<AdminGameDetail />} />

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { useAuth } from '../contexts/AuthContext'
export function AdminRoute({ children }: { children: React.ReactNode }) {
const { user, loading, isAdmin } = useAuth()
const location = useLocation()
const toastShownRef = useRef(false)
useEffect(() => {
if (!loading && user && !isAdmin && !toastShownRef.current) {
toastShownRef.current = true
toast.error('Admin access required')
}
}, [loading, user, isAdmin])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500" />
</div>
)
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (!isAdmin) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@@ -2,62 +2,108 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout' import { Layout } from './Layout'
import { AuthProvider } from '../contexts/AuthContext'
vi.mock('../hooks/useTheme', () => ({ vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }), useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
})) }))
const mockUseAuth = vi.fn()
vi.mock('../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}))
const loggedOutAuth = {
user: null,
session: null,
loading: false,
isAdmin: false,
signInWithEmail: vi.fn(),
signUpWithEmail: vi.fn(),
signInWithGoogle: vi.fn(),
signInWithDiscord: vi.fn(),
signOut: vi.fn(),
}
const adminAuth = {
...loggedOutAuth,
user: { email: 'admin@example.com' },
session: {},
isAdmin: true,
}
function renderLayout(initialPath = '/') { function renderLayout(initialPath = '/') {
return render( return render(
<MemoryRouter initialEntries={[initialPath]}> <MemoryRouter initialEntries={[initialPath]}>
<AuthProvider> <Layout />
<Layout />
</AuthProvider>
</MemoryRouter> </MemoryRouter>
) )
} }
describe('Layout', () => { describe('Layout', () => {
it('renders all desktop navigation links', () => { describe('when logged out', () => {
renderLayout() beforeEach(() => mockUseAuth.mockReturnValue(loggedOutAuth))
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument() it('renders logged-out navigation links', () => {
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument() renderLayout()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument() expect(screen.getAllByRole('link', { name: /^home$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument() expect(screen.getAllByRole('link', { name: /^runs$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
})
it('does not show authenticated links', () => {
renderLayout()
expect(screen.queryByRole('link', { name: /new run/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /my runs/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /admin/i })).not.toBeInTheDocument()
})
it('shows sign-in link', () => {
renderLayout()
expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument()
})
})
describe('when logged in as admin', () => {
beforeEach(() => mockUseAuth.mockReturnValue(adminAuth))
it('renders authenticated navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
}) })
it('renders the brand logo link', () => { it('renders the brand logo link', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout() renderLayout()
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
}) })
it('renders the theme toggle button', () => { it('renders the theme toggle button', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout() renderLayout()
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument() expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
}) })
it('initially hides the mobile dropdown menu', () => { it('initially hides the mobile dropdown menu', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout() renderLayout()
// Mobile menu items exist in DOM but menu is hidden; the mobile dropdown
// only appears inside the sm:hidden block after state toggle.
// The hamburger button should be present.
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
}) })
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
// After click, the menu open state adds a dropdown with nav links
// We can verify the menu is open by checking a class change or that
// the nav links appear in the mobile dropdown section.
// The mobile dropdown renders navLinks in a div inside sm:hidden
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
it('renders the footer with PokeDB attribution', () => { it('renders the footer with PokeDB attribution', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout() renderLayout()
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
}) })

View File

@@ -1,16 +1,8 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom' import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme' import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
const navLinks = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
{ to: '/admin', label: 'Admin' },
]
function NavLink({ function NavLink({
to, to,
active, active,
@@ -136,9 +128,34 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
export function Layout() { export function Layout() {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation() const location = useLocation()
const { user, isAdmin } = useAuth()
const navLinks = useMemo(() => {
if (!user) {
// Logged out: Home, Runs, Genlockes, Stats
return [
{ to: '/', label: 'Home' },
{ to: '/runs', label: 'Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
}
// Logged in: New Run, My Runs, Genlockes, Stats
const links = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
// Admin gets Admin link
if (isAdmin) {
links.push({ to: '/admin', label: 'Admin' })
}
return links
}, [user, isAdmin])
function isActive(to: string) { function isActive(to: string) {
if (to === '/runs/new') return location.pathname === '/runs/new' if (to === '/' || to === '/runs/new') return location.pathname === to
return location.pathname.startsWith(to) return location.pathname.startsWith(to)
} }

View File

@@ -1,3 +1,4 @@
export { AdminRoute } from './AdminRoute'
export { CustomRulesDisplay } from './CustomRulesDisplay' export { CustomRulesDisplay } from './CustomRulesDisplay'
export { ProtectedRoute } from './ProtectedRoute' export { ProtectedRoute } from './ProtectedRoute'
export { EggEncounterModal } from './EggEncounterModal' export { EggEncounterModal } from './EggEncounterModal'

View File

@@ -1,11 +1,20 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import type { User, Session, AuthError } from '@supabase/supabase-js' import type { User, Session, AuthError } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase' import { supabase } from '../lib/supabase'
import { api } from '../api/client'
interface UserProfile {
id: string
email: string
displayName: string | null
isAdmin: boolean
}
interface AuthState { interface AuthState {
user: User | null user: User | null
session: Session | null session: Session | null
loading: boolean loading: boolean
isAdmin: boolean
} }
interface AuthContextValue extends AuthState { interface AuthContextValue extends AuthState {
@@ -18,22 +27,35 @@ interface AuthContextValue extends AuthState {
const AuthContext = createContext<AuthContextValue | null>(null) const AuthContext = createContext<AuthContextValue | null>(null)
async function syncUserProfile(session: Session | null): Promise<boolean> {
if (!session) return false
try {
const profile = await api.post<UserProfile>('/users/me', {})
return profile.isAdmin
} catch {
return false
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ const [state, setState] = useState<AuthState>({
user: null, user: null,
session: null, session: null,
loading: true, loading: true,
isAdmin: false,
}) })
useEffect(() => { useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => { supabase.auth.getSession().then(async ({ data: { session } }) => {
setState({ user: session?.user ?? null, session, loading: false }) const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
}) })
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => { } = supabase.auth.onAuthStateChange(async (_event, session) => {
setState({ user: session?.user ?? null, session, loading: false }) const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
}) })
return () => subscription.unsubscribe() return () => subscription.unsubscribe()