Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Integration tests for the Games & Routes API."""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.game import Game
|
|
from app.models.version_group import VersionGroup
|
|
|
|
BASE = "/api/v1/games"
|
|
GAME_PAYLOAD = {
|
|
"name": "Pokemon Red",
|
|
"slug": "red",
|
|
"generation": 1,
|
|
"region": "kanto",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
async def game(auth_client: AsyncClient) -> dict:
|
|
"""A game created via the API (no version_group_id)."""
|
|
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
|
assert response.status_code == 201
|
|
return response.json()
|
|
|
|
|
|
@pytest.fixture
|
|
async def game_with_vg(db_session: AsyncSession) -> tuple[int, int]:
|
|
"""A game with a VersionGroup, required for route operations."""
|
|
vg = VersionGroup(name="Red/Blue", slug="red-blue")
|
|
db_session.add(vg)
|
|
await db_session.flush()
|
|
|
|
g = Game(
|
|
name="Pokemon Red",
|
|
slug="red-vg",
|
|
generation=1,
|
|
region="kanto",
|
|
version_group_id=vg.id,
|
|
)
|
|
db_session.add(g)
|
|
await db_session.commit()
|
|
await db_session.refresh(g)
|
|
return g.id, vg.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListGames:
|
|
async def test_empty_returns_empty_list(self, client: AsyncClient):
|
|
response = await client.get(BASE)
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
async def test_returns_created_game(self, client: AsyncClient, game: dict):
|
|
response = await client.get(BASE)
|
|
assert response.status_code == 200
|
|
slugs = [g["slug"] for g in response.json()]
|
|
assert "red" in slugs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateGame:
|
|
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
|
|
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "Pokemon Red"
|
|
assert data["slug"] == "red"
|
|
assert isinstance(data["id"], int)
|
|
|
|
async def test_duplicate_slug_returns_409(
|
|
self, auth_client: AsyncClient, game: dict
|
|
):
|
|
response = await auth_client.post(
|
|
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
|
)
|
|
assert response.status_code == 409
|
|
|
|
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
|
|
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — get
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetGame:
|
|
async def test_returns_game_with_empty_routes(
|
|
self, client: AsyncClient, game: dict
|
|
):
|
|
response = await client.get(f"{BASE}/{game['id']}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == game["id"]
|
|
assert data["slug"] == "red"
|
|
assert data["routes"] == []
|
|
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
|
assert (await client.get(f"{BASE}/9999")).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateGame:
|
|
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
|
|
response = await auth_client.put(
|
|
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "Pokemon Blue"
|
|
|
|
async def test_slug_unchanged_on_partial_update(
|
|
self, auth_client: AsyncClient, game: dict
|
|
):
|
|
response = await auth_client.put(
|
|
f"{BASE}/{game['id']}", json={"name": "New Name"}
|
|
)
|
|
assert response.json()["slug"] == "red"
|
|
|
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
|
assert (
|
|
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
|
|
).status_code == 404
|
|
|
|
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
|
|
await auth_client.post(
|
|
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
|
|
)
|
|
r1 = await auth_client.post(
|
|
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
|
)
|
|
game_id = r1.json()["id"]
|
|
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
|
assert response.status_code == 409
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteGame:
|
|
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
|
|
response = await auth_client.delete(f"{BASE}/{game['id']}")
|
|
assert response.status_code == 204
|
|
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
|
|
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
|
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Games — by-region
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListByRegion:
|
|
async def test_returns_list(self, client: AsyncClient):
|
|
response = await client.get(f"{BASE}/by-region")
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
async def test_region_structure(self, client: AsyncClient):
|
|
response = await client.get(f"{BASE}/by-region")
|
|
regions = response.json()
|
|
assert len(regions) > 0
|
|
first = regions[0]
|
|
assert "name" in first
|
|
assert "generation" in first
|
|
assert "games" in first
|
|
assert isinstance(first["games"], list)
|
|
|
|
async def test_game_appears_in_region(self, client: AsyncClient, game: dict):
|
|
response = await client.get(f"{BASE}/by-region")
|
|
all_games = [g for region in response.json() for g in region["games"]]
|
|
assert any(g["slug"] == "red" for g in all_games)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes — create / get
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateRoute:
|
|
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
|
game_id, _ = game_with_vg
|
|
response = await auth_client.post(
|
|
f"{BASE}/{game_id}/routes",
|
|
json={"name": "Pallet Town", "order": 1},
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "Pallet Town"
|
|
assert data["order"] == 1
|
|
assert isinstance(data["id"], int)
|
|
|
|
async def test_game_detail_includes_route(
|
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
|
):
|
|
game_id, _ = game_with_vg
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
|
)
|
|
response = await auth_client.get(f"{BASE}/{game_id}")
|
|
routes = response.json()["routes"]
|
|
assert len(routes) == 1
|
|
assert routes[0]["name"] == "Route 1"
|
|
|
|
async def test_game_without_version_group_returns_400(
|
|
self, auth_client: AsyncClient, game: dict
|
|
):
|
|
response = await auth_client.post(
|
|
f"{BASE}/{game['id']}/routes",
|
|
json={"name": "Route 1", "order": 1},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
async def test_list_routes_excludes_routes_without_encounters(
|
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
|
):
|
|
"""list_game_routes only returns routes that have Pokemon encounters."""
|
|
game_id, _ = game_with_vg
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
|
)
|
|
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes — update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateRoute:
|
|
async def test_updates_route_name(
|
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
|
):
|
|
game_id, _ = game_with_vg
|
|
r = (
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
|
)
|
|
).json()
|
|
response = await auth_client.put(
|
|
f"{BASE}/{game_id}/routes/{r['id']}",
|
|
json={"name": "New Name"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "New Name"
|
|
|
|
async def test_route_not_found_returns_404(
|
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
|
):
|
|
game_id, _ = game_with_vg
|
|
assert (
|
|
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
|
).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes — delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteRoute:
|
|
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
|
game_id, _ = game_with_vg
|
|
r = (
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
|
)
|
|
).json()
|
|
assert (
|
|
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
|
).status_code == 204
|
|
# No longer in game detail
|
|
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
|
|
assert all(route["id"] != r["id"] for route in detail["routes"])
|
|
|
|
async def test_route_not_found_returns_404(
|
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
|
):
|
|
game_id, _ = game_with_vg
|
|
assert (
|
|
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
|
|
).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes — reorder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReorderRoutes:
|
|
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
|
|
game_id, _ = game_with_vg
|
|
r1 = (
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
|
)
|
|
).json()
|
|
r2 = (
|
|
await auth_client.post(
|
|
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
|
)
|
|
).json()
|
|
|
|
response = await auth_client.put(
|
|
f"{BASE}/{game_id}/routes/reorder",
|
|
json={
|
|
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
by_id = {r["id"]: r["order"] for r in response.json()}
|
|
assert by_id[r1["id"]] == 2
|
|
assert by_id[r2["id"]] == 1
|