diff --git a/.beans/nuzlocke-tracker-ch77--integration-tests-for-games-routes-api.md b/.beans/nuzlocke-tracker-ch77--integration-tests-for-games-routes-api.md index f2dc843..1c6186b 100644 --- a/.beans/nuzlocke-tracker-ch77--integration-tests-for-games-routes-api.md +++ b/.beans/nuzlocke-tracker-ch77--integration-tests-for-games-routes-api.md @@ -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 \ No newline at end of file +- [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 \ No newline at end of file diff --git a/backend/tests/test_games.py b/backend/tests/test_games.py new file mode 100644 index 0000000..0626b1c --- /dev/null +++ b/backend/tests/test_games.py @@ -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