Add integration tests for Games & Routes API

25 tests covering game CRUD (create/list/get/update/delete), slug
uniqueness enforcement, by-region grouping, and route operations
(create/update/delete/reorder). Verifies that list_game_routes
excludes routes with no Pokemon encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 12:51:37 +01:00
parent 4aae12cd72
commit 79eabf4f9f
2 changed files with 340 additions and 15 deletions

View File

@@ -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
View 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