455 lines
16 KiB
Python
455 lines
16 KiB
Python
|
|
"""Integration tests for the Runs & Encounters API."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from httpx import AsyncClient
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.models.game import Game
|
||
|
|
from app.models.nuzlocke_run import NuzlockeRun
|
||
|
|
from app.models.pokemon import Pokemon
|
||
|
|
from app.models.route import Route
|
||
|
|
from app.models.version_group import VersionGroup
|
||
|
|
|
||
|
|
RUNS_BASE = "/api/v1/runs"
|
||
|
|
ENC_BASE = "/api/v1/encounters"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Shared fixtures
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
async def game_id(db_session: AsyncSession) -> int:
|
||
|
|
"""A minimal game (no version_group_id needed for run CRUD)."""
|
||
|
|
game = Game(name="Test Game", slug="test-game", generation=1, region="kanto")
|
||
|
|
db_session.add(game)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(game)
|
||
|
|
return game.id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
async def run(client: AsyncClient, game_id: int) -> dict:
|
||
|
|
"""An active run created via the API."""
|
||
|
|
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
|
||
|
|
assert response.status_code == 201
|
||
|
|
return response.json()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
async def enc_ctx(db_session: AsyncSession) -> dict:
|
||
|
|
"""Full context for encounter tests: game, run, pokemon, standalone and grouped routes."""
|
||
|
|
vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg")
|
||
|
|
db_session.add(vg)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
game = Game(
|
||
|
|
name="Enc Game",
|
||
|
|
slug="enc-game",
|
||
|
|
generation=1,
|
||
|
|
region="kanto",
|
||
|
|
version_group_id=vg.id,
|
||
|
|
)
|
||
|
|
db_session.add(game)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
pikachu = Pokemon(
|
||
|
|
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
|
||
|
|
)
|
||
|
|
charmander = Pokemon(
|
||
|
|
pokeapi_id=4, national_dex=4, name="charmander", types=["fire"]
|
||
|
|
)
|
||
|
|
db_session.add_all([pikachu, charmander])
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# A standalone route (no parent — no route-lock applies)
|
||
|
|
standalone = Route(name="Standalone Route", version_group_id=vg.id, order=1)
|
||
|
|
# A parent route with two children (route-lock applies to children)
|
||
|
|
parent = Route(name="Route Group", version_group_id=vg.id, order=2)
|
||
|
|
db_session.add_all([standalone, parent])
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
child1 = Route(
|
||
|
|
name="Child A", version_group_id=vg.id, order=1, parent_route_id=parent.id
|
||
|
|
)
|
||
|
|
child2 = Route(
|
||
|
|
name="Child B", version_group_id=vg.id, order=2, parent_route_id=parent.id
|
||
|
|
)
|
||
|
|
db_session.add_all([child1, child2])
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
run = NuzlockeRun(
|
||
|
|
game_id=game.id,
|
||
|
|
name="Enc Run",
|
||
|
|
status="active",
|
||
|
|
rules={"shinyClause": True, "giftClause": False},
|
||
|
|
)
|
||
|
|
db_session.add(run)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
for obj in [standalone, parent, child1, child2, pikachu, charmander, run]:
|
||
|
|
await db_session.refresh(obj)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"run_id": run.id,
|
||
|
|
"game_id": game.id,
|
||
|
|
"pikachu_id": pikachu.id,
|
||
|
|
"charmander_id": charmander.id,
|
||
|
|
"standalone_id": standalone.id,
|
||
|
|
"parent_id": parent.id,
|
||
|
|
"child1_id": child1.id,
|
||
|
|
"child2_id": child2.id,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Runs — list
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestListRuns:
|
||
|
|
async def test_empty_returns_empty_list(self, client: AsyncClient):
|
||
|
|
response = await client.get(RUNS_BASE)
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json() == []
|
||
|
|
|
||
|
|
async def test_returns_created_run(self, client: AsyncClient, run: dict):
|
||
|
|
response = await client.get(RUNS_BASE)
|
||
|
|
assert response.status_code == 200
|
||
|
|
ids = [r["id"] for r in response.json()]
|
||
|
|
assert run["id"] in ids
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Runs — create
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestCreateRun:
|
||
|
|
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
|
||
|
|
response = await client.post(
|
||
|
|
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
data = response.json()
|
||
|
|
assert data["name"] == "New Run"
|
||
|
|
assert data["status"] == "active"
|
||
|
|
assert data["gameId"] == game_id
|
||
|
|
assert isinstance(data["id"], int)
|
||
|
|
|
||
|
|
async def test_rules_stored(self, client: AsyncClient, game_id: int):
|
||
|
|
rules = {"duplicatesClause": True, "shinyClause": False}
|
||
|
|
response = await client.post(
|
||
|
|
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
assert response.json()["rules"]["duplicatesClause"] is True
|
||
|
|
|
||
|
|
async def test_invalid_game_returns_404(self, client: AsyncClient):
|
||
|
|
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
async def test_missing_required_returns_422(self, client: AsyncClient):
|
||
|
|
response = await client.post(RUNS_BASE, json={"name": "Run"})
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Runs — get
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetRun:
|
||
|
|
async def test_returns_run_with_game_and_encounters(
|
||
|
|
self, client: AsyncClient, run: dict
|
||
|
|
):
|
||
|
|
response = await client.get(f"{RUNS_BASE}/{run['id']}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["id"] == run["id"]
|
||
|
|
assert "game" in data
|
||
|
|
assert data["encounters"] == []
|
||
|
|
|
||
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
||
|
|
assert (await client.get(f"{RUNS_BASE}/9999")).status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Runs — update
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestUpdateRun:
|
||
|
|
async def test_updates_name(self, client: AsyncClient, run: dict):
|
||
|
|
response = await client.patch(
|
||
|
|
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["name"] == "Renamed"
|
||
|
|
|
||
|
|
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
|
||
|
|
response = await client.patch(
|
||
|
|
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["status"] == "completed"
|
||
|
|
assert data["completedAt"] is not None
|
||
|
|
|
||
|
|
async def test_fail_run(self, client: AsyncClient, run: dict):
|
||
|
|
response = await client.patch(
|
||
|
|
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["status"] == "failed"
|
||
|
|
|
||
|
|
async def test_ending_already_ended_run_returns_400(
|
||
|
|
self, client: AsyncClient, run: dict
|
||
|
|
):
|
||
|
|
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
|
||
|
|
response = await client.patch(
|
||
|
|
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 400
|
||
|
|
|
||
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
||
|
|
assert (
|
||
|
|
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||
|
|
).status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Runs — delete
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestDeleteRun:
|
||
|
|
async def test_deletes_run(self, client: AsyncClient, run: dict):
|
||
|
|
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||
|
|
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||
|
|
|
||
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
||
|
|
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Encounters — create
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestCreateEncounter:
|
||
|
|
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["standalone_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
data = response.json()
|
||
|
|
assert data["runId"] == enc_ctx["run_id"]
|
||
|
|
assert data["pokemonId"] == enc_ctx["pikachu_id"]
|
||
|
|
assert data["status"] == "caught"
|
||
|
|
assert data["isShiny"] is False
|
||
|
|
|
||
|
|
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/9999/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["standalone_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": 9999,
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
async def test_invalid_pokemon_returns_404(
|
||
|
|
self, client: AsyncClient, enc_ctx: dict
|
||
|
|
):
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["standalone_id"],
|
||
|
|
"pokemonId": 9999,
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
|
||
|
|
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["parent_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 400
|
||
|
|
|
||
|
|
async def test_route_lock_prevents_second_sibling_encounter(
|
||
|
|
self, client: AsyncClient, enc_ctx: dict
|
||
|
|
):
|
||
|
|
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
||
|
|
await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child1_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child2_id"],
|
||
|
|
"pokemonId": enc_ctx["charmander_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 409
|
||
|
|
|
||
|
|
async def test_shiny_bypasses_route_lock(
|
||
|
|
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
|
||
|
|
# First encounter occupies the group
|
||
|
|
await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child1_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
# Shiny encounter on sibling should succeed
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child2_id"],
|
||
|
|
"pokemonId": enc_ctx["charmander_id"],
|
||
|
|
"status": "caught",
|
||
|
|
"isShiny": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
assert response.json()["isShiny"] is True
|
||
|
|
|
||
|
|
async def test_gift_bypasses_route_lock_when_clause_on(
|
||
|
|
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""A gift encounter bypasses route-lock when giftClause is enabled."""
|
||
|
|
# Enable giftClause on the run
|
||
|
|
run = await db_session.get(NuzlockeRun, enc_ctx["run_id"])
|
||
|
|
run.rules = {"shinyClause": True, "giftClause": True}
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child1_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["child2_id"],
|
||
|
|
"pokemonId": enc_ctx["charmander_id"],
|
||
|
|
"status": "caught",
|
||
|
|
"origin": "gift",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
assert response.json()["origin"] == "gift"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Encounters — update
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestUpdateEncounter:
|
||
|
|
@pytest.fixture
|
||
|
|
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["standalone_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return response.json()
|
||
|
|
|
||
|
|
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
|
||
|
|
response = await client.patch(
|
||
|
|
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["nickname"] == "Sparky"
|
||
|
|
|
||
|
|
async def test_updates_status_to_fainted(
|
||
|
|
self, client: AsyncClient, encounter: dict
|
||
|
|
):
|
||
|
|
response = await client.patch(
|
||
|
|
f"{ENC_BASE}/{encounter['id']}",
|
||
|
|
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["status"] == "fainted"
|
||
|
|
assert data["faintLevel"] == 12
|
||
|
|
assert data["deathCause"] == "wild battle"
|
||
|
|
|
||
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
||
|
|
assert (
|
||
|
|
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||
|
|
).status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Encounters — delete
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestDeleteEncounter:
|
||
|
|
@pytest.fixture
|
||
|
|
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||
|
|
response = await client.post(
|
||
|
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||
|
|
json={
|
||
|
|
"routeId": enc_ctx["standalone_id"],
|
||
|
|
"pokemonId": enc_ctx["pikachu_id"],
|
||
|
|
"status": "caught",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return response.json()
|
||
|
|
|
||
|
|
async def test_deletes_encounter(
|
||
|
|
self, client: AsyncClient, encounter: dict, enc_ctx: dict
|
||
|
|
):
|
||
|
|
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
|
||
|
|
# Run detail should no longer include it
|
||
|
|
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||
|
|
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
|
||
|
|
|
||
|
|
async def test_not_found_returns_404(self, client: AsyncClient):
|
||
|
|
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404
|