573 lines
19 KiB
Python
573 lines
19 KiB
Python
"""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
|