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:
2026-02-21 12:58:28 +01:00
parent 79eabf4f9f
commit d6a0b60585
2 changed files with 466 additions and 11 deletions

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-0arz # nuzlocke-tracker-0arz
title: Integration tests for Runs & Encounters API title: Integration tests for Runs & Encounters API
status: draft status: completed
type: task type: task
priority: normal
created_at: 2026-02-10T09:33:21Z created_at: 2026-02-10T09:33:21Z
updated_at: 2026-02-10T09:33:21Z updated_at: 2026-02-21T11:54:42Z
parent: nuzlocke-tracker-yzpb parent: nuzlocke-tracker-yzpb
--- ---
@@ -12,15 +13,15 @@ Write integration tests for the core run tracking and encounter API endpoints. T
## Checklist ## Checklist
- [ ] Test run CRUD operations (create, list, get, update, delete) - [x] Test run CRUD operations (create, list, get, update, delete)
- [ ] Test run creation with rules configuration (JSONB field) - [x] Test run creation with rules configuration (JSONB field)
- [ ] Test encounter logging on a run (create encounter on a route) - [x] Test encounter logging on a run (create encounter on a route)
- [ ] Test encounter status changes (alive → dead, alive → retired, etc.) - [x] Test encounter status changes (alive → dead, faintLevel, deathCause)
- [ ] Test duplicate encounter prevention (dupes clause logic) - [x] Test route-lock enforcement (duplicate sibling encounter → 409)
- [ ] Test shiny encounter handling - [x] Test shiny encounter handling (shinyClause bypasses route-lock)
- [ ] Test egg encounter handling - [x] Test gift clause bypass (giftClause=true, origin=gift bypasses route-lock)
- [ ] Test ending a run (completion/failure) - [x] Test ending a run (completion/failure, completed_at set, 400 on double-end)
- [ ] Test error cases (encounter on invalid route, duplicate route encounters, etc.) - [x] Test error cases (404 for invalid run/route/pokemon, 400 for parent route, 422 for missing fields)
## Notes ## Notes

454
backend/tests/test_runs.py Normal file
View 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