Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
471 lines
16 KiB
Python
471 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(auth_client: AsyncClient, game_id: int) -> dict:
|
|
"""An active run created via the API."""
|
|
response = await auth_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, auth_client: AsyncClient, game_id: int):
|
|
response = await auth_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, auth_client: AsyncClient, game_id: int):
|
|
rules = {"duplicatesClause": True, "shinyClause": False}
|
|
response = await auth_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, auth_client: AsyncClient):
|
|
response = await auth_client.post(
|
|
RUNS_BASE, json={"gameId": 9999, "name": "Run"}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
async def test_missing_required_returns_422(self, auth_client: AsyncClient):
|
|
response = await auth_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, auth_client: AsyncClient, run: dict):
|
|
response = await auth_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, auth_client: AsyncClient, run: dict
|
|
):
|
|
response = await auth_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, auth_client: AsyncClient, run: dict):
|
|
response = await auth_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, auth_client: AsyncClient, run: dict
|
|
):
|
|
await auth_client.patch(
|
|
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
|
)
|
|
response = await auth_client.patch(
|
|
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
|
assert (
|
|
await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
|
).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runs — delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteRun:
|
|
async def test_deletes_run(self, auth_client: AsyncClient, run: dict):
|
|
assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
|
assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
|
|
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
|
assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encounters — create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateEncounter:
|
|
async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
|
|
response = await auth_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, auth_client: AsyncClient, enc_ctx: dict
|
|
):
|
|
response = await auth_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, auth_client: AsyncClient, enc_ctx: dict
|
|
):
|
|
response = await auth_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, auth_client: AsyncClient, enc_ctx: dict
|
|
):
|
|
response = await auth_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, auth_client: AsyncClient, enc_ctx: dict
|
|
):
|
|
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
|
response = await auth_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, auth_client: AsyncClient, enc_ctx: dict
|
|
):
|
|
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
|
await auth_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 auth_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, auth_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 auth_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 auth_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, auth_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 auth_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 auth_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, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
|
response = await auth_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, auth_client: AsyncClient, encounter: dict):
|
|
response = await auth_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, auth_client: AsyncClient, encounter: dict
|
|
):
|
|
response = await auth_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, auth_client: AsyncClient):
|
|
assert (
|
|
await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
|
).status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encounters — delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteEncounter:
|
|
@pytest.fixture
|
|
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
|
response = await auth_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, auth_client: AsyncClient, encounter: dict, enc_ctx: dict
|
|
):
|
|
assert (
|
|
await auth_client.delete(f"{ENC_BASE}/{encounter['id']}")
|
|
).status_code == 204
|
|
# Run detail should no longer include it
|
|
detail = (await auth_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, auth_client: AsyncClient):
|
|
assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404
|