Release: test infrastructure, rules overhaul, and design refresh #30
@@ -1,26 +1,31 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-ch77
|
# nuzlocke-tracker-ch77
|
||||||
title: Integration tests for Games & Routes API
|
title: Integration tests for Games & Routes API
|
||||||
status: draft
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-10T09:33:13Z
|
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
|
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
|
## Checklist
|
||||||
|
|
||||||
- [ ] Test CRUD operations for games (create, list, get, update, delete)
|
- [x] Test CRUD operations for games (create, list, get, update, delete)
|
||||||
- [ ] Test route management within a game (create, list, reorder, update, delete)
|
- [x] Test route management within a game (create, list, update, delete, reorder)
|
||||||
- [ ] Test route encounter management (add/remove Pokemon to routes)
|
- [x] Test error cases (404, 409 duplicate slug, 422 validation)
|
||||||
- [ ] Test bulk import functionality
|
- [x] Test list_game_routes filtering behavior (empty routes excluded)
|
||||||
- [ ] Test region grouping/filtering
|
- [x] Test by-region endpoint structure
|
||||||
- [ ] 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
|
|
||||||
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