Release: test infrastructure, rules overhaul, and design refresh #30
@@ -1,26 +1,31 @@
|
||||
---
|
||||
# nuzlocke-tracker-ch77
|
||||
title: Integration tests for Games & Routes API
|
||||
status: draft
|
||||
status: completed
|
||||
type: task
|
||||
priority: normal
|
||||
created_at: 2026-02-10T09:33:13Z
|
||||
updated_at: 2026-02-10T09:33:13Z
|
||||
updated_at: 2026-02-21T11:48:10Z
|
||||
parent: nuzlocke-tracker-yzpb
|
||||
---
|
||||
|
||||
Write integration tests for the games and routes API endpoints in `backend/src/app/api/games.py`.
|
||||
Write integration tests for the games and routes API endpoints in backend/src/app/api/games.py.
|
||||
|
||||
## Key behaviors to test
|
||||
|
||||
- Game CRUD: create (201), list, get with routes, update, delete (204)
|
||||
- Slug uniqueness enforced at create and update (409)
|
||||
- 404 for missing games
|
||||
- 422 for invalid request bodies
|
||||
- Route operations require version_group_id on the game (need VersionGroup fixture via db_session)
|
||||
- list_game_routes only returns routes with encounters (or parents of routes with encounters)
|
||||
- Game detail (GET /{id}) returns all routes regardless
|
||||
- Route create, update, delete, reorder
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Test CRUD operations for games (create, list, get, update, delete)
|
||||
- [ ] Test route management within a game (create, list, reorder, update, delete)
|
||||
- [ ] Test route encounter management (add/remove Pokemon to routes)
|
||||
- [ ] Test bulk import functionality
|
||||
- [ ] Test region grouping/filtering
|
||||
- [ ] Test error cases (404 for missing games, validation errors, duplicate handling)
|
||||
|
||||
## Notes
|
||||
|
||||
- Use the httpx AsyncClient fixture from the test infrastructure task
|
||||
- Each test should be independent — use fixtures to set up required data
|
||||
- Test both success and error response codes and bodies
|
||||
- [x] Test CRUD operations for games (create, list, get, update, delete)
|
||||
- [x] Test route management within a game (create, list, update, delete, reorder)
|
||||
- [x] Test error cases (404, 409 duplicate slug, 422 validation)
|
||||
- [x] Test list_game_routes filtering behavior (empty routes excluded)
|
||||
- [x] Test by-region endpoint structure
|
||||
320
backend/tests/test_games.py
Normal file
320
backend/tests/test_games.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""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(client: AsyncClient) -> dict:
|
||||
"""A game created via the API (no version_group_id)."""
|
||||
response = await 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, client: AsyncClient):
|
||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Pokemon Red"
|
||||
assert data["slug"] == "red"
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
|
||||
response = await client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_missing_required_field_returns_422(self, client: AsyncClient):
|
||||
response = await client.post(BASE, json={"name": "Pokemon Red"})
|
||||
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, client: AsyncClient, game: dict):
|
||||
response = await client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Pokemon Blue"
|
||||
|
||||
async def test_slug_unchanged_on_partial_update(
|
||||
self, client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
|
||||
assert response.json()["slug"] == "red"
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
|
||||
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
|
||||
r1 = await client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
||||
)
|
||||
game_id = r1.json()["id"]
|
||||
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Games — delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteGame:
|
||||
async def test_deletes_game(self, client: AsyncClient, game: dict):
|
||||
response = await client.delete(f"{BASE}/{game['id']}")
|
||||
assert response.status_code == 204
|
||||
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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, client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
response = await 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, client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await client.get(f"{BASE}/{game_id}")
|
||||
routes = response.json()["routes"]
|
||||
assert len(routes) == 1
|
||||
assert routes[0]["name"] == "Route 1"
|
||||
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.post(
|
||||
f"{BASE}/{game['id']}/routes",
|
||||
json={"name": "Route 1", "order": 1},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_list_routes_excludes_routes_without_encounters(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
"""list_game_routes only returns routes that have Pokemon encounters."""
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await 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, client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
||||
)
|
||||
).json()
|
||||
response = await 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, client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (
|
||||
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
).json()
|
||||
assert (
|
||||
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
).status_code == 204
|
||||
# No longer in game detail
|
||||
detail = (await client.get(f"{BASE}/{game_id}")).json()
|
||||
assert all(route["id"] != r["id"] for route in detail["routes"])
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — reorder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReorderRoutes:
|
||||
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r1 = (
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
||||
)
|
||||
).json()
|
||||
r2 = (
|
||||
await client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
||||
)
|
||||
).json()
|
||||
|
||||
response = await 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
|
||||
Reference in New Issue
Block a user