Add integration tests for Runs & Encounters API
28 tests covering run CRUD, rules JSONB storage, encounter creation, route-lock enforcement, shinyClause and giftClause bypasses, status transitions (complete/fail), and encounter update/delete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
454
backend/tests/test_runs.py
Normal file
454
backend/tests/test_runs.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user