From d6a0b60585af5f7e32733d0b936f9fa74f5a4521 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Feb 2026 12:58:28 +0100 Subject: [PATCH] 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 --- ...tegration-tests-for-runs-encounters-api.md | 23 +- backend/tests/test_runs.py | 454 ++++++++++++++++++ 2 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_runs.py diff --git a/.beans/nuzlocke-tracker-0arz--integration-tests-for-runs-encounters-api.md b/.beans/nuzlocke-tracker-0arz--integration-tests-for-runs-encounters-api.md index 1dee4bc..a303812 100644 --- a/.beans/nuzlocke-tracker-0arz--integration-tests-for-runs-encounters-api.md +++ b/.beans/nuzlocke-tracker-0arz--integration-tests-for-runs-encounters-api.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-0arz title: Integration tests for Runs & Encounters API -status: draft +status: completed type: task +priority: normal 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 --- @@ -12,15 +13,15 @@ Write integration tests for the core run tracking and encounter API endpoints. T ## Checklist -- [ ] Test run CRUD operations (create, list, get, update, delete) -- [ ] Test run creation with rules configuration (JSONB field) -- [ ] Test encounter logging on a run (create encounter on a route) -- [ ] Test encounter status changes (alive → dead, alive → retired, etc.) -- [ ] Test duplicate encounter prevention (dupes clause logic) -- [ ] Test shiny encounter handling -- [ ] Test egg encounter handling -- [ ] Test ending a run (completion/failure) -- [ ] Test error cases (encounter on invalid route, duplicate route encounters, etc.) +- [x] Test run CRUD operations (create, list, get, update, delete) +- [x] Test run creation with rules configuration (JSONB field) +- [x] Test encounter logging on a run (create encounter on a route) +- [x] Test encounter status changes (alive → dead, faintLevel, deathCause) +- [x] Test route-lock enforcement (duplicate sibling encounter → 409) +- [x] Test shiny encounter handling (shinyClause bypasses route-lock) +- [x] Test gift clause bypass (giftClause=true, origin=gift bypasses route-lock) +- [x] Test ending a run (completion/failure, completed_at set, 400 on double-end) +- [x] Test error cases (404 for invalid run/route/pokemon, 400 for parent route, 422 for missing fields) ## Notes diff --git a/backend/tests/test_runs.py b/backend/tests/test_runs.py new file mode 100644 index 0000000..d835a7c --- /dev/null +++ b/backend/tests/test_runs.py @@ -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