From 34835abe0c88c613866bfc0e4b158e5785a4b820 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Feb 2026 13:15:00 +0100 Subject: [PATCH] Add integration tests for Pokemon & Evolutions API Co-Authored-By: Claude Opus 4.6 --- ...ration-tests-for-pokemon-evolutions-api.md | 15 +- backend/tests/test_pokemon.py | 572 ++++++++++++++++++ 2 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_pokemon.py diff --git a/.beans/nuzlocke-tracker-ugb7--integration-tests-for-pokemon-evolutions-api.md b/.beans/nuzlocke-tracker-ugb7--integration-tests-for-pokemon-evolutions-api.md index b2b2b95..efb1882 100644 --- a/.beans/nuzlocke-tracker-ugb7--integration-tests-for-pokemon-evolutions-api.md +++ b/.beans/nuzlocke-tracker-ugb7--integration-tests-for-pokemon-evolutions-api.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-ugb7 title: Integration tests for Pokemon & Evolutions API -status: draft +status: completed type: task +priority: normal created_at: 2026-02-10T09:33:16Z -updated_at: 2026-02-10T09:33:16Z +updated_at: 2026-02-21T12:14:39Z parent: nuzlocke-tracker-yzpb --- @@ -12,11 +13,11 @@ Write integration tests for the Pokemon and evolutions API endpoints. ## Checklist -- [ ] Test Pokemon CRUD operations (create, list, search, update, delete) -- [ ] Test Pokemon filtering and search -- [ ] Test evolution chain CRUD (create, list, get, update, delete) -- [ ] Test evolution family resolution endpoint -- [ ] Test error cases (invalid Pokemon references, circular evolutions, etc.) +- [x] Test Pokemon CRUD operations (create, list, search, update, delete) +- [x] Test Pokemon filtering and search +- [x] Test evolution chain CRUD (create, list, get, update, delete) +- [x] Test evolution family resolution endpoint +- [x] Test error cases (invalid Pokemon references, circular evolutions, etc.) ## Notes diff --git a/backend/tests/test_pokemon.py b/backend/tests/test_pokemon.py new file mode 100644 index 0000000..f11e96f --- /dev/null +++ b/backend/tests/test_pokemon.py @@ -0,0 +1,572 @@ +"""Integration tests for the Pokemon & Evolutions API.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.encounter import Encounter +from app.models.game import Game +from app.models.nuzlocke_run import NuzlockeRun +from app.models.route import Route +from app.models.version_group import VersionGroup + +POKEMON_BASE = "/api/v1/pokemon" +EVO_BASE = "/api/v1/evolutions" +ROUTE_BASE = "/api/v1/routes" + +PIKACHU_DATA = { + "pokeapiId": 25, + "nationalDex": 25, + "name": "pikachu", + "types": ["electric"], +} +CHARMANDER_DATA = { + "pokeapiId": 4, + "nationalDex": 4, + "name": "charmander", + "types": ["fire"], +} + + +@pytest.fixture +async def pikachu(client: AsyncClient) -> dict: + response = await client.post(POKEMON_BASE, json=PIKACHU_DATA) + assert response.status_code == 201 + return response.json() + + +@pytest.fixture +async def charmander(client: AsyncClient) -> dict: + response = await client.post(POKEMON_BASE, json=CHARMANDER_DATA) + assert response.status_code == 201 + return response.json() + + +@pytest.fixture +async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict: + """Full context: game + route + two pokemon + nuzlocke encounter on pikachu.""" + vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg") + db_session.add(vg) + await db_session.flush() + + game = Game( + name="Poke Game", + slug="poke-game", + generation=1, + region="kanto", + version_group_id=vg.id, + ) + db_session.add(game) + await db_session.flush() + + route = Route(name="Poke Route", version_group_id=vg.id, order=1) + db_session.add(route) + await db_session.flush() + + r1 = await client.post(POKEMON_BASE, json=PIKACHU_DATA) + assert r1.status_code == 201 + pikachu = r1.json() + + r2 = await client.post(POKEMON_BASE, json=CHARMANDER_DATA) + assert r2.status_code == 201 + charmander = r2.json() + + run = NuzlockeRun(game_id=game.id, name="Poke Run", status="active", rules={}) + db_session.add(run) + await db_session.flush() + + # Nuzlocke encounter on pikachu — prevents pokemon deletion (409) + enc = Encounter( + run_id=run.id, + route_id=route.id, + pokemon_id=pikachu["id"], + status="caught", + ) + db_session.add(enc) + await db_session.commit() + + return { + "game_id": game.id, + "route_id": route.id, + "pikachu_id": pikachu["id"], + "charmander_id": charmander["id"], + } + + +# --------------------------------------------------------------------------- +# Pokemon — list +# --------------------------------------------------------------------------- + + +class TestListPokemon: + async def test_empty_returns_paginated_response(self, client: AsyncClient): + response = await client.get(POKEMON_BASE) + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + async def test_returns_created_pokemon(self, client: AsyncClient, pikachu: dict): + response = await client.get(POKEMON_BASE) + assert response.status_code == 200 + names = [p["name"] for p in response.json()["items"]] + assert "pikachu" in names + + async def test_search_by_name( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + response = await client.get(POKEMON_BASE, params={"search": "pika"}) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["name"] == "pikachu" + + async def test_filter_by_type( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + response = await client.get(POKEMON_BASE, params={"type": "electric"}) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["name"] == "pikachu" + + async def test_pagination( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + response = await client.get(POKEMON_BASE, params={"limit": 1, "offset": 0}) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 2 + + +# --------------------------------------------------------------------------- +# Pokemon — create +# --------------------------------------------------------------------------- + + +class TestCreatePokemon: + async def test_creates_pokemon(self, client: AsyncClient): + response = await client.post(POKEMON_BASE, json=PIKACHU_DATA) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "pikachu" + assert data["pokeapiId"] == 25 + assert data["types"] == ["electric"] + assert isinstance(data["id"], int) + + async def test_duplicate_pokeapi_id_returns_409( + self, client: AsyncClient, pikachu: dict + ): + response = await client.post( + POKEMON_BASE, + json={**PIKACHU_DATA, "name": "pikachu-copy"}, + ) + assert response.status_code == 409 + + async def test_missing_required_returns_422(self, client: AsyncClient): + response = await client.post(POKEMON_BASE, json={"name": "pikachu"}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Pokemon — get +# --------------------------------------------------------------------------- + + +class TestGetPokemon: + async def test_returns_pokemon(self, client: AsyncClient, pikachu: dict): + response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}") + assert response.status_code == 200 + assert response.json()["name"] == "pikachu" + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{POKEMON_BASE}/9999")).status_code == 404 + + +# --------------------------------------------------------------------------- +# Pokemon — update +# --------------------------------------------------------------------------- + + +class TestUpdatePokemon: + async def test_updates_name(self, client: AsyncClient, pikachu: dict): + response = await client.put( + f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "Pikachu" + + async def test_duplicate_pokeapi_id_returns_409( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + response = await client.put( + f"{POKEMON_BASE}/{pikachu['id']}", + json={"pokeapiId": charmander["pokeapiId"]}, + ) + assert response.status_code == 409 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert ( + await client.put(f"{POKEMON_BASE}/9999", json={"name": "x"}) + ).status_code == 404 + + +# --------------------------------------------------------------------------- +# Pokemon — delete +# --------------------------------------------------------------------------- + + +class TestDeletePokemon: + async def test_deletes_pokemon(self, client: AsyncClient, charmander: dict): + assert ( + await client.delete(f"{POKEMON_BASE}/{charmander['id']}") + ).status_code == 204 + assert ( + await client.get(f"{POKEMON_BASE}/{charmander['id']}") + ).status_code == 404 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.delete(f"{POKEMON_BASE}/9999")).status_code == 404 + + async def test_pokemon_with_encounters_returns_409( + self, client: AsyncClient, ctx: dict + ): + """Pokemon referenced by a nuzlocke encounter cannot be deleted.""" + response = await client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}") + assert response.status_code == 409 + + +# --------------------------------------------------------------------------- +# Pokemon — families +# --------------------------------------------------------------------------- + + +class TestPokemonFamilies: + async def test_empty_when_no_evolutions(self, client: AsyncClient): + response = await client.get(f"{POKEMON_BASE}/families") + assert response.status_code == 200 + assert response.json()["families"] == [] + + async def test_returns_family_grouping( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + response = await client.get(f"{POKEMON_BASE}/families") + assert response.status_code == 200 + families = response.json()["families"] + assert len(families) == 1 + assert set(families[0]) == {pikachu["id"], charmander["id"]} + + +# --------------------------------------------------------------------------- +# Pokemon — evolution chain +# --------------------------------------------------------------------------- + + +class TestPokemonEvolutionChain: + async def test_empty_for_unevolved_pokemon( + self, client: AsyncClient, pikachu: dict + ): + response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain") + assert response.status_code == 200 + assert response.json() == [] + + async def test_returns_chain_for_multi_stage( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain") + assert response.status_code == 200 + chain = response.json() + assert len(chain) == 1 + assert chain[0]["fromPokemonId"] == pikachu["id"] + assert chain[0]["toPokemonId"] == charmander["id"] + + async def test_not_found_returns_404(self, client: AsyncClient): + assert ( + await client.get(f"{POKEMON_BASE}/9999/evolution-chain") + ).status_code == 404 + + +# --------------------------------------------------------------------------- +# Evolutions — list +# --------------------------------------------------------------------------- + + +class TestListEvolutions: + async def test_empty_returns_paginated_response(self, client: AsyncClient): + response = await client.get(EVO_BASE) + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + async def test_returns_created_evolution( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + response = await client.get(EVO_BASE) + assert response.status_code == 200 + assert response.json()["total"] == 1 + + async def test_filter_by_trigger( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "use-item", + }, + ) + hit = await client.get(EVO_BASE, params={"trigger": "use-item"}) + assert hit.json()["total"] == 1 + miss = await client.get(EVO_BASE, params={"trigger": "level-up"}) + assert miss.json()["total"] == 0 + + +# --------------------------------------------------------------------------- +# Evolutions — create +# --------------------------------------------------------------------------- + + +class TestCreateEvolution: + async def test_creates_evolution( + self, client: AsyncClient, pikachu: dict, charmander: dict + ): + response = await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["fromPokemonId"] == pikachu["id"] + assert data["toPokemonId"] == charmander["id"] + assert data["trigger"] == "level-up" + assert data["fromPokemon"]["name"] == "pikachu" + assert data["toPokemon"]["name"] == "charmander" + + async def test_invalid_from_pokemon_returns_404( + self, client: AsyncClient, charmander: dict + ): + response = await client.post( + EVO_BASE, + json={ + "fromPokemonId": 9999, + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + assert response.status_code == 404 + + async def test_invalid_to_pokemon_returns_404( + self, client: AsyncClient, pikachu: dict + ): + response = await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": 9999, + "trigger": "level-up", + }, + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Evolutions — update +# --------------------------------------------------------------------------- + + +class TestUpdateEvolution: + @pytest.fixture + async def evolution( + self, client: AsyncClient, pikachu: dict, charmander: dict + ) -> dict: + response = await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + return response.json() + + async def test_updates_trigger(self, client: AsyncClient, evolution: dict): + response = await client.put( + f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"} + ) + assert response.status_code == 200 + assert response.json()["trigger"] == "use-item" + + async def test_not_found_returns_404(self, client: AsyncClient): + assert ( + await client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"}) + ).status_code == 404 + + +# --------------------------------------------------------------------------- +# Evolutions — delete +# --------------------------------------------------------------------------- + + +class TestDeleteEvolution: + @pytest.fixture + async def evolution( + self, client: AsyncClient, pikachu: dict, charmander: dict + ) -> dict: + response = await client.post( + EVO_BASE, + json={ + "fromPokemonId": pikachu["id"], + "toPokemonId": charmander["id"], + "trigger": "level-up", + }, + ) + return response.json() + + async def test_deletes_evolution(self, client: AsyncClient, evolution: dict): + assert (await client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204 + assert (await client.get(EVO_BASE)).json()["total"] == 0 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.delete(f"{EVO_BASE}/9999")).status_code == 404 + + +# --------------------------------------------------------------------------- +# Route Encounters — list / create / update / delete +# --------------------------------------------------------------------------- + + +class TestRouteEncounters: + async def test_empty_list_for_route(self, client: AsyncClient, ctx: dict): + response = await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon") + assert response.status_code == 200 + assert response.json() == [] + + async def test_creates_route_encounter(self, client: AsyncClient, ctx: dict): + response = await client.post( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", + json={ + "pokemonId": ctx["charmander_id"], + "gameId": ctx["game_id"], + "encounterMethod": "grass", + "encounterRate": 10, + "minLevel": 5, + "maxLevel": 10, + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["pokemonId"] == ctx["charmander_id"] + assert data["encounterRate"] == 10 + assert data["pokemon"]["name"] == "charmander" + + async def test_invalid_route_returns_404(self, client: AsyncClient, ctx: dict): + response = await client.post( + f"{ROUTE_BASE}/9999/pokemon", + json={ + "pokemonId": ctx["charmander_id"], + "gameId": ctx["game_id"], + "encounterMethod": "grass", + "encounterRate": 10, + "minLevel": 5, + "maxLevel": 10, + }, + ) + assert response.status_code == 404 + + async def test_invalid_pokemon_returns_404(self, client: AsyncClient, ctx: dict): + response = await client.post( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", + json={ + "pokemonId": 9999, + "gameId": ctx["game_id"], + "encounterMethod": "grass", + "encounterRate": 10, + "minLevel": 5, + "maxLevel": 10, + }, + ) + assert response.status_code == 404 + + async def test_updates_route_encounter(self, client: AsyncClient, ctx: dict): + r = await client.post( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", + json={ + "pokemonId": ctx["charmander_id"], + "gameId": ctx["game_id"], + "encounterMethod": "grass", + "encounterRate": 10, + "minLevel": 5, + "maxLevel": 10, + }, + ) + enc = r.json() + response = await client.put( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}", + json={"encounterRate": 25}, + ) + assert response.status_code == 200 + assert response.json()["encounterRate"] == 25 + + async def test_update_not_found_returns_404(self, client: AsyncClient, ctx: dict): + assert ( + await client.put( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999", + json={"encounterRate": 5}, + ) + ).status_code == 404 + + async def test_deletes_route_encounter(self, client: AsyncClient, ctx: dict): + r = await client.post( + f"{ROUTE_BASE}/{ctx['route_id']}/pokemon", + json={ + "pokemonId": ctx["charmander_id"], + "gameId": ctx["game_id"], + "encounterMethod": "grass", + "encounterRate": 10, + "minLevel": 5, + "maxLevel": 10, + }, + ) + enc = r.json() + assert ( + await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}") + ).status_code == 204 + assert ( + await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon") + ).json() == [] + + async def test_delete_not_found_returns_404(self, client: AsyncClient, ctx: dict): + assert ( + await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999") + ).status_code == 404