"""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