Add integration tests for Pokemon & Evolutions API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 13:15:00 +01:00
parent ca736e0f39
commit 34835abe0c
2 changed files with 580 additions and 7 deletions

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-ugb7 # nuzlocke-tracker-ugb7
title: Integration tests for Pokemon & Evolutions API title: Integration tests for Pokemon & Evolutions API
status: draft status: completed
type: task type: task
priority: normal
created_at: 2026-02-10T09:33:16Z 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 parent: nuzlocke-tracker-yzpb
--- ---
@@ -12,11 +13,11 @@ Write integration tests for the Pokemon and evolutions API endpoints.
## Checklist ## Checklist
- [ ] Test Pokemon CRUD operations (create, list, search, update, delete) - [x] Test Pokemon CRUD operations (create, list, search, update, delete)
- [ ] Test Pokemon filtering and search - [x] Test Pokemon filtering and search
- [ ] Test evolution chain CRUD (create, list, get, update, delete) - [x] Test evolution chain CRUD (create, list, get, update, delete)
- [ ] Test evolution family resolution endpoint - [x] Test evolution family resolution endpoint
- [ ] Test error cases (invalid Pokemon references, circular evolutions, etc.) - [x] Test error cases (invalid Pokemon references, circular evolutions, etc.)
## Notes ## Notes

View File

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